@greatwait/cli 0.1.0-alpha.3

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,210 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createServer } from "node:http";
3
+ import { homedir } from "node:os";
4
+ import { createInterface } from "node:readline";
5
+ import { InterruptedError, } from "./runtime.js";
6
+ /**
7
+ * The one file that knows there is a process.
8
+ *
9
+ * Everything above this takes its surroundings as a parameter, so the shape of
10
+ * this file is the list of things a test has to be able to fake: a browser, a
11
+ * listener, a terminal, a clock, a signal.
12
+ */
13
+ /**
14
+ * A listener for exactly one authorization redirect.
15
+ *
16
+ * **Only the redirect satisfies it.** A browser sent to a loopback address will
17
+ * happily make other requests to it first — a speculative preconnect, a
18
+ * `/favicon.ico` once the page renders — and a listener that resolved on the
19
+ * first request would resolve with one of those, carrying no `state`, killing
20
+ * the sign-in with a message about a state mismatch while the real redirect
21
+ * lands a moment later on a closed socket. Anything that is not the redirect is
22
+ * answered and the wait continues.
23
+ *
24
+ * Port zero: the operating system picks, and the port is known before the
25
+ * client is registered for it, which RFC 8252's exact-match rule requires.
26
+ */
27
+ export async function openLoopback() {
28
+ let deliver;
29
+ let fail;
30
+ const received = new Promise((resolve, reject) => {
31
+ deliver = resolve;
32
+ fail = reject;
33
+ });
34
+ // **Nobody is necessarily waiting on this when `close` runs.** A sign-in
35
+ // abandoned before the wait begins — dynamic registration refused, the
36
+ // authorization address unbuildable — reaches the `finally` that closes the
37
+ // listener while `received` has never been awaited, and an unhandled
38
+ // rejection ends the process in Node with a stack trace instead of the exit
39
+ // class the refusal earned. A handler attached here is never the *only* one:
40
+ // a caller that does await it still sees the rejection.
41
+ received.catch(() => undefined);
42
+ const server = createServer((request, response) => {
43
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
44
+ const isRedirect = url.pathname === "/callback" &&
45
+ (url.searchParams.has("code") || url.searchParams.has("error"));
46
+ if (!isRedirect) {
47
+ response.writeHead(204);
48
+ response.end();
49
+ return;
50
+ }
51
+ response.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
52
+ response.end("Greatwait received the answer. You can close this tab.\n");
53
+ deliver?.(url.searchParams);
54
+ });
55
+ await new Promise((resolve, reject) => {
56
+ server.once("error", reject);
57
+ // `127.0.0.1` rather than every interface: this socket receives an
58
+ // authorization code, and nothing outside this machine has any business
59
+ // reaching it.
60
+ server.listen(0, "127.0.0.1", () => {
61
+ resolve();
62
+ });
63
+ });
64
+ const address = server.address();
65
+ if (address === null || typeof address === "string") {
66
+ server.close();
67
+ throw new Error("the loopback listener did not bind a port");
68
+ }
69
+ return {
70
+ close: () => {
71
+ fail?.(new Error("the sign-in was abandoned"));
72
+ server.close();
73
+ },
74
+ received,
75
+ redirectUri: `http://127.0.0.1:${String(address.port)}/callback`,
76
+ };
77
+ }
78
+ /** The platform's "open this" command, or a rejection where there is none. */
79
+ async function openBrowser(url) {
80
+ const command = process.platform === "darwin"
81
+ ? { args: [url], name: "open" }
82
+ : process.platform === "win32"
83
+ ? { args: ["/c", "start", "", url], name: "cmd" }
84
+ : { args: [url], name: "xdg-open" };
85
+ await new Promise((resolve, reject) => {
86
+ const child = spawn(command.name, command.args, {
87
+ // The browser outlives this process and must not hold its streams.
88
+ detached: true,
89
+ stdio: "ignore",
90
+ });
91
+ child.once("error", reject);
92
+ child.once("spawn", () => {
93
+ child.unref();
94
+ resolve();
95
+ });
96
+ });
97
+ }
98
+ /**
99
+ * One line, not echoed.
100
+ *
101
+ * `readline` with the terminal's own echo turned off rather than a library:
102
+ * what has to be true is that the characters never reach the screen or the
103
+ * scrollback, and that is a property of the terminal rather than of the prompt.
104
+ */
105
+ export async function promptSecret(question, streams = { input: process.stdin, output: process.stderr }) {
106
+ const input = streams.input;
107
+ const wasRaw = input.isRaw === true;
108
+ const readline = createInterface({ input, output: streams.output });
109
+ streams.output.write(question);
110
+ if (input.isTTY)
111
+ input.setRawMode(true);
112
+ try {
113
+ return await new Promise((resolve, reject) => {
114
+ let typed = "";
115
+ const onData = (chunk) => {
116
+ for (const byte of chunk) {
117
+ if (byte === 0x0d || byte === 0x0a) {
118
+ input.off("data", onData);
119
+ streams.output.write("\n");
120
+ resolve(typed);
121
+ return;
122
+ }
123
+ if (byte === 0x7f || byte === 0x08) {
124
+ typed = typed.slice(0, -1);
125
+ continue;
126
+ }
127
+ if (byte === 0x03) {
128
+ input.off("data", onData);
129
+ streams.output.write("\n");
130
+ // **Ctrl-C at the prompt is an interruption, not an empty
131
+ // answer.** Resolving empty made it a `usage` refusal about a
132
+ // credential nobody entered, which is the wrong sentence and the
133
+ // wrong exit number: a shell reports `130` for a process the
134
+ // person stopped, and every other wait in this command already
135
+ // does.
136
+ reject(new InterruptedError());
137
+ return;
138
+ }
139
+ typed += String.fromCharCode(byte);
140
+ }
141
+ };
142
+ input.on("data", onData);
143
+ });
144
+ }
145
+ finally {
146
+ if (input.isTTY)
147
+ input.setRawMode(wasRaw);
148
+ readline.close();
149
+ }
150
+ }
151
+ async function readStdin() {
152
+ if (process.stdin.isTTY)
153
+ return "";
154
+ const chunks = [];
155
+ for await (const chunk of process.stdin) {
156
+ chunks.push(chunk);
157
+ }
158
+ return Buffer.concat(chunks).toString("utf8");
159
+ }
160
+ /**
161
+ * A sleep that ends when the signal fires.
162
+ *
163
+ * The reason Ctrl-C is a criterion of its own: the published poll interval is
164
+ * five seconds, so a wait that only noticed its signal between sleeps would
165
+ * hold the terminal for up to five seconds after somebody asked it to stop.
166
+ */
167
+ function sleep(milliseconds, signal) {
168
+ return new Promise((resolve) => {
169
+ const timer = setTimeout(() => {
170
+ signal.removeEventListener("abort", onAbort);
171
+ resolve();
172
+ }, milliseconds);
173
+ function onAbort() {
174
+ clearTimeout(timer);
175
+ resolve();
176
+ }
177
+ signal.addEventListener("abort", onAbort, { once: true });
178
+ });
179
+ }
180
+ export function nodeRuntime(signal) {
181
+ return {
182
+ argv: process.argv.slice(2),
183
+ env: process.env,
184
+ fetch: ((url, init) => fetch(url, init)),
185
+ home: homedir(),
186
+ interactive: process.stdin.isTTY === true,
187
+ listen: openLoopback,
188
+ now: () => Date.now(),
189
+ openBrowser,
190
+ platform: process.platform,
191
+ promptSecret: (question) => promptSecret(question),
192
+ randomBytes: (length) => crypto.getRandomValues(new Uint8Array(length)),
193
+ randomUuid: () => crypto.randomUUID(),
194
+ readStdin,
195
+ signal,
196
+ sleep,
197
+ stderr: (text) => {
198
+ process.stderr.write(text);
199
+ },
200
+ stdout: (text) => {
201
+ process.stdout.write(text);
202
+ },
203
+ // **`undefined` on Windows**, where a file mode carries no access
204
+ // information at all: `stat` reports `0o666` for everything, so an
205
+ // owner-only check there would be asserting a constant.
206
+ uid: process.platform === "win32"
207
+ ? undefined
208
+ : (process.getuid?.() ?? undefined),
209
+ };
210
+ }
@@ -0,0 +1,96 @@
1
+ import { type CliRuntime } from "./runtime.js";
2
+ /**
3
+ * The delegated-authorization half of `login`, spoken as a public client.
4
+ *
5
+ * **Everything here is discovered, not configured.** The origin is the only
6
+ * thing this command is told: it reads RFC 9728 protected-resource metadata to
7
+ * learn which authorization server protects that origin, then RFC 8414 metadata
8
+ * to learn where that server's endpoints are, and uses exactly the URLs it
9
+ * found. A client that constructed `/oauth/token` from the origin would work
10
+ * against this service and against nothing else, and would keep working after
11
+ * the service moved the endpoint — right up until it did not.
12
+ *
13
+ * **No client secret, ever.** A command installed on somebody's laptop cannot
14
+ * hold one: whatever it shipped with, every copy has. That is what makes S256
15
+ * PKCE mandatory rather than advisable here — the code is bound to a verifier
16
+ * this process generated and never sent, so a code intercepted on its way back
17
+ * is worth nothing.
18
+ *
19
+ * **The loopback listener binds before registration.** RFC 8252 has the
20
+ * authorization server match a redirect URI exactly, port included, so the port
21
+ * has to be known before the client is registered. Registering first and
22
+ * binding afterwards is how a client ends up registered for a port something
23
+ * else has taken.
24
+ */
25
+ export interface AuthorizationServer {
26
+ readonly authorizationEndpoint: string;
27
+ readonly registrationEndpoint: string | undefined;
28
+ readonly resource: string;
29
+ readonly revocationEndpoint: string | undefined;
30
+ readonly scopesSupported: readonly string[];
31
+ readonly tokenEndpoint: string;
32
+ }
33
+ export interface OAuthTokens {
34
+ readonly accessToken: string;
35
+ readonly expiresInSeconds: number;
36
+ readonly refreshToken: string;
37
+ readonly scope: string;
38
+ }
39
+ /**
40
+ * Discovery, in the order a client that had never heard of this service does it.
41
+ *
42
+ * The protected-resource document is asked first because it is the one a `401`
43
+ * points at, and it is what says *which* authorization server to trust for this
44
+ * origin. Going straight to the authorization-server document would be assuming
45
+ * the answer.
46
+ */
47
+ export declare function discover(runtime: CliRuntime, origin: string): Promise<AuthorizationServer>;
48
+ export declare function registerClient(runtime: CliRuntime, server: AuthorizationServer, options: {
49
+ readonly redirectUri: string;
50
+ readonly scopes: readonly string[];
51
+ }): Promise<string>;
52
+ export interface AuthorizationRequest {
53
+ readonly authorizationUrl: string;
54
+ readonly state: string;
55
+ readonly verifier: string;
56
+ }
57
+ export declare function buildAuthorization(runtime: CliRuntime, server: AuthorizationServer, options: {
58
+ readonly clientId: string;
59
+ readonly redirectUri: string;
60
+ readonly scopes: readonly string[];
61
+ }): Promise<AuthorizationRequest>;
62
+ /**
63
+ * Turns the redirect into a code, refusing anything that is not this run's.
64
+ *
65
+ * `state` is compared before `code` is read. A redirect carrying somebody
66
+ * else's state is not a code to exchange — it is a request this process did not
67
+ * make — and reading the code first would exchange it anyway.
68
+ */
69
+ export declare function codeFrom(returned: URLSearchParams, state: string): string;
70
+ export declare function exchangeCode(runtime: CliRuntime, server: AuthorizationServer, options: {
71
+ readonly clientId: string;
72
+ readonly code: string;
73
+ readonly redirectUri: string;
74
+ readonly verifier: string;
75
+ }): Promise<OAuthTokens>;
76
+ export declare function refresh(runtime: CliRuntime, server: AuthorizationServer, options: {
77
+ readonly clientId: string;
78
+ readonly refreshToken: string;
79
+ }): Promise<OAuthTokens>;
80
+ /**
81
+ * Revokes the refresh token, and says whether the server took it.
82
+ *
83
+ * **The refresh token rather than the access token**, because RFC 7009 lets a
84
+ * server end the whole grant when it is given one, and ending the grant is what
85
+ * `logout` means. Revoking only the access token would leave a refresh token
86
+ * this command has just deleted and the service still honours.
87
+ *
88
+ * A failure here is reported, not thrown: local material is removed either way.
89
+ * A `logout` that refused to forget a credential because the network was down
90
+ * would leave the credential on the disk, which is the opposite of what was
91
+ * asked for.
92
+ */
93
+ export declare function revoke(runtime: CliRuntime, server: AuthorizationServer, options: {
94
+ readonly clientId: string;
95
+ readonly token: string;
96
+ }): Promise<boolean>;
package/dist/oauth.js ADDED
@@ -0,0 +1,205 @@
1
+ import { CliError } from "./runtime.js";
2
+ function base64Url(bytes) {
3
+ return Buffer.from(bytes).toString("base64url");
4
+ }
5
+ async function readJson(runtime, url, init) {
6
+ const response = await runtime.fetch(url, {
7
+ ...init,
8
+ signal: runtime.signal,
9
+ });
10
+ const text = await response.text();
11
+ let body;
12
+ try {
13
+ body = JSON.parse(text);
14
+ }
15
+ catch {
16
+ body = undefined;
17
+ }
18
+ return { body, status: response.status };
19
+ }
20
+ function requireString(value, field, source) {
21
+ if (typeof value !== "string" || value === "") {
22
+ throw new CliError(`${source} does not name a ${field}`, "internal", "This origin publishes authorization metadata this command cannot use. Sign in with an API key instead: greatwait login --api-key");
23
+ }
24
+ return value;
25
+ }
26
+ /**
27
+ * Discovery, in the order a client that had never heard of this service does it.
28
+ *
29
+ * The protected-resource document is asked first because it is the one a `401`
30
+ * points at, and it is what says *which* authorization server to trust for this
31
+ * origin. Going straight to the authorization-server document would be assuming
32
+ * the answer.
33
+ */
34
+ export async function discover(runtime, origin) {
35
+ const resourceUrl = `${origin}/.well-known/oauth-protected-resource`;
36
+ const resource = await readJson(runtime, resourceUrl);
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 greatwait doctor --api-url <origin>.");
39
+ }
40
+ const resourceBody = resource.body;
41
+ const servers = Array.isArray(resourceBody.authorization_servers)
42
+ ? resourceBody.authorization_servers
43
+ : [];
44
+ const issuer = requireString(servers[0], "authorization server", resourceUrl);
45
+ const metadataUrl = `${issuer}/.well-known/oauth-authorization-server`;
46
+ const metadata = await readJson(runtime, metadataUrl);
47
+ if (metadata.status !== 200) {
48
+ throw new CliError(`${issuer} does not publish authorization-server metadata (${String(metadata.status)})`, "validation");
49
+ }
50
+ const body = metadata.body;
51
+ const scopes = body["scopes_supported"];
52
+ return {
53
+ authorizationEndpoint: requireString(body["authorization_endpoint"], "authorization endpoint", metadataUrl),
54
+ registrationEndpoint: typeof body["registration_endpoint"] === "string"
55
+ ? body["registration_endpoint"]
56
+ : undefined,
57
+ // The audience this token is for, taken from the document rather than from
58
+ // the origin the command was given. RFC 8707's whole point is that a token
59
+ // minted for one audience must not be accepted by another, and a client
60
+ // that names an audience it invented is asserting something it did not
61
+ // check.
62
+ resource: requireString(resourceBody.resource, "resource", resourceUrl),
63
+ revocationEndpoint: typeof body["revocation_endpoint"] === "string"
64
+ ? body["revocation_endpoint"]
65
+ : undefined,
66
+ scopesSupported: Array.isArray(scopes)
67
+ ? scopes.filter((scope) => typeof scope === "string")
68
+ : [],
69
+ tokenEndpoint: requireString(body["token_endpoint"], "token endpoint", metadataUrl),
70
+ };
71
+ }
72
+ export async function registerClient(runtime, server, options) {
73
+ if (server.registrationEndpoint === undefined) {
74
+ throw new CliError("this authorization server does not offer dynamic client registration", "validation", "Sign in with an API key instead: greatwait login --api-key");
75
+ }
76
+ const registered = await readJson(runtime, server.registrationEndpoint, {
77
+ body: JSON.stringify({
78
+ client_name: "Greatwait CLI",
79
+ client_uri: "https://greatwait.dev",
80
+ grant_types: ["authorization_code", "refresh_token"],
81
+ redirect_uris: [options.redirectUri],
82
+ response_types: ["code"],
83
+ scope: options.scopes.join(" "),
84
+ // A public client: no secret to present, which is what the standard
85
+ // calls this and what PKCE compensates for.
86
+ token_endpoint_auth_method: "none",
87
+ }),
88
+ headers: { "content-type": "application/json" },
89
+ method: "POST",
90
+ });
91
+ if (registered.status !== 201 && registered.status !== 200) {
92
+ throw new CliError(`dynamic client registration was refused (${String(registered.status)}: ${registered.body?.error ??
93
+ "no reason given"})`, "validation");
94
+ }
95
+ return requireString(registered.body.client_id, "client_id", server.registrationEndpoint);
96
+ }
97
+ export async function buildAuthorization(runtime, server, options) {
98
+ const state = base64Url(runtime.randomBytes(24));
99
+ const verifier = base64Url(runtime.randomBytes(48));
100
+ const challenge = base64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
101
+ const url = new URL(server.authorizationEndpoint);
102
+ url.searchParams.set("client_id", options.clientId);
103
+ url.searchParams.set("code_challenge", challenge);
104
+ url.searchParams.set("code_challenge_method", "S256");
105
+ url.searchParams.set("redirect_uri", options.redirectUri);
106
+ url.searchParams.set("resource", server.resource);
107
+ url.searchParams.set("response_type", "code");
108
+ url.searchParams.set("scope", options.scopes.join(" "));
109
+ url.searchParams.set("state", state);
110
+ return { authorizationUrl: url.toString(), state, verifier };
111
+ }
112
+ /**
113
+ * Turns the redirect into a code, refusing anything that is not this run's.
114
+ *
115
+ * `state` is compared before `code` is read. A redirect carrying somebody
116
+ * else's state is not a code to exchange — it is a request this process did not
117
+ * make — and reading the code first would exchange it anyway.
118
+ */
119
+ export function codeFrom(returned, state) {
120
+ const error = returned.get("error");
121
+ if (error !== null) {
122
+ throw new CliError(`authorization was refused (${error})`, "authentication", error === "access_denied"
123
+ ? "Nothing was granted. Run greatwait login again and approve the request, or use an API key."
124
+ : undefined);
125
+ }
126
+ const returnedState = returned.get("state");
127
+ if (returnedState !== state) {
128
+ throw new CliError(`the redirect carried ${returnedState === null ? "no state" : "a state this run did not issue"}`, "authentication", "A browser tab left open from an earlier sign-in is the usual cause. Close it and run greatwait login again.");
129
+ }
130
+ const code = returned.get("code");
131
+ if (code === null) {
132
+ throw new CliError("the redirect carried no code", "authentication");
133
+ }
134
+ return code;
135
+ }
136
+ async function exchange(runtime, server, form) {
137
+ const response = await readJson(runtime, server.tokenEndpoint, {
138
+ body: new URLSearchParams(form).toString(),
139
+ headers: { "content-type": "application/x-www-form-urlencoded" },
140
+ method: "POST",
141
+ });
142
+ if (response.status !== 200) {
143
+ const error = response.body?.error;
144
+ throw new CliError(`the authorization server refused the token request (${error ?? String(response.status)})`, "authentication", error === "invalid_grant"
145
+ ? "The grant is no longer usable — it expired, was revoked, or was already spent. Run greatwait login again."
146
+ : undefined);
147
+ }
148
+ const body = response.body;
149
+ return {
150
+ accessToken: requireString(body["access_token"], "access_token", server.tokenEndpoint),
151
+ expiresInSeconds: typeof body["expires_in"] === "number" ? body["expires_in"] : 0,
152
+ refreshToken: requireString(body["refresh_token"], "refresh_token", server.tokenEndpoint),
153
+ scope: typeof body["scope"] === "string" ? body["scope"] : "",
154
+ };
155
+ }
156
+ export async function exchangeCode(runtime, server, options) {
157
+ return exchange(runtime, server, {
158
+ client_id: options.clientId,
159
+ code: options.code,
160
+ code_verifier: options.verifier,
161
+ grant_type: "authorization_code",
162
+ redirect_uri: options.redirectUri,
163
+ resource: server.resource,
164
+ });
165
+ }
166
+ export async function refresh(runtime, server, options) {
167
+ return exchange(runtime, server, {
168
+ client_id: options.clientId,
169
+ grant_type: "refresh_token",
170
+ refresh_token: options.refreshToken,
171
+ resource: server.resource,
172
+ });
173
+ }
174
+ /**
175
+ * Revokes the refresh token, and says whether the server took it.
176
+ *
177
+ * **The refresh token rather than the access token**, because RFC 7009 lets a
178
+ * server end the whole grant when it is given one, and ending the grant is what
179
+ * `logout` means. Revoking only the access token would leave a refresh token
180
+ * this command has just deleted and the service still honours.
181
+ *
182
+ * A failure here is reported, not thrown: local material is removed either way.
183
+ * A `logout` that refused to forget a credential because the network was down
184
+ * would leave the credential on the disk, which is the opposite of what was
185
+ * asked for.
186
+ */
187
+ export async function revoke(runtime, server, options) {
188
+ if (server.revocationEndpoint === undefined)
189
+ return false;
190
+ try {
191
+ const response = await readJson(runtime, server.revocationEndpoint, {
192
+ body: new URLSearchParams({
193
+ client_id: options.clientId,
194
+ token: options.token,
195
+ token_type_hint: "refresh_token",
196
+ }).toString(),
197
+ headers: { "content-type": "application/x-www-form-urlencoded" },
198
+ method: "POST",
199
+ });
200
+ return response.status === 200;
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ }
@@ -0,0 +1,66 @@
1
+ import type { Problem } from "@greatwait/contracts";
2
+ import { type ExitClass } from "./exit.js";
3
+ /**
4
+ * What a machine reads, and what a person reads, kept apart on purpose.
5
+ *
6
+ * **`--json` is not a serialization of the human text.** The project's own CLI
7
+ * invariants say so, and the reason is that the two have different obligations:
8
+ * the human line may be rewritten whenever it reads badly, and the machine
9
+ * shape may not change without a version. Deriving one from the other means
10
+ * every wording change is a contract change nobody notices until a script
11
+ * breaks.
12
+ *
13
+ * So a command returns *data*, and this file renders it twice.
14
+ */
15
+ /**
16
+ * The version of the machine shape, in every `--json` document.
17
+ *
18
+ * One number for the envelope rather than one per command: a consumer's first
19
+ * question is "can I parse this at all", and a per-command version makes that a
20
+ * table they have to hold. The payload under `data` is the operation's own
21
+ * published response shape wherever there is one, which is versioned already by
22
+ * the contract it comes from.
23
+ */
24
+ export declare const OUTPUT_VERSION = 1;
25
+ export interface CommandResult {
26
+ /** Rendered for a person, one line or a short block. Never parsed. */
27
+ readonly human: string;
28
+ /** The machine shape, under `data`. */
29
+ readonly data: unknown;
30
+ /**
31
+ * A non-zero class for a command that answered, and whose answer is bad news.
32
+ *
33
+ * **`doctor` is why this exists.** It has a real machine shape — a check per
34
+ * name with an outcome — and its refusal is that shape rather than a
35
+ * different one. Raising instead put the whole rendered table into the
36
+ * error's `message` string, so a script asking which check failed got a
37
+ * paragraph to parse. A command that *cannot* answer still raises; this is
38
+ * for one that answered "no".
39
+ */
40
+ readonly exit?: ExitClass;
41
+ /**
42
+ * What to do next, in a person's words. Printed after `human` and never in
43
+ * the JSON: a machine acts on `data` and on the exit class, and a sentence
44
+ * rewritten because it read badly must not be a change to a versioned
45
+ * contract.
46
+ */
47
+ readonly nextMove?: string;
48
+ }
49
+ export interface CommandFailure {
50
+ readonly exit: ExitClass;
51
+ readonly human: string;
52
+ readonly nextMove?: string;
53
+ /** The service's own problem document, where the service produced one. */
54
+ readonly problem?: Problem;
55
+ }
56
+ export declare function renderResult(command: string, result: CommandResult, json: boolean): string;
57
+ export declare function renderFailure(command: string, failure: CommandFailure, json: boolean): string;
58
+ /**
59
+ * A secret, as anything but the command that just minted it may print it.
60
+ *
61
+ * The prefix is kept because it is what a person matches against a listing, and
62
+ * it is the part the service itself publishes as `displayPrefix`. Everything
63
+ * after it becomes a fixed run of dots rather than a length-preserving mask: a
64
+ * mask that preserved length would publish how long the secret is.
65
+ */
66
+ export declare function redactSecret(value: string): string;
package/dist/output.js ADDED
@@ -0,0 +1,76 @@
1
+ import { EXIT_CLASSES } from "./exit.js";
2
+ /**
3
+ * What a machine reads, and what a person reads, kept apart on purpose.
4
+ *
5
+ * **`--json` is not a serialization of the human text.** The project's own CLI
6
+ * invariants say so, and the reason is that the two have different obligations:
7
+ * the human line may be rewritten whenever it reads badly, and the machine
8
+ * shape may not change without a version. Deriving one from the other means
9
+ * every wording change is a contract change nobody notices until a script
10
+ * breaks.
11
+ *
12
+ * So a command returns *data*, and this file renders it twice.
13
+ */
14
+ /**
15
+ * The version of the machine shape, in every `--json` document.
16
+ *
17
+ * One number for the envelope rather than one per command: a consumer's first
18
+ * question is "can I parse this at all", and a per-command version makes that a
19
+ * table they have to hold. The payload under `data` is the operation's own
20
+ * published response shape wherever there is one, which is versioned already by
21
+ * the contract it comes from.
22
+ */
23
+ export const OUTPUT_VERSION = 1;
24
+ export function renderResult(command, result, json) {
25
+ if (!json) {
26
+ return result.nextMove === undefined
27
+ ? `${result.human}\n`
28
+ : `${result.human}\n\n${result.nextMove}\n`;
29
+ }
30
+ return `${JSON.stringify({
31
+ command,
32
+ data: result.data,
33
+ ...(result.exit === undefined
34
+ ? {}
35
+ : { exit: result.exit, exitCode: EXIT_CLASSES[result.exit] }),
36
+ outputVersion: OUTPUT_VERSION,
37
+ ok: result.exit === undefined,
38
+ }, null, 2)}\n`;
39
+ }
40
+ export function renderFailure(command, failure, json) {
41
+ if (!json) {
42
+ const detail = failure.problem?.detail === undefined
43
+ ? ""
44
+ : `\n${failure.problem.detail}`;
45
+ return failure.nextMove === undefined
46
+ ? `${failure.human}${detail}\n`
47
+ : `${failure.human}${detail}\n\n${failure.nextMove}\n`;
48
+ }
49
+ return `${JSON.stringify({
50
+ command,
51
+ error: {
52
+ // **The public problem code survives.** A script that already branches
53
+ // on `idempotency_key_reused` against the REST surface must not have to
54
+ // learn a second vocabulary to branch on it here, and the exit class is
55
+ // deliberately coarser than the code rather than a rename of it.
56
+ ...(failure.problem === undefined ? {} : { problem: failure.problem }),
57
+ exit: failure.exit,
58
+ exitCode: EXIT_CLASSES[failure.exit],
59
+ message: failure.human,
60
+ },
61
+ ok: false,
62
+ outputVersion: OUTPUT_VERSION,
63
+ }, null, 2)}\n`;
64
+ }
65
+ /**
66
+ * A secret, as anything but the command that just minted it may print it.
67
+ *
68
+ * The prefix is kept because it is what a person matches against a listing, and
69
+ * it is the part the service itself publishes as `displayPrefix`. Everything
70
+ * after it becomes a fixed run of dots rather than a length-preserving mask: a
71
+ * mask that preserved length would publish how long the secret is.
72
+ */
73
+ export function redactSecret(value) {
74
+ const visible = value.slice(0, 8);
75
+ return `${visible}${"•".repeat(12)}`;
76
+ }