@greatwait/cli 0.1.0-alpha.5 → 0.1.0-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog.d.ts +4 -5
- package/dist/catalog.js +507 -175
- package/dist/commands.js +208 -56
- package/dist/member-catalog.d.ts +1 -0
- package/dist/member-catalog.js +1 -0
- package/dist/member-commands.d.ts +6 -0
- package/dist/member-commands.js +192 -0
- package/dist/operate.d.ts +21 -0
- package/dist/operate.js +130 -6
- package/dist/remediation.d.ts +1 -1
- package/dist/support.d.ts +2 -0
- package/dist/support.js +7 -0
- package/package.json +3 -3
package/dist/commands.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { readPrivateJsonOption } from "./support.js";
|
|
2
|
+
import { MEMBER_IMPLEMENTATIONS } from "./member-commands.js";
|
|
1
3
|
import { GreatwaitClientError, createGreatwaitClient, } from "@greatwait/api-client";
|
|
2
|
-
import { isTerminalOccurrenceState, responseSchemaFor, } from "@greatwait/contracts";
|
|
4
|
+
import { endpointHeaderChangesSchema, isTerminalOccurrenceState, responseSchemaFor, } from "@greatwait/contracts";
|
|
3
5
|
import { parseDuration } from "./args.js";
|
|
4
6
|
import { COMMAND_CATALOG, REQUESTED_SCOPES } from "./catalog.js";
|
|
5
7
|
import { confirmChange } from "./confirm.js";
|
|
@@ -9,7 +11,7 @@ import { redactSecret } from "./output.js";
|
|
|
9
11
|
import { CliError, sleepOrAbort } from "./runtime.js";
|
|
10
12
|
import { publishedWait, recoveryFor, renderJourney, STEP_COMMANDS, } from "./journey.js";
|
|
11
13
|
import { anonymousClient, ENVIRONMENT_NAMES, openSession, resolveOrigin, storeFor, } from "./session.js";
|
|
12
|
-
import { OPERATE_COMMANDS, scheduleRequest } from "./operate.js";
|
|
14
|
+
import { OPERATE_COMMANDS, destinationLines, scheduleRequest } from "./operate.js";
|
|
13
15
|
import { apiUrlOption, expectNoExtraArguments, parseResponse, positionals, } from "./support.js";
|
|
14
16
|
/* -------------------------------------------------------------------------- */
|
|
15
17
|
/* Signing in */
|
|
@@ -571,8 +573,17 @@ const destinationsCreate = async (runtime, args) => {
|
|
|
571
573
|
if (url === undefined) {
|
|
572
574
|
throw new CliError(`${runtime.programName} destinations create needs --url`, "usage", `Usage: ${runtime.programName} destinations create --url https://your-receiver.example.com/greatwait`);
|
|
573
575
|
}
|
|
576
|
+
const headerInput = await readPrivateJsonOption(runtime, "--headers", args.options["--headers"]);
|
|
577
|
+
const headers = headerInput === undefined
|
|
578
|
+
? undefined
|
|
579
|
+
: endpointHeaderChangesSchema.safeParse(headerInput);
|
|
580
|
+
if (headers !== undefined && !headers.success)
|
|
581
|
+
throw new CliError("--headers settings are invalid", "usage", headers.error.issues.map((issue) => issue.message).join("; "));
|
|
574
582
|
const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
|
|
575
|
-
const created = responseSchemaFor("endpoints.create")?.parse(await session.client.request("endpoints_create", {
|
|
583
|
+
const created = responseSchemaFor("endpoints.create")?.parse(await session.client.request("endpoints_create", {
|
|
584
|
+
url,
|
|
585
|
+
...(headers?.success ? { headers: headers.data } : {}),
|
|
586
|
+
}));
|
|
576
587
|
return {
|
|
577
588
|
// **The secret is here and in no later output.** The service returns it
|
|
578
589
|
// from this call and from no other, which is the product behaviour rather
|
|
@@ -628,16 +639,27 @@ const destinationsDisable = async (runtime, args) => {
|
|
|
628
639
|
/* Schedules */
|
|
629
640
|
/* -------------------------------------------------------------------------- */
|
|
630
641
|
const schedulesCreate = async (runtime, args) => {
|
|
631
|
-
const usage = `${runtime.programName} schedules create --endpoint <destination> (--in <duration> | --at <instant> | --every <duration> | --cron <expression>)`;
|
|
632
|
-
if (args.options["--endpoint"] === undefined
|
|
633
|
-
|
|
642
|
+
const usage = `${runtime.programName} schedules create (--endpoint <destination> | --url <https url>) (--in <duration> | --at <instant> | --every <duration> | --cron <expression>) [--wait]`;
|
|
643
|
+
if (args.options["--endpoint"] === undefined &&
|
|
644
|
+
args.options["--url"] === undefined) {
|
|
645
|
+
throw new CliError(`${runtime.programName} schedules create needs --url or --endpoint`, "usage", `Usage: ${usage}`);
|
|
646
|
+
}
|
|
647
|
+
const wait = args.booleans.has("--wait");
|
|
648
|
+
// Parsed before anything is sent: a typo in --timeout found after the create
|
|
649
|
+
// would exit without the schedule's id or retry key, and the corrected rerun
|
|
650
|
+
// would make a second schedule.
|
|
651
|
+
const timeoutMs = wait ? waitTimeoutMs(args) : undefined;
|
|
652
|
+
if (!wait && args.options["--timeout"] !== undefined) {
|
|
653
|
+
// Refused rather than ignored: somebody who passed a timeout believes the
|
|
654
|
+
// command will wait for something.
|
|
655
|
+
throw new CliError("--timeout only means something with --wait", "usage", `Usage: ${usage} [--timeout <duration>]`);
|
|
634
656
|
}
|
|
635
657
|
expectNoExtraArguments(args, 0, usage);
|
|
636
658
|
const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
|
|
637
659
|
// **The same builder `schedules replace` uses.** The two send the identical
|
|
638
660
|
// definition — a replace is a create with an identifier — and writing the
|
|
639
661
|
// recurrence rules out twice is how one of them comes to accept a cron
|
|
640
|
-
// expression the other does not.
|
|
662
|
+
// expression the other does not. It also refuses `--url` with `--endpoint`.
|
|
641
663
|
const request = await scheduleRequest(runtime, args, usage);
|
|
642
664
|
// **Generated when it is not given, and printed either way.** A create that
|
|
643
665
|
// silently invented a fresh key on every run would make a retry a second
|
|
@@ -648,30 +670,125 @@ const schedulesCreate = async (runtime, args) => {
|
|
|
648
670
|
const answer = await session.client.requestDetailed("schedules_create", request, { "Idempotency-Key": idempotencyKey });
|
|
649
671
|
const created = parseResponse("schedules.create", answer.body);
|
|
650
672
|
const replayed = answer.headers["Idempotency-Replayed"] === "true";
|
|
673
|
+
const schedule = created.schedule.id;
|
|
674
|
+
const occurrence = created.occurrence.id;
|
|
675
|
+
const data = {
|
|
676
|
+
...(created.endpointSigningSecret === undefined
|
|
677
|
+
? {}
|
|
678
|
+
: {
|
|
679
|
+
endpoint: created.schedule.endpoint,
|
|
680
|
+
endpointSigningSecret: created.endpointSigningSecret,
|
|
681
|
+
}),
|
|
682
|
+
idempotencyKey,
|
|
683
|
+
occurrence,
|
|
684
|
+
plannedFor: created.occurrence.plannedFor,
|
|
685
|
+
replayed,
|
|
686
|
+
schedule,
|
|
687
|
+
};
|
|
688
|
+
const human = [
|
|
689
|
+
`Schedule ${schedule}`,
|
|
690
|
+
`Occurrence ${occurrence}`,
|
|
691
|
+
`Planned for ${created.occurrence.plannedFor}`,
|
|
692
|
+
`Retry key ${idempotencyKey}`,
|
|
693
|
+
...destinationLines(created.schedule, created.endpointSigningSecret, args.options["--url"]),
|
|
694
|
+
...(replayed
|
|
695
|
+
? [
|
|
696
|
+
``,
|
|
697
|
+
`This key had already been used for this exact request, so nothing was`,
|
|
698
|
+
`created a second time and no second callback will be delivered.`,
|
|
699
|
+
]
|
|
700
|
+
: []),
|
|
701
|
+
];
|
|
702
|
+
const watchLater = `${runtime.programName} occurrences wait ${schedule} ${occurrence}`;
|
|
703
|
+
if (!wait) {
|
|
704
|
+
return {
|
|
705
|
+
data,
|
|
706
|
+
human: human.join("\n"),
|
|
707
|
+
nextMove: `Next: ${watchLater}`,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
const repeats = created.schedule.recurrence.kind !== "at";
|
|
711
|
+
if (timeoutMs === undefined)
|
|
712
|
+
throw new CliError("--wait lost its timeout", "internal");
|
|
713
|
+
const dueInMs = Date.parse(created.occurrence.plannedFor) - runtime.now();
|
|
714
|
+
if (dueInMs > timeoutMs) {
|
|
715
|
+
// **Said at once rather than discovered at the deadline.** Polling for
|
|
716
|
+
// three minutes towards a call due tomorrow would end in the same timeout
|
|
717
|
+
// with nothing learned; the schedule exists either way.
|
|
718
|
+
return {
|
|
719
|
+
data: {
|
|
720
|
+
...data,
|
|
721
|
+
wait: { settled: false, state: created.occurrence.state },
|
|
722
|
+
},
|
|
723
|
+
exit: "timeout",
|
|
724
|
+
human: [
|
|
725
|
+
...human,
|
|
726
|
+
``,
|
|
727
|
+
`Not waiting: the first call is due at ${created.occurrence.plannedFor}, after the wait would end.`,
|
|
728
|
+
].join("\n"),
|
|
729
|
+
nextMove: `Watch it nearer the time: ${watchLater}`,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
// The schedule's identifiers before the wait, so a person who interrupts it
|
|
733
|
+
// still has them. A machine reads them from the JSON at the end.
|
|
734
|
+
if (!args.booleans.has("--json")) {
|
|
735
|
+
runtime.stderr(`${human.join("\n")}\n\n`);
|
|
736
|
+
}
|
|
737
|
+
const watched = await watchOccurrence(runtime, session, schedule, occurrence, timeoutMs);
|
|
738
|
+
const repeating = repeats
|
|
739
|
+
? [
|
|
740
|
+
``,
|
|
741
|
+
`This schedule repeats. Only its first call was waited for, and it keeps running.`,
|
|
742
|
+
]
|
|
743
|
+
: [];
|
|
744
|
+
if (!watched.settled) {
|
|
745
|
+
return {
|
|
746
|
+
data: { ...data, wait: { settled: false, state: watched.state } },
|
|
747
|
+
exit: "timeout",
|
|
748
|
+
human: [
|
|
749
|
+
`The first call was still ${watched.state} when the wait ended.`,
|
|
750
|
+
...repeating,
|
|
751
|
+
].join("\n"),
|
|
752
|
+
...(() => {
|
|
753
|
+
const nextMove = recoveryFor("await-occurrence", "not-settled");
|
|
754
|
+
return nextMove === undefined ? {} : { nextMove };
|
|
755
|
+
})(),
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
const attempts = parseResponse("attempts.list", await session.client.request("attempts_list", schedule, occurrence)).data;
|
|
759
|
+
const delivered = watched.occurrence.state === "succeeded";
|
|
651
760
|
return {
|
|
652
761
|
data: {
|
|
653
|
-
|
|
654
|
-
occurrence:
|
|
655
|
-
plannedFor: created.occurrence.plannedFor,
|
|
656
|
-
replayed,
|
|
657
|
-
schedule: created.schedule.id,
|
|
762
|
+
...data,
|
|
763
|
+
wait: { attempts, occurrence: watched.occurrence, settled: true },
|
|
658
764
|
},
|
|
659
765
|
human: [
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
]
|
|
670
|
-
: []),
|
|
766
|
+
delivered
|
|
767
|
+
? `Delivered. ${outcomeOf(attempts.find((attempt) => attempt.state === "succeeded"))}`
|
|
768
|
+
: `Not delivered: the first call settled ${watched.occurrence.state}.`,
|
|
769
|
+
// The attempt rows only when they explain something: a delivered call
|
|
770
|
+
// has said all it needs to in the line above.
|
|
771
|
+
...(delivered || attempts.length === 0
|
|
772
|
+
? []
|
|
773
|
+
: [``, ...attempts.map(attemptLine)]),
|
|
774
|
+
...repeating,
|
|
671
775
|
].join("\n"),
|
|
672
|
-
|
|
776
|
+
...(delivered
|
|
777
|
+
? {}
|
|
778
|
+
: (() => {
|
|
779
|
+
const nextMove = recoveryFor("read-attempt", "attempt-failed");
|
|
780
|
+
return nextMove === undefined ? {} : { nextMove };
|
|
781
|
+
})()),
|
|
673
782
|
};
|
|
674
783
|
};
|
|
784
|
+
function attemptLine(attempt) {
|
|
785
|
+
return `${attempt.state.padEnd(20)}${attempt.responseStatus === undefined ? "no response" : `answered ${String(attempt.responseStatus)}`}${attempt.diagnostic === undefined ? "" : ` ${attempt.diagnostic}`}${attempt.remediation === undefined ? "" : ` / ${attempt.remediation}`}`;
|
|
786
|
+
}
|
|
787
|
+
function outcomeOf(attempt) {
|
|
788
|
+
return attempt?.responseStatus === undefined
|
|
789
|
+
? "Your receiver accepted the call."
|
|
790
|
+
: `Your receiver answered ${String(attempt.responseStatus)}.`;
|
|
791
|
+
}
|
|
675
792
|
const schedulesRead = async (runtime, args) => {
|
|
676
793
|
const id = args.command[0];
|
|
677
794
|
if (id === undefined) {
|
|
@@ -688,11 +805,21 @@ const schedulesRead = async (runtime, args) => {
|
|
|
688
805
|
recurrence: view.schedule.recurrence.kind,
|
|
689
806
|
schedule: view.schedule.id,
|
|
690
807
|
status: view.schedule.status,
|
|
808
|
+
autoPauseAfterFailures: view.schedule.autoPauseAfterFailures,
|
|
809
|
+
consecutiveFailedRuns: view.schedule.consecutiveFailedRuns,
|
|
810
|
+
autoPauseReason: view.schedule.autoPauseReason,
|
|
691
811
|
version: view.schedule.version,
|
|
692
812
|
},
|
|
693
813
|
human: [
|
|
694
814
|
`Schedule ${view.schedule.id}`,
|
|
695
815
|
`Status ${view.schedule.status}`,
|
|
816
|
+
`Auto-pause ${String(view.schedule.autoPauseAfterFailures ?? 5)}`,
|
|
817
|
+
`Failed runs ${String(view.schedule.consecutiveFailedRuns ?? 0)}`,
|
|
818
|
+
...(view.schedule.autoPauseReason == null
|
|
819
|
+
? []
|
|
820
|
+
: [
|
|
821
|
+
`Paused ${String(view.schedule.autoPauseReason.failedRuns)} failed runs; ${view.schedule.autoPauseReason.status === undefined ? "no HTTP response" : `HTTP ${String(view.schedule.autoPauseReason.status)}`} (${view.schedule.autoPauseReason.diagnostic}) at ${view.schedule.autoPauseReason.pausedAt}`,
|
|
822
|
+
]),
|
|
696
823
|
`Recurrence ${view.schedule.recurrence.kind}`,
|
|
697
824
|
`Version ${String(view.schedule.version)}`,
|
|
698
825
|
...(etag === undefined ? [] : [`ETag ${etag}`]),
|
|
@@ -767,21 +894,28 @@ const exportsCancel = async (runtime, args) => {
|
|
|
767
894
|
/* -------------------------------------------------------------------------- */
|
|
768
895
|
/* Waiting */
|
|
769
896
|
/* -------------------------------------------------------------------------- */
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
// The interval and the deadline come from the published journey. A number
|
|
779
|
-
// here would be a fourth answer to a question the website, the curl page and
|
|
780
|
-
// the JavaScript page already answer identically.
|
|
781
|
-
const { intervalMs, timeoutMs } = publishedWait();
|
|
897
|
+
/**
|
|
898
|
+
* How long a wait lasts: `--timeout`, or the published journey's deadline.
|
|
899
|
+
*
|
|
900
|
+
* The interval and the deadline come from the published journey. A number
|
|
901
|
+
* here would be a fourth answer to a question the website, the curl page and
|
|
902
|
+
* the JavaScript page already answer identically.
|
|
903
|
+
*/
|
|
904
|
+
function waitTimeoutMs(args) {
|
|
782
905
|
const override = args.options["--timeout"];
|
|
783
|
-
|
|
784
|
-
|
|
906
|
+
return override === undefined
|
|
907
|
+
? publishedWait().timeoutMs
|
|
908
|
+
: parseDuration(override) * 1000;
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Reads one occurrence until it settles or the deadline passes, reporting each
|
|
912
|
+
* new state on standard error. Shared by `occurrences wait` and `schedules
|
|
913
|
+
* create --wait`, so the two cannot come to disagree about what settled means
|
|
914
|
+
* or how long the wait lasts.
|
|
915
|
+
*/
|
|
916
|
+
async function watchOccurrence(runtime, session, schedule, occurrence, timeoutMs) {
|
|
917
|
+
const { intervalMs } = publishedWait();
|
|
918
|
+
const deadline = runtime.now() + timeoutMs;
|
|
785
919
|
const seen = [];
|
|
786
920
|
for (;;) {
|
|
787
921
|
const view = responseSchemaFor("occurrences.read")?.parse(await session.client.request("occurrences_read", schedule, occurrence));
|
|
@@ -791,24 +925,43 @@ const occurrencesWait = async (runtime, args) => {
|
|
|
791
925
|
runtime.stderr(` ${state}\n`);
|
|
792
926
|
}
|
|
793
927
|
if (isTerminalOccurrenceState(state)) {
|
|
794
|
-
return {
|
|
795
|
-
data: {
|
|
796
|
-
attempts: view.occurrence.attemptCount,
|
|
797
|
-
occurrence,
|
|
798
|
-
schedule,
|
|
799
|
-
settled: true,
|
|
800
|
-
state,
|
|
801
|
-
states: seen,
|
|
802
|
-
},
|
|
803
|
-
human: `Occurrence ${occurrence} settled ${state} after ${String(view.occurrence.attemptCount)} ${view.occurrence.attemptCount === 1 ? "attempt" : "attempts"}.`,
|
|
804
|
-
nextMove: `Next: ${runtime.programName} attempts list ${schedule} ${occurrence}`,
|
|
805
|
-
};
|
|
928
|
+
return { occurrence: view.occurrence, settled: true, states: seen };
|
|
806
929
|
}
|
|
807
|
-
|
|
808
|
-
|
|
930
|
+
const remaining = deadline - runtime.now();
|
|
931
|
+
if (remaining <= 0) {
|
|
932
|
+
return { settled: false, state, states: seen };
|
|
809
933
|
}
|
|
810
|
-
|
|
934
|
+
// Cut to the time left, so the last read happens at the deadline rather
|
|
935
|
+
// than an interval past it — a --timeout that is not a multiple of the
|
|
936
|
+
// interval means what it says.
|
|
937
|
+
await sleepOrAbort(runtime, Math.min(intervalMs, remaining));
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
const occurrencesWait = async (runtime, args) => {
|
|
941
|
+
const schedule = args.command[0];
|
|
942
|
+
const occurrence = args.command[1];
|
|
943
|
+
if (schedule === undefined || occurrence === undefined) {
|
|
944
|
+
throw new CliError(`${runtime.programName} occurrences wait needs a schedule and an occurrence`, "usage", `Usage: ${runtime.programName} occurrences wait <schedule> <occurrence>`);
|
|
811
945
|
}
|
|
946
|
+
expectNoExtraArguments(args, 2, `${runtime.programName} occurrences wait <schedule> <occurrence>`);
|
|
947
|
+
const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
|
|
948
|
+
const watched = await watchOccurrence(runtime, session, schedule, occurrence, waitTimeoutMs(args));
|
|
949
|
+
if (!watched.settled) {
|
|
950
|
+
throw new CliError(`occurrence ${occurrence} was still ${watched.state} when the deadline passed`, "timeout", recoveryFor("await-occurrence", "not-settled"));
|
|
951
|
+
}
|
|
952
|
+
const { attemptCount, state } = watched.occurrence;
|
|
953
|
+
return {
|
|
954
|
+
data: {
|
|
955
|
+
attempts: attemptCount,
|
|
956
|
+
occurrence,
|
|
957
|
+
schedule,
|
|
958
|
+
settled: true,
|
|
959
|
+
state,
|
|
960
|
+
states: watched.states,
|
|
961
|
+
},
|
|
962
|
+
human: `Occurrence ${occurrence} settled ${state} after ${String(attemptCount)} ${attemptCount === 1 ? "attempt" : "attempts"}.`,
|
|
963
|
+
nextMove: `Next: ${runtime.programName} attempts list ${schedule} ${occurrence}`,
|
|
964
|
+
};
|
|
812
965
|
};
|
|
813
966
|
const attemptsList = async (runtime, args) => {
|
|
814
967
|
const schedule = args.command[0];
|
|
@@ -824,9 +977,7 @@ const attemptsList = async (runtime, args) => {
|
|
|
824
977
|
data: { attempts: page.data, occurrence, schedule },
|
|
825
978
|
human: page.data.length === 0
|
|
826
979
|
? "No attempt has been made yet."
|
|
827
|
-
: page.data
|
|
828
|
-
.map((attempt) => `${attempt.state.padEnd(20)}${attempt.responseStatus === undefined ? "no response" : `answered ${String(attempt.responseStatus)}`}${attempt.diagnostic === undefined ? "" : ` ${attempt.diagnostic}`}${attempt.remediation === undefined ? "" : ` / ${attempt.remediation}`}`)
|
|
829
|
-
.join("\n"),
|
|
980
|
+
: page.data.map(attemptLine).join("\n"),
|
|
830
981
|
// **The journey's own sentence, not a paraphrase of the code.**
|
|
831
982
|
// `diagnostic` and `remediation` are closed vocabularies rather than prose
|
|
832
983
|
// — the step's `observe` says to read them, and its recovery entry is the
|
|
@@ -854,6 +1005,7 @@ const attemptsList = async (runtime, args) => {
|
|
|
854
1005
|
*/
|
|
855
1006
|
const IMPLEMENTATIONS = {
|
|
856
1007
|
...OPERATE_COMMANDS,
|
|
1008
|
+
...MEMBER_IMPLEMENTATIONS,
|
|
857
1009
|
"attempts list": attemptsList,
|
|
858
1010
|
context,
|
|
859
1011
|
"destinations create": destinationsCreate,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MEMBER_COMMANDS } from "./catalog.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MEMBER_COMMANDS } from "./catalog.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ParsedArguments } from "./args.js";
|
|
2
|
+
import type { CommandResult } from "./output.js";
|
|
3
|
+
import { type CliRuntime } from "./runtime.js";
|
|
4
|
+
export declare const MEMBER_IMPLEMENTATIONS: {
|
|
5
|
+
[k: string]: (runtime: CliRuntime, args: ParsedArguments) => Promise<CommandResult>;
|
|
6
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { findOperation, invitationListSchema, memberActionSchema, memberListSchema, responseSchemaFor, } from "@greatwait/contracts";
|
|
2
|
+
import { usageLine } from "./catalog.js";
|
|
3
|
+
import { MEMBER_COMMANDS } from "./member-catalog.js";
|
|
4
|
+
import { confirmChange } from "./confirm.js";
|
|
5
|
+
import { CliError } from "./runtime.js";
|
|
6
|
+
import { openSession } from "./session.js";
|
|
7
|
+
import { apiUrlOption, positionals } from "./support.js";
|
|
8
|
+
/**
|
|
9
|
+
* The member and invitation commands (W1-09), shipped in alpha.6 (W9-46).
|
|
10
|
+
*
|
|
11
|
+
* **Flags rather than a JSON body.** Until alpha.6 these took `--body` JSON,
|
|
12
|
+
* which is how a request is spelled in curl and not how a person invites a
|
|
13
|
+
* teammate; the docs' Teammates page lists these commands, so they read as
|
|
14
|
+
* commands. Every request still passes the operation's own schema before it is
|
|
15
|
+
* sent, so a flag cannot produce a body the contract refuses.
|
|
16
|
+
*
|
|
17
|
+
* **The retry key is generated when it is not given, and printed.** The same
|
|
18
|
+
* rule `schedules create` keeps: a change whose answer was lost is retried
|
|
19
|
+
* with the key it printed, and runs once.
|
|
20
|
+
*/
|
|
21
|
+
const ROLES = ["admin", "member"];
|
|
22
|
+
function roleOption(args, fallback) {
|
|
23
|
+
const role = args.options["--role"] ?? fallback;
|
|
24
|
+
if (role === undefined) {
|
|
25
|
+
throw new CliError("--role is required: admin or member", "usage");
|
|
26
|
+
}
|
|
27
|
+
if (!ROLES.includes(role)) {
|
|
28
|
+
throw new CliError(`--role is admin or member, not ${role}`, "usage");
|
|
29
|
+
}
|
|
30
|
+
return role;
|
|
31
|
+
}
|
|
32
|
+
function projectsOption(args) {
|
|
33
|
+
const value = args.options["--projects"];
|
|
34
|
+
if (value === undefined)
|
|
35
|
+
return [];
|
|
36
|
+
return value
|
|
37
|
+
.split(",")
|
|
38
|
+
.map((project) => project.trim())
|
|
39
|
+
.filter((project) => project.length > 0);
|
|
40
|
+
}
|
|
41
|
+
function rows(table) {
|
|
42
|
+
const widths = (table[0] ?? []).map((_, column) => Math.max(...table.map((row) => (row[column] ?? "").length)));
|
|
43
|
+
return table
|
|
44
|
+
.map((row) => row
|
|
45
|
+
.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widths[column] ?? 0))
|
|
46
|
+
.join(" "))
|
|
47
|
+
.join("\n");
|
|
48
|
+
}
|
|
49
|
+
export const MEMBER_IMPLEMENTATIONS = Object.fromEntries(MEMBER_COMMANDS.map((declaration) => [
|
|
50
|
+
declaration.words,
|
|
51
|
+
async (runtime, args) => {
|
|
52
|
+
const operation = findOperation(declaration.operations[0]);
|
|
53
|
+
const ids = positionals(args, declaration.positionals.map((positional) => positional.name), usageLine(declaration, runtime.programName));
|
|
54
|
+
const session = await openSession(runtime, {
|
|
55
|
+
apiUrl: apiUrlOption(args),
|
|
56
|
+
});
|
|
57
|
+
if (declaration.words === "members list") {
|
|
58
|
+
const { data } = memberListSchema.parse(await session.client.request("members_list"));
|
|
59
|
+
return {
|
|
60
|
+
data: { data },
|
|
61
|
+
human: data.length === 0
|
|
62
|
+
? "No members yet."
|
|
63
|
+
: rows([
|
|
64
|
+
["ROLE", "EMAIL", "PROJECTS", "MEMBER"],
|
|
65
|
+
...data.map((member) => [
|
|
66
|
+
member.role,
|
|
67
|
+
`${member.email}${member.self ? " (you)" : ""}`,
|
|
68
|
+
member.allProjects
|
|
69
|
+
? "all"
|
|
70
|
+
: member.projects.join(",") || "none",
|
|
71
|
+
member.id,
|
|
72
|
+
]),
|
|
73
|
+
]),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (declaration.words === "invitations list") {
|
|
77
|
+
const { data } = invitationListSchema.parse(await session.client.request("invitations_list"));
|
|
78
|
+
return {
|
|
79
|
+
data: { data },
|
|
80
|
+
human: data.length === 0
|
|
81
|
+
? "No invitations yet."
|
|
82
|
+
: rows([
|
|
83
|
+
["STATUS", "ROLE", "EMAIL", "PROJECTS", "INVITATION"],
|
|
84
|
+
...data.map((invitation) => [
|
|
85
|
+
invitation.status,
|
|
86
|
+
invitation.role,
|
|
87
|
+
invitation.email,
|
|
88
|
+
invitation.projects.join(",") || "none",
|
|
89
|
+
invitation.id,
|
|
90
|
+
]),
|
|
91
|
+
]),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const [first, second] = ids;
|
|
95
|
+
const body = operation.id === "invitations.create"
|
|
96
|
+
? {
|
|
97
|
+
email: args.options["--email"] ??
|
|
98
|
+
(() => {
|
|
99
|
+
throw new CliError("--email is required: the address to send the invitation to", "usage", `Usage: ${usageLine(declaration, runtime.programName)}`);
|
|
100
|
+
})(),
|
|
101
|
+
projects: projectsOption(args),
|
|
102
|
+
role: roleOption(args, "member"),
|
|
103
|
+
}
|
|
104
|
+
: operation.id === "members.role"
|
|
105
|
+
? { role: roleOption(args) }
|
|
106
|
+
: operation.id === "invitations.resend" ||
|
|
107
|
+
operation.id === "projectmembers.add"
|
|
108
|
+
? {}
|
|
109
|
+
: { confirmed: true };
|
|
110
|
+
const parsed = operation.request.safeParse(body);
|
|
111
|
+
if (!parsed.success) {
|
|
112
|
+
throw new CliError(`That isn't a valid ${declaration.words}: ${parsed.error.issues
|
|
113
|
+
.map((issue) => `${issue.path.join(".")} ${issue.message}`.trim())
|
|
114
|
+
.join("; ")}`, "usage");
|
|
115
|
+
}
|
|
116
|
+
const key = args.options["--idempotency-key"] ??
|
|
117
|
+
`greatwait-cli-${runtime.randomUuid()}`;
|
|
118
|
+
const effect = () => {
|
|
119
|
+
switch (operation.id) {
|
|
120
|
+
case "invitations.create":
|
|
121
|
+
return [
|
|
122
|
+
`email ${String(body["email"])}`,
|
|
123
|
+
`role ${String(body["role"])}`,
|
|
124
|
+
`projects ${body["projects"].join(", ") || "none"}`,
|
|
125
|
+
];
|
|
126
|
+
case "invitations.resend":
|
|
127
|
+
return [`invitation ${first}`];
|
|
128
|
+
case "invitations.revoke":
|
|
129
|
+
return [`invitation ${first}`, "Its link stops working."];
|
|
130
|
+
case "members.remove":
|
|
131
|
+
return [
|
|
132
|
+
`member ${first}`,
|
|
133
|
+
"API keys the organization owns keep working. Revoke them separately if you need to.",
|
|
134
|
+
];
|
|
135
|
+
case "members.role":
|
|
136
|
+
return [
|
|
137
|
+
`member ${first}`,
|
|
138
|
+
`role ${String(body["role"])}`,
|
|
139
|
+
];
|
|
140
|
+
default:
|
|
141
|
+
return [`member ${first}`, `project ${second}`];
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const confirmation = await confirmChange(runtime, args, {
|
|
145
|
+
action: `${declaration.summary}`,
|
|
146
|
+
effect: effect(),
|
|
147
|
+
});
|
|
148
|
+
// Before sending, so a lost answer still leaves the key on screen: the
|
|
149
|
+
// retry that reuses it changes nothing twice.
|
|
150
|
+
runtime.stderr(`Retry key ${key}. Reuse it with --idempotency-key if the answer is lost.\n`);
|
|
151
|
+
const headers = { "Idempotency-Key": key };
|
|
152
|
+
const send = () => {
|
|
153
|
+
switch (operation.id) {
|
|
154
|
+
case "invitations.create":
|
|
155
|
+
return session.client.request("invitations_create", parsed.data, headers);
|
|
156
|
+
case "invitations.resend":
|
|
157
|
+
return session.client.request("invitations_resend", first, {}, headers);
|
|
158
|
+
case "invitations.revoke":
|
|
159
|
+
return session.client.request("invitations_revoke", first, { confirmed: true }, headers);
|
|
160
|
+
case "members.role":
|
|
161
|
+
return session.client.request("members_role", first, parsed.data, headers);
|
|
162
|
+
case "members.remove":
|
|
163
|
+
return session.client.request("members_remove", first, { confirmed: true }, headers);
|
|
164
|
+
case "projectmembers.add":
|
|
165
|
+
return session.client.request("projectmembers_add", second, first, {}, headers);
|
|
166
|
+
case "projectmembers.remove":
|
|
167
|
+
return session.client.request("projectmembers_remove", second, first, { confirmed: true }, headers);
|
|
168
|
+
default:
|
|
169
|
+
throw new CliError(`${operation.id} has no command`, "internal");
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
// Each answer through its operation's own schema: an invitation comes
|
|
173
|
+
// back as the invitation, every other change as an acknowledgement.
|
|
174
|
+
const done = (responseSchemaFor(operation.id) ?? memberActionSchema).parse(await send());
|
|
175
|
+
const said = {
|
|
176
|
+
"invitations.create": `Invited ${String(body["email"])} as ${String(body["role"])}. Invitation ${done.id}.`,
|
|
177
|
+
"invitations.resend": `Sent invitation ${done.id} again.`,
|
|
178
|
+
"invitations.revoke": `Revoked invitation ${done.id}. Its link no longer works.`,
|
|
179
|
+
"members.remove": `Removed ${done.id} from the organization.`,
|
|
180
|
+
"members.role": `${done.id} is now ${String(body["role"])}.`,
|
|
181
|
+
"projectmembers.add": `${done.id} can now see ${second}.`,
|
|
182
|
+
"projectmembers.remove": `${done.id} can no longer see ${second}.`,
|
|
183
|
+
};
|
|
184
|
+
return {
|
|
185
|
+
data: { ...done, confirmation, idempotencyKey: key },
|
|
186
|
+
human: [
|
|
187
|
+
said[operation.id] ?? `Done. ${done.id}.`,
|
|
188
|
+
`Retry key ${key}`,
|
|
189
|
+
].join("\n"),
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
]));
|
package/dist/operate.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SigningSecretNotice } from "@greatwait/contracts";
|
|
1
2
|
import { type ParsedArguments } from "./args.js";
|
|
2
3
|
import { type CommandResult } from "./output.js";
|
|
3
4
|
import { type CliRuntime } from "./runtime.js";
|
|
@@ -34,6 +35,26 @@ type Command = (runtime: CliRuntime, args: ParsedArguments) => Promise<CommandRe
|
|
|
34
35
|
* and on a build runner.
|
|
35
36
|
*/
|
|
36
37
|
declare function scheduleRequest(runtime: CliRuntime, args: ParsedArguments, usage: string): Promise<Readonly<Record<string, unknown>>>;
|
|
38
|
+
/**
|
|
39
|
+
* Where a schedule written with `--url` delivers, and where its signing
|
|
40
|
+
* secret is.
|
|
41
|
+
*
|
|
42
|
+
* **Only the API's notice, never a secret.** A schedule write that names a
|
|
43
|
+
* `url` answers with `endpointSigningSecret` — `returned: false` and the
|
|
44
|
+
* dashboard page where a member reveals the secret — and nothing else about
|
|
45
|
+
* it, so there is nothing here that could print one. Absent when the write
|
|
46
|
+
* named `--endpoint`: that caller already holds the destination it chose.
|
|
47
|
+
*/
|
|
48
|
+
export declare function destinationLines(schedule: {
|
|
49
|
+
readonly endpoint?: string;
|
|
50
|
+
readonly endpointUrl?: string;
|
|
51
|
+
}, notice: SigningSecretNotice | undefined,
|
|
52
|
+
/**
|
|
53
|
+
* The `--url` that was sent. A schedule write answers without
|
|
54
|
+
* `endpointUrl` (only reads carry it), and the address is the one the
|
|
55
|
+
* destination was found or made at.
|
|
56
|
+
*/
|
|
57
|
+
requestedUrl?: string): string[];
|
|
37
58
|
/** The operate-and-diagnose commands, keyed by the words that select them. */
|
|
38
59
|
export declare const OPERATE_COMMANDS: Readonly<Record<string, Command>>;
|
|
39
60
|
export { scheduleRequest, type Command };
|