@greatwait/cli 0.2.3 → 0.3.1

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/bulk.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { ParsedArguments } from "./args.js";
2
+ import { type CliRuntime } from "./runtime.js";
3
+ import type { CommandResult } from "./output.js";
4
+ type Command = (runtime: CliRuntime, args: ParsedArguments) => Promise<CommandResult>;
5
+ export declare const BULK_COMMANDS: Readonly<Record<string, Command>>;
6
+ export {};
package/dist/bulk.js ADDED
@@ -0,0 +1,178 @@
1
+ import { bulkPreviewRequestSchema, bulkConfirmRequestSchema, bulkControlRequestSchema, bulkJobSchema, bulkListRequestSchema, bulkItemListRequestSchema, } from "@greatwait/contracts";
2
+ import { confirmChange } from "./confirm.js";
3
+ import { CliError } from "./runtime.js";
4
+ import { openSession } from "./session.js";
5
+ import { apiUrlOption, readJsonOption, parseResponse, positionals, pageRequest, drainPages, continuationNote, table, } from "./support.js";
6
+ const session = (runtime, args) => openSession(runtime, { apiUrl: apiUrlOption(args) });
7
+ function jobId(runtime, args, command) {
8
+ const [id] = positionals(args, ["<job>"], `${runtime.programName} bulk ${command} <job>`);
9
+ const parsed = bulkJobSchema.shape.id.safeParse(id);
10
+ if (!parsed.success)
11
+ throw new CliError("Give a valid bjo_ job identity from bulk preview", "usage");
12
+ return parsed.data;
13
+ }
14
+ function renderJob(job) {
15
+ const counts = job.counts;
16
+ return {
17
+ data: job,
18
+ human: [
19
+ `Job ${job.id}`,
20
+ `Operation ${job.intent.operation} ${job.intent.target}`,
21
+ `State ${job.state}`,
22
+ `Version ${String(job.version)}`,
23
+ `Selection ${job.intent.selection.kind === "all_matching" ? "all matching results" : "explicit selected IDs"}`,
24
+ `Counts ${job.complete ? "exact" : "incomplete preparation; lower bounds"}`,
25
+ `Matched ${String(counts.matched)}; eligible ${String(counts.eligible)}; ineligible ${String(counts.ineligible)}`,
26
+ `Progress pending ${String(counts.pending)}; running ${String(counts.running)}; succeeded ${String(counts.succeeded)}; no-op ${String(counts.noOp)}; skipped ${String(counts.skipped)}; conflicted ${String(counts.conflicted)}; failed ${String(counts.failed)}; cancelled ${String(counts.cancelled)}`,
27
+ ...(job.intentDigest === undefined
28
+ ? []
29
+ : [`Intent ${job.intentDigest}`]),
30
+ ...(job.previewExpiresAt === undefined
31
+ ? []
32
+ : [`Confirm by ${job.previewExpiresAt}`]),
33
+ ...(job.executionExpiresAt === undefined
34
+ ? []
35
+ : [`Deadline ${job.executionExpiresAt}`]),
36
+ ...job.warnings.map((warning) => `Warning ${warning}`),
37
+ `Billing ${job.billing.note}`,
38
+ ...(job.billing.currentLimits === undefined
39
+ ? []
40
+ : [`Admission ${JSON.stringify(job.billing.currentLimits)}`]),
41
+ ].join("\n"),
42
+ };
43
+ }
44
+ const preview = async (runtime, args) => {
45
+ const parsed = bulkPreviewRequestSchema.safeParse(await readJsonOption(runtime, "--intent", args.options["--intent"]));
46
+ if (!parsed.success)
47
+ throw new CliError("--intent must match the canonical bulk operation request", "usage", parsed.error.issues
48
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
49
+ .join("; "));
50
+ const active = await session(runtime, args);
51
+ const job = parseResponse("bulk.preview", await active.client.request("bulk_preview", parsed.data));
52
+ return {
53
+ ...renderJob(job),
54
+ nextMove: `Inspect this frozen selection: ${runtime.programName} bulk read ${job.id}; ${runtime.programName} bulk items ${job.id} --all`,
55
+ };
56
+ };
57
+ const read = async (runtime, args) => {
58
+ const id = jobId(runtime, args, "read");
59
+ const active = await session(runtime, args);
60
+ return renderJob(parseResponse("bulk.read", await active.client.request("bulk_read", id)));
61
+ };
62
+ const confirm = async (runtime, args) => {
63
+ const id = jobId(runtime, args, "confirm");
64
+ const parsed = bulkConfirmRequestSchema.safeParse({
65
+ intentDigest: args.options["--intent-digest"],
66
+ confirmed: true,
67
+ acknowledgeDuplicateEffects: args.booleans.has("--acknowledge-duplicate-effects"),
68
+ });
69
+ if (!parsed.success)
70
+ throw new CliError("Give the exact --intent-digest from bulk read", "usage");
71
+ const active = await session(runtime, args);
72
+ const current = parseResponse("bulk.read", await active.client.request("bulk_read", id));
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");
75
+ if (current.intent.operation === "retry" &&
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.");
78
+ const confirmation = await confirmChange(runtime, args, {
79
+ action: `Confirm ${current.intent.operation} for frozen bulk job ${id}.`,
80
+ effect: [
81
+ `authority ${JSON.stringify(current.authority)}`,
82
+ `intent ${parsed.data.intentDigest}`,
83
+ `selection ${JSON.stringify(current.intent.selection)}`,
84
+ `parameters ${JSON.stringify(current.intent.parameters)}`,
85
+ `matched ${String(current.counts.matched)}; eligible ${String(current.counts.eligible)}; ineligible ${String(current.counts.ineligible)}`,
86
+ ...current.warnings,
87
+ "Already-sent requests cannot be recalled and ordinary quotas and per-attempt billing apply.",
88
+ ],
89
+ });
90
+ const key = args.options["--idempotency-key"] ??
91
+ `greatwait-bulk-${id}-${parsed.data.intentDigest}`;
92
+ runtime.stderr(`Retry key ${key}; retain it with this intent if the response is lost.\n`);
93
+ const job = parseResponse("bulk.confirm", await active.client.request("bulk_confirm", id, parsed.data, {
94
+ "Idempotency-Key": key,
95
+ }));
96
+ return {
97
+ ...renderJob(job),
98
+ data: { job, confirmation, idempotencyKey: key },
99
+ nextMove: `Monitor: ${runtime.programName} bulk read ${id}`,
100
+ };
101
+ };
102
+ function control(action) {
103
+ return async (runtime, args) => {
104
+ const id = jobId(runtime, args, action);
105
+ const raw = args.options["--expected-version"];
106
+ const parsed = bulkControlRequestSchema.safeParse({
107
+ expectedVersion: raw === undefined ? undefined : Number(raw),
108
+ });
109
+ if (!parsed.success)
110
+ throw new CliError("Give --expected-version from bulk read", "usage");
111
+ const active = await session(runtime, args);
112
+ const current = parseResponse("bulk.read", await active.client.request("bulk_read", id));
113
+ const confirmation = await confirmChange(runtime, args, {
114
+ action: `${action} remaining work in bulk job ${id}.`,
115
+ effect: [
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.",
118
+ ],
119
+ });
120
+ const job = parseResponse(`bulk.${action}`, await active.client.request(`bulk_${action}`, id, parsed.data));
121
+ return { ...renderJob(job), data: { job, confirmation } };
122
+ };
123
+ }
124
+ function list(items) {
125
+ return async (runtime, args) => {
126
+ const id = items ? jobId(runtime, args, "items") : undefined;
127
+ const active = await session(runtime, args);
128
+ const operation = items ? "bulk.items" : "bulk.list";
129
+ const state = args.options["--state"];
130
+ const parsed = (items ? bulkItemListRequestSchema : bulkListRequestSchema).safeParse({
131
+ ...pageRequest(args),
132
+ ...(state === undefined ? {} : { state }),
133
+ });
134
+ if (!parsed.success)
135
+ throw new CliError("Invalid bulk listing page or state", "usage");
136
+ const command = `${runtime.programName} bulk ${items ? `items ${id}` : "list"}${state === undefined ? "" : ` --state ${state}`}`;
137
+ const pages = await drainPages(runtime, args, pageRequest(args), async (request) => {
138
+ const query = { ...request, ...(state === undefined ? {} : { state }) };
139
+ const response = items
140
+ ? await active.client.request("bulk_items", id, query)
141
+ : await active.client.request("bulk_list", query);
142
+ const page = parseResponse(operation, response);
143
+ return { ...page, freshness: { kind: "authoritative" } };
144
+ });
145
+ const note = continuationNote(pages, command);
146
+ return {
147
+ data: {
148
+ rows: pages.rows,
149
+ pages: pages.pages,
150
+ ceilingReached: pages.ceilingReached,
151
+ ...(pages.nextCursor === undefined
152
+ ? {}
153
+ : { nextCursor: pages.nextCursor }),
154
+ },
155
+ human: table(items
156
+ ? ["STATE", "REASON", "SCHEDULE", "ITEM"]
157
+ : ["STATE", "OPERATION", "MATCHED", "JOB"], pages.rows.map((row) => "intent" in row
158
+ ? [
159
+ row.state,
160
+ row.intent.operation,
161
+ String(row.counts.matched),
162
+ row.id,
163
+ ]
164
+ : [row.state, row.reason, row.scheduleId, row.id]), items ? "No items match this page." : "No authorized bulk jobs match."),
165
+ ...(note === undefined ? {} : { nextMove: note }),
166
+ };
167
+ };
168
+ }
169
+ export const BULK_COMMANDS = {
170
+ "bulk preview": preview,
171
+ "bulk confirm": confirm,
172
+ "bulk read": read,
173
+ "bulk list": list(false),
174
+ "bulk items": list(true),
175
+ "bulk pause": control("pause"),
176
+ "bulk resume": control("resume"),
177
+ "bulk cancel": control("cancel"),
178
+ };
package/dist/catalog.js CHANGED
@@ -279,6 +279,111 @@ export const MEMBER_COMMANDS = [
279
279
  },
280
280
  ];
281
281
  export const COMMAND_CATALOG = [
282
+ {
283
+ group: "Bulk operations",
284
+ words: "bulk preview",
285
+ summary: "Prepare an exact all-matching or selected-ID preview for retry, cancel, reschedule, pause or resume.",
286
+ operations: ["bulk.preview"],
287
+ positionals: [],
288
+ options: [
289
+ API_URL,
290
+ {
291
+ flag: "--intent",
292
+ value: "<json|@file|->",
293
+ summary: "Canonical bulk request: operation, target, selection and operation-specific parameters. Preview never sends deliveries or changes schedules.",
294
+ },
295
+ ],
296
+ },
297
+ {
298
+ group: "Bulk operations",
299
+ words: "bulk confirm",
300
+ summary: "Confirm the exact frozen preview and start its bounded asynchronous job.",
301
+ operations: ["bulk.read", "bulk.confirm"],
302
+ confirms: true,
303
+ positionals: [
304
+ { name: "<job>", summary: "The bjo_ identity returned by bulk preview." },
305
+ ],
306
+ options: [
307
+ API_URL,
308
+ CONFIRM,
309
+ IDEMPOTENCY,
310
+ {
311
+ flag: "--intent-digest",
312
+ value: "<digest>",
313
+ summary: "Exact digest from the complete unexpired preview.",
314
+ },
315
+ {
316
+ flag: "--acknowledge-duplicate-effects",
317
+ summary: "Explicitly acknowledge retry's potential duplicate receiver effects; required for retry.",
318
+ },
319
+ ],
320
+ },
321
+ {
322
+ group: "Bulk operations",
323
+ words: "bulk read",
324
+ summary: "Read preview progress, job counts, limits, warnings and results.",
325
+ operations: ["bulk.read"],
326
+ positionals: [
327
+ { name: "<job>", summary: "The bjo_ identity returned by bulk preview." },
328
+ ],
329
+ options: [API_URL],
330
+ },
331
+ {
332
+ group: "Bulk operations",
333
+ words: "bulk list",
334
+ summary: "List authorized retained bulk jobs in this project and environment.",
335
+ operations: ["bulk.list"],
336
+ positionals: [],
337
+ options: [
338
+ API_URL,
339
+ ...PAGE_OPTIONS,
340
+ {
341
+ flag: "--state",
342
+ value: "<state>",
343
+ summary: "Only jobs in this lifecycle state.",
344
+ },
345
+ ],
346
+ },
347
+ {
348
+ group: "Bulk operations",
349
+ words: "bulk items",
350
+ summary: "Inspect every frozen target and its durable outcome.",
351
+ operations: ["bulk.items"],
352
+ positionals: [
353
+ { name: "<job>", summary: "The bjo_ identity returned by bulk preview." },
354
+ ],
355
+ options: [
356
+ API_URL,
357
+ ...PAGE_OPTIONS,
358
+ {
359
+ flag: "--state",
360
+ value: "<state>",
361
+ summary: "Only items in this state.",
362
+ },
363
+ ],
364
+ },
365
+ ...["pause", "resume", "cancel"].map((action) => ({
366
+ group: "Bulk operations",
367
+ words: `bulk ${action}`,
368
+ summary: `${action[0].toUpperCase()}${action.slice(1)} remaining bulk-job work; already-started items may finish.`,
369
+ operations: ["bulk.read", `bulk.${action}`],
370
+ confirms: true,
371
+ positionals: [
372
+ {
373
+ name: "<job>",
374
+ summary: "The bjo_ identity returned by bulk preview.",
375
+ },
376
+ ],
377
+ options: [
378
+ API_URL,
379
+ CONFIRM,
380
+ {
381
+ flag: "--expected-version",
382
+ value: "<version>",
383
+ summary: "Mandatory job version from bulk read; stale controls conflict.",
384
+ },
385
+ ],
386
+ })),
282
387
  {
283
388
  group: "Planning",
284
389
  words: "notifications show",
@@ -388,6 +493,14 @@ export const COMMAND_CATALOG = [
388
493
  summary: "Sign out, and forget the credential stored on this machine.",
389
494
  words: "logout",
390
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
+ },
391
504
  {
392
505
  group: "Session",
393
506
  operations: ["context.read"],
@@ -557,6 +670,58 @@ export const COMMAND_CATALOG = [
557
670
  summary: "Find retained runs, newest first, by host identifier or tag.",
558
671
  words: "executions list",
559
672
  },
673
+ {
674
+ group: "Schedules",
675
+ operations: ["attempts.search"],
676
+ options: [
677
+ API_URL,
678
+ {
679
+ flag: "--started-from",
680
+ value: "<instant>",
681
+ summary: "Required UTC attempt start, inclusive (RFC 3339).",
682
+ },
683
+ {
684
+ flag: "--started-before",
685
+ value: "<instant>",
686
+ summary: "Required UTC attempt end, exclusive (RFC 3339).",
687
+ },
688
+ {
689
+ flag: "--http-status",
690
+ value: "<status>",
691
+ summary: "Only attempts with this HTTP response status, such as 503.",
692
+ },
693
+ {
694
+ flag: "--status-class",
695
+ value: "<class>",
696
+ summary: "Only one HTTP status class: 1xx through 5xx.",
697
+ },
698
+ {
699
+ flag: "--outcome",
700
+ value: "<outcome>",
701
+ summary: "The matched attempt's outcome, independently of later recovery.",
702
+ },
703
+ {
704
+ flag: "--diagnostic",
705
+ value: "<category>",
706
+ summary: "A classified attempt diagnostic, including transport failures without a response.",
707
+ },
708
+ {
709
+ flag: "--endpoint",
710
+ value: "<destination>",
711
+ summary: "Only attempts for this destination (ep_).",
712
+ },
713
+ {
714
+ flag: "--schedule",
715
+ value: "<schedule>",
716
+ summary: "Only attempts for this schedule (sch_).",
717
+ },
718
+ ...CORRELATION_FILTER_OPTIONS,
719
+ ...PAGE_OPTIONS,
720
+ ],
721
+ positionals: [],
722
+ summary: "Search retained attempts by actual UTC start time, including failures that later recovered.",
723
+ words: "attempts search",
724
+ },
560
725
  /* --- Schedules -------------------------------------------------------- */
561
726
  {
562
727
  group: "Schedules",
@@ -566,9 +731,29 @@ export const COMMAND_CATALOG = [
566
731
  ...CORRELATION_FILTER_OPTIONS,
567
732
  {
568
733
  flag: "--endpoint",
569
- summary: "Only the schedules that call this destination. Its id starts ep_.",
734
+ summary: "Only schedules that call this destination (ep_), or none for timer-only.",
570
735
  value: "<destination>",
571
736
  },
737
+ {
738
+ flag: "--status",
739
+ value: "<status>",
740
+ summary: "Active, paused, cancelled or completed schedules.",
741
+ },
742
+ {
743
+ flag: "--recurrence",
744
+ value: "<kind>",
745
+ summary: "One recurrence kind: at, every or cron.",
746
+ },
747
+ {
748
+ flag: "--text",
749
+ value: "<text>",
750
+ summary: "Case-insensitive text in a schedule name, address or identifier.",
751
+ },
752
+ {
753
+ flag: "--due-before",
754
+ value: "<instant>",
755
+ summary: "Only schedules with an unsettled run before this RFC 3339 instant.",
756
+ },
572
757
  ...PAGE_OPTIONS,
573
758
  ],
574
759
  positionals: [],
@@ -654,6 +839,21 @@ export const COMMAND_CATALOG = [
654
839
  summary: "JSON your receiver gets with the call. Inline, @file.json, or - for standard input.",
655
840
  value: "<json>",
656
841
  },
842
+ {
843
+ flag: "--completion",
844
+ value: "<response|callback>",
845
+ summary: "How work finishes: response or callback. Defaults to response on create; replace keeps the stored value when omitted.",
846
+ },
847
+ {
848
+ flag: "--on-overlap",
849
+ value: "<skip|queue>",
850
+ 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.",
851
+ },
852
+ {
853
+ flag: "--delivery-timeout",
854
+ value: "<duration>",
855
+ 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.",
856
+ },
657
857
  IDEMPOTENCY,
658
858
  {
659
859
  flag: "--wait",
@@ -736,6 +936,21 @@ export const COMMAND_CATALOG = [
736
936
  summary: "JSON your receiver gets with the call. Inline, @file.json, or - for standard input.",
737
937
  value: "<json>",
738
938
  },
939
+ {
940
+ flag: "--completion",
941
+ value: "<response|callback>",
942
+ summary: "How work finishes: response or callback. Defaults to response on create; replace keeps the stored value when omitted.",
943
+ },
944
+ {
945
+ flag: "--on-overlap",
946
+ value: "<skip|queue>",
947
+ 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.",
948
+ },
949
+ {
950
+ flag: "--delivery-timeout",
951
+ value: "<duration>",
952
+ summary: "Override only the receiver timeout, such as 20s or 5m (whole seconds). Keeps completion and overlap settings. Response completion allows up to 30m; existing callback completion allows up to 1d. Omit to preserve the timeout.",
953
+ },
739
954
  IF_MATCH,
740
955
  CONFIRM,
741
956
  ],
@@ -745,7 +960,7 @@ export const COMMAND_CATALOG = [
745
960
  summary: "The schedule's id, starting sch_, from schedules create or schedules list.",
746
961
  },
747
962
  ],
748
- summary: "Change when a schedule runs. Its destination, data and HTTP settings stay unless you give new ones.",
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.",
749
964
  words: "schedules replace",
750
965
  },
751
966
  {
package/dist/commands.js CHANGED
@@ -10,7 +10,7 @@ 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
15
  import { apiUrlOption, expectNoExtraArguments, parseResponse, positionals, } from "./support.js";
16
16
  /* -------------------------------------------------------------------------- */
@@ -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
  : {
@@ -803,12 +811,20 @@ const schedulesRead = async (runtime, args) => {
803
811
  const answer = await session.client.requestDetailed("schedules_read", id);
804
812
  const view = responseSchemaFor("schedules.read")?.parse(answer.body);
805
813
  const etag = answer.headers["ETag"];
814
+ const failedOneShot = view.schedule.status === "active" &&
815
+ view.schedule.recurrence.kind === "at" &&
816
+ view.occurrence.generation === view.schedule.generation &&
817
+ view.occurrence.state === "terminal_failed";
806
818
  return {
807
819
  data: {
808
820
  ...(etag === undefined ? {} : { etag }),
809
821
  recurrence: view.schedule.recurrence.kind,
810
822
  schedule: view.schedule.id,
811
823
  status: view.schedule.status,
824
+ ...(view.schedule.delivery === undefined
825
+ ? {}
826
+ : { delivery: view.schedule.delivery }),
827
+ ...(failedOneShot ? { runState: view.occurrence.state } : {}),
812
828
  autoPauseAfterFailures: view.schedule.autoPauseAfterFailures,
813
829
  consecutiveFailedRuns: view.schedule.consecutiveFailedRuns,
814
830
  autoPauseReason: view.schedule.autoPauseReason,
@@ -820,7 +836,15 @@ const schedulesRead = async (runtime, args) => {
820
836
  },
821
837
  human: [
822
838
  `Schedule ${view.schedule.id}`,
823
- `Status ${view.schedule.status}`,
839
+ `Status ${failedOneShot ? "failed (schedule active)" : view.schedule.status}`,
840
+ ...(failedOneShot
841
+ ? ["Next The only run failed. Nothing more is planned."]
842
+ : []),
843
+ ...(view.schedule.delivery === undefined
844
+ ? []
845
+ : [
846
+ `Delivery ${String(view.schedule.delivery.maxDurationSeconds)}s timeout (${view.schedule.delivery.completion}, overlap ${view.schedule.delivery.onOverlap})`,
847
+ ]),
824
848
  `Auto-pause ${String(view.schedule.autoPauseAfterFailures ?? 5)}`,
825
849
  `Failed runs ${String(view.schedule.consecutiveFailedRuns ?? 0)}`,
826
850
  ...(view.schedule.autoPauseReason == null
@@ -1036,6 +1060,7 @@ const IMPLEMENTATIONS = {
1036
1060
  "occurrences wait": occurrencesWait,
1037
1061
  "schedules create": schedulesCreate,
1038
1062
  "schedules read": schedulesRead,
1063
+ token,
1039
1064
  whoami,
1040
1065
  };
1041
1066
  /**
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,5 +1,6 @@
1
+ import { BULK_COMMANDS } from "./bulk.js";
1
2
  import { writeFile } from "node:fs/promises";
2
- import { projectNotificationSettingsUpdateRequestSchema, statusSigningKeyRequestSchema, autoPauseAfterFailuresSchema, httpRequestSchema, externalIdentifierSchema, scheduleTagsSchema, scheduleTagSchema, 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, projectAttemptSearchRequestSchema, normalizeAttemptSelection, sanitizedDiagnosticText, diagnosticBodyText, responseBodySentence, requestCaptureSentence, attemptStatusGuidance, endpointHeaderChangesSchema, billingAutoTopUpApplyRequestSchema, billingAutoTopUpPreviewRequestSchema, billingAutoTopUpPreviewSchema, billingCeilingUpdateRequestSchema, billingFundingQuoteRequestSchema, billingFundingQuoteSchema, billingNotificationSettingsUpdateRequestSchema, billingRefundRequestCreateSchema, billingRefundResolutionRequestSchema, billingRedeemRequestSchema, projectBillingQuoteSchema, projectBillingQuoteRequestSchema, } from "@greatwait/contracts";
3
4
  import { parseDuration } from "./args.js";
4
5
  import { confirmChange } from "./confirm.js";
5
6
  import { redactSecret } from "./output.js";
@@ -79,7 +80,7 @@ const RECURRENCE_FLAGS = ["--in", "--at", "--every", "--cron"];
79
80
  * machine's would make the same expression mean different things on a laptop
80
81
  * and on a build runner.
81
82
  */
82
- async function scheduleRequest(runtime, args, usage) {
83
+ async function scheduleRequest(runtime, args, usage, mode = "create") {
83
84
  const given = RECURRENCE_FLAGS.filter((flag) => args.options[flag] !== undefined);
84
85
  if (given.length !== 1) {
85
86
  throw new CliError(given.length === 0
@@ -125,6 +126,33 @@ async function scheduleRequest(runtime, args, usage) {
125
126
  throw new CliError('--auto-pause-after-failures must be a whole number from 1 to 100, or "never"', "usage");
126
127
  const latencyClass = args.options["--latency-class"];
127
128
  const partitionKey = args.options["--partition-key"];
129
+ const timeoutInput = args.options["--delivery-timeout"];
130
+ const timeout = timeoutInput === undefined
131
+ ? undefined
132
+ : deliveryDurationSecondsSchema.safeParse(parseDuration(timeoutInput));
133
+ if (timeout !== undefined && !timeout.success)
134
+ throw new CliError("--delivery-timeout is invalid", "usage", timeout.error.issues.map((issue) => issue.message).join("; "));
135
+ const completionInput = args.options["--completion"];
136
+ const completion = completionInput === undefined
137
+ ? undefined
138
+ : deliveryCompletionModeSchema.safeParse(completionInput);
139
+ if (completion !== undefined && !completion.success)
140
+ throw new CliError('--completion (delivery.completion) must be "response" or "callback"', "usage");
141
+ const overlapInput = args.options["--on-overlap"];
142
+ const overlap = overlapInput === undefined
143
+ ? undefined
144
+ : deliveryOverlapPolicySchema.safeParse(overlapInput);
145
+ if (overlap !== undefined && !overlap.success)
146
+ throw new CliError('--on-overlap (delivery.onOverlap) must be "skip" or "queue"', "usage");
147
+ const delivery = {
148
+ ...(timeout?.success ? { maxDurationSeconds: timeout.data } : {}),
149
+ ...(completion?.success ? { completion: completion.data } : {}),
150
+ ...(overlap?.success ? { onOverlap: overlap.data } : {}),
151
+ };
152
+ // Replacement validates the resulting pair after reading the stored settings.
153
+ // An omitted completion there can mean a retained callback, not response.
154
+ if (mode === "create")
155
+ validateDelivery(delivery);
128
156
  const external_identifier = args.options["--external-identifier"];
129
157
  if (external_identifier !== undefined &&
130
158
  !externalIdentifierSchema.safeParse(external_identifier).success)
@@ -138,6 +166,7 @@ async function scheduleRequest(runtime, args, usage) {
138
166
  ...(external_identifier === undefined ? {} : { external_identifier }),
139
167
  ...(parsedTags?.success ? { tags: parsedTags.data } : {}),
140
168
  ...(http?.success ? { http: http.data } : {}),
169
+ ...(Object.keys(delivery).length === 0 ? {} : { delivery }),
141
170
  ...(endpoint === undefined ? {} : { endpoint }),
142
171
  ...(url === undefined ? {} : { url }),
143
172
  ...(latencyClass === undefined ? {} : { latencyClass }),
@@ -147,6 +176,14 @@ async function scheduleRequest(runtime, args, usage) {
147
176
  ...recurrence,
148
177
  };
149
178
  }
179
+ /** Use the canonical contract's relation between completion and timeout. */
180
+ function validateDelivery(delivery) {
181
+ const parsed = scheduleRequestSchema.safeParse({ delaySeconds: 1, delivery });
182
+ if (!parsed.success)
183
+ throw new CliError(parsed.error.issues
184
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
185
+ .join("; "), "usage");
186
+ }
150
187
  /**
151
188
  * Where a schedule written with `--url` delivers, and where its signing
152
189
  * secret is.
@@ -352,16 +389,92 @@ const executionsList = async (runtime, args) => {
352
389
  row: (run) => [run.state, run.plannedFor, run.schedule, run.id],
353
390
  });
354
391
  };
392
+ const attemptsSearch = async (runtime, args) => {
393
+ const from = args.options["--started-from"];
394
+ const before = args.options["--started-before"];
395
+ if (from === undefined || before === undefined)
396
+ throw new CliError("--started-from and --started-before are required UTC timestamps", "usage");
397
+ const parsed = projectAttemptSearchRequestSchema.safeParse({
398
+ ...correlationFilters(args),
399
+ startedFrom: parseInstant(from, "--started-from"),
400
+ startedBefore: parseInstant(before, "--started-before"),
401
+ responseStatus: args.options["--http-status"] === undefined
402
+ ? undefined
403
+ : Number(args.options["--http-status"]),
404
+ statusClass: args.options["--status-class"],
405
+ outcome: args.options["--outcome"],
406
+ diagnostic: args.options["--diagnostic"],
407
+ endpoint: args.options["--endpoint"],
408
+ schedule: args.options["--schedule"],
409
+ });
410
+ if (!parsed.success)
411
+ throw new CliError("The attempt filters are invalid", "usage", parsed.error.issues
412
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
413
+ .join("; "));
414
+ const active = await session(runtime, args);
415
+ const flags = [
416
+ "--started-from",
417
+ "--started-before",
418
+ "--http-status",
419
+ "--status-class",
420
+ "--outcome",
421
+ "--diagnostic",
422
+ "--endpoint",
423
+ "--schedule",
424
+ ];
425
+ return listing(runtime, args, {
426
+ columns: [
427
+ "STARTED (UTC)",
428
+ "ATTEMPT OUTCOME",
429
+ "HTTP / DIAGNOSTIC",
430
+ "RUN OUTCOME",
431
+ "SCHEDULE",
432
+ "RUN",
433
+ "ATTEMPT",
434
+ ],
435
+ command: `${runtime.programName} attempts search${flags.flatMap((flag) => (args.options[flag] === undefined ? [] : [` ${flag} '${args.options[flag].replaceAll("'", "'\"'\"'")}'`])).join("")}${correlationCommand(args)}`,
436
+ empty: "No retained attempts match these filters. Older evidence may have expired.",
437
+ read: (request) => page("attempts.search", () => active.client.request("attempts_search", {
438
+ ...normalizeAttemptSelection(parsed.data),
439
+ ...request,
440
+ })),
441
+ row: (result) => [
442
+ result.attempt.startedAt,
443
+ result.attempt.state ?? "in_flight",
444
+ result.attempt.responseStatus?.toString() ??
445
+ result.attempt.diagnostic ??
446
+ "pending",
447
+ result.occurrence.state,
448
+ result.schedule.id,
449
+ result.occurrence.id,
450
+ result.attempt.id,
451
+ ],
452
+ });
453
+ };
355
454
  const schedulesList = async (runtime, args) => {
356
- const filters = correlationFilters(args);
455
+ const parsed = scheduleSelectionSchema.safeParse({
456
+ ...correlationFilters(args),
457
+ endpoint: args.options["--endpoint"],
458
+ status: args.options["--status"],
459
+ recurrence: args.options["--recurrence"],
460
+ text: args.options["--text"],
461
+ dueBefore: args.options["--due-before"] === undefined
462
+ ? undefined
463
+ : parseInstant(args.options["--due-before"], "--due-before"),
464
+ });
465
+ if (!parsed.success)
466
+ throw new CliError("The schedule filters are invalid", "usage", parsed.error.issues
467
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
468
+ .join("; "));
469
+ const filters = normalizeScheduleSelection(parsed.data);
357
470
  const active = await session(runtime, args);
358
471
  const endpoint = args.options["--endpoint"];
359
472
  return listing(runtime, args, {
360
473
  columns: ["STATUS", "NAME", "RECURRENCE", "DELIVERS TO", "SCHEDULE"],
361
- command: `${runtime.programName} schedules list${endpoint === undefined ? "" : ` --endpoint ${endpoint}`}${correlationCommand(args)}`,
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("")}`,
362
475
  // An empty filtered page answers the filter, not the project: "no
363
476
  // schedules yet" read as the project being empty when nothing matched.
364
- empty: Object.keys(filters).length > 0
477
+ empty: Object.keys(filters).some((key) => key !== "endpoint")
365
478
  ? "No schedule matches these filters."
366
479
  : endpoint === undefined
367
480
  ? "This project has no schedules yet."
@@ -390,11 +503,19 @@ const schedulesList = async (runtime, args) => {
390
503
  async function scheduleChange(runtime, args, options) {
391
504
  const active = await session(runtime, args);
392
505
  let confirmation;
506
+ let currentView;
393
507
  if (options.confirm !== undefined) {
394
- const current = parseResponse("schedules.read", await active.client.request("schedules_read", options.id)).schedule;
508
+ const answer = await active.client.requestDetailed("schedules_read", options.id);
509
+ const current = parseResponse("schedules.read", answer.body).schedule;
510
+ currentView = {
511
+ schedule: current,
512
+ ...(answer.headers["ETag"] === undefined
513
+ ? {}
514
+ : { etag: answer.headers["ETag"] }),
515
+ };
395
516
  confirmation = await confirmChange(runtime, args, options.confirm(current));
396
517
  }
397
- const answer = await options.act(active, options.id);
518
+ const answer = await options.act(active, options.id, currentView);
398
519
  const view = parseResponse(options.operation, answer.body);
399
520
  const nextMove = options.nextMove?.(view.schedule);
400
521
  return {
@@ -424,15 +545,47 @@ async function scheduleChange(runtime, args, options) {
424
545
  const schedulesReplace = async (runtime, args) => {
425
546
  const usage = `${runtime.programName} schedules replace <schedule> (--endpoint <destination> | --url <https url>) (--in <duration> | --at <instant> | --every <duration> | --cron <expression>)`;
426
547
  const [id] = positionals(args, ["<schedule>"], usage);
427
- const request = await scheduleRequest(runtime, args, usage);
548
+ const request = await scheduleRequest(runtime, args, usage, "replace");
428
549
  return scheduleChange(runtime, args, {
429
- act: (active, scheduleId) => active.client.requestDetailed("schedules_replace", scheduleId, request, conditionalHeaders(args)),
550
+ act: (active, scheduleId, current) => {
551
+ if (current === undefined)
552
+ throw new CliError("Replacement needs its schedule read", "internal");
553
+ const delivery = {
554
+ ...current.schedule.delivery,
555
+ ...request["delivery"],
556
+ };
557
+ const changesDelivery = request["delivery"] !== undefined;
558
+ validateDelivery(delivery);
559
+ if (changesDelivery &&
560
+ current.schedule.endpoint === undefined &&
561
+ request["endpoint"] === undefined &&
562
+ request["url"] === undefined)
563
+ throw new CliError("Delivery settings need a callback destination", "usage", "Give --endpoint or --url when attaching a callback to a timer-only schedule.");
564
+ // The API replaces delivery settings with defaults when callback fields
565
+ // are supplied. Carry all stored settings, including completion/overlap.
566
+ // Naming the retained endpoint also makes a delivery-only change reach
567
+ // that callback replacement path. Fence the read against concurrent edits.
568
+ return active.client.requestDetailed("schedules_replace", scheduleId, {
569
+ ...request,
570
+ ...(Object.keys(delivery).length === 0 ? {} : { delivery }),
571
+ ...(changesDelivery &&
572
+ request["endpoint"] === undefined &&
573
+ request["url"] === undefined &&
574
+ current.schedule.endpoint !== undefined
575
+ ? { endpoint: current.schedule.endpoint }
576
+ : {}),
577
+ }, {
578
+ ...(current.etag === undefined ? {} : { "If-Match": current.etag }),
579
+ ...conditionalHeaders(args),
580
+ });
581
+ },
430
582
  confirm: (current) => ({
431
583
  action: `Replace the definition of schedule ${current.id}.`,
432
584
  effect: [
433
585
  `now ${recurrenceOf(current)}, ${current.status}, version ${String(current.version)}`,
434
- `after the timing on this command line. Destination, data and HTTP`,
435
- ` settings are kept unless this command line gives new ones.`,
586
+ `after the timing on this command line. Destination, data, HTTP and`,
587
+ ` delivery settings are kept unless this command line gives new ones.`,
588
+ `Latency class and lateness policy are re-derived unless supplied.`,
436
589
  `Occurrences already planned under the old definition are discarded.`,
437
590
  ],
438
591
  }),
@@ -1769,6 +1922,7 @@ function notificationPreferenceResult(settings) {
1769
1922
  /* -------------------------------------------------------------------------- */
1770
1923
  /** The operate-and-diagnose commands, keyed by the words that select them. */
1771
1924
  export const OPERATE_COMMANDS = {
1925
+ ...BULK_COMMANDS,
1772
1926
  "notifications show": async (runtime, args) => {
1773
1927
  const [project] = positionals(args, ["<project>"], `${runtime.programName} notifications show <project>`);
1774
1928
  const active = await session(runtime, args);
@@ -1887,6 +2041,7 @@ export const OPERATE_COMMANDS = {
1887
2041
  "schedules cancel": schedulesCancel,
1888
2042
  "schedules list": schedulesList,
1889
2043
  "executions list": executionsList,
2044
+ "attempts search": attemptsSearch,
1890
2045
  "schedules pause": schedulesPause,
1891
2046
  "schedules replace": schedulesReplace,
1892
2047
  "schedules resume": schedulesResume,
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/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.2.3",
11
- "@greatwait/contracts": "0.2.3"
10
+ "@greatwait/api-client": "0.3.1",
11
+ "@greatwait/contracts": "0.3.1"
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.2.3"
51
+ "version": "0.3.1"
52
52
  }