@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 +9 -0
- package/dist/args.js +1 -1
- package/dist/bulk.js +4 -4
- package/dist/catalog.js +81 -11
- package/dist/commands.js +87 -33
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +152 -28
- package/dist/output.js +3 -0
- package/dist/session.d.ts +11 -0
- package/dist/session.js +34 -32
- package/dist/support.d.ts +16 -0
- package/dist/support.js +24 -0
- package/package.json +3 -3
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
|
|
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
|
|
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", "
|
|
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
|
-
"
|
|
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
|
|
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: "
|
|
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: "
|
|
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
|
|
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: "
|
|
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
|
|
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: "
|
|
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.
|
|
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;
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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}
|
|
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}
|
|
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
|
-
|
|
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: {
|
|
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
|
-
...(
|
|
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
|
|
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
|
|
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
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
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
|
-
:
|
|
1011
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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 =
|
|
527
|
-
|
|
528
|
-
|
|
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("
|
|
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
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
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
|
|
103
|
+
const secret = await presentableSecret(runtime, store, resolved.origin, stored);
|
|
107
104
|
return {
|
|
108
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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:
|
|
150
|
+
expiresAtMs: refreshedExpiresAtMs,
|
|
152
151
|
kind: "oauth",
|
|
153
152
|
refreshToken: rotated.refreshToken,
|
|
154
153
|
scope: rotated.scope,
|
|
155
154
|
});
|
|
156
|
-
return
|
|
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.
|
|
11
|
-
"@greatwait/contracts": "0.3.
|
|
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.
|
|
51
|
+
"version": "0.3.2"
|
|
52
52
|
}
|