@greatwait/cli 0.3.0 → 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/README.md CHANGED
@@ -43,6 +43,13 @@ directory, readable by you and nobody else. A staging credential is never
43
43
  presented to production, because the two are separate entries rather than one
44
44
  current context.
45
45
 
46
+ For a short script that needs a bearer token, `greatwait token` prints the
47
+ credential for the selected API origin, refreshing an expiring OAuth sign-in
48
+ first. Add `--api-url <origin>` to choose an origin explicitly. `--json` prints
49
+ `{token, origin, expiresAt}`; an API key is returned unchanged with `null` expiry.
50
+ This output is a secret: keep it out of logs, shell tracing and shared files.
51
+ Use a scoped API key for unattended automation.
52
+
46
53
  ## First callback
47
54
 
48
55
  Use a public HTTPS receiver you control. Once signed in, schedule directly to
@@ -99,6 +106,8 @@ grant and remove local credentials.
99
106
  ## Output and exit codes
100
107
 
101
108
  `--json` is a versioned machine shape, not a serialization of the human text.
109
+ The deliberate secret-output command `token --json` returns the credential object
110
+ described above.
102
111
  The exit status is a class rather than a code per failure:
103
112
 
104
113
  | code | meaning |
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: [
@@ -493,6 +493,14 @@ export const COMMAND_CATALOG = [
493
493
  summary: "Sign out, and forget the credential stored on this machine.",
494
494
  words: "logout",
495
495
  },
496
+ {
497
+ group: "Session",
498
+ operations: [],
499
+ options: [API_URL],
500
+ positionals: [],
501
+ summary: "Print the credential for the selected origin, refreshing OAuth first when needed. Output is a secret: keep it out of logs and shell tracing. --json prints token, origin and expiresAt (null for API keys).",
502
+ words: "token",
503
+ },
496
504
  {
497
505
  group: "Session",
498
506
  operations: ["context.read"],
@@ -695,7 +703,7 @@ export const COMMAND_CATALOG = [
695
703
  {
696
704
  flag: "--diagnostic",
697
705
  value: "<category>",
698
- summary: "A classified attempt diagnostic, including transport failures without a response.",
706
+ summary: "Why an attempt failed, including network failures with no response.",
699
707
  },
700
708
  {
701
709
  flag: "--endpoint",
@@ -746,12 +754,42 @@ export const COMMAND_CATALOG = [
746
754
  value: "<instant>",
747
755
  summary: "Only schedules with an unsettled run before this RFC 3339 instant.",
748
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
+ },
749
772
  ...PAGE_OPTIONS,
750
773
  ],
751
774
  positionals: [],
752
775
  summary: "List this project's schedules.",
753
776
  words: "schedules list",
754
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
+ },
755
793
  {
756
794
  group: "Schedules",
757
795
  operations: ["schedules.read"],
@@ -818,7 +856,7 @@ export const COMMAND_CATALOG = [
818
856
  },
819
857
  {
820
858
  flag: "--latency-class",
821
- 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.",
822
860
  value: "<class>",
823
861
  },
824
862
  {
@@ -831,10 +869,20 @@ export const COMMAND_CATALOG = [
831
869
  summary: "JSON your receiver gets with the call. Inline, @file.json, or - for standard input.",
832
870
  value: "<json>",
833
871
  },
872
+ {
873
+ flag: "--completion",
874
+ value: "<response|callback>",
875
+ summary: "How work finishes: response or callback. Defaults to response on create; replace keeps the stored value when omitted.",
876
+ },
877
+ {
878
+ flag: "--on-overlap",
879
+ value: "<skip|queue>",
880
+ summary: "When the next run is due before work finishes: skip or queue. Defaults to skip on create; replace keeps the stored value when omitted.",
881
+ },
834
882
  {
835
883
  flag: "--delivery-timeout",
836
884
  value: "<duration>",
837
- summary: "How long the receiver may take, such as 20s or 5m (whole seconds). Defaults to 5s on create; replace keeps the stored timeout. Response completion allows up to 30m; longer needs existing callback completion. Separate from --timeout for --wait.",
885
+ summary: "How long the receiver may take, such as 20s or 5m (whole seconds). Defaults to 5s on create; replace keeps the stored timeout. Response completion allows up to 30m; callback completion allows up to 24h. Separate from --timeout for --wait.",
838
886
  },
839
887
  IDEMPOTENCY,
840
888
  {
@@ -905,7 +953,7 @@ export const COMMAND_CATALOG = [
905
953
  },
906
954
  {
907
955
  flag: "--latency-class",
908
- 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.",
909
957
  value: "<class>",
910
958
  },
911
959
  {
@@ -918,6 +966,16 @@ export const COMMAND_CATALOG = [
918
966
  summary: "JSON your receiver gets with the call. Inline, @file.json, or - for standard input.",
919
967
  value: "<json>",
920
968
  },
969
+ {
970
+ flag: "--completion",
971
+ value: "<response|callback>",
972
+ summary: "How work finishes: response or callback. Defaults to response on create; replace keeps the stored value when omitted.",
973
+ },
974
+ {
975
+ flag: "--on-overlap",
976
+ value: "<skip|queue>",
977
+ summary: "When the next run is due before work finishes: skip or queue. Defaults to skip on create; replace keeps the stored value when omitted.",
978
+ },
921
979
  {
922
980
  flag: "--delivery-timeout",
923
981
  value: "<duration>",
@@ -932,7 +990,7 @@ export const COMMAND_CATALOG = [
932
990
  summary: "The schedule's id, starting sch_, from schedules create or schedules list.",
933
991
  },
934
992
  ],
935
- 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.",
936
994
  words: "schedules replace",
937
995
  },
938
996
  {
@@ -1013,7 +1071,9 @@ export const COMMAND_CATALOG = [
1013
1071
  {
1014
1072
  group: "Data exports",
1015
1073
  operations: ["exports.part"],
1016
- 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]],
1017
1077
  positionals: [
1018
1078
  {
1019
1079
  name: "<export>",
@@ -1796,6 +1856,11 @@ export const COMMAND_CATALOG = [
1796
1856
  * control plane refuses on purpose.
1797
1857
  */
1798
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
+ },
1799
1864
  {
1800
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.",
1801
1866
  ground: "principal-unreachable",
@@ -1861,6 +1926,11 @@ export const OPERATION_OMISSIONS = [
1861
1926
  ground: "principal-unreachable",
1862
1927
  operation: "projects.create",
1863
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
+ },
1864
1934
  {
1865
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.",
1866
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";
@@ -10,9 +10,9 @@ import { awaitRedirect, buildAuthorization, codeFrom, discover, exchangeCode, re
10
10
  import { redactSecret } from "./output.js";
11
11
  import { CliError, sleepOrAbort } from "./runtime.js";
12
12
  import { publishedWait, recoveryFor, renderJourney, STEP_COMMANDS, } from "./journey.js";
13
- import { anonymousClient, ENVIRONMENT_NAMES, openSession, resolveOrigin, storeFor, } from "./session.js";
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.**
@@ -317,6 +317,13 @@ const CREDENTIAL_LABELS = {
317
317
  "environment-key": `an API credential from ${ENVIRONMENT_NAMES.apiKey}`,
318
318
  oauth: "an OAuth grant",
319
319
  };
320
+ const token = async (runtime, args) => {
321
+ expectNoExtraArguments(args, 0, `${runtime.programName} token [--api-url <origin>]`);
322
+ const { token, origin, expiresAt } = await openCredential(runtime, {
323
+ apiUrl: apiUrlOption(args),
324
+ });
325
+ return { human: token, data: { token, origin, expiresAt } };
326
+ };
320
327
  const whoami = async (runtime, args) => {
321
328
  expectNoExtraArguments(args, 0, `${runtime.programName} whoami [--api-url <origin>]`);
322
329
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
@@ -677,6 +684,7 @@ const schedulesCreate = async (runtime, args) => {
677
684
  const schedule = created.schedule.id;
678
685
  const occurrence = created.occurrence.id;
679
686
  const data = {
687
+ delivery: created.schedule.delivery,
680
688
  ...(created.endpointSigningSecret === undefined
681
689
  ? {}
682
690
  : {
@@ -759,12 +767,26 @@ const schedulesCreate = async (runtime, args) => {
759
767
  })(),
760
768
  };
761
769
  }
762
- 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);
763
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);
764
781
  return {
765
782
  data: {
766
783
  ...data,
767
- wait: { attempts, occurrence: watched.occurrence, settled: true },
784
+ wait: {
785
+ attempts,
786
+ ...(nextCursor === undefined ? {} : { attemptsNextCursor: nextCursor }),
787
+ occurrence: watched.occurrence,
788
+ settled: true,
789
+ },
768
790
  },
769
791
  human: [
770
792
  delivered
@@ -777,14 +799,28 @@ const schedulesCreate = async (runtime, args) => {
777
799
  : [``, ...attempts.map(attemptLine)]),
778
800
  ...repeating,
779
801
  ].join("\n"),
780
- ...(delivered
781
- ? {}
782
- : (() => {
783
- const nextMove = recoveryFor("read-attempt", "attempt-failed");
784
- return nextMove === undefined ? {} : { nextMove };
785
- })()),
802
+ ...(moves.length === 0 ? {} : { nextMove: moves.join("\n") }),
786
803
  };
787
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
+ }
788
824
  function attemptLine(attempt) {
789
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}`}`;
790
826
  }
@@ -887,11 +923,12 @@ const exportsRead = async (runtime, args) => {
887
923
  };
888
924
  const exportsPart = async (runtime, args) => {
889
925
  const [exportId] = positionals(args, ["<export>"], `${runtime.programName} exports part <export> [--cursor <cursor>]`);
890
- const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
926
+ const apiUrl = apiUrlOption(args);
927
+ const session = await openSession(runtime, { apiUrl });
891
928
  const cursor = args.options["--cursor"];
892
929
  const part = parseResponse("exports.part", await session.client.request("exports_part", exportId, cursor === undefined ? {} : { cursor }));
893
930
  if (part.nextCursor !== undefined && !args.booleans.has("--json")) {
894
- 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`);
895
932
  }
896
933
  return {
897
934
  data: part,
@@ -1001,25 +1038,41 @@ const attemptsList = async (runtime, args) => {
1001
1038
  }
1002
1039
  expectNoExtraArguments(args, 2, `${runtime.programName} attempts list <schedule> <occurrence>`);
1003
1040
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
1004
- const page = responseSchemaFor("attempts.list")?.parse(await session.client.request("attempts_list", schedule, occurrence));
1005
- 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);
1006
1059
  return {
1007
- data: { attempts: page.data, occurrence, schedule },
1008
- 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
1009
1073
  ? "No attempt has been made yet."
1010
- : page.data.map(attemptLine).join("\n"),
1011
- // **The journey's own sentence, not a paraphrase of the code.**
1012
- // `diagnostic` and `remediation` are closed vocabularies rather than prose
1013
- // — the step's `observe` says to read them, and its recovery entry is the
1014
- // sentence written for a person meeting a failed attempt. Inventing a
1015
- // second one here would be this command maintaining its own copy of every
1016
- // delivery failure the service can classify.
1017
- ...(failed === undefined
1018
- ? {}
1019
- : (() => {
1020
- const nextMove = recoveryFor("read-attempt", "attempt-failed");
1021
- return nextMove === undefined ? {} : { nextMove };
1022
- })()),
1074
+ : attempts.map(attemptLine).join("\n"),
1075
+ ...(notes.length === 0 ? {} : { nextMove: notes.join("\n") }),
1023
1076
  };
1024
1077
  };
1025
1078
  /* -------------------------------------------------------------------------- */
@@ -1052,6 +1105,7 @@ const IMPLEMENTATIONS = {
1052
1105
  "occurrences wait": occurrencesWait,
1053
1106
  "schedules create": schedulesCreate,
1054
1107
  "schedules read": schedulesRead,
1108
+ token,
1055
1109
  whoami,
1056
1110
  };
1057
1111
  /**
package/dist/operate.d.ts CHANGED
@@ -34,7 +34,7 @@ type Command = (runtime: CliRuntime, args: ParsedArguments) => Promise<CommandRe
34
34
  * machine's would make the same expression mean different things on a laptop
35
35
  * and on a build runner.
36
36
  */
37
- declare function scheduleRequest(runtime: CliRuntime, args: ParsedArguments, usage: string): Promise<Readonly<Record<string, unknown>>>;
37
+ declare function scheduleRequest(runtime: CliRuntime, args: ParsedArguments, usage: string, mode?: "create" | "replace"): Promise<Readonly<Record<string, unknown>>>;
38
38
  /**
39
39
  * Where a schedule written with `--url` delivers, and where its signing
40
40
  * secret is.
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, 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
  }
@@ -80,7 +84,7 @@ const RECURRENCE_FLAGS = ["--in", "--at", "--every", "--cron"];
80
84
  * machine's would make the same expression mean different things on a laptop
81
85
  * and on a build runner.
82
86
  */
83
- async function scheduleRequest(runtime, args, usage) {
87
+ async function scheduleRequest(runtime, args, usage, mode = "create") {
84
88
  const given = RECURRENCE_FLAGS.filter((flag) => args.options[flag] !== undefined);
85
89
  if (given.length !== 1) {
86
90
  throw new CliError(given.length === 0
@@ -132,6 +136,27 @@ async function scheduleRequest(runtime, args, usage) {
132
136
  : deliveryDurationSecondsSchema.safeParse(parseDuration(timeoutInput));
133
137
  if (timeout !== undefined && !timeout.success)
134
138
  throw new CliError("--delivery-timeout is invalid", "usage", timeout.error.issues.map((issue) => issue.message).join("; "));
139
+ const completionInput = args.options["--completion"];
140
+ const completion = completionInput === undefined
141
+ ? undefined
142
+ : deliveryCompletionModeSchema.safeParse(completionInput);
143
+ if (completion !== undefined && !completion.success)
144
+ throw new CliError('--completion (delivery.completion) must be "response" or "callback"', "usage");
145
+ const overlapInput = args.options["--on-overlap"];
146
+ const overlap = overlapInput === undefined
147
+ ? undefined
148
+ : deliveryOverlapPolicySchema.safeParse(overlapInput);
149
+ if (overlap !== undefined && !overlap.success)
150
+ throw new CliError('--on-overlap (delivery.onOverlap) must be "skip" or "queue"', "usage");
151
+ const delivery = {
152
+ ...(timeout?.success ? { maxDurationSeconds: timeout.data } : {}),
153
+ ...(completion?.success ? { completion: completion.data } : {}),
154
+ ...(overlap?.success ? { onOverlap: overlap.data } : {}),
155
+ };
156
+ // Replacement validates the resulting pair after reading the stored settings.
157
+ // An omitted completion there can mean a retained callback, not response.
158
+ if (mode === "create")
159
+ validateDelivery(delivery);
135
160
  const external_identifier = args.options["--external-identifier"];
136
161
  if (external_identifier !== undefined &&
137
162
  !externalIdentifierSchema.safeParse(external_identifier).success)
@@ -145,9 +170,7 @@ async function scheduleRequest(runtime, args, usage) {
145
170
  ...(external_identifier === undefined ? {} : { external_identifier }),
146
171
  ...(parsedTags?.success ? { tags: parsedTags.data } : {}),
147
172
  ...(http?.success ? { http: http.data } : {}),
148
- ...(timeout?.success
149
- ? { delivery: { maxDurationSeconds: timeout.data } }
150
- : {}),
173
+ ...(Object.keys(delivery).length === 0 ? {} : { delivery }),
151
174
  ...(endpoint === undefined ? {} : { endpoint }),
152
175
  ...(url === undefined ? {} : { url }),
153
176
  ...(latencyClass === undefined ? {} : { latencyClass }),
@@ -157,6 +180,14 @@ async function scheduleRequest(runtime, args, usage) {
157
180
  ...recurrence,
158
181
  };
159
182
  }
183
+ /** Use the canonical contract's relation between completion and timeout. */
184
+ function validateDelivery(delivery) {
185
+ const parsed = scheduleRequestSchema.safeParse({ delaySeconds: 1, delivery });
186
+ if (!parsed.success)
187
+ throw new CliError(parsed.error.issues
188
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
189
+ .join("; "), "usage");
190
+ }
160
191
  /**
161
192
  * Where a schedule written with `--url` delivers, and where its signing
162
193
  * secret is.
@@ -424,6 +455,16 @@ const attemptsSearch = async (runtime, args) => {
424
455
  ],
425
456
  });
426
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
+ }
427
468
  const schedulesList = async (runtime, args) => {
428
469
  const parsed = scheduleSelectionSchema.safeParse({
429
470
  ...correlationFilters(args),
@@ -434,17 +475,38 @@ const schedulesList = async (runtime, args) => {
434
475
  dueBefore: args.options["--due-before"] === undefined
435
476
  ? undefined
436
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"),
437
481
  });
438
482
  if (!parsed.success)
439
483
  throw new CliError("The schedule filters are invalid", "usage", parsed.error.issues
440
484
  .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
441
485
  .join("; "));
486
+ const order = oneOf(args, "--order", SCHEDULE_ORDERS);
487
+ const direction = oneOf(args, "--direction", SCHEDULE_DIRECTIONS);
442
488
  const filters = normalizeScheduleSelection(parsed.data);
443
489
  const active = await session(runtime, args);
444
490
  const endpoint = args.options["--endpoint"];
445
491
  return listing(runtime, args, {
446
492
  columns: ["STATUS", "NAME", "RECURRENCE", "DELIVERS TO", "SCHEDULE"],
447
- 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("")}`,
448
510
  // An empty filtered page answers the filter, not the project: "no
449
511
  // schedules yet" read as the project being empty when nothing matched.
450
512
  empty: Object.keys(filters).some((key) => key !== "endpoint")
@@ -456,6 +518,8 @@ const schedulesList = async (runtime, args) => {
456
518
  ...request,
457
519
  ...(endpoint === undefined ? {} : { endpoint }),
458
520
  ...filters,
521
+ ...(order === undefined ? {} : { order }),
522
+ ...(direction === undefined ? {} : { direction }),
459
523
  })),
460
524
  row: (schedule) => [
461
525
  schedule.status,
@@ -466,6 +530,64 @@ const schedulesList = async (runtime, args) => {
466
530
  ],
467
531
  });
468
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
+ }
469
591
  /**
470
592
  * A schedule lifecycle change: read, preview where one is owed, act, report.
471
593
  *
@@ -518,35 +640,30 @@ async function scheduleChange(runtime, args, options) {
518
640
  const schedulesReplace = async (runtime, args) => {
519
641
  const usage = `${runtime.programName} schedules replace <schedule> (--endpoint <destination> | --url <https url>) (--in <duration> | --at <instant> | --every <duration> | --cron <expression>)`;
520
642
  const [id] = positionals(args, ["<schedule>"], usage);
521
- const request = await scheduleRequest(runtime, args, usage);
643
+ const request = await scheduleRequest(runtime, args, usage, "replace");
522
644
  return scheduleChange(runtime, args, {
523
645
  act: (active, scheduleId, current) => {
524
646
  if (current === undefined)
525
647
  throw new CliError("Replacement needs its schedule read", "internal");
526
- const delivery = current.schedule.delivery;
527
- const timeout = args.options["--delivery-timeout"];
528
- if (timeout !== undefined &&
648
+ const delivery = {
649
+ ...current.schedule.delivery,
650
+ ...request["delivery"],
651
+ };
652
+ const changesDelivery = request["delivery"] !== undefined;
653
+ validateDelivery(delivery);
654
+ if (changesDelivery &&
529
655
  current.schedule.endpoint === undefined &&
530
656
  request["endpoint"] === undefined &&
531
657
  request["url"] === undefined)
532
- throw new CliError("--delivery-timeout needs a callback destination", "usage", "Give --endpoint or --url when attaching a callback to a timer-only schedule.");
658
+ throw new CliError("Delivery settings need a callback destination", "usage", "Give --endpoint or --url when attaching a callback to a timer-only schedule.");
533
659
  // The API replaces delivery settings with defaults when callback fields
534
660
  // are supplied. Carry all stored settings, including completion/overlap.
535
- // Naming the retained endpoint also makes a timeout-only change reach
661
+ // Naming the retained endpoint also makes a delivery-only change reach
536
662
  // that callback replacement path. Fence the read against concurrent edits.
537
663
  return active.client.requestDetailed("schedules_replace", scheduleId, {
538
664
  ...request,
539
- ...(delivery === undefined
540
- ? {}
541
- : {
542
- delivery: {
543
- ...delivery,
544
- ...(timeout === undefined
545
- ? {}
546
- : { maxDurationSeconds: parseDuration(timeout) }),
547
- },
548
- }),
549
- ...(timeout !== undefined &&
665
+ ...(Object.keys(delivery).length === 0 ? {} : { delivery }),
666
+ ...(changesDelivery &&
550
667
  request["endpoint"] === undefined &&
551
668
  request["url"] === undefined &&
552
669
  current.schedule.endpoint !== undefined
@@ -611,7 +728,9 @@ const occurrencesList = async (runtime, args) => {
611
728
  const active = await session(runtime, args);
612
729
  return listing(runtime, args, {
613
730
  columns: ["STATE", "PLANNED FOR", "OCCURRENCE", "ATTEMPTS"],
614
- 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" : ""}`,
615
734
  empty: "This schedule has no retained occurrences.",
616
735
  read: (request) => page("occurrences.list", () => active.client.request("occurrences_list", schedule, {
617
736
  ...request,
@@ -713,7 +832,7 @@ const attemptsRead = async (runtime, args) => {
713
832
  }
714
833
  diagnosticLines.push("What we sent");
715
834
  if (found.request === undefined)
716
- diagnosticLines.push(requestCaptureSentence(found.requestState, found.state === undefined));
835
+ diagnosticLines.push(requestCaptureSentence(found.requestState, found.state === undefined, found.requestUnavailable));
717
836
  else
718
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);
719
838
  const guidance = attemptStatusGuidance(found.responseStatus);
@@ -1890,6 +2009,10 @@ function notificationPreferenceResult(settings) {
1890
2009
  ? []
1891
2010
  : [
1892
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}`]),
1893
2016
  `Signing key ${webhook.signingKey?.id ?? "not provisioned"}`,
1894
2017
  ]),
1895
2018
  ].join("\n"),
@@ -2018,6 +2141,7 @@ export const OPERATE_COMMANDS = {
2018
2141
  "schedules archive": schedulesArchive,
2019
2142
  "schedules cancel": schedulesCancel,
2020
2143
  "schedules list": schedulesList,
2144
+ "schedules summary": schedulesSummary,
2021
2145
  "executions list": executionsList,
2022
2146
  "attempts search": attemptsSearch,
2023
2147
  "schedules pause": schedulesPause,
package/dist/output.js CHANGED
@@ -27,6 +27,9 @@ export function renderResult(command, result, json) {
27
27
  ? `${result.human}\n`
28
28
  : `${result.human}\n\n${result.nextMove}\n`;
29
29
  }
30
+ // The token command is a shell credential primitive with a bare JSON shape.
31
+ if (command === "token")
32
+ return `${JSON.stringify(result.data)}\n`;
30
33
  return `${JSON.stringify({
31
34
  command,
32
35
  data: result.data,
package/dist/session.d.ts CHANGED
@@ -49,9 +49,20 @@ export declare function anonymousClient(runtime: CliRuntime, origin: string): De
49
49
  * reason nothing on their machine explains. Persisting first costs one write
50
50
  * and removes the whole failure.
51
51
  */
52
+ export interface SessionCredential {
53
+ readonly token: string;
54
+ readonly expiresAt: string | null;
55
+ readonly credentialKind: Session["credentialKind"];
56
+ readonly origin: string;
57
+ readonly originSource: ResolvedOrigin["source"];
58
+ }
52
59
  export declare function openSession(runtime: CliRuntime, options: {
53
60
  readonly apiUrl?: string | undefined;
54
61
  }): Promise<Session>;
62
+ /** Resolve the exact credential used by commands, persisting refresh before use. */
63
+ export declare function openCredential(runtime: CliRuntime, options: {
64
+ readonly apiUrl?: string | undefined;
65
+ }): Promise<SessionCredential>;
55
66
  /**
56
67
  * How long before expiry a grant is refreshed rather than presented.
57
68
  *
package/dist/session.js CHANGED
@@ -69,31 +69,28 @@ export function anonymousClient(runtime, origin) {
69
69
  fetch: runtime.fetch,
70
70
  });
71
71
  }
72
- /**
73
- * An authenticated session, refreshing an expired grant on the way.
74
- *
75
- * **The rotated pair is written back before the token is used.** A refresh
76
- * exchange invalidates the refresh token it was given, so a process that
77
- * refreshed, made its call, and crashed would leave a stored refresh token the
78
- * service no longer honours — and the person would have to sign in again for a
79
- * reason nothing on their machine explains. Persisting first costs one write
80
- * and removes the whole failure.
81
- */
82
72
  export async function openSession(runtime, options) {
73
+ const credential = await openCredential(runtime, options);
74
+ return {
75
+ client: createGreatwaitClient({
76
+ baseUrl: credential.origin,
77
+ credential: credential.token,
78
+ fetch: runtime.fetch,
79
+ }),
80
+ credentialKind: credential.credentialKind,
81
+ origin: credential.origin,
82
+ originSource: credential.originSource,
83
+ };
84
+ }
85
+ /** Resolve the exact credential used by commands, persisting refresh before use. */
86
+ export async function openCredential(runtime, options) {
83
87
  const store = storeFor(runtime);
84
88
  const resolved = await resolveOrigin(runtime, store, options.apiUrl);
85
- // **The environment wins, and only for an API key.** Non-interactive
86
- // automation is the case this exists for, and the journey's own variable is
87
- // the one it already exports. A stored OAuth grant is a person's, and a cron
88
- // job that silently used one would be acting as them.
89
89
  const fromEnvironment = runtime.env[ENVIRONMENT_NAMES.apiKey];
90
90
  if (fromEnvironment !== undefined && fromEnvironment !== "") {
91
91
  return {
92
- client: createGreatwaitClient({
93
- baseUrl: resolved.origin,
94
- credential: fromEnvironment,
95
- fetch: runtime.fetch,
96
- }),
92
+ token: fromEnvironment,
93
+ expiresAt: null,
97
94
  credentialKind: "environment-key",
98
95
  origin: resolved.origin,
99
96
  originSource: resolved.source,
@@ -101,15 +98,11 @@ export async function openSession(runtime, options) {
101
98
  }
102
99
  const stored = await store.read(resolved.origin);
103
100
  if (stored === undefined) {
104
- throw new CliError(`no credential is stored for ${resolved.origin}`, "authentication", `Sign in — ${runtime.programName} login --api-url ${resolved.origin} — or export ${ENVIRONMENT_NAMES.apiKey} for a non-interactive run.`);
101
+ throw new CliError(`no credential is stored for ${resolved.origin}`, "authentication", `Sign in with ${runtime.programName} login --api-url ${resolved.origin} — or export ${ENVIRONMENT_NAMES.apiKey} for a non-interactive run.`);
105
102
  }
106
- const credential = await presentableSecret(runtime, store, resolved.origin, stored);
103
+ const secret = await presentableSecret(runtime, store, resolved.origin, stored);
107
104
  return {
108
- client: createGreatwaitClient({
109
- baseUrl: resolved.origin,
110
- credential,
111
- fetch: runtime.fetch,
112
- }),
105
+ ...secret,
113
106
  credentialKind: stored.kind,
114
107
  origin: resolved.origin,
115
108
  originSource: resolved.source,
@@ -132,26 +125,35 @@ async function presentableSecret(runtime, store, origin, stored) {
132
125
  if (stored.secret === undefined) {
133
126
  throw new CliError(`the stored credential for ${origin} carries no secret`, "authentication", `Sign in again: ${runtime.programName} login --api-url ${origin} --api-key`);
134
127
  }
135
- return stored.secret;
128
+ return { token: stored.secret, expiresAt: null };
136
129
  }
137
130
  const { accessToken, clientId, expiresAtMs, refreshToken } = stored;
138
131
  if (accessToken === undefined || clientId === undefined) {
139
132
  throw new CliError(`the stored grant for ${origin} is incomplete`, "authentication", `Sign in again: ${runtime.programName} login --api-url ${origin}`);
140
133
  }
141
134
  if (expiresAtMs === undefined ||
142
- expiresAtMs - REFRESH_MARGIN_MS > runtime.now() ||
143
- refreshToken === undefined) {
144
- return accessToken;
135
+ expiresAtMs - REFRESH_MARGIN_MS > runtime.now()) {
136
+ return {
137
+ token: accessToken,
138
+ expiresAt: expiresAtMs === undefined ? null : new Date(expiresAtMs).toISOString(),
139
+ };
140
+ }
141
+ if (refreshToken === undefined) {
142
+ throw new CliError(`the stored grant for ${origin} needs refreshing but carries no refresh token`, "authentication", `Sign in with ${runtime.programName} login --api-url ${origin}`);
145
143
  }
146
144
  const server = await discover(runtime, origin);
147
145
  const rotated = await refresh(runtime, server, { clientId, refreshToken });
146
+ const refreshedExpiresAtMs = runtime.now() + rotated.expiresInSeconds * 1000;
148
147
  await store.write(origin, {
149
148
  accessToken: rotated.accessToken,
150
149
  clientId,
151
- expiresAtMs: runtime.now() + rotated.expiresInSeconds * 1000,
150
+ expiresAtMs: refreshedExpiresAtMs,
152
151
  kind: "oauth",
153
152
  refreshToken: rotated.refreshToken,
154
153
  scope: rotated.scope,
155
154
  });
156
- return rotated.accessToken;
155
+ return {
156
+ token: rotated.accessToken,
157
+ expiresAt: new Date(refreshedExpiresAtMs).toISOString(),
158
+ };
157
159
  }
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.0",
11
- "@greatwait/contracts": "0.3.0"
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.0"
51
+ "version": "0.3.2"
52
52
  }