@greatwait/cli 0.3.1 → 0.3.2

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/args.js CHANGED
@@ -29,7 +29,7 @@ const SECRET_BEARING_FLAGS = new Set(["--api-key"]);
29
29
  * repeats the value back.
30
30
  */
31
31
  export function secretArgumentAdvice(programName) {
32
- return `A credential given as an argument is visible to every process on this machine and is written to your shell history — treat the one you just typed as exposed and replace it. Run the command with a bare --api-key and paste the secret when asked, or pipe it in: printf %s "$KEY" | ${programName} login --api-key`;
32
+ return `A key passed as an argument is visible to every process on this machine and lands in your shell history, so treat it as exposed and replace it. Next time, run the command with a bare --api-key and paste the key when asked, or pipe it in: printf %s "$KEY" | ${programName} login --api-key`;
33
33
  }
34
34
  export function parseArguments(argv,
35
35
  /**
package/dist/bulk.js CHANGED
@@ -71,10 +71,10 @@ const confirm = async (runtime, args) => {
71
71
  const active = await session(runtime, args);
72
72
  const current = parseResponse("bulk.read", await active.client.request("bulk_read", id));
73
73
  if (!current.complete || current.intentDigest !== parsed.data.intentDigest)
74
- throw new CliError("The job is incomplete or the intent digest differs; refresh the preview", "validation");
74
+ throw new CliError("The job isn't finished, or the preview changed. Make a new preview", "validation");
75
75
  if (current.intent.operation === "retry" &&
76
76
  !parsed.data.acknowledgeDuplicateEffects)
77
- throw new CliError("Retry requires --acknowledge-duplicate-effects", "usage", "A receiver may already have acted; explicit redelivery can repeat external effects.");
77
+ throw new CliError("Retry requires --acknowledge-duplicate-effects", "usage", "Your server may already have acted. Redelivering can make it act twice.");
78
78
  const confirmation = await confirmChange(runtime, args, {
79
79
  action: `Confirm ${current.intent.operation} for frozen bulk job ${id}.`,
80
80
  effect: [
@@ -84,7 +84,7 @@ const confirm = async (runtime, args) => {
84
84
  `parameters ${JSON.stringify(current.intent.parameters)}`,
85
85
  `matched ${String(current.counts.matched)}; eligible ${String(current.counts.eligible)}; ineligible ${String(current.counts.ineligible)}`,
86
86
  ...current.warnings,
87
- "Already-sent requests cannot be recalled and ordinary quotas and per-attempt billing apply.",
87
+ "Requests already sent can't be recalled, and each attempt is billed as usual.",
88
88
  ],
89
89
  });
90
90
  const key = args.options["--idempotency-key"] ??
@@ -114,7 +114,7 @@ function control(action) {
114
114
  action: `${action} remaining work in bulk job ${id}.`,
115
115
  effect: [
116
116
  `operation ${current.intent.operation}; state ${current.state}; expected version ${String(parsed.data.expectedVersion)}`,
117
- "This controls the job, not selected schedules. Already-started items may finish; committed effects remain.",
117
+ "This controls the job, not the schedules in it. Items already started may finish.",
118
118
  ],
119
119
  });
120
120
  const job = parseResponse(`bulk.${action}`, await active.client.request(`bulk_${action}`, id, parsed.data));
package/dist/catalog.js CHANGED
@@ -282,7 +282,7 @@ export const COMMAND_CATALOG = [
282
282
  {
283
283
  group: "Bulk operations",
284
284
  words: "bulk preview",
285
- summary: "Prepare an exact all-matching or selected-ID preview for retry, cancel, reschedule, pause or resume.",
285
+ summary: "Preview a retry, cancel, reschedule, pause or resume across matching or chosen items.",
286
286
  operations: ["bulk.preview"],
287
287
  positionals: [],
288
288
  options: [
@@ -290,14 +290,14 @@ export const COMMAND_CATALOG = [
290
290
  {
291
291
  flag: "--intent",
292
292
  value: "<json|@file|->",
293
- summary: "Canonical bulk request: operation, target, selection and operation-specific parameters. Preview never sends deliveries or changes schedules.",
293
+ summary: "The bulk request: operation, target, selection and parameters. A preview never sends anything or changes schedules.",
294
294
  },
295
295
  ],
296
296
  },
297
297
  {
298
298
  group: "Bulk operations",
299
299
  words: "bulk confirm",
300
- summary: "Confirm the exact frozen preview and start its bounded asynchronous job.",
300
+ summary: "Confirm a preview and start its job.",
301
301
  operations: ["bulk.read", "bulk.confirm"],
302
302
  confirms: true,
303
303
  positionals: [
@@ -314,7 +314,7 @@ export const COMMAND_CATALOG = [
314
314
  },
315
315
  {
316
316
  flag: "--acknowledge-duplicate-effects",
317
- summary: "Explicitly acknowledge retry's potential duplicate receiver effects; required for retry.",
317
+ summary: "Confirm you understand a retry may reach your server twice. Required for retry.",
318
318
  },
319
319
  ],
320
320
  },
@@ -331,7 +331,7 @@ export const COMMAND_CATALOG = [
331
331
  {
332
332
  group: "Bulk operations",
333
333
  words: "bulk list",
334
- summary: "List authorized retained bulk jobs in this project and environment.",
334
+ summary: "List bulk jobs in this project and environment.",
335
335
  operations: ["bulk.list"],
336
336
  positionals: [],
337
337
  options: [
@@ -703,7 +703,7 @@ export const COMMAND_CATALOG = [
703
703
  {
704
704
  flag: "--diagnostic",
705
705
  value: "<category>",
706
- summary: "A classified attempt diagnostic, including transport failures without a response.",
706
+ summary: "Why an attempt failed, including network failures with no response.",
707
707
  },
708
708
  {
709
709
  flag: "--endpoint",
@@ -754,12 +754,42 @@ export const COMMAND_CATALOG = [
754
754
  value: "<instant>",
755
755
  summary: "Only schedules with an unsettled run before this RFC 3339 instant.",
756
756
  },
757
+ {
758
+ flag: "--due-after",
759
+ value: "<instant>",
760
+ summary: "Only schedules whose next run is at or after this RFC 3339 instant.",
761
+ },
762
+ {
763
+ flag: "--order",
764
+ value: "<order>",
765
+ summary: "created (the default) or due. due lists what runs next first, and leaves out schedules with no next run.",
766
+ },
767
+ {
768
+ flag: "--direction",
769
+ value: "<direction>",
770
+ summary: "asc (the default) or desc: newest created, or latest due, first. Keep it when you carry on with --cursor.",
771
+ },
757
772
  ...PAGE_OPTIONS,
758
773
  ],
759
774
  positionals: [],
760
775
  summary: "List this project's schedules.",
761
776
  words: "schedules list",
762
777
  },
778
+ {
779
+ group: "Schedules",
780
+ operations: ["schedules.summary"],
781
+ options: [
782
+ API_URL,
783
+ {
784
+ flag: "--time-zone",
785
+ value: "<zone>",
786
+ summary: "IANA time zone the daily counts are taken in, such as Europe/London. Defaults to UTC.",
787
+ },
788
+ ],
789
+ positionals: [],
790
+ summary: "Count this project's schedules by status, and those created and finished each day for 30 days.",
791
+ words: "schedules summary",
792
+ },
763
793
  {
764
794
  group: "Schedules",
765
795
  operations: ["schedules.read"],
@@ -826,7 +856,7 @@ export const COMMAND_CATALOG = [
826
856
  },
827
857
  {
828
858
  flag: "--latency-class",
829
- 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.",
859
+ summary: "auto, realtime or bulk. realtime goes first when both are due. auto, the default, makes a call due within a minute realtime and everything else bulk.",
830
860
  value: "<class>",
831
861
  },
832
862
  {
@@ -923,7 +953,7 @@ export const COMMAND_CATALOG = [
923
953
  },
924
954
  {
925
955
  flag: "--latency-class",
926
- 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.",
956
+ summary: "auto, realtime or bulk. realtime goes first when both are due. auto, the default, makes a call due within a minute realtime and everything else bulk.",
927
957
  value: "<class>",
928
958
  },
929
959
  {
@@ -960,7 +990,7 @@ export const COMMAND_CATALOG = [
960
990
  summary: "The schedule's id, starting sch_, from schedules create or schedules list.",
961
991
  },
962
992
  ],
963
- summary: "Change when a schedule runs. Destination, data, HTTP and delivery settings stay unless you give new ones. Timing is replaced; latency class and lateness policy are re-derived; pending occurrences are discarded. A concurrent edit can refuse this change.",
993
+ summary: "Change when a schedule runs. Destination, data, HTTP and delivery settings stay unless you give new ones. Priority and late-run policy are worked out again, and pending runs are dropped. Refused if someone else changed it first.",
964
994
  words: "schedules replace",
965
995
  },
966
996
  {
@@ -1041,7 +1071,9 @@ export const COMMAND_CATALOG = [
1041
1071
  {
1042
1072
  group: "Data exports",
1043
1073
  operations: ["exports.part"],
1044
- options: [API_URL, PAGE_OPTIONS[0]],
1074
+ // The cursor, not the page size: a part is one bounded file, and the
1075
+ // command carries on from the cursor the previous part printed.
1076
+ options: [API_URL, PAGE_OPTIONS[1]],
1045
1077
  positionals: [
1046
1078
  {
1047
1079
  name: "<export>",
@@ -1824,6 +1856,11 @@ export const COMMAND_CATALOG = [
1824
1856
  * control plane refuses on purpose.
1825
1857
  */
1826
1858
  export const OPERATION_OMISSIONS = [
1859
+ {
1860
+ because: "Dismiss answers the delivery-failure line a member sees in the workspace chrome (W9-79); a terminal has no such line to clear. `billing notices` lists the notices, and the MCP tool provides the automation path.",
1861
+ ground: "declared",
1862
+ operation: "billing.noticesdismiss",
1863
+ },
1827
1864
  {
1828
1865
  because: "Organization lifecycle authority belongs to a signed-in organization administrator in a browser session. A CLI credential is deliberately project-bound and cannot acquire or widen itself to organization authority.",
1829
1866
  ground: "principal-unreachable",
@@ -1889,6 +1926,11 @@ export const OPERATION_OMISSIONS = [
1889
1926
  ground: "principal-unreachable",
1890
1927
  operation: "projects.create",
1891
1928
  },
1929
+ {
1930
+ because: "The counted project read is the deletion preview a signed-in person sees in Project General before deleting; a credential is not accepted.",
1931
+ ground: "principal-unreachable",
1932
+ operation: "projects.read",
1933
+ },
1892
1934
  {
1893
1935
  because: "Project General is the accepted rename surface in this story. The generated SDK and MCP expose the operation to automation; adding a second interactive CLI design is outside it.",
1894
1936
  ground: "declared",
package/dist/commands.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readPrivateJsonOption } from "./support.js";
2
2
  import { MEMBER_IMPLEMENTATIONS } from "./member-commands.js";
3
- import { GreatwaitClientError, createGreatwaitClient, } from "@greatwait/api-client";
4
- import { endpointHeaderChangesSchema, isTerminalOccurrenceState, responseSchemaFor, } from "@greatwait/contracts";
3
+ import { GreatwaitClientError, WAIT_ATTEMPT_PAGES_CEILING, createGreatwaitClient, } from "@greatwait/api-client";
4
+ import { PAGE_SIZE, endpointHeaderChangesSchema, isTerminalOccurrenceState, responseSchemaFor, } from "@greatwait/contracts";
5
5
  import { parseDuration } from "./args.js";
6
6
  import { COMMAND_CATALOG, REQUESTED_SCOPES } from "./catalog.js";
7
7
  import { confirmChange } from "./confirm.js";
@@ -12,7 +12,7 @@ import { CliError, sleepOrAbort } from "./runtime.js";
12
12
  import { publishedWait, recoveryFor, renderJourney, STEP_COMMANDS, } from "./journey.js";
13
13
  import { anonymousClient, ENVIRONMENT_NAMES, openCredential, openSession, resolveOrigin, storeFor, } from "./session.js";
14
14
  import { OPERATE_COMMANDS, destinationLines, scheduleRequest } from "./operate.js";
15
- import { apiUrlOption, expectNoExtraArguments, parseResponse, positionals, } from "./support.js";
15
+ import { apiUrlOption, continuationNote, drainPages, expectNoExtraArguments, pageFlagsCommand, pageRequest, parseResponse, positionals, shellQuote, } from "./support.js";
16
16
  /* -------------------------------------------------------------------------- */
17
17
  /* Signing in */
18
18
  /* -------------------------------------------------------------------------- */
@@ -186,7 +186,7 @@ const login = async (runtime, args) => {
186
186
  },
187
187
  human: described.known
188
188
  ? `Signed in to ${origin}, in project ${described.context.project} (${described.context.environmentKind}).`
189
- : `Signed in to ${origin}. The grant is real — the service issued it — but it cannot say what it reaches.`,
189
+ : `Signed in to ${origin}, but the service couldn't say what this sign-in can reach.`,
190
190
  ...(described.known ? {} : { nextMove: described.reason }),
191
191
  };
192
192
  }
@@ -232,7 +232,7 @@ async function describeThrough(client, origin, programName) {
232
232
  if (error.status === 404) {
233
233
  return {
234
234
  known: false,
235
- reason: `${origin} does not publish GET /v1/context, so it cannot say which project this credential acts in. It is running a build from before that operation existed.`,
235
+ reason: `${origin} is running an older build without GET /v1/context, so it can't say which project this credential acts in.`,
236
236
  };
237
237
  }
238
238
  // **Anything else is the service having a moment, not a bad credential.**
@@ -767,12 +767,26 @@ const schedulesCreate = async (runtime, args) => {
767
767
  })(),
768
768
  };
769
769
  }
770
- const attempts = parseResponse("attempts.list", await session.client.request("attempts_list", schedule, occurrence)).data;
770
+ // Every attempt page, bounded the way the SDK's wait is (W9-80): the first
771
+ // page alone settled a long retry history looking complete.
772
+ const { attempts, nextCursor } = await settledAttempts(session, schedule, occurrence);
771
773
  const delivered = watched.occurrence.state === "succeeded";
774
+ const more = nextCursor === undefined
775
+ ? undefined
776
+ : `More attempts than this wait reads. Continue with: ${runtime.programName} attempts list ${schedule} ${occurrence}${pageFlagsCommand(args)} --cursor ${nextCursor}`;
777
+ const failedMove = delivered
778
+ ? undefined
779
+ : recoveryFor("read-attempt", "attempt-failed");
780
+ const moves = [failedMove, more].filter((move) => move !== undefined);
772
781
  return {
773
782
  data: {
774
783
  ...data,
775
- wait: { attempts, occurrence: watched.occurrence, settled: true },
784
+ wait: {
785
+ attempts,
786
+ ...(nextCursor === undefined ? {} : { attemptsNextCursor: nextCursor }),
787
+ occurrence: watched.occurrence,
788
+ settled: true,
789
+ },
776
790
  },
777
791
  human: [
778
792
  delivered
@@ -785,14 +799,28 @@ const schedulesCreate = async (runtime, args) => {
785
799
  : [``, ...attempts.map(attemptLine)]),
786
800
  ...repeating,
787
801
  ].join("\n"),
788
- ...(delivered
789
- ? {}
790
- : (() => {
791
- const nextMove = recoveryFor("read-attempt", "attempt-failed");
792
- return nextMove === undefined ? {} : { nextMove };
793
- })()),
802
+ ...(moves.length === 0 ? {} : { nextMove: moves.join("\n") }),
794
803
  };
795
804
  };
805
+ /**
806
+ * A settled occurrence's attempts: every page at the contract's largest size,
807
+ * up to the SDK's own ceiling, and the cursor if that stopped the read.
808
+ */
809
+ async function settledAttempts(session, schedule, occurrence) {
810
+ const attempts = [];
811
+ let cursor;
812
+ for (let pages = 0; pages < WAIT_ATTEMPT_PAGES_CEILING; pages += 1) {
813
+ const page = parseResponse("attempts.list", await session.client.request("attempts_list", schedule, occurrence, {
814
+ ...(cursor === undefined ? {} : { cursor }),
815
+ limit: PAGE_SIZE.max,
816
+ }));
817
+ attempts.push(...page.data);
818
+ cursor = page.nextCursor;
819
+ if (cursor === undefined)
820
+ break;
821
+ }
822
+ return { attempts, nextCursor: cursor };
823
+ }
796
824
  function attemptLine(attempt) {
797
825
  return `${attempt.state.padEnd(20)}${attempt.responseStatus === undefined ? "no response" : `answered ${String(attempt.responseStatus)}`}${attempt.diagnostic === undefined ? "" : ` ${attempt.diagnostic}`}${attempt.remediation === undefined ? "" : ` / ${attempt.remediation}`}`;
798
826
  }
@@ -895,11 +923,12 @@ const exportsRead = async (runtime, args) => {
895
923
  };
896
924
  const exportsPart = async (runtime, args) => {
897
925
  const [exportId] = positionals(args, ["<export>"], `${runtime.programName} exports part <export> [--cursor <cursor>]`);
898
- const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
926
+ const apiUrl = apiUrlOption(args);
927
+ const session = await openSession(runtime, { apiUrl });
899
928
  const cursor = args.options["--cursor"];
900
929
  const part = parseResponse("exports.part", await session.client.request("exports_part", exportId, cursor === undefined ? {} : { cursor }));
901
930
  if (part.nextCursor !== undefined && !args.booleans.has("--json")) {
902
- runtime.stderr(`More to export. Append the next bounded part with: ${runtime.programName} exports part ${exportId} --cursor '${part.nextCursor}' >> ${part.fileName}\n`);
931
+ runtime.stderr(`More to export. Append the next bounded part with: ${runtime.programName} exports part ${exportId}${apiUrl === undefined ? "" : ` --api-url ${shellQuote(apiUrl)}`} --cursor ${shellQuote(part.nextCursor)} >> ${part.fileName}\n`);
903
932
  }
904
933
  return {
905
934
  data: part,
@@ -1009,25 +1038,41 @@ const attemptsList = async (runtime, args) => {
1009
1038
  }
1010
1039
  expectNoExtraArguments(args, 2, `${runtime.programName} attempts list <schedule> <occurrence>`);
1011
1040
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
1012
- const page = responseSchemaFor("attempts.list")?.parse(await session.client.request("attempts_list", schedule, occurrence));
1013
- const failed = page.data.find((attempt) => attempt.state !== "succeeded");
1041
+ // **One page, `--limit`, `--cursor` and `--all`, like every other listing**
1042
+ // (W9-80). This read only ever fetched the first page and ignored the page
1043
+ // flags its own help declared, so an occurrence retried past one page
1044
+ // showed its earliest tries and never said there were more.
1045
+ const drained = await drainPages(runtime, args, pageRequest(args), async (request) => parseResponse("attempts.list", await session.client.request("attempts_list", schedule, occurrence, request)));
1046
+ const attempts = drained.rows;
1047
+ const failed = attempts.find((attempt) => attempt.state !== "succeeded");
1048
+ const continuation = continuationNote(drained, `${runtime.programName} attempts list ${schedule} ${occurrence}${pageFlagsCommand(args)}`);
1049
+ // **The journey's own sentence, not a paraphrase of the code.**
1050
+ // `diagnostic` and `remediation` are closed vocabularies rather than prose
1051
+ // — the step's `observe` says to read them, and its recovery entry is the
1052
+ // sentence written for a person meeting a failed attempt. Inventing a
1053
+ // second one here would be this command maintaining its own copy of every
1054
+ // delivery failure the service can classify.
1055
+ const recovery = failed === undefined
1056
+ ? undefined
1057
+ : recoveryFor("read-attempt", "attempt-failed");
1058
+ const notes = [recovery, continuation].filter((note) => note !== undefined);
1014
1059
  return {
1015
- data: { attempts: page.data, occurrence, schedule },
1016
- human: page.data.length === 0
1060
+ // The keys this always had, plus the page bookkeeping every listing
1061
+ // reports; `nextCursor` only when there is more to read.
1062
+ data: {
1063
+ attempts,
1064
+ ceilingReached: drained.ceilingReached,
1065
+ ...(drained.nextCursor === undefined
1066
+ ? {}
1067
+ : { nextCursor: drained.nextCursor }),
1068
+ occurrence,
1069
+ pages: drained.pages,
1070
+ schedule,
1071
+ },
1072
+ human: attempts.length === 0
1017
1073
  ? "No attempt has been made yet."
1018
- : page.data.map(attemptLine).join("\n"),
1019
- // **The journey's own sentence, not a paraphrase of the code.**
1020
- // `diagnostic` and `remediation` are closed vocabularies rather than prose
1021
- // — the step's `observe` says to read them, and its recovery entry is the
1022
- // sentence written for a person meeting a failed attempt. Inventing a
1023
- // second one here would be this command maintaining its own copy of every
1024
- // delivery failure the service can classify.
1025
- ...(failed === undefined
1026
- ? {}
1027
- : (() => {
1028
- const nextMove = recoveryFor("read-attempt", "attempt-failed");
1029
- return nextMove === undefined ? {} : { nextMove };
1030
- })()),
1074
+ : attempts.map(attemptLine).join("\n"),
1075
+ ...(notes.length === 0 ? {} : { nextMove: notes.join("\n") }),
1031
1076
  };
1032
1077
  };
1033
1078
  /* -------------------------------------------------------------------------- */
package/dist/operate.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { BULK_COMMANDS } from "./bulk.js";
2
2
  import { writeFile } from "node:fs/promises";
3
- import { projectNotificationSettingsUpdateRequestSchema, statusSigningKeyRequestSchema, autoPauseAfterFailuresSchema, deliveryDurationSecondsSchema, deliveryCompletionModeSchema, deliveryOverlapPolicySchema, scheduleRequestSchema, httpRequestSchema, externalIdentifierSchema, scheduleTagsSchema, scheduleTagSchema, scheduleSelectionSchema, normalizeScheduleSelection, projectAttemptSearchRequestSchema, normalizeAttemptSelection, sanitizedDiagnosticText, diagnosticBodyText, responseBodySentence, requestCaptureSentence, attemptStatusGuidance, endpointHeaderChangesSchema, billingAutoTopUpApplyRequestSchema, billingAutoTopUpPreviewRequestSchema, billingAutoTopUpPreviewSchema, billingCeilingUpdateRequestSchema, billingFundingQuoteRequestSchema, billingFundingQuoteSchema, billingNotificationSettingsUpdateRequestSchema, billingRefundRequestCreateSchema, billingRefundResolutionRequestSchema, billingRedeemRequestSchema, projectBillingQuoteSchema, projectBillingQuoteRequestSchema, } from "@greatwait/contracts";
3
+ import { projectNotificationSettingsUpdateRequestSchema, statusSigningKeyRequestSchema, autoPauseAfterFailuresSchema, deliveryDurationSecondsSchema, deliveryCompletionModeSchema, deliveryOverlapPolicySchema, scheduleRequestSchema, httpRequestSchema, externalIdentifierSchema, scheduleTagsSchema, scheduleTagSchema, scheduleSelectionSchema, normalizeScheduleSelection, timeZoneSchema, SCHEDULE_DIRECTIONS, SCHEDULE_ORDERS, projectAttemptSearchRequestSchema, normalizeAttemptSelection, sanitizedDiagnosticText, diagnosticBodyText, responseBodySentence, requestCaptureSentence, attemptStatusGuidance, endpointHeaderChangesSchema, billingAutoTopUpApplyRequestSchema, billingAutoTopUpPreviewRequestSchema, billingAutoTopUpPreviewSchema, billingCeilingUpdateRequestSchema, billingFundingQuoteRequestSchema, billingFundingQuoteSchema, billingNotificationSettingsUpdateRequestSchema, billingRefundRequestCreateSchema, billingRefundResolutionRequestSchema, billingRedeemRequestSchema, projectBillingQuoteSchema, projectBillingQuoteRequestSchema, } from "@greatwait/contracts";
4
4
  import { parseDuration } from "./args.js";
5
5
  import { confirmChange } from "./confirm.js";
6
6
  import { redactSecret } from "./output.js";
7
7
  import { CliError } from "./runtime.js";
8
8
  import { openSession } from "./session.js";
9
- import { apiUrlOption, conditionalHeaders, continuationNote, drainPages, freshnessNote, pageRequest, parseInstant, parseResponse, positionals, readJsonOption, readPrivateJsonOption, table, } from "./support.js";
9
+ import { apiUrlOption, conditionalHeaders, continuationNote, drainPages, freshnessNote, pageFlagsCommand, pageRequest, parseInstant, parseResponse, positionals, readJsonOption, readPrivateJsonOption, shellQuote, table, } from "./support.js";
10
10
  /* -------------------------------------------------------------------------- */
11
11
  /* Shared plumbing */
12
12
  /* -------------------------------------------------------------------------- */
@@ -24,7 +24,7 @@ async function session(runtime, args) {
24
24
  async function listing(runtime, args, options) {
25
25
  const drained = await drainPages(runtime, args, pageRequest(args), options.read);
26
26
  const notes = [
27
- continuationNote(drained, options.command),
27
+ continuationNote(drained, `${options.command}${pageFlagsCommand(args)}`),
28
28
  freshnessNote(drained.freshness),
29
29
  ].filter((note) => note !== undefined);
30
30
  return {
@@ -37,7 +37,11 @@ async function listing(runtime, args, options) {
37
37
  pages: drained.pages,
38
38
  rows: drained.rows,
39
39
  },
40
- human: table(options.columns, drained.rows.map(options.row), options.empty),
40
+ // An empty read that still carries a cursor is not the answer (W9-90):
41
+ // a filtered page can come back empty with more to read.
42
+ human: table(options.columns, drained.rows.map(options.row), drained.nextCursor === undefined
43
+ ? options.empty
44
+ : "Nothing matched in the part read so far; more remains to be read."),
41
45
  ...(notes.length === 0 ? {} : { nextMove: notes.join("\n") }),
42
46
  };
43
47
  }
@@ -451,6 +455,16 @@ const attemptsSearch = async (runtime, args) => {
451
455
  ],
452
456
  });
453
457
  };
458
+ /** An option limited to a published set of words, refused before asking. */
459
+ function oneOf(args, flag, values) {
460
+ const value = args.options[flag];
461
+ if (value === undefined)
462
+ return undefined;
463
+ if (!values.includes(value)) {
464
+ throw new CliError(`${flag} must be ${values.slice(0, -1).join(", ")} or ${values[values.length - 1]}`, "usage");
465
+ }
466
+ return value;
467
+ }
454
468
  const schedulesList = async (runtime, args) => {
455
469
  const parsed = scheduleSelectionSchema.safeParse({
456
470
  ...correlationFilters(args),
@@ -461,17 +475,38 @@ const schedulesList = async (runtime, args) => {
461
475
  dueBefore: args.options["--due-before"] === undefined
462
476
  ? undefined
463
477
  : parseInstant(args.options["--due-before"], "--due-before"),
478
+ dueAfter: args.options["--due-after"] === undefined
479
+ ? undefined
480
+ : parseInstant(args.options["--due-after"], "--due-after"),
464
481
  });
465
482
  if (!parsed.success)
466
483
  throw new CliError("The schedule filters are invalid", "usage", parsed.error.issues
467
484
  .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
468
485
  .join("; "));
486
+ const order = oneOf(args, "--order", SCHEDULE_ORDERS);
487
+ const direction = oneOf(args, "--direction", SCHEDULE_DIRECTIONS);
469
488
  const filters = normalizeScheduleSelection(parsed.data);
470
489
  const active = await session(runtime, args);
471
490
  const endpoint = args.options["--endpoint"];
472
491
  return listing(runtime, args, {
473
492
  columns: ["STATUS", "NAME", "RECURRENCE", "DELIVERS TO", "SCHEDULE"],
474
- command: `${runtime.programName} schedules list${endpoint === undefined ? "" : ` --endpoint ${endpoint}`}${correlationCommand(args)}${["--status", "--recurrence", "--text", "--due-before"].flatMap((flag) => (args.options[flag] === undefined ? [] : [` ${flag} '${args.options[flag].replaceAll("'", "'\"'\"'")}'`])).join("")}`,
493
+ // Every flag that shaped the request is repeated, quoted: the service
494
+ // refuses a cursor under a different order, direction or filter, so a
495
+ // hint that dropped one could not be pasted (W9-80).
496
+ command: `${runtime.programName} schedules list${correlationCommand(args)}${[
497
+ "--endpoint",
498
+ "--status",
499
+ "--recurrence",
500
+ "--text",
501
+ "--due-before",
502
+ "--due-after",
503
+ "--order",
504
+ "--direction",
505
+ ]
506
+ .flatMap((flag) => args.options[flag] === undefined
507
+ ? []
508
+ : [` ${flag} ${shellQuote(args.options[flag])}`])
509
+ .join("")}`,
475
510
  // An empty filtered page answers the filter, not the project: "no
476
511
  // schedules yet" read as the project being empty when nothing matched.
477
512
  empty: Object.keys(filters).some((key) => key !== "endpoint")
@@ -483,6 +518,8 @@ const schedulesList = async (runtime, args) => {
483
518
  ...request,
484
519
  ...(endpoint === undefined ? {} : { endpoint }),
485
520
  ...filters,
521
+ ...(order === undefined ? {} : { order }),
522
+ ...(direction === undefined ? {} : { direction }),
486
523
  })),
487
524
  row: (schedule) => [
488
525
  schedule.status,
@@ -493,6 +530,64 @@ const schedulesList = async (runtime, args) => {
493
530
  ],
494
531
  });
495
532
  };
533
+ /**
534
+ * Whole-project schedule counts (W9-80, FXO-3446).
535
+ *
536
+ * The counts a listing cannot give: `schedules list` reads one page, and a
537
+ * total taken from it is the page size. The service computes these over every
538
+ * retained schedule and reuses the answer for up to five minutes, so the age
539
+ * is printed beside them rather than presented as live.
540
+ */
541
+ const schedulesSummary = async (runtime, args) => {
542
+ positionals(args, [], `${runtime.programName} schedules summary`);
543
+ const timeZone = args.options["--time-zone"];
544
+ if (timeZone !== undefined && !timeZoneSchema.safeParse(timeZone).success)
545
+ throw new CliError(`${timeZone} is not an IANA time zone`, "usage", "Write --time-zone as a zone name, such as Europe/London or America/Chicago.");
546
+ const active = await session(runtime, args);
547
+ const view = parseResponse("schedules.summary", await active.client.request("schedules_summary", timeZone === undefined ? {} : { timeZone }));
548
+ const { counts, pausedAfterFailures } = view;
549
+ const busyDays = view.days.filter((day) => day.created > 0 || day.finished > 0);
550
+ const total = (key) => view.days.reduce((sum, day) => sum + day[key], 0);
551
+ return {
552
+ data: view,
553
+ human: [
554
+ `As of ${ageOf(view.freshness.lagSeconds)} (${view.freshness.asOf})`,
555
+ `Active ${String(counts.active)}`,
556
+ `Paused ${String(counts.paused)}${pausedAfterFailures.count === 0
557
+ ? ""
558
+ : `, ${String(pausedAfterFailures.count)} paused itself after repeated failures`}`,
559
+ `Completed ${String(counts.completed)}`,
560
+ `Cancelled ${String(counts.cancelled)}`,
561
+ ...(pausedAfterFailures.schedules.length === 0
562
+ ? []
563
+ : [
564
+ ``,
565
+ table(["PAUSED ITSELF", "AFTER", "WHY", "SCHEDULE"], pausedAfterFailures.schedules.map((schedule) => [
566
+ schedule.name ?? "—",
567
+ `${String(schedule.autoPauseReason.failedRuns)} failed runs`,
568
+ schedule.autoPauseReason.diagnostic,
569
+ schedule.id,
570
+ ]), ""),
571
+ ]),
572
+ ``,
573
+ `${view.window.from} to ${view.window.to} (${view.window.timeZone}): ${String(total("created"))} created, ${String(total("finished"))} finished`,
574
+ // Thirty rows, most of them zero on a quiet project, would bury the
575
+ // days that had something in them; the zero days are in --json.
576
+ table(["DAY", "CREATED", "FINISHED"], busyDays.map((day) => [
577
+ day.date,
578
+ String(day.created),
579
+ String(day.finished),
580
+ ]), "No schedule was created or finished in these 30 days."),
581
+ ].join("\n"),
582
+ nextMove: "Counts cover retained schedules: retention removes cancelled schedules and one-off schedules that ran, once their history expires.",
583
+ };
584
+ };
585
+ /** How long ago, in the unit a person reads: seconds, then minutes. */
586
+ function ageOf(seconds) {
587
+ if (seconds < 60)
588
+ return `${String(seconds)}s ago`;
589
+ return `${String(Math.floor(seconds / 60))} min ago`;
590
+ }
496
591
  /**
497
592
  * A schedule lifecycle change: read, preview where one is owed, act, report.
498
593
  *
@@ -633,7 +728,9 @@ const occurrencesList = async (runtime, args) => {
633
728
  const active = await session(runtime, args);
634
729
  return listing(runtime, args, {
635
730
  columns: ["STATE", "PLANNED FOR", "OCCURRENCE", "ATTEMPTS"],
636
- command: `${runtime.programName} occurrences list ${schedule}`,
731
+ // `--newest` is repeated because the service refuses a cursor issued for
732
+ // the other order: a hint without it could not be pasted (W9-80).
733
+ command: `${runtime.programName} occurrences list ${schedule}${args.booleans.has("--newest") ? " --newest" : ""}`,
637
734
  empty: "This schedule has no retained occurrences.",
638
735
  read: (request) => page("occurrences.list", () => active.client.request("occurrences_list", schedule, {
639
736
  ...request,
@@ -735,7 +832,7 @@ const attemptsRead = async (runtime, args) => {
735
832
  }
736
833
  diagnosticLines.push("What we sent");
737
834
  if (found.request === undefined)
738
- diagnosticLines.push(requestCaptureSentence(found.requestState, found.state === undefined));
835
+ diagnosticLines.push(requestCaptureSentence(found.requestState, found.state === undefined, found.requestUnavailable));
739
836
  else
740
837
  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);
741
838
  const guidance = attemptStatusGuidance(found.responseStatus);
@@ -1912,6 +2009,10 @@ function notificationPreferenceResult(settings) {
1912
2009
  ? []
1913
2010
  : [
1914
2011
  `Events ${webhook.events.join(", ")}`,
2012
+ // W9-84: the destination is an endpoint, whose key signs.
2013
+ ...(webhook.endpoint === undefined
2014
+ ? []
2015
+ : [`Endpoint ${webhook.endpoint}`]),
1915
2016
  `Signing key ${webhook.signingKey?.id ?? "not provisioned"}`,
1916
2017
  ]),
1917
2018
  ].join("\n"),
@@ -2040,6 +2141,7 @@ export const OPERATE_COMMANDS = {
2040
2141
  "schedules archive": schedulesArchive,
2041
2142
  "schedules cancel": schedulesCancel,
2042
2143
  "schedules list": schedulesList,
2144
+ "schedules summary": schedulesSummary,
2043
2145
  "executions list": executionsList,
2044
2146
  "attempts search": attemptsSearch,
2045
2147
  "schedules pause": schedulesPause,
package/dist/support.d.ts CHANGED
@@ -103,6 +103,22 @@ export interface DrainedPages<TRow> {
103
103
  export declare function drainPages<TRow>(runtime: CliRuntime, args: ParsedArguments, request: PageRequest, read: (request: PageRequest) => Promise<Page<TRow>>): Promise<DrainedPages<TRow>>;
104
104
  /** The freshness of an answer, in a sentence, or nothing to say. */
105
105
  export declare function freshnessNote(freshness: Freshness): string | undefined;
106
+ /**
107
+ * One argument, single-quoted for a POSIX shell, so a pasted hint means what
108
+ * it printed: `$`, backticks and spaces stay literal, and an embedded quote is
109
+ * closed, escaped and reopened.
110
+ */
111
+ export declare function shellQuote(value: string): string;
112
+ /**
113
+ * The page flags a continuation hint must repeat (W9-80, FXO-3446).
114
+ *
115
+ * **A cursor is only valid with the request that issued it**, and the
116
+ * origin it came from. A hint that dropped `--api-url` sent the cursor to the
117
+ * signed-in default origin, and one that dropped `--limit` read the next page
118
+ * at a different size from the one the person chose. `--cursor` and `--all`
119
+ * are added by {@link continuationNote}.
120
+ */
121
+ export declare function pageFlagsCommand(args: ParsedArguments): string;
106
122
  /**
107
123
  * What to say about a listing that stopped before the collection did.
108
124
  *
package/dist/support.js CHANGED
@@ -216,6 +216,30 @@ export function freshnessNote(freshness) {
216
216
  return undefined;
217
217
  return `Read from a projection that was ${String(freshness.lagSeconds)}s behind at ${freshness.asOf}, so something newer than that may be missing.`;
218
218
  }
219
+ /**
220
+ * One argument, single-quoted for a POSIX shell, so a pasted hint means what
221
+ * it printed: `$`, backticks and spaces stay literal, and an embedded quote is
222
+ * closed, escaped and reopened.
223
+ */
224
+ export function shellQuote(value) {
225
+ return `'${value.replaceAll("'", "'\"'\"'")}'`;
226
+ }
227
+ /**
228
+ * The page flags a continuation hint must repeat (W9-80, FXO-3446).
229
+ *
230
+ * **A cursor is only valid with the request that issued it**, and the
231
+ * origin it came from. A hint that dropped `--api-url` sent the cursor to the
232
+ * signed-in default origin, and one that dropped `--limit` read the next page
233
+ * at a different size from the one the person chose. `--cursor` and `--all`
234
+ * are added by {@link continuationNote}.
235
+ */
236
+ export function pageFlagsCommand(args) {
237
+ const limit = args.options["--limit"];
238
+ const apiUrl = args.options["--api-url"];
239
+ // `--limit` is a whole number by the time a page was read (`pageRequest`
240
+ // refuses anything else), so it needs no quoting; an origin might.
241
+ return `${limit === undefined ? "" : ` --limit ${limit}`}${apiUrl === undefined ? "" : ` --api-url ${shellQuote(apiUrl)}`}`;
242
+ }
219
243
  /**
220
244
  * What to say about a listing that stopped before the collection did.
221
245
  *
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.3.1",
11
- "@greatwait/contracts": "0.3.1"
10
+ "@greatwait/api-client": "0.3.2",
11
+ "@greatwait/contracts": "0.3.2"
12
12
  },
13
13
  "description": "The GreatWait command line: sign in, schedule a call to a URL, and read its delivery outcome over the public API.",
14
14
  "engines": {
@@ -48,5 +48,5 @@
48
48
  },
49
49
  "sideEffects": false,
50
50
  "type": "module",
51
- "version": "0.3.1"
51
+ "version": "0.3.2"
52
52
  }