@greatwait/cli 0.1.0-alpha.4 → 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.
@@ -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
+ ]));
@@ -49,7 +49,7 @@ export async function openLoopback() {
49
49
  return;
50
50
  }
51
51
  response.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
52
- response.end("Greatwait received the answer. You can close this tab.\n");
52
+ response.end("GreatWait received the answer. You can close this tab.\n");
53
53
  deliver?.(url.searchParams);
54
54
  });
55
55
  await new Promise((resolve, reject) => {
package/dist/oauth.js CHANGED
@@ -35,7 +35,7 @@ export async function discover(runtime, origin) {
35
35
  const resourceUrl = `${origin}/.well-known/oauth-protected-resource`;
36
36
  const resource = await readJson(runtime, resourceUrl);
37
37
  if (resource.status !== 200) {
38
- throw new CliError(`${origin} does not publish protected-resource metadata (${String(resource.status)})`, "validation", `Check the origin. A Greatwait API answers GET /__greatwait/version with no credential; try ${runtime.programName} doctor --api-url <origin>.`);
38
+ throw new CliError(`${origin} does not publish protected-resource metadata (${String(resource.status)})`, "validation", `Check the origin. A GreatWait API answers GET /__greatwait/version with no credential; try ${runtime.programName} doctor --api-url <origin>.`);
39
39
  }
40
40
  const resourceBody = resource.body;
41
41
  const servers = Array.isArray(resourceBody.authorization_servers)
@@ -75,7 +75,7 @@ export async function registerClient(runtime, server, options) {
75
75
  }
76
76
  const registered = await readJson(runtime, server.registrationEndpoint, {
77
77
  body: JSON.stringify({
78
- client_name: "Greatwait CLI",
78
+ client_name: "GreatWait CLI",
79
79
  client_uri: "https://greatwait.dev",
80
80
  grant_types: ["authorization_code", "refresh_token"],
81
81
  redirect_uris: [options.redirectUri],
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 };