@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 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: [API_URL],
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 { httpRequestSchema, endpointHeaderChangesSchema, billingAutoTopUpApplyRequestSchema, billingAutoTopUpPreviewRequestSchema, billingAutoTopUpPreviewSchema, billingCeilingUpdateRequestSchema, billingFundingQuoteRequestSchema, billingFundingQuoteSchema, billingNotificationSettingsUpdateRequestSchema, billingRefundRequestCreateSchema, billingRefundResolutionRequestSchema, projectBillingQuoteSchema, projectBillingQuoteRequestSchema, } from "@greatwait/contracts";
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.6",
11
- "@greatwait/contracts": "0.1.0-alpha.6"
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.6"
51
+ "version": "0.1.0-alpha.8"
52
52
  }