@greatwait/cli 0.1.0-alpha.5 → 0.1.0-alpha.6

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/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", { url }));
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
- throw new CliError(`${runtime.programName} schedules create needs --endpoint`, "usage", `Usage: ${usage}`);
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
- idempotencyKey,
654
- occurrence: created.occurrence.id,
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
- `Schedule ${created.schedule.id}`,
661
- `Occurrence ${created.occurrence.id}`,
662
- `Planned for ${created.occurrence.plannedFor}`,
663
- `Retry key ${idempotencyKey}`,
664
- ...(replayed
665
- ? [
666
- ``,
667
- `This key had already been used for this exact request, so nothing was`,
668
- `created a second time and no second callback will be delivered.`,
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
- nextMove: `Next: ${runtime.programName} occurrences wait ${created.schedule.id} ${created.occurrence.id}`,
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) {
@@ -767,21 +884,28 @@ const exportsCancel = async (runtime, args) => {
767
884
  /* -------------------------------------------------------------------------- */
768
885
  /* Waiting */
769
886
  /* -------------------------------------------------------------------------- */
770
- const occurrencesWait = async (runtime, args) => {
771
- const schedule = args.command[0];
772
- const occurrence = args.command[1];
773
- if (schedule === undefined || occurrence === undefined) {
774
- throw new CliError(`${runtime.programName} occurrences wait needs a schedule and an occurrence`, "usage", `Usage: ${runtime.programName} occurrences wait <schedule> <occurrence>`);
775
- }
776
- expectNoExtraArguments(args, 2, `${runtime.programName} occurrences wait <schedule> <occurrence>`);
777
- const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
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();
887
+ /**
888
+ * How long a wait lasts: `--timeout`, or the published journey's deadline.
889
+ *
890
+ * The interval and the deadline come from the published journey. A number
891
+ * here would be a fourth answer to a question the website, the curl page and
892
+ * the JavaScript page already answer identically.
893
+ */
894
+ function waitTimeoutMs(args) {
782
895
  const override = args.options["--timeout"];
783
- const deadline = runtime.now() +
784
- (override === undefined ? timeoutMs : parseDuration(override) * 1000);
896
+ return override === undefined
897
+ ? publishedWait().timeoutMs
898
+ : parseDuration(override) * 1000;
899
+ }
900
+ /**
901
+ * Reads one occurrence until it settles or the deadline passes, reporting each
902
+ * new state on standard error. Shared by `occurrences wait` and `schedules
903
+ * create --wait`, so the two cannot come to disagree about what settled means
904
+ * or how long the wait lasts.
905
+ */
906
+ async function watchOccurrence(runtime, session, schedule, occurrence, timeoutMs) {
907
+ const { intervalMs } = publishedWait();
908
+ const deadline = runtime.now() + timeoutMs;
785
909
  const seen = [];
786
910
  for (;;) {
787
911
  const view = responseSchemaFor("occurrences.read")?.parse(await session.client.request("occurrences_read", schedule, occurrence));
@@ -791,24 +915,43 @@ const occurrencesWait = async (runtime, args) => {
791
915
  runtime.stderr(` ${state}\n`);
792
916
  }
793
917
  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
- };
918
+ return { occurrence: view.occurrence, settled: true, states: seen };
806
919
  }
807
- if (runtime.now() >= deadline) {
808
- throw new CliError(`occurrence ${occurrence} was still ${state} when the deadline passed`, "timeout", recoveryFor("await-occurrence", "not-settled"));
920
+ const remaining = deadline - runtime.now();
921
+ if (remaining <= 0) {
922
+ return { settled: false, state, states: seen };
809
923
  }
810
- await sleepOrAbort(runtime, intervalMs);
924
+ // Cut to the time left, so the last read happens at the deadline rather
925
+ // than an interval past it — a --timeout that is not a multiple of the
926
+ // interval means what it says.
927
+ await sleepOrAbort(runtime, Math.min(intervalMs, remaining));
928
+ }
929
+ }
930
+ const occurrencesWait = async (runtime, args) => {
931
+ const schedule = args.command[0];
932
+ const occurrence = args.command[1];
933
+ if (schedule === undefined || occurrence === undefined) {
934
+ throw new CliError(`${runtime.programName} occurrences wait needs a schedule and an occurrence`, "usage", `Usage: ${runtime.programName} occurrences wait <schedule> <occurrence>`);
811
935
  }
936
+ expectNoExtraArguments(args, 2, `${runtime.programName} occurrences wait <schedule> <occurrence>`);
937
+ const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
938
+ const watched = await watchOccurrence(runtime, session, schedule, occurrence, waitTimeoutMs(args));
939
+ if (!watched.settled) {
940
+ throw new CliError(`occurrence ${occurrence} was still ${watched.state} when the deadline passed`, "timeout", recoveryFor("await-occurrence", "not-settled"));
941
+ }
942
+ const { attemptCount, state } = watched.occurrence;
943
+ return {
944
+ data: {
945
+ attempts: attemptCount,
946
+ occurrence,
947
+ schedule,
948
+ settled: true,
949
+ state,
950
+ states: watched.states,
951
+ },
952
+ human: `Occurrence ${occurrence} settled ${state} after ${String(attemptCount)} ${attemptCount === 1 ? "attempt" : "attempts"}.`,
953
+ nextMove: `Next: ${runtime.programName} attempts list ${schedule} ${occurrence}`,
954
+ };
812
955
  };
813
956
  const attemptsList = async (runtime, args) => {
814
957
  const schedule = args.command[0];
@@ -824,9 +967,7 @@ const attemptsList = async (runtime, args) => {
824
967
  data: { attempts: page.data, occurrence, schedule },
825
968
  human: page.data.length === 0
826
969
  ? "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"),
970
+ : page.data.map(attemptLine).join("\n"),
830
971
  // **The journey's own sentence, not a paraphrase of the code.**
831
972
  // `diagnostic` and `remediation` are closed vocabularies rather than prose
832
973
  // — the step's `observe` says to read them, and its recovery entry is the
@@ -854,6 +995,7 @@ const attemptsList = async (runtime, args) => {
854
995
  */
855
996
  const IMPLEMENTATIONS = {
856
997
  ...OPERATE_COMMANDS,
998
+ ...MEMBER_IMPLEMENTATIONS,
857
999
  "attempts list": attemptsList,
858
1000
  context,
859
1001
  "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 };