@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.
- package/README.md +1 -1
- package/dist/catalog.d.ts +4 -5
- package/dist/catalog.js +551 -168
- package/dist/cli.js +4 -2
- package/dist/commands.js +206 -60
- package/dist/exit.d.ts +15 -2
- package/dist/exit.js +25 -2
- 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/node-runtime.js +1 -1
- package/dist/oauth.js +2 -2
- package/dist/operate.d.ts +21 -0
- package/dist/operate.js +254 -17
- package/dist/remediation.d.ts +1 -1
- package/dist/remediation.js +8 -0
- package/dist/session.js +1 -1
- package/dist/support.d.ts +5 -7
- package/dist/support.js +11 -13
- package/package.json +4 -4
|
@@ -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/node-runtime.js
CHANGED
|
@@ -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("
|
|
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
|
|
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: "
|
|
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 };
|