@greatwait/cli 0.1.0-alpha.3 → 0.1.0-alpha.4

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.
@@ -0,0 +1,1394 @@
1
+ import { billingAutoTopUpApplyRequestSchema, billingAutoTopUpPreviewRequestSchema, billingAutoTopUpPreviewSchema, billingCeilingUpdateRequestSchema, billingFundingQuoteRequestSchema, billingFundingQuoteSchema, billingNotificationSettingsUpdateRequestSchema, billingRefundRequestCreateSchema, billingRefundResolutionRequestSchema, projectBillingQuoteSchema, projectBillingQuoteRequestSchema, } from "@greatwait/contracts";
2
+ import { parseDuration } from "./args.js";
3
+ import { confirmChange } from "./confirm.js";
4
+ import { redactSecret } from "./output.js";
5
+ import { CliError } from "./runtime.js";
6
+ import { openSession } from "./session.js";
7
+ import { apiUrlOption, conditionalHeaders, continuationNote, drainPages, freshnessNote, pageRequest, parseInstant, parseResponse, positionals, readJsonOption, table, } from "./support.js";
8
+ /* -------------------------------------------------------------------------- */
9
+ /* Shared plumbing */
10
+ /* -------------------------------------------------------------------------- */
11
+ async function session(runtime, args) {
12
+ return openSession(runtime, { apiUrl: apiUrlOption(args) });
13
+ }
14
+ /**
15
+ * A listing, from one page or from all of them, rendered the same way.
16
+ *
17
+ * The freshness of a projection is reported rather than dropped: a cross
18
+ * partition listing may lag, and the contract says so in every page. A command
19
+ * that showed the rows and not the age would present a stale answer as fact,
20
+ * which is the failure the field exists to prevent.
21
+ */
22
+ async function listing(runtime, args, options) {
23
+ const drained = await drainPages(runtime, args, pageRequest(args), options.read);
24
+ const notes = [
25
+ continuationNote(drained, options.command),
26
+ freshnessNote(drained.freshness),
27
+ ].filter((note) => note !== undefined);
28
+ return {
29
+ data: {
30
+ ceilingReached: drained.ceilingReached,
31
+ freshness: drained.freshness,
32
+ ...(drained.nextCursor === undefined
33
+ ? {}
34
+ : { nextCursor: drained.nextCursor }),
35
+ pages: drained.pages,
36
+ rows: drained.rows,
37
+ },
38
+ human: table(options.columns, drained.rows.map(options.row), options.empty),
39
+ ...(notes.length === 0 ? {} : { nextMove: notes.join("\n") }),
40
+ };
41
+ }
42
+ /** A page read through the client, validated against the published shape. */
43
+ async function page(operation, call) {
44
+ return parseResponse(operation, await call());
45
+ }
46
+ /** The freshness a single read reported, where it reports one. */
47
+ function freshnessOf(view) {
48
+ return view.freshness ?? { kind: "authoritative" };
49
+ }
50
+ /**
51
+ * The idempotency key for a mutation, generated when it was not given.
52
+ *
53
+ * Printed either way. A create that silently invented a fresh key on every run
54
+ * would make a retry a second delivery, which is the failure the key exists to
55
+ * prevent; one that hid the key would leave the caller unable to make that
56
+ * retry at all.
57
+ */
58
+ function idempotencyKey(runtime, args) {
59
+ return (args.options["--idempotency-key"] ?? `greatwait-cli-${runtime.randomUuid()}`);
60
+ }
61
+ /* -------------------------------------------------------------------------- */
62
+ /* The schedule request, shared by create and replace */
63
+ /* -------------------------------------------------------------------------- */
64
+ /** The recurrence flags, exactly one of which must be given. */
65
+ const RECURRENCE_FLAGS = ["--in", "--at", "--every", "--cron"];
66
+ /**
67
+ * A schedule definition from the command line.
68
+ *
69
+ * **One recurrence, named explicitly.** The four spellings are the four the
70
+ * contract publishes, and giving none or giving two is refused here rather than
71
+ * sent: "you gave --in and --cron" is a sentence about the line somebody typed,
72
+ * and `invalid_request` a round trip later is not.
73
+ *
74
+ * `--time-zone` is what keeps daylight saving honest. A cron expression without
75
+ * one fires on a fixed offset, so "every weekday at 09:00" drifts an hour twice
76
+ * a year; with one, the service moves the instant and the local time stays put.
77
+ * The command line does not choose a default zone, because guessing the
78
+ * machine's would make the same expression mean different things on a laptop
79
+ * and on a build runner.
80
+ */
81
+ async function scheduleRequest(runtime, args, usage) {
82
+ const given = RECURRENCE_FLAGS.filter((flag) => args.options[flag] !== undefined);
83
+ if (given.length !== 1) {
84
+ throw new CliError(given.length === 0
85
+ ? "give one of --in, --at, --every or --cron"
86
+ : `give one recurrence, not ${given.join(" and ")}`, "usage", `Usage: ${usage}`);
87
+ }
88
+ const only = given[0];
89
+ const value = args.options[only];
90
+ const timeZone = args.options["--time-zone"];
91
+ if (timeZone !== undefined && only !== "--cron") {
92
+ // Refused rather than ignored: somebody who passed a zone believes it is
93
+ // doing something, and on `--at` or `--in` there is no local time for it to
94
+ // mean anything about.
95
+ throw new CliError("--time-zone only means something with --cron", "usage", "An instant and a delay are absolute already; a cron expression is the one that names a local time.");
96
+ }
97
+ const recurrence = only === "--in"
98
+ ? { delaySeconds: parseDuration(value) }
99
+ : only === "--at"
100
+ ? { at: parseInstant(value) }
101
+ : only === "--every"
102
+ ? { everySeconds: parseDuration(value) }
103
+ : { cron: value };
104
+ const endpoint = args.options["--endpoint"];
105
+ const data = await readJsonOption(runtime, "--data", args.options["--data"]);
106
+ const latencyClass = args.options["--latency-class"];
107
+ const partitionKey = args.options["--partition-key"];
108
+ return {
109
+ ...(data === undefined ? {} : { data }),
110
+ ...(endpoint === undefined ? {} : { endpoint }),
111
+ ...(latencyClass === undefined ? {} : { latencyClass }),
112
+ ...(partitionKey === undefined ? {} : { partitionKey }),
113
+ ...(timeZone === undefined ? {} : { timeZone }),
114
+ ...recurrence,
115
+ };
116
+ }
117
+ /** How a schedule's recurrence reads in one column. */
118
+ function recurrenceOf(schedule) {
119
+ return schedule.recurrence.kind;
120
+ }
121
+ /* -------------------------------------------------------------------------- */
122
+ /* Destinations */
123
+ /* -------------------------------------------------------------------------- */
124
+ const destinationsList = async (runtime, args) => {
125
+ const active = await session(runtime, args);
126
+ return listing(runtime, args, {
127
+ columns: ["STATUS", "NAME", "URL", "DESTINATION"],
128
+ command: `${runtime.programName} destinations list`,
129
+ empty: "This project has no destinations yet.",
130
+ read: (request) => page("endpoints.list", () => active.client.request("endpoints_list", request)),
131
+ row: (endpoint) => [
132
+ endpoint.status,
133
+ endpoint.name ?? endpoint.url,
134
+ endpoint.url,
135
+ endpoint.id,
136
+ ],
137
+ });
138
+ };
139
+ /** One destination, rendered the way both read and the writes report it. */
140
+ function endpointBlock(endpoint) {
141
+ return [
142
+ `Destination ${endpoint.id}`,
143
+ `Status ${endpoint.status}`,
144
+ `URL ${endpoint.url}`,
145
+ `Version ${String(endpoint.version)}`,
146
+ `Signing key ${endpoint.signingKeys.current}`,
147
+ ...(endpoint.signingKeys.next === undefined
148
+ ? []
149
+ : [
150
+ `Next key ${endpoint.signingKeys.next.id}, accepted alongside the current one until ${endpoint.signingKeys.next.overlapEndsAt}`,
151
+ ]),
152
+ ];
153
+ }
154
+ const destinationsRead = async (runtime, args) => {
155
+ const [id] = positionals(args, ["<destination>"], `${runtime.programName} destinations read <destination>`);
156
+ const active = await session(runtime, args);
157
+ const answer = await active.client.requestDetailed("endpoints_read", id);
158
+ const endpoint = parseResponse("endpoints.read", answer.body);
159
+ const etag = answer.headers["ETag"];
160
+ return {
161
+ data: { ...(etag === undefined ? {} : { etag }), endpoint },
162
+ human: [
163
+ ...endpointBlock(endpoint),
164
+ ...(etag === undefined ? [] : [`ETag ${etag}`]),
165
+ ].join("\n"),
166
+ ...(etag === undefined
167
+ ? {}
168
+ : {
169
+ nextMove: "Pass that ETag as --if-match on a later update or rotation to be refused rather than overwrite a change somebody else made.",
170
+ }),
171
+ };
172
+ };
173
+ const destinationsUpdate = async (runtime, args) => {
174
+ const [id] = positionals(args, ["<destination>"], `${runtime.programName} destinations update <destination> --url <https url>`);
175
+ const url = args.options["--url"];
176
+ if (url === undefined) {
177
+ throw new CliError(`${runtime.programName} destinations update needs --url`, "usage", `Usage: ${runtime.programName} destinations update <destination> --url https://your-receiver.example.com/greatwait`);
178
+ }
179
+ const active = await session(runtime, args);
180
+ // Read first, so the preview is the destination as it stands rather than the
181
+ // arguments read back.
182
+ const current = parseResponse("endpoints.read", await active.client.request("endpoints_read", id));
183
+ const confirmation = await confirmChange(runtime, args, {
184
+ action: `Point destination ${current.id} at a different receiver.`,
185
+ effect: [
186
+ `now ${current.url}`,
187
+ `after ${url}`,
188
+ `Deliveries in flight keep going to the receiver they started for.`,
189
+ ],
190
+ });
191
+ const updated = parseResponse("endpoints.update", await active.client.request("endpoints_update", id, { url }, conditionalHeaders(args)));
192
+ return {
193
+ data: { confirmation, endpoint: updated },
194
+ human: endpointBlock(updated).join("\n"),
195
+ };
196
+ };
197
+ const destinationsRotateKey = async (runtime, args) => {
198
+ const [id] = positionals(args, ["<destination>"], `${runtime.programName} destinations rotate-key <destination>`);
199
+ const active = await session(runtime, args);
200
+ const current = parseResponse("endpoints.read", await active.client.request("endpoints_read", id));
201
+ const confirmation = await confirmChange(runtime, args, {
202
+ action: `Mint the next signing key for destination ${current.id}.`,
203
+ effect: [
204
+ `current key ${current.signingKeys.current}`,
205
+ `The new secret is shown once here and cannot be recovered afterwards.`,
206
+ `Your receiver must accept both keys until the overlap ends, or it will`,
207
+ `reject callbacks signed with the new one.`,
208
+ ],
209
+ });
210
+ const rotated = parseResponse("endpoints.rotateSigningKey", await active.client.request("endpoints_rotateSigningKey", id, {}, conditionalHeaders(args)));
211
+ const overlapEndsAt = rotated.endpoint.signingKeys.next?.overlapEndsAt;
212
+ return {
213
+ // **The secret is here and in no later output.** The service returns it
214
+ // from this call and from no other, so printing it once with the warning is
215
+ // the only place it can honestly appear.
216
+ data: {
217
+ confirmation,
218
+ endpoint: rotated.endpoint,
219
+ keyId: rotated.keyId,
220
+ secret: rotated.secret,
221
+ },
222
+ human: [
223
+ `Destination ${rotated.endpoint.id}`,
224
+ `New key ${rotated.keyId}`,
225
+ ...(overlapEndsAt === undefined ? [] : [`Overlap ends ${overlapEndsAt}`]),
226
+ ``,
227
+ `Signing key ${rotated.secret}`,
228
+ ``,
229
+ `This key is shown once and is not recoverable. Add it to your receiver's`,
230
+ `accepted set now, alongside the current one; reading this destination back`,
231
+ `will show ${redactSecret(rotated.secret)} instead.`,
232
+ ].join("\n"),
233
+ };
234
+ };
235
+ /* -------------------------------------------------------------------------- */
236
+ /* Schedules */
237
+ /* -------------------------------------------------------------------------- */
238
+ const schedulesList = async (runtime, args) => {
239
+ const active = await session(runtime, args);
240
+ const endpoint = args.options["--endpoint"];
241
+ return listing(runtime, args, {
242
+ columns: ["STATUS", "NAME", "RECURRENCE", "DELIVERS TO", "SCHEDULE"],
243
+ command: `${runtime.programName} schedules list${endpoint === undefined ? "" : ` --endpoint ${endpoint}`}`,
244
+ empty: endpoint === undefined
245
+ ? "This project has no schedules yet."
246
+ : "No schedule delivers to that destination.",
247
+ read: (request) => page("schedules.list", () => active.client.request("schedules_list", {
248
+ ...request,
249
+ ...(endpoint === undefined ? {} : { endpoint }),
250
+ })),
251
+ row: (schedule) => [
252
+ schedule.status,
253
+ schedule.name ?? "—",
254
+ recurrenceOf(schedule),
255
+ schedule.endpointUrl ?? schedule.endpoint ?? "—",
256
+ schedule.id,
257
+ ],
258
+ });
259
+ };
260
+ /**
261
+ * A schedule lifecycle change: read, preview where one is owed, act, report.
262
+ *
263
+ * The five that share this — replace, pause, resume, cancel, archive — differ
264
+ * only in what they send and what they say afterwards, and writing each one out
265
+ * separately is how two of them end up handling `If-Match` differently.
266
+ */
267
+ async function scheduleChange(runtime, args, options) {
268
+ const active = await session(runtime, args);
269
+ let confirmation;
270
+ if (options.confirm !== undefined) {
271
+ const current = parseResponse("schedules.read", await active.client.request("schedules_read", options.id)).schedule;
272
+ confirmation = await confirmChange(runtime, args, options.confirm(current));
273
+ }
274
+ const answer = await options.act(active, options.id);
275
+ const view = parseResponse(options.operation, answer.body);
276
+ const nextMove = options.nextMove?.(view.schedule);
277
+ return {
278
+ data: {
279
+ ...(confirmation === undefined ? {} : { confirmation }),
280
+ ...(view.occurrence === undefined ? {} : { occurrence: view.occurrence }),
281
+ schedule: view.schedule,
282
+ },
283
+ human: [
284
+ `Schedule ${view.schedule.id}`,
285
+ `Status ${view.schedule.status}`,
286
+ `Recurrence ${recurrenceOf(view.schedule)}`,
287
+ `Version ${String(view.schedule.version)}`,
288
+ ...(view.occurrence === undefined
289
+ ? []
290
+ : [
291
+ `Next ${view.occurrence.id} planned for ${view.occurrence.plannedFor} (${view.occurrence.state})`,
292
+ ]),
293
+ ].join("\n"),
294
+ ...(nextMove === undefined ? {} : { nextMove }),
295
+ };
296
+ }
297
+ const schedulesReplace = async (runtime, args) => {
298
+ const usage = `${runtime.programName} schedules replace <schedule> --endpoint <destination> (--in <duration> | --at <instant> | --every <duration> | --cron <expression>)`;
299
+ const [id] = positionals(args, ["<schedule>"], usage);
300
+ const request = await scheduleRequest(runtime, args, usage);
301
+ return scheduleChange(runtime, args, {
302
+ act: (active, scheduleId) => active.client.requestDetailed("schedules_replace", scheduleId, request, conditionalHeaders(args)),
303
+ confirm: (current) => ({
304
+ action: `Replace the definition of schedule ${current.id}.`,
305
+ effect: [
306
+ `now ${recurrenceOf(current)}, ${current.status}, version ${String(current.version)}`,
307
+ `after the definition on this command line, and nothing of the old one`,
308
+ `Occurrences already planned under the old definition are discarded.`,
309
+ ],
310
+ }),
311
+ id: id,
312
+ nextMove: (schedule) => `Read it back with: ${runtime.programName} schedules read ${schedule.id}`,
313
+ operation: "schedules.replace",
314
+ });
315
+ };
316
+ const schedulesPause = async (runtime, args) => scheduleChange(runtime, args, {
317
+ act: (active, id) => active.client.requestDetailed("schedules_pause", id, conditionalHeaders(args)),
318
+ id: positionals(args, ["<schedule>"], `${runtime.programName} schedules pause <schedule>`)[0],
319
+ nextMove: (schedule) => `Nothing fires until it is resumed: ${runtime.programName} schedules resume ${schedule.id}`,
320
+ operation: "schedules.pause",
321
+ });
322
+ const schedulesResume = async (runtime, args) => scheduleChange(runtime, args, {
323
+ act: (active, id) => active.client.requestDetailed("schedules_resume", id, conditionalHeaders(args)),
324
+ id: positionals(args, ["<schedule>"], `${runtime.programName} schedules resume <schedule>`)[0],
325
+ operation: "schedules.resume",
326
+ });
327
+ const schedulesCancel = async (runtime, args) => scheduleChange(runtime, args, {
328
+ act: (active, id) => active.client.requestDetailed("schedules_cancel", id, conditionalHeaders(args)),
329
+ confirm: (current) => ({
330
+ action: `Cancel schedule ${current.id}.`,
331
+ effect: [
332
+ `recurrence ${recurrenceOf(current)}`,
333
+ `status ${current.status}`,
334
+ `Everything it had still to deliver is discarded. This cannot be undone;`,
335
+ `pause instead if you may want it back.`,
336
+ ],
337
+ }),
338
+ id: positionals(args, ["<schedule>"], `${runtime.programName} schedules cancel <schedule>`)[0],
339
+ nextMove: () => `Attempts already recorded stay readable: ${runtime.programName} occurrences list <schedule>`,
340
+ operation: "schedules.cancel",
341
+ });
342
+ const schedulesArchive = async (runtime, args) => scheduleChange(runtime, args, {
343
+ act: (active, id) => active.client.requestDetailed("schedules_archive", id, conditionalHeaders(args)),
344
+ id: positionals(args, ["<schedule>"], `${runtime.programName} schedules archive <schedule>`)[0],
345
+ operation: "schedules.archive",
346
+ });
347
+ /* -------------------------------------------------------------------------- */
348
+ /* Occurrences and attempts */
349
+ /* -------------------------------------------------------------------------- */
350
+ const occurrencesList = async (runtime, args) => {
351
+ const [schedule] = positionals(args, ["<schedule>"], `${runtime.programName} occurrences list <schedule>`);
352
+ const active = await session(runtime, args);
353
+ return listing(runtime, args, {
354
+ columns: ["STATE", "PLANNED FOR", "OCCURRENCE", "ATTEMPTS"],
355
+ command: `${runtime.programName} occurrences list ${schedule}`,
356
+ empty: "This schedule has no retained occurrences.",
357
+ read: (request) => page("occurrences.list", () => active.client.request("occurrences_list", schedule, {
358
+ ...request,
359
+ ...(args.booleans.has("--newest")
360
+ ? { order: "newest" }
361
+ : {}),
362
+ })),
363
+ row: (occurrence) => [
364
+ occurrence.state,
365
+ occurrence.plannedFor,
366
+ occurrence.id,
367
+ String(occurrence.attemptCount),
368
+ ],
369
+ });
370
+ };
371
+ const occurrencesRead = async (runtime, args) => {
372
+ const [schedule, occurrence] = positionals(args, ["<schedule>", "<occurrence>"], `${runtime.programName} occurrences read <schedule> <occurrence>`);
373
+ const active = await session(runtime, args);
374
+ const view = parseResponse("occurrences.read", await active.client.request("occurrences_read", schedule, occurrence));
375
+ return {
376
+ data: { freshness: freshnessOf(view), occurrence: view.occurrence },
377
+ human: [
378
+ `Occurrence ${view.occurrence.id}`,
379
+ `State ${view.occurrence.state}`,
380
+ `Planned for ${view.occurrence.plannedFor}`,
381
+ `Attempts ${String(view.occurrence.attemptCount)}`,
382
+ ...(view.occurrence.settledAt === undefined
383
+ ? []
384
+ : [`Settled ${view.occurrence.settledAt}`]),
385
+ ].join("\n"),
386
+ nextMove: `See what happened: ${runtime.programName} attempts list ${schedule} ${occurrence}`,
387
+ };
388
+ };
389
+ const occurrencesRedeliver = async (runtime, args) => {
390
+ const [schedule, occurrence] = positionals(args, ["<schedule>", "<occurrence>"], `${runtime.programName} occurrences redeliver <schedule> <occurrence>`);
391
+ const active = await session(runtime, args);
392
+ const current = parseResponse("occurrences.read", await active.client.request("occurrences_read", schedule, occurrence)).occurrence;
393
+ const confirmation = await confirmChange(runtime, args, {
394
+ action: `Deliver occurrence ${current.id} again.`,
395
+ effect: [
396
+ `state ${current.state}`,
397
+ `attempts ${String(current.attemptCount)} already made`,
398
+ `Your receiver gets this payload a second time. If the first attempt was`,
399
+ `ambiguous it may already have processed it, so redelivery is safe only`,
400
+ `where the receiver is idempotent.`,
401
+ ],
402
+ });
403
+ // **The key is required rather than optional here**, which is why it is
404
+ // generated when nobody supplied one: the contract declares it mandatory for
405
+ // redelivery precisely so a repeated call cannot become a third delivery.
406
+ const key = idempotencyKey(runtime, args);
407
+ const view = parseResponse("occurrences.redeliver", await active.client.request("occurrences_redeliver", schedule, occurrence, { "Idempotency-Key": key }));
408
+ return {
409
+ data: { attempt: view.attempt, confirmation, idempotencyKey: key },
410
+ human: [
411
+ `Attempt ${view.attempt.id}`,
412
+ `Number ${String(view.attempt.attemptNumber)}`,
413
+ `Started ${view.attempt.startedAt}`,
414
+ `Retry key ${key}`,
415
+ ].join("\n"),
416
+ nextMove: `Watch it settle: ${runtime.programName} occurrences wait ${schedule} ${occurrence}`,
417
+ };
418
+ };
419
+ const attemptsRead = async (runtime, args) => {
420
+ const [schedule, occurrence, attempt] = positionals(args, ["<schedule>", "<occurrence>", "<attempt>"], `${runtime.programName} attempts read <schedule> <occurrence> <attempt>`);
421
+ const active = await session(runtime, args);
422
+ const view = parseResponse("attempts.read", await active.client.request("attempts_read", schedule, occurrence, attempt));
423
+ const found = view.attempt;
424
+ return {
425
+ data: { attempt: found, freshness: freshnessOf(view) },
426
+ human: [
427
+ `Attempt ${found.id}`,
428
+ `Number ${String(found.attemptNumber)}`,
429
+ ...(found.state === undefined ? [] : [`State ${found.state}`]),
430
+ `Trigger ${found.trigger.kind}${found.trigger.sourceAttemptId === undefined ? "" : ` of ${found.trigger.sourceAttemptId}`}`,
431
+ `Started ${found.startedAt}`,
432
+ ...(found.durationMs === undefined
433
+ ? []
434
+ : [`Took ${String(found.durationMs)}ms`]),
435
+ ...(found.responseStatus === undefined
436
+ ? [`Response none recorded`]
437
+ : [`Response ${String(found.responseStatus)}`]),
438
+ ...(found.diagnostic === undefined
439
+ ? []
440
+ : [`Diagnosis ${found.diagnostic}`]),
441
+ ...(found.remediation === undefined
442
+ ? []
443
+ : [`Remedy ${found.remediation}`]),
444
+ `Delivery ${found.delivery}`,
445
+ ].join("\n"),
446
+ ...(found.remediation === "review_before_redelivery"
447
+ ? {
448
+ nextMove: "This attempt was ambiguous: the receiver may have processed it even though Greatwait never saw an answer. Check the receiver before redelivering.",
449
+ }
450
+ : {}),
451
+ };
452
+ };
453
+ /* -------------------------------------------------------------------------- */
454
+ /* Planning */
455
+ /* -------------------------------------------------------------------------- */
456
+ /** The `--from` and `--to` window, normalised the way the contract spells it. */
457
+ function window(args) {
458
+ const from = args.options["--from"];
459
+ const to = args.options["--to"];
460
+ return {
461
+ ...(from === undefined ? {} : { from: parseInstant(from, "--from") }),
462
+ ...(to === undefined ? {} : { to: parseInstant(to, "--to") }),
463
+ };
464
+ }
465
+ const forecast = async (runtime, args) => {
466
+ const active = await session(runtime, args);
467
+ const bucket = args.options["--bucket"];
468
+ const breakdown = args.options["--breakdown"];
469
+ const view = parseResponse("forecast.read", await active.client.request("forecast_read", {
470
+ ...(breakdown === undefined ? {} : { breakdown }),
471
+ ...(bucket === undefined ? {} : { bucketSeconds: Number(bucket) }),
472
+ ...window(args),
473
+ }));
474
+ const total = view.buckets.reduce((sum, bucket_) => sum + bucket_.count, 0);
475
+ return {
476
+ data: view,
477
+ human: [
478
+ `Window ${view.window.from} to ${view.window.to}, in ${String(view.window.bucketSeconds)}s buckets`,
479
+ `Due ${String(total)}`,
480
+ ``,
481
+ table(["FROM", "DUE", "FIRST FIRE"], view.buckets.map((bucket_) => [
482
+ bucket_.start,
483
+ String(bucket_.count),
484
+ String(bucket_.nextFireCount),
485
+ ]), "Nothing is due in this window."),
486
+ ].join("\n"),
487
+ // **The forecast is always a projection**, so its age is not an aside: a
488
+ // plan made from a number ninety seconds old is a different plan from one
489
+ // made from a number ninety minutes old, and only one of those is visible
490
+ // without this line.
491
+ ...(freshnessNote(view.freshness) === undefined
492
+ ? {}
493
+ : { nextMove: freshnessNote(view.freshness) }),
494
+ };
495
+ };
496
+ const usage = async (runtime, args) => {
497
+ const active = await session(runtime, args);
498
+ const view = parseResponse("usage.read", await active.client.request("usage_read", window(args)));
499
+ return {
500
+ data: view,
501
+ human: [
502
+ `Window ${view.window.from} to ${view.window.to}`,
503
+ `Occurrences ${String(view.totalBillableOccurrences)}`,
504
+ ``,
505
+ table(["DAY", "TERMINAL", "BY OUTCOME"], view.days.map((day) => [
506
+ day.start,
507
+ String(day.billableOccurrences),
508
+ Object.entries(day.byTerminalState)
509
+ .map(([state, count]) => `${state} ${String(count)}`)
510
+ .join(", "),
511
+ ]), "No terminal occurrences in this window."),
512
+ ].join("\n"),
513
+ ...(freshnessNote(view.freshness) === undefined
514
+ ? {}
515
+ : { nextMove: freshnessNote(view.freshness) }),
516
+ };
517
+ };
518
+ /** Exact fixed-scale rendering without converting ledger money to Number. */
519
+ function money(amount) {
520
+ const value = BigInt(amount.microdollars);
521
+ const negative = value < 0n;
522
+ const absolute = negative ? -value : value;
523
+ const divisor = 10n ** BigInt(amount.scale);
524
+ return `${negative ? "-" : ""}$${String(absolute / divisor)}.${String(absolute % divisor).padStart(amount.scale, "0")}`;
525
+ }
526
+ const billing = async (runtime, args) => {
527
+ const active = await session(runtime, args);
528
+ const view = parseResponse("billing.summary", await active.client.request("billing_summary"));
529
+ return {
530
+ data: view,
531
+ human: [
532
+ `Available ${money(view.credit.available)}`,
533
+ `Reserved ${money(view.credit.reserved)}`,
534
+ `Consumed ${money(view.credit.consumed)}`,
535
+ `Enforcement ${view.activation.mode} from ${view.activation.boundary}`,
536
+ `Policy desired r${String(view.desiredRevision)}, effective r${String(view.effectiveRevision)}${view.pending ? " (pending)" : ""}, ${view.priceVersion}`,
537
+ `Budget policy desired r${String(view.budgetPolicy.desiredRevision)}, effective r${String(view.budgetPolicy.effectiveRevision)}${view.budgetPolicy.pending ? " (pending)" : ""}`,
538
+ `Desired advisory ${view.budgetPolicy.desired.advisoryBudget === null ? "off" : money(view.budgetPolicy.desired.advisoryBudget)}`,
539
+ `Desired hard cap ${view.budgetPolicy.desired.hardCap === null ? "off" : money(view.budgetPolicy.desired.hardCap)}`,
540
+ ...(view.period === undefined
541
+ ? []
542
+ : [
543
+ `Period ${view.period.start} to ${view.period.end}`,
544
+ `Period used ${money(view.period.settledUsage)}`,
545
+ `In flight ${money(view.period.reservedUsage)}`,
546
+ `Subscriptions ${money(view.period.subscribedProjectFees)}`,
547
+ ]),
548
+ ...(view.period?.hardCap === undefined
549
+ ? []
550
+ : [
551
+ `Period cap ${money(view.period.hardCap)}`,
552
+ `Cap remaining ${money(view.period.remainingToHardCap)}`,
553
+ ]),
554
+ ...(view.period?.advisoryBudgetProgress === undefined
555
+ ? []
556
+ : [
557
+ `Advisory ${money(view.period.advisoryBudget)}`,
558
+ `Advisory left ${money(view.period.advisoryBudgetProgress.remaining)}`,
559
+ ]),
560
+ ...(view.period === undefined
561
+ ? []
562
+ : [
563
+ `Auto top-up ${money(view.period.automaticExecutionTopUp.settled)} settled, ${money(view.period.automaticExecutionTopUp.pending)} pending`,
564
+ ...(view.period.automaticExecutionTopUp.cap === undefined
565
+ ? []
566
+ : [
567
+ `Auto cap ${money(view.period.automaticExecutionTopUp.cap)}`,
568
+ `Auto remaining ${money(view.period.automaticExecutionTopUp.remainingToCap)}`,
569
+ ]),
570
+ ]),
571
+ ...(view.trialExpiresAt === undefined
572
+ ? []
573
+ : [`Trial ends ${view.trialExpiresAt}`]),
574
+ ...(view.activation.mode === "shadow" || view.blockingReasons.length === 0
575
+ ? []
576
+ : [`Blocked ${view.blockingReasons.join(", ")}`]),
577
+ ].join("\n"),
578
+ ...(view.budgetPolicy.pending
579
+ ? {
580
+ nextMove: 'Open the live workspace Overview and choose "Apply pending policy", or use the billing.reconcile MCP tool.',
581
+ }
582
+ : {}),
583
+ };
584
+ };
585
+ function fundingResult(view) {
586
+ const automatic = view.automaticExecutionTopUp;
587
+ const rule = automatic.rule;
588
+ const configuration = rule.desired ?? rule.effective;
589
+ return {
590
+ data: view,
591
+ human: [
592
+ `Available ${money(view.credit.available)}`,
593
+ `Reserved ${money(view.credit.reserved)}`,
594
+ `Amount owed ${money(view.credit.amountOwed)}`,
595
+ `Subscriptions ${money(view.subscribedProjectFees)}`,
596
+ `Auto ceiling ${automatic.ceiling === null ? "off" : money(automatic.ceiling)} (revision ${String(automatic.revision)})`,
597
+ `UTC period ${automatic.currentPeriod.start} to ${automatic.currentPeriod.end}`,
598
+ `Auto settled ${money(automatic.currentPeriod.settled)}`,
599
+ `Auto pending ${money(automatic.currentPeriod.pending)}`,
600
+ `Auto remaining ${automatic.currentPeriod.remaining === null ? "off" : money(automatic.currentPeriod.remaining)}`,
601
+ `Auto rule ${rule.state}`,
602
+ ...(configuration === null
603
+ ? []
604
+ : [
605
+ `Auto trigger below ${money(configuration.triggerBelow)} usable credit`,
606
+ `Auto purchase ${money(configuration.credit)} fixed credit`,
607
+ ]),
608
+ ...(rule.suspension === null
609
+ ? []
610
+ : [
611
+ `Auto suspended ${rule.suspension.reason} since ${rule.suspension.at}`,
612
+ ]),
613
+ ...(rule.cooldownUntil === null
614
+ ? []
615
+ : [`Auto cooldown until ${rule.cooldownUntil}`]),
616
+ ...(rule.setupUrl === undefined
617
+ ? []
618
+ : [`Payment setup ${rule.setupUrl}`]),
619
+ ...view.paymentOperations.map((operation) => `${operation.id}: ${operation.kind} ${operation.state}, credit ${money(operation.credit)}${operation.gross === null ? ", tax and gross calculated at checkout" : `, gross ${money(operation.gross)} including tax ${money(operation.tax)}`}${operation.reason === undefined ? "" : ` (${operation.reason})`}${(operation.receiptUrl ?? operation.paymentUrl) ? ` ${operation.receiptUrl ?? operation.paymentUrl}` : ""}`),
620
+ ].join("\n"),
621
+ };
622
+ }
623
+ const billingFunding = async (runtime, args) => {
624
+ const active = await session(runtime, args);
625
+ return fundingResult(parseResponse("billing.funding", await active.client.request("billing_funding")));
626
+ };
627
+ const billingNotices = async (runtime, args) => {
628
+ const active = await session(runtime, args);
629
+ return listing(runtime, args, {
630
+ columns: [
631
+ "WHEN",
632
+ "SEVERITY",
633
+ "NOTICE",
634
+ "SCOPE",
635
+ "AMOUNTS",
636
+ "PAYMENT",
637
+ "EMAIL",
638
+ "RECOVERY",
639
+ ],
640
+ command: `${runtime.programName} billing notices`,
641
+ empty: "This organization has no billing notices.",
642
+ read: (request) => page("billing.notices", () => active.client.request("billing_notices", request)),
643
+ row: (notice) => [
644
+ notice.updatedAt,
645
+ notice.severity,
646
+ `${notice.title}: ${notice.detail}`,
647
+ notice.scope.kind === "organization"
648
+ ? "organization"
649
+ : `project ${notice.scope.project}`,
650
+ Object.entries(notice.amounts)
651
+ .flatMap(([name, amount]) => amount === undefined ? [] : [`${name} ${money(amount)}`])
652
+ .join("; "),
653
+ notice.payment === null
654
+ ? "none"
655
+ : `${notice.payment.operation}: ${notice.payment.state}`,
656
+ notice.email.state === "failed"
657
+ ? `failed (${notice.email.failureCode ?? "unknown"})`
658
+ : notice.email.state,
659
+ notice.recoveryPath,
660
+ ],
661
+ });
662
+ };
663
+ function notificationSettingsResult(settings) {
664
+ return {
665
+ data: settings,
666
+ human: [
667
+ `Low credit ${money(settings.lowCreditThreshold)}`,
668
+ `Noncritical email ${settings.noncriticalEmail ? "on" : "off"}`,
669
+ "Critical email mandatory",
670
+ "Recipients owners and active billing-role members",
671
+ `Revision ${String(settings.revision)}`,
672
+ ].join("\n"),
673
+ };
674
+ }
675
+ const billingNotificationSettings = async (runtime, args) => {
676
+ const active = await session(runtime, args);
677
+ return notificationSettingsResult(parseResponse("billing.notificationsettings", await active.client.request("billing_notificationsettings")));
678
+ };
679
+ const billingNotificationSettingsUpdate = async (runtime, args) => {
680
+ const noncritical = args.options["--noncritical-email"];
681
+ const parsed = billingNotificationSettingsUpdateRequestSchema.safeParse({
682
+ expectedRevision: expectedRevision(args),
683
+ lowCreditThreshold: budgetMoney(args, "--low-credit"),
684
+ noncriticalEmail: noncritical === "on" ? true : noncritical === "off" ? false : undefined,
685
+ });
686
+ if (!parsed.success) {
687
+ throw new CliError("--low-credit must be a positive exact-cent USD amount and --noncritical-email must be on or off", "usage");
688
+ }
689
+ const confirmation = await confirmChange(runtime, args, {
690
+ action: "Replace this organization's billing notification settings.",
691
+ effect: [
692
+ `low-credit threshold ${money(parsed.data.lowCreditThreshold)}`,
693
+ `noncritical email ${parsed.data.noncriticalEmail ? "on" : "off"}`,
694
+ "Critical email remains mandatory for owners and active billing-role members.",
695
+ ],
696
+ });
697
+ const active = await session(runtime, args);
698
+ const key = idempotencyKey(runtime, args);
699
+ runtime.stderr(`Retry key ${key}; retain this key if the response is lost.\n`);
700
+ const settings = parseResponse("billing.notificationsettingsupdate", await active.client.request("billing_notificationsettingsupdate", parsed.data, { "Idempotency-Key": key }));
701
+ const result = notificationSettingsResult(settings);
702
+ return {
703
+ ...result,
704
+ data: { confirmation, idempotencyKey: key, ...settings },
705
+ };
706
+ };
707
+ const billingQuoteFunding = async (runtime, args) => {
708
+ const parsed = billingFundingQuoteRequestSchema.safeParse({
709
+ credit: budgetMoney(args, "--credit"),
710
+ });
711
+ if (!parsed.success) {
712
+ throw new CliError("--credit must be at least USD 10, in exact cents", "usage");
713
+ }
714
+ const active = await session(runtime, args);
715
+ const quote = parseResponse("billing.fundingquote", await active.client.request("billing_fundingquote", parsed.data));
716
+ return {
717
+ data: quote,
718
+ human: [
719
+ `Execution credit ${money(quote.credit)}`,
720
+ "Tax and gross are calculated in authenticated hosted checkout.",
721
+ `Funding revision r${String(quote.expectedRevision)}; quote expires ${quote.expiresAt}`,
722
+ ].join("\n"),
723
+ nextMove: "Confirm this exact object with billing purchase --quote, retaining the quote and idempotency key for retries.",
724
+ };
725
+ };
726
+ const billingPurchase = async (runtime, args) => {
727
+ const parsed = billingFundingQuoteSchema.safeParse(await readJsonOption(runtime, "--quote", args.options["--quote"]));
728
+ if (!parsed.success) {
729
+ throw new CliError("--quote must contain the exact object returned by billing quote-funding", "usage");
730
+ }
731
+ const quote = parsed.data;
732
+ const confirmation = await confirmChange(runtime, args, {
733
+ action: "Create this manual execution-credit purchase.",
734
+ effect: [
735
+ `execution credit ${money(quote.credit)}`,
736
+ "Tax and gross will be shown in authenticated hosted checkout.",
737
+ "This manual purchase does not consume automatic payment authority, and a redirect never grants credit.",
738
+ ],
739
+ });
740
+ const active = await session(runtime, args);
741
+ const key = idempotencyKey(runtime, args);
742
+ runtime.stderr(`Retry key ${key}; retain this key and quote if the response is lost.\n`);
743
+ const view = parseResponse("billing.purchase", await active.client.request("billing_purchase", {
744
+ confirmed: true,
745
+ expectedRevision: quote.expectedRevision,
746
+ quote: quote.quote,
747
+ }, { "Idempotency-Key": key }));
748
+ const result = fundingResult(view);
749
+ return {
750
+ ...result,
751
+ data: { confirmation, idempotencyKey: key, ...view },
752
+ };
753
+ };
754
+ function expectedRevision(args) {
755
+ const text = args.options["--expected-revision"];
756
+ const revision = Number(text);
757
+ if (text === undefined || !Number.isSafeInteger(revision) || revision < 0) {
758
+ throw new CliError("--expected-revision must be a nonnegative whole number", "usage");
759
+ }
760
+ return revision;
761
+ }
762
+ const billingCeiling = async (runtime, args) => {
763
+ const parsed = billingCeilingUpdateRequestSchema.safeParse({
764
+ automaticExecutionTopUpCeiling: budgetMoney(args, "--ceiling"),
765
+ expectedRevision: expectedRevision(args),
766
+ });
767
+ if (!parsed.success) {
768
+ throw new CliError("--ceiling must be a nonnegative exact-cent USD amount, or off", "usage");
769
+ }
770
+ const ceiling = parsed.data.automaticExecutionTopUpCeiling;
771
+ const confirmation = await confirmChange(runtime, args, {
772
+ action: "Replace this organization's automatic payment ceiling.",
773
+ effect: [
774
+ `UTC-month ceiling ${ceiling === null ? "off" : money(ceiling)}`,
775
+ `expected revision ${String(parsed.data.expectedRevision)}`,
776
+ "Pending gross payments and tax count; manual purchases and subscriptions remain separate; refunds do not restore authority.",
777
+ ],
778
+ });
779
+ const active = await session(runtime, args);
780
+ const key = idempotencyKey(runtime, args);
781
+ runtime.stderr(`Retry key ${key}; retain this key if the response is lost.\n`);
782
+ const view = parseResponse("billing.ceiling", await active.client.request("billing_ceiling", parsed.data, {
783
+ "Idempotency-Key": key,
784
+ }));
785
+ const result = fundingResult(view);
786
+ return {
787
+ ...result,
788
+ data: { confirmation, idempotencyKey: key, ...view },
789
+ };
790
+ };
791
+ function automaticTopUpConfiguration(value) {
792
+ return value === null
793
+ ? "off"
794
+ : `${money(value.credit)} fixed credit below ${money(value.triggerBelow)}, ${money(value.ceiling)} UTC-month gross ceiling`;
795
+ }
796
+ const billingAutoTopUpPreview = async (runtime, args) => {
797
+ const action = args.options["--action"];
798
+ const expected = expectedRevision(args);
799
+ if ((action === "disable" || action === "resume") &&
800
+ ["--ceiling", "--credit", "--trigger-below"].some((flag) => args.options[flag] !== undefined)) {
801
+ throw new CliError(`${action} does not accept --credit, --trigger-below or --ceiling`, "usage");
802
+ }
803
+ const candidate = action === "enable" || action === "edit"
804
+ ? {
805
+ action,
806
+ ceiling: budgetMoney(args, "--ceiling"),
807
+ credit: budgetMoney(args, "--credit"),
808
+ expectedRevision: expected,
809
+ triggerBelow: budgetMoney(args, "--trigger-below"),
810
+ }
811
+ : { action, expectedRevision: expected };
812
+ const parsed = billingAutoTopUpPreviewRequestSchema.safeParse(candidate);
813
+ if (!parsed.success) {
814
+ throw new CliError("--action must be enable, edit, disable or resume; enable/edit require positive exact-cent --credit, --trigger-below and --ceiling values", "usage");
815
+ }
816
+ const active = await session(runtime, args);
817
+ const preview = parseResponse("billing.autotopuppreview", await active.client.request("billing_autotopuppreview", parsed.data));
818
+ return {
819
+ data: preview,
820
+ human: [
821
+ `Action ${preview.action}`,
822
+ `Current rule ${automaticTopUpConfiguration(preview.before)}`,
823
+ `Proposed rule ${automaticTopUpConfiguration(preview.after)}`,
824
+ `Authority ${preview.expandsAuthority ? "expands" : "does not expand"}`,
825
+ `UTC period ${preview.currentPeriod.start} to ${preview.currentPeriod.end}`,
826
+ `Auto settled ${money(preview.currentPeriod.settled)}`,
827
+ `Auto pending ${money(preview.currentPeriod.pending)}`,
828
+ `Subscriptions ${money(preview.subscribedProjectFees)} separately authorized`,
829
+ `Expires ${preview.expiresAt}`,
830
+ ].join("\n"),
831
+ nextMove: "Confirm this exact object with billing apply-topup --preview. Applying the rule does not create a payment; retain the preview and retry key if the response is lost.",
832
+ };
833
+ };
834
+ const billingAutoTopUpApply = async (runtime, args) => {
835
+ const parsed = billingAutoTopUpPreviewSchema.safeParse(await readJsonOption(runtime, "--preview", args.options["--preview"]));
836
+ if (!parsed.success) {
837
+ throw new CliError("--preview must contain the exact object returned by billing preview-topup", "usage");
838
+ }
839
+ const preview = parsed.data;
840
+ const request = billingAutoTopUpApplyRequestSchema.parse({
841
+ confirmed: true,
842
+ expectedRevision: preview.expectedRevision,
843
+ preview: preview.preview,
844
+ });
845
+ const confirmation = await confirmChange(runtime, args, {
846
+ action: `${preview.action[0]?.toUpperCase() ?? ""}${preview.action.slice(1)} this organization's automatic top-up rule.`,
847
+ effect: [
848
+ `current ${automaticTopUpConfiguration(preview.before)}`,
849
+ `proposed ${automaticTopUpConfiguration(preview.after)}`,
850
+ `${preview.expandsAuthority ? "This expands" : "This does not expand"} automatic payment authority.`,
851
+ "Tax and pending gross payments count against the UTC-month ceiling; subscriptions and manual purchases remain separate.",
852
+ preview.action === "disable"
853
+ ? "Queued unsubmitted automatic work is fenced; submitted payments remain visible until reconciled."
854
+ : "Applying this rule creates no payment. A later low-balance trigger remains single-flight and cooldown bounded.",
855
+ ],
856
+ });
857
+ const active = await session(runtime, args);
858
+ const key = idempotencyKey(runtime, args);
859
+ runtime.stderr(`Retry key ${key}; retain this key and preview if the response is lost.\n`);
860
+ const view = parseResponse("billing.autotopup", await active.client.request("billing_autotopup", request, {
861
+ "Idempotency-Key": key,
862
+ }));
863
+ const result = fundingResult(view);
864
+ return {
865
+ ...result,
866
+ data: { confirmation, idempotencyKey: key, ...view },
867
+ };
868
+ };
869
+ const billingRefundRequest = async (runtime, args) => {
870
+ const parsed = billingRefundRequestCreateSchema.safeParse({
871
+ amount: budgetMoney(args, "--amount"),
872
+ confirmed: true,
873
+ paymentOperation: args.options["--payment-operation"],
874
+ reason: args.options["--reason"],
875
+ });
876
+ if (!parsed.success) {
877
+ throw new CliError("Provide a positive exact-cent --amount, --payment-operation and --reason", "usage");
878
+ }
879
+ const confirmation = await confirmChange(runtime, args, {
880
+ action: "Record this unused-credit refund request for operator review.",
881
+ effect: [
882
+ `amount ${money(parsed.data.amount)}`,
883
+ `payment ${parsed.data.paymentOperation}`,
884
+ `reason ${parsed.data.reason}`,
885
+ "This records a request only. It does not submit a provider refund or cash out credit.",
886
+ ],
887
+ });
888
+ const active = await session(runtime, args);
889
+ const key = idempotencyKey(runtime, args);
890
+ const view = parseResponse("billing.refundrequest", await active.client.request("billing_refundrequest", parsed.data, {
891
+ "Idempotency-Key": key,
892
+ }));
893
+ return {
894
+ data: { confirmation, idempotencyKey: key, ...view },
895
+ human: [
896
+ `Request ${view.id}: ${view.state}`,
897
+ `Payment ${view.paymentOperation}; amount ${money(view.amount)}`,
898
+ `Requested ${view.requestedAt}`,
899
+ `Retry key ${key}`,
900
+ ].join("\n"),
901
+ nextMove: "An operator must review this request before any provider refund.",
902
+ };
903
+ };
904
+ const billingRefundResolution = async (runtime, args) => {
905
+ const refundRequest = args.options["--refund-request"];
906
+ if (refundRequest === undefined ||
907
+ refundRequest.length === 0 ||
908
+ refundRequest.length > 255) {
909
+ throw new CliError("Provide a bounded --refund-request", "usage");
910
+ }
911
+ const decision = args.options["--decision"];
912
+ const hasProviderFields = ["--provider-refund", "--gross", "--tax"].some((option) => args.options[option] !== undefined);
913
+ if (decision === "declined" && hasProviderFields) {
914
+ throw new CliError("A declined decision does not accept --provider-refund, --gross or --tax", "usage");
915
+ }
916
+ const parsed = billingRefundResolutionRequestSchema.safeParse(decision === "completed"
917
+ ? {
918
+ confirmed: true,
919
+ decision,
920
+ gross: budgetMoney(args, "--gross"),
921
+ providerRefund: args.options["--provider-refund"],
922
+ tax: budgetMoney(args, "--tax"),
923
+ }
924
+ : { confirmed: true, decision });
925
+ if (!parsed.success) {
926
+ throw new CliError("Use --decision completed with an existing --provider-refund and positive exact-cent --gross plus exact-cent --tax, or use --decision declined alone", "usage");
927
+ }
928
+ const confirmation = await confirmChange(runtime, args, {
929
+ action: `Record this refund request as ${parsed.data.decision}.`,
930
+ effect: parsed.data.decision === "completed"
931
+ ? [
932
+ `request ${refundRequest}`,
933
+ `existing Stripe refund ${parsed.data.providerRefund ?? ""}`,
934
+ `gross ${money(parsed.data.gross)}, including tax ${money(parsed.data.tax)}`,
935
+ "This verifies and records an existing provider refund. It never creates one.",
936
+ ]
937
+ : [
938
+ `request ${refundRequest}`,
939
+ "Declining releases the reserved execution credit and does not contact the provider.",
940
+ ],
941
+ });
942
+ const active = await session(runtime, args);
943
+ const key = idempotencyKey(runtime, args);
944
+ runtime.stderr(`Retry key ${key}; retain this key if the response is lost.\n`);
945
+ const view = parseResponse("billing.refundresolution", await active.client.request("billing_refundresolution", refundRequest, parsed.data, { "Idempotency-Key": key }));
946
+ const result = fundingResult(view);
947
+ return {
948
+ ...result,
949
+ data: { confirmation, idempotencyKey: key, ...view },
950
+ };
951
+ };
952
+ function projectBillingResult(view) {
953
+ return {
954
+ data: view,
955
+ human: [
956
+ `Monthly project fees ${money(view.recurringProjectFees)}`,
957
+ `Policy desired r${String(view.desiredRevision)}, effective r${String(view.effectiveRevision)}${view.pending ? " (pending)" : ""}`,
958
+ ...view.projects.map((project) => `${project.name} (${project.project}): ${project.access}${project.paidThroughAt ? `, paid through ${project.paidThroughAt}` : ""}${project.cancellationAt ? `, cancels ${project.cancellationAt}` : ""}${project.renewalGraceUntil ? `, renewal grace until ${project.renewalGraceUntil}` : ""}`),
959
+ ...view.pendingOperations.map((operation) => `${operation.project}: ${operation.state}${operation.reason ? ` (${operation.reason})` : ""}${operation.paymentUrl ? ` ${operation.paymentUrl}` : ""}`),
960
+ ].join("\n"),
961
+ };
962
+ }
963
+ const billingProjects = async (runtime, args) => {
964
+ const active = await session(runtime, args);
965
+ return projectBillingResult(parseResponse("billing.projects", await active.client.request("billing_projects")));
966
+ };
967
+ function projectQuoteLines(quote) {
968
+ return [
969
+ `Project ${quote.project}${quote.fromProject ? `; former included ${quote.fromProject}` : ""}`,
970
+ `Charge ${money(quote.charge)}; monthly project fees ${money(quote.monthlyProjectFees)}`,
971
+ `Effective ${quote.effectiveAt}; quote expires ${quote.expiresAt}`,
972
+ ...(quote.formerIncludedWillBlock
973
+ ? [
974
+ "The former included project will lose included access; this does not subscribe it.",
975
+ ]
976
+ : []),
977
+ ...(quote.action === "cancel"
978
+ ? ["Paid-through access remains; there is no partial-period credit."]
979
+ : []),
980
+ "Project access changes only after payment and effective policy reconciliation where required.",
981
+ ];
982
+ }
983
+ const billingQuoteProject = async (runtime, args) => {
984
+ const parsed = projectBillingQuoteRequestSchema.safeParse({
985
+ action: args.options["--action"],
986
+ project: args.options["--target-project"],
987
+ ...(args.options["--from-project"]
988
+ ? { fromProject: args.options["--from-project"] }
989
+ : {}),
990
+ });
991
+ if (!parsed.success)
992
+ throw new CliError("Provide --action and a valid --target-project (and --from-project for an included transfer).", "usage");
993
+ const active = await session(runtime, args);
994
+ const quote = parseResponse("billing.quote", await active.client.request("billing_quote", parsed.data));
995
+ return {
996
+ data: quote,
997
+ human: [`Preview ${quote.action}`, ...projectQuoteLines(quote)].join("\n"),
998
+ nextMove: "Confirm the exact quote with billing change-project --quote, retaining the quote and idempotency key for retries.",
999
+ };
1000
+ };
1001
+ const billingChangeProject = async (runtime, args) => {
1002
+ const parsed = projectBillingQuoteSchema.safeParse(await readJsonOption(runtime, "--quote", args.options["--quote"]));
1003
+ if (!parsed.success)
1004
+ throw new CliError("--quote must contain the exact quote object returned by billing quote-project.", "usage");
1005
+ const quote = parsed.data;
1006
+ const confirmation = await confirmChange(runtime, args, {
1007
+ action: `Confirm project billing ${quote.action}`,
1008
+ effect: projectQuoteLines(quote),
1009
+ });
1010
+ const active = await session(runtime, args);
1011
+ const key = idempotencyKey(runtime, args);
1012
+ runtime.stderr(`Retry key ${key}; retain this key and quote if the response is lost.\n`);
1013
+ const view = parseResponse("billing.change", await active.client.request("billing_change", {
1014
+ quote: quote.quote,
1015
+ expectedRevision: quote.expectedRevision,
1016
+ confirmed: true,
1017
+ }, { "Idempotency-Key": key }));
1018
+ return {
1019
+ ...projectBillingResult(view),
1020
+ data: { confirmation, idempotencyKey: key, ...view },
1021
+ };
1022
+ };
1023
+ const billingPortal = async (runtime, args) => {
1024
+ await confirmChange(runtime, args, {
1025
+ action: "Create this organization's hosted billing recovery link.",
1026
+ effect: [
1027
+ "Manage payment methods and invoices. This does not purchase credit or grant project access.",
1028
+ ],
1029
+ });
1030
+ const active = await session(runtime, args);
1031
+ const view = parseResponse("billing.portal", await active.client.request("billing_portal"));
1032
+ return { data: view, human: view.url };
1033
+ };
1034
+ function budgetMoney(args, flag) {
1035
+ const raw = args.options[flag];
1036
+ if (raw === undefined) {
1037
+ throw new CliError(`${flag} is required`, "usage");
1038
+ }
1039
+ if (raw === "off")
1040
+ return null;
1041
+ const match = /^(?<whole>0|[1-9]\d*)(?:\.(?<cents>\d{1,2}))?$/u.exec(raw);
1042
+ if (match?.groups === undefined) {
1043
+ throw new CliError(`${flag} must be a nonnegative USD amount with at most two decimal places, or off`, "usage");
1044
+ }
1045
+ const cents = BigInt(match.groups.whole) * 100n +
1046
+ BigInt((match.groups.cents ?? "").padEnd(2, "0"));
1047
+ return { currency: "USD", microdollars: String(cents * 10000n), scale: 6 };
1048
+ }
1049
+ const billingBudgets = async (runtime, args) => {
1050
+ const revisionText = args.options["--expected-revision"];
1051
+ const expectedRevision = Number(revisionText);
1052
+ if (revisionText === undefined ||
1053
+ !Number.isSafeInteger(expectedRevision) ||
1054
+ expectedRevision < 0) {
1055
+ throw new CliError("--expected-revision must be a nonnegative whole number", "usage");
1056
+ }
1057
+ const advisoryBudget = budgetMoney(args, "--advisory-budget");
1058
+ const hardCap = budgetMoney(args, "--hard-cap");
1059
+ const confirmation = await confirmChange(runtime, args, {
1060
+ action: "Replace this organization's current UTC-month execution budgets.",
1061
+ effect: [
1062
+ `advisory ${advisoryBudget === null ? "off" : money(advisoryBudget)}`,
1063
+ `hard cap ${hardCap === null ? "off" : money(hardCap)}`,
1064
+ `expected revision ${String(expectedRevision)}`,
1065
+ "This does not change automatic top-up authority or purchase credit.",
1066
+ ],
1067
+ });
1068
+ const active = await session(runtime, args);
1069
+ const key = idempotencyKey(runtime, args);
1070
+ const view = parseResponse("billing.budgets", await active.client.request("billing_budgets", { advisoryBudget, expectedRevision, hardCap }, { "Idempotency-Key": key }));
1071
+ return {
1072
+ data: { confirmation, idempotencyKey: key, ...view },
1073
+ human: [
1074
+ `Advisory ${view.budgetPolicy.desired.advisoryBudget === null ? "off" : money(view.budgetPolicy.desired.advisoryBudget)}`,
1075
+ `Hard cap ${view.budgetPolicy.desired.hardCap === null ? "off" : money(view.budgetPolicy.desired.hardCap)}`,
1076
+ `Policy desired r${String(view.budgetPolicy.desiredRevision)}, effective r${String(view.budgetPolicy.effectiveRevision)}${view.budgetPolicy.pending ? " (pending)" : ""}`,
1077
+ `Retry key ${key}`,
1078
+ ].join("\n"),
1079
+ ...(view.budgetPolicy.pending
1080
+ ? {
1081
+ nextMove: "The desired budget is saved and awaits billing reconciliation.",
1082
+ }
1083
+ : {}),
1084
+ };
1085
+ };
1086
+ const billingLedger = async (runtime, args) => {
1087
+ const active = await session(runtime, args);
1088
+ return listing(runtime, args, {
1089
+ columns: ["SEQUENCE", "RECORDED", "KIND", "AMOUNT", "REFERENCE"],
1090
+ command: `${runtime.programName} billing ledger`,
1091
+ empty: "This organization has no billing entries.",
1092
+ read: (request) => page("billing.ledger", () => active.client.request("billing_ledger", request)),
1093
+ row: (entry) => [
1094
+ entry.sequence,
1095
+ entry.recordedAt,
1096
+ entry.kind,
1097
+ money(entry.amount),
1098
+ entry.reference.attempt ??
1099
+ entry.reference.creditLot ??
1100
+ entry.reference.paymentOperation ??
1101
+ entry.reference.event,
1102
+ ],
1103
+ });
1104
+ };
1105
+ const timing = async (runtime, args) => {
1106
+ const active = await session(runtime, args);
1107
+ const view = parseResponse("timing.summary", await active.client.request("timing_summary"));
1108
+ return {
1109
+ data: view,
1110
+ human: [
1111
+ `Window ${view.windowStart} to ${view.windowEnd}`,
1112
+ `Policy ${view.policyVersion}`,
1113
+ `Delivered ${String(view.deliveredSampleCount)} samples`,
1114
+ `Excluded ${String(view.customerBlockedCount)} customer blocked, ${String(view.platformBlockedCount)} platform blocked, ${String(view.skippedCount)} skipped, ${String(view.missingEvidenceCount)} missing evidence`,
1115
+ ...(view.p95LatenessMs === undefined
1116
+ ? []
1117
+ : [`Delivered p95 ${String(view.p95LatenessMs)}ms`]),
1118
+ ...(view.p99LatenessMs === undefined
1119
+ ? []
1120
+ : [`Delivered p99 ${String(view.p99LatenessMs)}ms`]),
1121
+ ``,
1122
+ table(["TARGET", "VERDICT", "WITHIN", "BREACHES", "OUTSTANDING"], view.targets.map((target) => [
1123
+ `${String(target.targetMs)}ms`,
1124
+ target.verdict,
1125
+ `${String(target.withinTargetCount)}/${String(target.maturedCount)}`,
1126
+ String(target.breachCount),
1127
+ String(target.eligibleOutstandingBreachCount),
1128
+ ]), "No timing targets were reported."),
1129
+ ].join("\n"),
1130
+ };
1131
+ };
1132
+ /* -------------------------------------------------------------------------- */
1133
+ /* Credentials */
1134
+ /* -------------------------------------------------------------------------- */
1135
+ const credentialsList = async (runtime, args) => {
1136
+ const active = await session(runtime, args);
1137
+ return listing(runtime, args, {
1138
+ columns: ["PREFIX", "CREDENTIAL", "SCOPES", "LAST USED"],
1139
+ command: `${runtime.programName} credentials list`,
1140
+ empty: "This project has no API credentials.",
1141
+ read: (request) => page("credentials.list", () => active.client.request("credentials_list", request)),
1142
+ // **The prefix and nothing else.** It is what the service publishes as the
1143
+ // way to recognise a credential, and it is the whole of what this listing
1144
+ // can honestly show: the secret half exists only in the answer to the call
1145
+ // that minted it.
1146
+ row: (credential) => [
1147
+ credential.displayPrefix,
1148
+ credential.id,
1149
+ credential.scopes.join(" "),
1150
+ credential.lastUsedAt ?? "never",
1151
+ ],
1152
+ });
1153
+ };
1154
+ const credentialsRotate = async (runtime, args) => {
1155
+ const [id] = positionals(args, ["<credential>"], `${runtime.programName} credentials rotate <credential>`);
1156
+ const active = await session(runtime, args);
1157
+ const confirmation = await confirmChange(runtime, args, {
1158
+ action: `Rotate credential ${id}.`,
1159
+ effect: [
1160
+ `A replacement is minted and its secret is shown once, here.`,
1161
+ `The current credential keeps working until the overlap ends, then stops.`,
1162
+ `Anything still presenting the old secret after that is refused.`,
1163
+ ],
1164
+ });
1165
+ const view = parseResponse("credentials.rotate", await active.client.request("credentials_rotate", id, {}));
1166
+ const secret = view.credential.secret;
1167
+ return {
1168
+ data: { confirmation, credential: view.credential, retired: view.retired },
1169
+ human: [
1170
+ `Credential ${view.credential.id}`,
1171
+ `Prefix ${view.credential.displayPrefix}`,
1172
+ `Scopes ${view.credential.scopes.join(", ")}`,
1173
+ `Replaces ${view.retired.id}, refused after ${view.retired.expiresAt ?? "the overlap ends"}`,
1174
+ ...(secret === undefined
1175
+ ? [
1176
+ ``,
1177
+ `The service did not return a secret with this rotation, so there is`,
1178
+ `nothing to copy. Read the credential list to see what it did do.`,
1179
+ ]
1180
+ : [
1181
+ ``,
1182
+ `Secret ${secret}`,
1183
+ ``,
1184
+ `This is shown once and is not recoverable. Put it where the old one`,
1185
+ `was before the overlap ends; listing credentials will show`,
1186
+ `${redactSecret(secret)} instead.`,
1187
+ ]),
1188
+ ].join("\n"),
1189
+ };
1190
+ };
1191
+ const credentialsRevoke = async (runtime, args) => {
1192
+ const [id] = positionals(args, ["<credential>"], `${runtime.programName} credentials revoke <credential>`);
1193
+ const active = await session(runtime, args);
1194
+ const confirmation = await confirmChange(runtime, args, {
1195
+ action: `Revoke credential ${id}.`,
1196
+ effect: [
1197
+ `It stops working immediately and everywhere, with no overlap.`,
1198
+ `Anything still using it starts failing to authenticate at once.`,
1199
+ `Rotate instead if something is still presenting it.`,
1200
+ ],
1201
+ });
1202
+ const view = parseResponse("credentials.revoke", await active.client.request("credentials_revoke", id));
1203
+ return {
1204
+ data: { confirmation, credential: view.credential },
1205
+ human: [
1206
+ `Credential ${view.credential.id}`,
1207
+ `Prefix ${view.credential.displayPrefix}`,
1208
+ `Revoked ${view.credential.revokedAt ?? "now"}`,
1209
+ ].join("\n"),
1210
+ };
1211
+ };
1212
+ /* -------------------------------------------------------------------------- */
1213
+ /* Test sinks */
1214
+ /* -------------------------------------------------------------------------- */
1215
+ function sinkBlock(sink) {
1216
+ return [
1217
+ `Sink ${sink.id}`,
1218
+ `Destination ${sink.endpoint}`,
1219
+ `URL ${sink.url}`,
1220
+ `Answering ${sink.program.behavior}`,
1221
+ `Received ${String(sink.deliveryCount)}`,
1222
+ ];
1223
+ }
1224
+ const sinksCreate = async (runtime, args) => {
1225
+ const endpoint = args.options["--endpoint"];
1226
+ if (endpoint === undefined) {
1227
+ throw new CliError(`${runtime.programName} sinks create needs --endpoint`, "usage", `Usage: ${runtime.programName} sinks create --endpoint <destination>`);
1228
+ }
1229
+ const active = await session(runtime, args);
1230
+ const program = await readJsonOption(runtime, "--program", args.options["--program"]);
1231
+ const sink = parseResponse("testsinks.create", await active.client.request("testsinks_create", {
1232
+ endpoint,
1233
+ ...(program === undefined ? {} : { program }),
1234
+ }));
1235
+ return {
1236
+ data: { sink },
1237
+ human: sinkBlock(sink).join("\n"),
1238
+ nextMove: `Point a schedule at that destination, then read what arrived: ${runtime.programName} sinks deliveries ${sink.id}`,
1239
+ };
1240
+ };
1241
+ const sinksList = async (runtime, args) => {
1242
+ const active = await session(runtime, args);
1243
+ return listing(runtime, args, {
1244
+ columns: ["ANSWERING", "SINK", "DESTINATION", "RECEIVED"],
1245
+ command: `${runtime.programName} sinks list`,
1246
+ empty: "This project has no test sinks.",
1247
+ read: (request) => page("testsinks.list", () => active.client.request("testsinks_list", request)),
1248
+ row: (sink) => [
1249
+ sink.program.behavior,
1250
+ sink.id,
1251
+ sink.endpoint,
1252
+ String(sink.deliveryCount),
1253
+ ],
1254
+ });
1255
+ };
1256
+ const sinksRead = async (runtime, args) => {
1257
+ const [id] = positionals(args, ["<sink>"], `${runtime.programName} sinks read <sink>`);
1258
+ const active = await session(runtime, args);
1259
+ const sink = parseResponse("testsinks.read", await active.client.request("testsinks_read", id));
1260
+ return { data: { sink }, human: sinkBlock(sink).join("\n") };
1261
+ };
1262
+ const sinksConfigure = async (runtime, args) => {
1263
+ const [id] = positionals(args, ["<sink>"], `${runtime.programName} sinks configure <sink> --program <json>`);
1264
+ const program = await readJsonOption(runtime, "--program", args.options["--program"]);
1265
+ if (program === undefined) {
1266
+ throw new CliError(`${runtime.programName} sinks configure needs --program`, "usage", `Usage: ${runtime.programName} sinks configure <sink> --program '{"behavior":"timeout"}'`);
1267
+ }
1268
+ const active = await session(runtime, args);
1269
+ const sink = parseResponse("testsinks.configure", await active.client.request("testsinks_configure", id, program));
1270
+ return {
1271
+ data: { sink },
1272
+ human: sinkBlock(sink).join("\n"),
1273
+ nextMove: "Deliveries from now on meet this behaviour. The ones already recorded are unchanged.",
1274
+ };
1275
+ };
1276
+ const sinksDelete = async (runtime, args) => {
1277
+ const [id] = positionals(args, ["<sink>"], `${runtime.programName} sinks delete <sink>`);
1278
+ const active = await session(runtime, args);
1279
+ const current = parseResponse("testsinks.read", await active.client.request("testsinks_read", id));
1280
+ const confirmation = await confirmChange(runtime, args, {
1281
+ action: `Delete test sink ${current.id}.`,
1282
+ effect: [
1283
+ `received ${String(current.deliveryCount)} deliveries, which go with it`,
1284
+ `The recorded bodies are the only copy Greatwait keeps. Read anything you`,
1285
+ `still need first.`,
1286
+ ],
1287
+ });
1288
+ const sink = parseResponse("testsinks.delete", await active.client.request("testsinks_delete", id));
1289
+ return {
1290
+ data: { confirmation, sink },
1291
+ human: `Test sink ${sink.id} is gone, with the ${String(current.deliveryCount)} deliveries recorded against it.`,
1292
+ };
1293
+ };
1294
+ const sinksDeliveries = async (runtime, args) => {
1295
+ const [id] = positionals(args, ["<sink>"], `${runtime.programName} sinks deliveries <sink>`);
1296
+ const active = await session(runtime, args);
1297
+ return listing(runtime, args, {
1298
+ columns: ["ANSWERED", "DISPOSITION", "DELIVERY", "RECEIVED"],
1299
+ command: `${runtime.programName} sinks deliveries ${id}`,
1300
+ empty: "Nothing has arrived at this sink.",
1301
+ read: (request) => page("sinkdeliveries.list", () => active.client.request("sinkdeliveries_list", id, request)),
1302
+ row: (delivery) => [
1303
+ delivery.respondedWith === undefined
1304
+ ? "none"
1305
+ : String(delivery.respondedWith),
1306
+ delivery.disposition,
1307
+ delivery.id,
1308
+ delivery.receivedAt,
1309
+ ],
1310
+ });
1311
+ };
1312
+ const sinksDelivery = async (runtime, args) => {
1313
+ const [id, delivery] = positionals(args, ["<sink>", "<delivery>"], `${runtime.programName} sinks delivery <sink> <delivery>`);
1314
+ const active = await session(runtime, args);
1315
+ const record = parseResponse("sinkdeliveries.read", await active.client.request("sinkdeliveries_read", id, delivery));
1316
+ return {
1317
+ data: { delivery: record },
1318
+ human: [
1319
+ `Delivery ${record.id}`,
1320
+ `Received ${record.receivedAt}`,
1321
+ `Answered ${record.respondedWith === undefined ? "nothing" : String(record.respondedWith)}`,
1322
+ `Disposition ${record.disposition}`,
1323
+ ...(record.occurrenceId === undefined
1324
+ ? []
1325
+ : [`Occurrence ${record.occurrenceId}`]),
1326
+ ...(record.signature === undefined
1327
+ ? []
1328
+ : [
1329
+ `Signature ${record.signature}${record.keyId === undefined
1330
+ ? " — signed with no key this sink accepts"
1331
+ : ` (key ${record.keyId})`}`,
1332
+ ]),
1333
+ ...(record.body === undefined
1334
+ ? []
1335
+ : [
1336
+ ``,
1337
+ `Body${record.bodyTruncated === true ? ` (truncated; ${String(record.receivedBytes ?? 0)} bytes arrived)` : ""}`,
1338
+ record.body,
1339
+ ]),
1340
+ ].join("\n"),
1341
+ };
1342
+ };
1343
+ /* -------------------------------------------------------------------------- */
1344
+ /* Dispatch */
1345
+ /* -------------------------------------------------------------------------- */
1346
+ /** The operate-and-diagnose commands, keyed by the words that select them. */
1347
+ export const OPERATE_COMMANDS = {
1348
+ "attempts read": attemptsRead,
1349
+ billing,
1350
+ "billing budgets": billingBudgets,
1351
+ "billing apply-topup": billingAutoTopUpApply,
1352
+ "billing ceiling": billingCeiling,
1353
+ "billing funding": billingFunding,
1354
+ "billing notices": billingNotices,
1355
+ "billing notification-settings": billingNotificationSettings,
1356
+ "billing update-notifications": billingNotificationSettingsUpdate,
1357
+ "billing ledger": billingLedger,
1358
+ "billing projects": billingProjects,
1359
+ "billing purchase": billingPurchase,
1360
+ "billing quote-funding": billingQuoteFunding,
1361
+ "billing quote-project": billingQuoteProject,
1362
+ "billing preview-topup": billingAutoTopUpPreview,
1363
+ "billing refund-request": billingRefundRequest,
1364
+ "billing resolve-refund": billingRefundResolution,
1365
+ "billing change-project": billingChangeProject,
1366
+ "billing portal": billingPortal,
1367
+ timing,
1368
+ "credentials list": credentialsList,
1369
+ "credentials revoke": credentialsRevoke,
1370
+ "credentials rotate": credentialsRotate,
1371
+ "destinations list": destinationsList,
1372
+ "destinations read": destinationsRead,
1373
+ "destinations rotate-key": destinationsRotateKey,
1374
+ "destinations update": destinationsUpdate,
1375
+ forecast,
1376
+ "occurrences list": occurrencesList,
1377
+ "occurrences read": occurrencesRead,
1378
+ "occurrences redeliver": occurrencesRedeliver,
1379
+ "schedules archive": schedulesArchive,
1380
+ "schedules cancel": schedulesCancel,
1381
+ "schedules list": schedulesList,
1382
+ "schedules pause": schedulesPause,
1383
+ "schedules replace": schedulesReplace,
1384
+ "schedules resume": schedulesResume,
1385
+ "sinks configure": sinksConfigure,
1386
+ "sinks create": sinksCreate,
1387
+ "sinks deliveries": sinksDeliveries,
1388
+ "sinks delivery": sinksDelivery,
1389
+ "sinks delete": sinksDelete,
1390
+ "sinks list": sinksList,
1391
+ "sinks read": sinksRead,
1392
+ usage,
1393
+ };
1394
+ export { scheduleRequest };