@greatwait/cli 0.1.0-alpha.6 → 0.1.0-alpha.8
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/catalog.js +89 -1
- package/dist/commands.js +10 -0
- package/dist/operate.js +145 -1
- package/package.json +3 -3
package/dist/catalog.js
CHANGED
|
@@ -255,6 +255,77 @@ export const MEMBER_COMMANDS = [
|
|
|
255
255
|
},
|
|
256
256
|
];
|
|
257
257
|
export const COMMAND_CATALOG = [
|
|
258
|
+
{
|
|
259
|
+
group: "Planning",
|
|
260
|
+
words: "notifications show",
|
|
261
|
+
summary: "Show this project and environment's delivery notification settings.",
|
|
262
|
+
operations: ["projectnotifications.read"],
|
|
263
|
+
positionals: [
|
|
264
|
+
{
|
|
265
|
+
name: "<project>",
|
|
266
|
+
summary: "The project identifier in this credential's authority.",
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
options: [API_URL],
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
group: "Planning",
|
|
273
|
+
words: "notifications update",
|
|
274
|
+
summary: "Change failure email and failed-event webhook preferences at the revision you read.",
|
|
275
|
+
confirms: true,
|
|
276
|
+
operations: ["projectnotifications.update"],
|
|
277
|
+
positionals: [
|
|
278
|
+
{
|
|
279
|
+
name: "<project>",
|
|
280
|
+
summary: "The project identifier in this credential's authority.",
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
options: [
|
|
284
|
+
API_URL,
|
|
285
|
+
{
|
|
286
|
+
flag: "--settings",
|
|
287
|
+
value: "<json|@file|->",
|
|
288
|
+
summary: "JSON expectedRevision and email/statusWebhook settings; null statusWebhook disables it. No signing secret is accepted.",
|
|
289
|
+
},
|
|
290
|
+
IDEMPOTENCY,
|
|
291
|
+
CONFIRM,
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
group: "Planning",
|
|
296
|
+
words: "notifications signing-key",
|
|
297
|
+
summary: "Mint or rotate a failed-event webhook signing secret, shown once.",
|
|
298
|
+
confirms: true,
|
|
299
|
+
operations: ["projectnotifications.rotateSigningKey"],
|
|
300
|
+
positionals: [
|
|
301
|
+
{
|
|
302
|
+
name: "<project>",
|
|
303
|
+
summary: "The project identifier in this credential's authority.",
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
options: [
|
|
307
|
+
API_URL,
|
|
308
|
+
{
|
|
309
|
+
flag: "--expected-revision",
|
|
310
|
+
value: "<revision>",
|
|
311
|
+
summary: "The current revision from notifications show; a stale retry is refused.",
|
|
312
|
+
},
|
|
313
|
+
CONFIRM,
|
|
314
|
+
],
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
group: "Planning",
|
|
318
|
+
words: "notifications deliveries",
|
|
319
|
+
summary: "List failed-event webhook deliveries and retries.",
|
|
320
|
+
operations: ["statusdeliveries.list"],
|
|
321
|
+
positionals: [
|
|
322
|
+
{
|
|
323
|
+
name: "<project>",
|
|
324
|
+
summary: "The project identifier in this credential's authority.",
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
options: [API_URL, ...PAGE_OPTIONS],
|
|
328
|
+
},
|
|
258
329
|
/* --- Session ---------------------------------------------------------- */
|
|
259
330
|
{
|
|
260
331
|
group: "Session",
|
|
@@ -529,6 +600,11 @@ export const COMMAND_CATALOG = [
|
|
|
529
600
|
summary: "Your own label, such as a customer id. Each label is paced on its own, so one busy label can't hold up the rest. Can't be changed later.",
|
|
530
601
|
value: "<key>",
|
|
531
602
|
},
|
|
603
|
+
{
|
|
604
|
+
flag: "--auto-pause-after-failures",
|
|
605
|
+
summary: 'Pause a recurring schedule after 1–100 consecutive failed runs, or "never". Defaults to 5 on create; omission on replace keeps the setting. Resume resets the count.',
|
|
606
|
+
value: "<count|never>",
|
|
607
|
+
},
|
|
532
608
|
{
|
|
533
609
|
flag: "--latency-class",
|
|
534
610
|
summary: "realtime or bulk. realtime goes first when both are due. By default a call due within two minutes is realtime, and a repeating one is bulk.",
|
|
@@ -605,6 +681,11 @@ export const COMMAND_CATALOG = [
|
|
|
605
681
|
summary: "Your own label, such as a customer id. Each label is paced on its own, so one busy label can't hold up the rest. Can't be changed later.",
|
|
606
682
|
value: "<key>",
|
|
607
683
|
},
|
|
684
|
+
{
|
|
685
|
+
flag: "--auto-pause-after-failures",
|
|
686
|
+
summary: 'Pause a recurring schedule after 1–100 consecutive failed runs, or "never". Defaults to 5 on create; omission on replace keeps the setting. Resume resets the count.',
|
|
687
|
+
value: "<count|never>",
|
|
688
|
+
},
|
|
608
689
|
{
|
|
609
690
|
flag: "--latency-class",
|
|
610
691
|
summary: "realtime or bulk. realtime goes first when both are due. By default a call due within two minutes is realtime, and a repeating one is bulk.",
|
|
@@ -834,7 +915,14 @@ export const COMMAND_CATALOG = [
|
|
|
834
915
|
{
|
|
835
916
|
group: "Occurrences and attempts",
|
|
836
917
|
operations: ["attempts.read"],
|
|
837
|
-
options: [
|
|
918
|
+
options: [
|
|
919
|
+
API_URL,
|
|
920
|
+
{
|
|
921
|
+
flag: "--response-file",
|
|
922
|
+
value: "path",
|
|
923
|
+
summary: "Save the captured response body in a new private file.",
|
|
924
|
+
},
|
|
925
|
+
],
|
|
838
926
|
positionals: [
|
|
839
927
|
{
|
|
840
928
|
name: "<schedule>",
|
package/dist/commands.js
CHANGED
|
@@ -805,11 +805,21 @@ const schedulesRead = async (runtime, args) => {
|
|
|
805
805
|
recurrence: view.schedule.recurrence.kind,
|
|
806
806
|
schedule: view.schedule.id,
|
|
807
807
|
status: view.schedule.status,
|
|
808
|
+
autoPauseAfterFailures: view.schedule.autoPauseAfterFailures,
|
|
809
|
+
consecutiveFailedRuns: view.schedule.consecutiveFailedRuns,
|
|
810
|
+
autoPauseReason: view.schedule.autoPauseReason,
|
|
808
811
|
version: view.schedule.version,
|
|
809
812
|
},
|
|
810
813
|
human: [
|
|
811
814
|
`Schedule ${view.schedule.id}`,
|
|
812
815
|
`Status ${view.schedule.status}`,
|
|
816
|
+
`Auto-pause ${String(view.schedule.autoPauseAfterFailures ?? 5)}`,
|
|
817
|
+
`Failed runs ${String(view.schedule.consecutiveFailedRuns ?? 0)}`,
|
|
818
|
+
...(view.schedule.autoPauseReason == null
|
|
819
|
+
? []
|
|
820
|
+
: [
|
|
821
|
+
`Paused ${String(view.schedule.autoPauseReason.failedRuns)} failed runs; ${view.schedule.autoPauseReason.status === undefined ? "no HTTP response" : `HTTP ${String(view.schedule.autoPauseReason.status)}`} (${view.schedule.autoPauseReason.diagnostic}) at ${view.schedule.autoPauseReason.pausedAt}`,
|
|
822
|
+
]),
|
|
813
823
|
`Recurrence ${view.schedule.recurrence.kind}`,
|
|
814
824
|
`Version ${String(view.schedule.version)}`,
|
|
815
825
|
...(etag === undefined ? [] : [`ETag ${etag}`]),
|
package/dist/operate.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { projectNotificationSettingsUpdateRequestSchema, statusSigningKeyRequestSchema, autoPauseAfterFailuresSchema, httpRequestSchema, sanitizedDiagnosticText, diagnosticBodyText, responseBodySentence, requestCaptureSentence, attemptStatusGuidance, endpointHeaderChangesSchema, billingAutoTopUpApplyRequestSchema, billingAutoTopUpPreviewRequestSchema, billingAutoTopUpPreviewSchema, billingCeilingUpdateRequestSchema, billingFundingQuoteRequestSchema, billingFundingQuoteSchema, billingNotificationSettingsUpdateRequestSchema, billingRefundRequestCreateSchema, billingRefundResolutionRequestSchema, projectBillingQuoteSchema, projectBillingQuoteRequestSchema, } from "@greatwait/contracts";
|
|
2
3
|
import { parseDuration } from "./args.js";
|
|
3
4
|
import { confirmChange } from "./confirm.js";
|
|
4
5
|
import { redactSecret } from "./output.js";
|
|
@@ -116,6 +117,12 @@ async function scheduleRequest(runtime, args, usage) {
|
|
|
116
117
|
: httpRequestSchema.safeParse(httpInput);
|
|
117
118
|
if (http !== undefined && !http.success)
|
|
118
119
|
throw new CliError("--http settings are invalid", "usage", http.error.issues.map((issue) => issue.message).join("; "));
|
|
120
|
+
const pauseInput = args.options["--auto-pause-after-failures"];
|
|
121
|
+
const pause = pauseInput === undefined
|
|
122
|
+
? undefined
|
|
123
|
+
: autoPauseAfterFailuresSchema.safeParse(pauseInput === "never" ? pauseInput : Number(pauseInput));
|
|
124
|
+
if (pause !== undefined && !pause.success)
|
|
125
|
+
throw new CliError('--auto-pause-after-failures must be a whole number from 1 to 100, or "never"', "usage");
|
|
119
126
|
const latencyClass = args.options["--latency-class"];
|
|
120
127
|
const partitionKey = args.options["--partition-key"];
|
|
121
128
|
return {
|
|
@@ -124,6 +131,7 @@ async function scheduleRequest(runtime, args, usage) {
|
|
|
124
131
|
...(endpoint === undefined ? {} : { endpoint }),
|
|
125
132
|
...(url === undefined ? {} : { url }),
|
|
126
133
|
...(latencyClass === undefined ? {} : { latencyClass }),
|
|
134
|
+
...(pause?.success ? { autoPauseAfterFailures: pause.data } : {}),
|
|
127
135
|
...(partitionKey === undefined ? {} : { partitionKey }),
|
|
128
136
|
...(timeZone === undefined ? {} : { timeZone }),
|
|
129
137
|
...recurrence,
|
|
@@ -494,9 +502,52 @@ const attemptsRead = async (runtime, args) => {
|
|
|
494
502
|
const active = await session(runtime, args);
|
|
495
503
|
const view = parseResponse("attempts.read", await active.client.request("attempts_read", schedule, occurrence, attempt));
|
|
496
504
|
const found = view.attempt;
|
|
505
|
+
const responseFile = args.options["--response-file"];
|
|
506
|
+
if (responseFile !== undefined) {
|
|
507
|
+
if (found.response?.body === undefined)
|
|
508
|
+
throw new CliError(responseBodySentence(found.response?.bodyState ?? "not_captured", found.responseStatus === undefined), "validation");
|
|
509
|
+
const body = found.response.body;
|
|
510
|
+
try {
|
|
511
|
+
await writeFile(responseFile, body.encoding === "base64"
|
|
512
|
+
? Buffer.from(body.content, "base64")
|
|
513
|
+
: Buffer.from(body.content, "utf8"), { mode: 0o600, flag: "wx" });
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
throw new CliError("The response file could not be created. Choose a path that does not already exist.", "usage");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const diagnosticLines = [];
|
|
520
|
+
if (found.response !== undefined) {
|
|
521
|
+
const response = found.response;
|
|
522
|
+
diagnosticLines.push(`Server response ${String(found.responseStatus ?? "")} ${response.statusText}`, ...response.headers.map((h) => `${h.name}: ${h.value}`));
|
|
523
|
+
if (response.body === undefined)
|
|
524
|
+
diagnosticLines.push(responseBodySentence(response.bodyState, found.responseStatus === undefined));
|
|
525
|
+
else {
|
|
526
|
+
const body = response.body;
|
|
527
|
+
if (body.incomplete)
|
|
528
|
+
diagnosticLines.push("The response stream stopped during capture; this is a partial answer.");
|
|
529
|
+
if (body.truncated)
|
|
530
|
+
diagnosticLines.push(`Response cut at 10 KiB (10,240 bytes)${body.originalBytes === undefined ? "" : `; original length ${String(body.originalBytes)} bytes`}.`);
|
|
531
|
+
diagnosticLines.push(diagnosticBodyText(body) !== undefined
|
|
532
|
+
? diagnosticBodyText(body)
|
|
533
|
+
: `Binary response (${String(body.capturedBytes)} bytes). Download with --response-file <path>.`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
diagnosticLines.push("What we sent");
|
|
537
|
+
if (found.request === undefined)
|
|
538
|
+
diagnosticLines.push(requestCaptureSentence(found.requestState, found.state === undefined));
|
|
539
|
+
else
|
|
540
|
+
diagnosticLines.push(`${found.request.method} ${found.request.url}`, ...found.request.headers.map((h) => `${h.name}: ${h.value}`), found.request.body === "" ? "We sent no body." : found.request.body);
|
|
541
|
+
const guidance = attemptStatusGuidance(found.responseStatus);
|
|
542
|
+
if (guidance !== undefined)
|
|
543
|
+
diagnosticLines.push(guidance);
|
|
497
544
|
return {
|
|
498
545
|
data: { attempt: found, freshness: freshnessOf(view) },
|
|
499
546
|
human: [
|
|
547
|
+
...diagnosticLines.map(sanitizedDiagnosticText),
|
|
548
|
+
...(responseFile === undefined
|
|
549
|
+
? []
|
|
550
|
+
: [`Response saved to ${sanitizedDiagnosticText(responseFile)}`]),
|
|
500
551
|
`Attempt ${found.id}`,
|
|
501
552
|
`Number ${String(found.attemptNumber)}`,
|
|
502
553
|
...(found.state === undefined ? [] : [`State ${found.state}`]),
|
|
@@ -1570,11 +1621,104 @@ const sinksDelivery = async (runtime, args) => {
|
|
|
1570
1621
|
].join("\n"),
|
|
1571
1622
|
};
|
|
1572
1623
|
};
|
|
1624
|
+
function notificationPreferenceResult(settings) {
|
|
1625
|
+
const webhook = settings.statusWebhook;
|
|
1626
|
+
return {
|
|
1627
|
+
data: settings,
|
|
1628
|
+
human: [
|
|
1629
|
+
`Project ${settings.project}`,
|
|
1630
|
+
`Revision ${String(settings.revision)}`,
|
|
1631
|
+
`Email ${settings.email.enabled ? "enabled" : "disabled"}; ${settings.email.scheduleKinds}`,
|
|
1632
|
+
`Recipients ${settings.email.recipients.kind === "admins" ? "organization admins" : settings.email.recipients.memberIds.join(", ")}`,
|
|
1633
|
+
`Webhook ${webhook === undefined ? "disabled" : webhook.url}`,
|
|
1634
|
+
...(webhook === undefined
|
|
1635
|
+
? []
|
|
1636
|
+
: [
|
|
1637
|
+
`Events ${webhook.events.join(", ")}`,
|
|
1638
|
+
`Signing key ${webhook.signingKey?.id ?? "not provisioned"}`,
|
|
1639
|
+
]),
|
|
1640
|
+
].join("\n"),
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1573
1643
|
/* -------------------------------------------------------------------------- */
|
|
1574
1644
|
/* Dispatch */
|
|
1575
1645
|
/* -------------------------------------------------------------------------- */
|
|
1576
1646
|
/** The operate-and-diagnose commands, keyed by the words that select them. */
|
|
1577
1647
|
export const OPERATE_COMMANDS = {
|
|
1648
|
+
"notifications show": async (runtime, args) => {
|
|
1649
|
+
const [project] = positionals(args, ["<project>"], `${runtime.programName} notifications show <project>`);
|
|
1650
|
+
const active = await session(runtime, args);
|
|
1651
|
+
const settings = parseResponse("projectnotifications.read", await active.client.request("projectnotifications_read", project));
|
|
1652
|
+
return notificationPreferenceResult(settings);
|
|
1653
|
+
},
|
|
1654
|
+
"notifications update": async (runtime, args) => {
|
|
1655
|
+
const [project] = positionals(args, ["<project>"], `${runtime.programName} notifications update <project> --settings <json|@file|->`);
|
|
1656
|
+
const parsed = projectNotificationSettingsUpdateRequestSchema.safeParse(await readJsonOption(runtime, "--settings", args.options["--settings"]));
|
|
1657
|
+
if (!parsed.success)
|
|
1658
|
+
throw new CliError("--settings must contain expectedRevision and valid email/statusWebhook preferences", "usage");
|
|
1659
|
+
const confirmation = await confirmChange(runtime, args, {
|
|
1660
|
+
action: `Update delivery notifications for ${project}.`,
|
|
1661
|
+
effect: [
|
|
1662
|
+
"Changes failure email recipients and/or failed-event webhook destination.",
|
|
1663
|
+
"Failed-event hooks are included and free, including retries.",
|
|
1664
|
+
"The signing secret is never returned by this settings change.",
|
|
1665
|
+
],
|
|
1666
|
+
});
|
|
1667
|
+
const active = await session(runtime, args);
|
|
1668
|
+
const key = idempotencyKey(runtime, args);
|
|
1669
|
+
runtime.stderr(`Retry key ${key}; retain it if the response is lost.\n`);
|
|
1670
|
+
const settings = parseResponse("projectnotifications.update", await active.client.request("projectnotifications_update", project, parsed.data, { "Idempotency-Key": key }));
|
|
1671
|
+
return {
|
|
1672
|
+
...notificationPreferenceResult(settings),
|
|
1673
|
+
data: { settings, confirmation, idempotencyKey: key },
|
|
1674
|
+
};
|
|
1675
|
+
},
|
|
1676
|
+
"notifications signing-key": async (runtime, args) => {
|
|
1677
|
+
const [project] = positionals(args, ["<project>"], `${runtime.programName} notifications signing-key <project> --expected-revision <revision>`);
|
|
1678
|
+
const request = statusSigningKeyRequestSchema.safeParse({
|
|
1679
|
+
expectedRevision: expectedRevision(args),
|
|
1680
|
+
});
|
|
1681
|
+
if (!request.success)
|
|
1682
|
+
throw new CliError("--expected-revision must be a positive whole number", "usage");
|
|
1683
|
+
const confirmation = await confirmChange(runtime, args, {
|
|
1684
|
+
action: `Mint or rotate the status signing key for ${project}.`,
|
|
1685
|
+
effect: [
|
|
1686
|
+
"The previous key is replaced; queued notifications using that old key are suppressed.",
|
|
1687
|
+
"The secret is shown once in this response and cannot be read back. A lost response needs a fresh revision read and a new rotation.",
|
|
1688
|
+
],
|
|
1689
|
+
});
|
|
1690
|
+
const active = await session(runtime, args);
|
|
1691
|
+
const result = parseResponse("projectnotifications.rotateSigningKey", await active.client.request("projectnotifications_rotateSigningKey", project, request.data));
|
|
1692
|
+
return {
|
|
1693
|
+
data: { ...result, confirmation },
|
|
1694
|
+
human: [
|
|
1695
|
+
`Signing key ${result.keyId}`,
|
|
1696
|
+
`Secret ${result.secret}`,
|
|
1697
|
+
`Revision ${String(result.settings.revision)}`,
|
|
1698
|
+
"Save this secret now. It will not be returned again.",
|
|
1699
|
+
].join("\n"),
|
|
1700
|
+
};
|
|
1701
|
+
},
|
|
1702
|
+
"notifications deliveries": async (runtime, args) => {
|
|
1703
|
+
const [project] = positionals(args, ["<project>"], `${runtime.programName} notifications deliveries <project>`);
|
|
1704
|
+
const active = await session(runtime, args);
|
|
1705
|
+
return listing(runtime, args, {
|
|
1706
|
+
columns: ["Event", "State", "Calls", "Delivery"],
|
|
1707
|
+
command: `${runtime.programName} notifications deliveries ${project}`,
|
|
1708
|
+
empty: "No status notifications have been queued.",
|
|
1709
|
+
read: async (request) => {
|
|
1710
|
+
const result = parseResponse("statusdeliveries.list", await active.client.request("statusdeliveries_list", project, request));
|
|
1711
|
+
return {
|
|
1712
|
+
data: result.data,
|
|
1713
|
+
freshness: { kind: "authoritative" },
|
|
1714
|
+
...(result.nextCursor === undefined
|
|
1715
|
+
? {}
|
|
1716
|
+
: { nextCursor: result.nextCursor }),
|
|
1717
|
+
};
|
|
1718
|
+
},
|
|
1719
|
+
row: (row) => [row.event, row.state, String(row.attempts), row.id],
|
|
1720
|
+
});
|
|
1721
|
+
},
|
|
1578
1722
|
"limits show": limitsShow,
|
|
1579
1723
|
"attempts read": attemptsRead,
|
|
1580
1724
|
billing,
|
package/package.json
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
"url": "https://github.com/flowxo/greatwait/issues"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@greatwait/api-client": "0.1.0-alpha.
|
|
11
|
-
"@greatwait/contracts": "0.1.0-alpha.
|
|
10
|
+
"@greatwait/api-client": "0.1.0-alpha.8",
|
|
11
|
+
"@greatwait/contracts": "0.1.0-alpha.8"
|
|
12
12
|
},
|
|
13
13
|
"description": "The GreatWait command line: sign in, create a destination and a one-shot schedule, and wait for the callback, over the public API.",
|
|
14
14
|
"engines": {
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
},
|
|
49
49
|
"sideEffects": false,
|
|
50
50
|
"type": "module",
|
|
51
|
-
"version": "0.1.0-alpha.
|
|
51
|
+
"version": "0.1.0-alpha.8"
|
|
52
52
|
}
|