@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,74 @@
1
+ export interface StoredCredential {
2
+ /** `api-key` carries `secret`; `oauth` carries the token set. */
3
+ readonly kind: "api-key" | "oauth";
4
+ readonly secret?: string;
5
+ readonly accessToken?: string;
6
+ readonly refreshToken?: string;
7
+ /** Epoch milliseconds. Absent for an API key, which does not rotate itself. */
8
+ readonly expiresAtMs?: number;
9
+ /** The dynamically registered client, needed to refresh and to revoke. */
10
+ readonly clientId?: string;
11
+ readonly scope?: string;
12
+ }
13
+ export interface ConfigLocations {
14
+ readonly credentials: string;
15
+ readonly directory: string;
16
+ readonly selection: string;
17
+ }
18
+ /**
19
+ * The configuration directory for this platform.
20
+ *
21
+ * `XDG_CONFIG_HOME` wins wherever it is set, including on macOS and Windows:
22
+ * somebody who has set it has said where their configuration goes, and a tool
23
+ * that ignores it puts a file where they will not find it. Otherwise the
24
+ * platform's own convention — `%APPDATA%` on Windows, Application Support on
25
+ * macOS, `~/.config` elsewhere.
26
+ *
27
+ * Both inputs are parameters rather than reads of `process`, because the
28
+ * refusals below have to be exercised on a platform the test is not running on.
29
+ */
30
+ export declare function configDirectory(options: {
31
+ readonly env: Readonly<Record<string, string | undefined>>;
32
+ readonly home: string;
33
+ readonly platform: string;
34
+ }): string;
35
+ export declare function configLocations(options: {
36
+ readonly env: Readonly<Record<string, string | undefined>>;
37
+ readonly home: string;
38
+ readonly platform: string;
39
+ }): ConfigLocations;
40
+ /** The default home, read once here so nothing else touches the environment. */
41
+ export declare function defaultHome(): string;
42
+ export declare class UnsafeConfigError extends Error {
43
+ readonly path: string;
44
+ constructor(path: string, reason: string);
45
+ }
46
+ /** A stored origin key: scheme and authority, lower case, no trailing slash. */
47
+ export declare function originKey(apiUrl: string): string;
48
+ export interface CredentialStore {
49
+ forget(origin: string): Promise<void>;
50
+ locations(): ConfigLocations;
51
+ read(origin: string): Promise<StoredCredential | undefined>;
52
+ origins(): Promise<readonly string[]>;
53
+ readSelection(): Promise<string | undefined>;
54
+ select(origin: string | undefined): Promise<void>;
55
+ write(origin: string, credential: StoredCredential): Promise<void>;
56
+ }
57
+ export declare function createCredentialStore(options: {
58
+ readonly env: Readonly<Record<string, string | undefined>>;
59
+ readonly home: string;
60
+ readonly platform: string;
61
+ /** `undefined` on a platform whose file mode says nothing about access. */
62
+ readonly uid: number | undefined;
63
+ }): CredentialStore;
64
+ /**
65
+ * Whether the configuration directory itself is owner-only.
66
+ *
67
+ * Reported by `doctor` rather than refused on every read: a directory a group
68
+ * can list is not a directory a group can read a `0600` file out of, so this is
69
+ * a warning about the next file somebody puts there rather than a reason to
70
+ * refuse the one already checked.
71
+ */
72
+ export declare function directoryConcerns(paths: ConfigLocations, uid: number | undefined): Promise<readonly string[]>;
73
+ export declare const CONFIG_FILE_MODE = 384;
74
+ export declare const CONFIG_DIRECTORY_MODE = 448;
package/dist/config.js ADDED
@@ -0,0 +1,242 @@
1
+ import { chmod, lstat, mkdir, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, posix, win32 } from "node:path";
4
+ /**
5
+ * Where a credential and the remembered origin live, and what makes a file here
6
+ * safe to read.
7
+ *
8
+ * **Per origin, and the origin is the key.** A person working against staging
9
+ * and production has two credentials, and the failure this arrangement exists
10
+ * to prevent is one being presented to the other — "staging can never answer as
11
+ * production", which is a property of *storage* rather than of a flag somebody
12
+ * remembers to pass. There is no merged view: a lookup takes an origin and
13
+ * either finds that origin's material or finds nothing.
14
+ *
15
+ * **Owner-only, checked on read rather than promised on write.** Writing `0600`
16
+ * says nothing about a file that already existed, was restored from a backup,
17
+ * or was created by a different tool. The refusals below are what a reader can
18
+ * rely on, and they are refusals rather than repairs: a file this process did
19
+ * not create, whose permissions it does not recognise, is one whose contents it
20
+ * has no business trusting either.
21
+ */
22
+ /** The one file secrets live in. Split from `config.json` so the two can differ
23
+ * in permissions and in what a diagnostic may print. */
24
+ const CREDENTIALS_FILE = "credentials.json";
25
+ const SELECTION_FILE = "config.json";
26
+ const OWNER_ONLY_FILE = 0o600;
27
+ const OWNER_ONLY_DIRECTORY = 0o700;
28
+ /**
29
+ * The configuration directory for this platform.
30
+ *
31
+ * `XDG_CONFIG_HOME` wins wherever it is set, including on macOS and Windows:
32
+ * somebody who has set it has said where their configuration goes, and a tool
33
+ * that ignores it puts a file where they will not find it. Otherwise the
34
+ * platform's own convention — `%APPDATA%` on Windows, Application Support on
35
+ * macOS, `~/.config` elsewhere.
36
+ *
37
+ * Both inputs are parameters rather than reads of `process`, because the
38
+ * refusals below have to be exercised on a platform the test is not running on.
39
+ */
40
+ export function configDirectory(options) {
41
+ // **The path flavour follows the platform argument, not the machine.**
42
+ // `join` from `node:path` is whichever one this process happens to be
43
+ // running on, so a `win32` branch built with it produces forward slashes
44
+ // everywhere but Windows — and a case that asserts the result with the same
45
+ // `join` agrees with it. On a real Windows process the two are identical;
46
+ // what this buys is that the injected platform is honestly injected.
47
+ const paths = options.platform === "win32" ? win32 : posix;
48
+ const xdg = options.env["XDG_CONFIG_HOME"];
49
+ if (xdg !== undefined && xdg !== "") {
50
+ return paths.join(xdg, "greatwait");
51
+ }
52
+ if (options.platform === "win32") {
53
+ const appData = options.env["APPDATA"];
54
+ return paths.join(appData !== undefined && appData !== ""
55
+ ? appData
56
+ : paths.join(options.home, "AppData", "Roaming"), "greatwait");
57
+ }
58
+ if (options.platform === "darwin") {
59
+ return paths.join(options.home, "Library", "Application Support", "greatwait");
60
+ }
61
+ return paths.join(options.home, ".config", "greatwait");
62
+ }
63
+ export function configLocations(options) {
64
+ // The same flavour the directory above was built with. Mixing them produced
65
+ // a path whose directory part used backslashes and whose filename was
66
+ // appended with a forward slash — never reachable in production, where the
67
+ // native `join` and the real platform always agree, and exactly the
68
+ // inconsistency that makes an injected-platform case prove less than it
69
+ // looks like it proves.
70
+ const paths = options.platform === "win32" ? win32 : posix;
71
+ const directory = configDirectory(options);
72
+ return {
73
+ credentials: paths.join(directory, CREDENTIALS_FILE),
74
+ directory,
75
+ selection: paths.join(directory, SELECTION_FILE),
76
+ };
77
+ }
78
+ /** The default home, read once here so nothing else touches the environment. */
79
+ export function defaultHome() {
80
+ return homedir();
81
+ }
82
+ export class UnsafeConfigError extends Error {
83
+ path;
84
+ constructor(path, reason) {
85
+ super(`${path} ${reason}`);
86
+ this.name = "UnsafeConfigError";
87
+ this.path = path;
88
+ }
89
+ }
90
+ /**
91
+ * Reads a file this process is willing to trust, or refuses saying why.
92
+ *
93
+ * **`lstat`, not `stat`.** A symbolic link is followed by `stat`, so a check
94
+ * built on it describes the target while the thing being opened is the link —
95
+ * and a link is how a file with safe-looking permissions points at one without
96
+ * them. Refusing the link outright is the only answer that does not depend on
97
+ * winning a race with whoever can replace it.
98
+ *
99
+ * A missing file is not an error: it is the state before `login`.
100
+ */
101
+ async function readTrusted(path, uid) {
102
+ let entry;
103
+ try {
104
+ entry = await lstat(path);
105
+ }
106
+ catch (error) {
107
+ if (error.code === "ENOENT")
108
+ return undefined;
109
+ throw error;
110
+ }
111
+ if (entry.isSymbolicLink()) {
112
+ throw new UnsafeConfigError(path, "is a symbolic link; a credential file is read only where it is stored");
113
+ }
114
+ if (!entry.isFile()) {
115
+ throw new UnsafeConfigError(path, "is not a regular file");
116
+ }
117
+ if (uid !== undefined && entry.uid !== uid) {
118
+ throw new UnsafeConfigError(path, `is owned by uid ${String(entry.uid)} and this process is uid ${String(uid)}`);
119
+ }
120
+ // **Group and other, not an exact mode.** `0400` and `0600` are both fine and
121
+ // an equality check would refuse one of them; what matters is that nobody
122
+ // else can read it. Windows reports a mode that says nothing about access
123
+ // control, which is why the caller passes `undefined` for `uid` there and
124
+ // this bit is skipped with it.
125
+ if (uid !== undefined && (entry.mode & 0o077) !== 0) {
126
+ throw new UnsafeConfigError(path, `is readable by somebody other than its owner (mode ${(entry.mode & 0o777).toString(8).padStart(3, "0")}); run chmod 600 on it or delete it and sign in again`);
127
+ }
128
+ return readFile(path, "utf8");
129
+ }
130
+ async function writeOwnerOnly(path, text) {
131
+ await mkdir(dirname(path), { mode: OWNER_ONLY_DIRECTORY, recursive: true });
132
+ // **A temporary file and a rename, so a crash cannot leave half a token
133
+ // set.** The rename is atomic within a directory, and the mode is set on the
134
+ // temporary file before anything is written into it rather than after — a
135
+ // file that is briefly world-readable while it holds a credential is a file
136
+ // that was world-readable while it held a credential.
137
+ const temporary = `${path}.${String(process.pid)}.tmp`;
138
+ await writeFile(temporary, text, { mode: OWNER_ONLY_FILE });
139
+ await chmod(temporary, OWNER_ONLY_FILE);
140
+ await rename(temporary, path);
141
+ }
142
+ /** A stored origin key: scheme and authority, lower case, no trailing slash. */
143
+ export function originKey(apiUrl) {
144
+ const url = new URL(apiUrl);
145
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
146
+ throw new Error(`${apiUrl} is not an http or https origin`);
147
+ }
148
+ return url.origin.toLowerCase();
149
+ }
150
+ export function createCredentialStore(options) {
151
+ const paths = configLocations(options);
152
+ async function readCredentials() {
153
+ const text = await readTrusted(paths.credentials, options.uid);
154
+ if (text === undefined)
155
+ return { origins: {}, version: 1 };
156
+ let parsed;
157
+ try {
158
+ parsed = JSON.parse(text);
159
+ }
160
+ catch {
161
+ throw new UnsafeConfigError(paths.credentials, "is not readable as JSON; delete it and sign in again");
162
+ }
163
+ const origins = parsed.origins;
164
+ if (typeof origins !== "object" || origins === null) {
165
+ throw new UnsafeConfigError(paths.credentials, "does not carry an `origins` object; delete it and sign in again");
166
+ }
167
+ return {
168
+ origins: origins,
169
+ version: 1,
170
+ };
171
+ }
172
+ return {
173
+ async forget(origin) {
174
+ const file = await readCredentials();
175
+ const remaining = Object.fromEntries(Object.entries(file.origins).filter(([key]) => key !== origin));
176
+ if (Object.keys(remaining).length === 0) {
177
+ // Nothing left to hold: remove the file rather than leaving an empty
178
+ // one, so `logout` leaves no trace that there was ever a credential.
179
+ await rm(paths.credentials, { force: true });
180
+ return;
181
+ }
182
+ await writeOwnerOnly(paths.credentials, `${JSON.stringify({ origins: remaining, version: 1 }, null, 2)}\n`);
183
+ },
184
+ locations: () => paths,
185
+ async origins() {
186
+ return Object.keys((await readCredentials()).origins).sort();
187
+ },
188
+ async read(origin) {
189
+ return (await readCredentials()).origins[origin];
190
+ },
191
+ async readSelection() {
192
+ const text = await readTrusted(paths.selection, options.uid);
193
+ if (text === undefined)
194
+ return undefined;
195
+ try {
196
+ return JSON.parse(text).selected;
197
+ }
198
+ catch {
199
+ throw new UnsafeConfigError(paths.selection, "is not readable as JSON; delete it and select an origin again");
200
+ }
201
+ },
202
+ async select(origin) {
203
+ const body = origin === undefined
204
+ ? { version: 1 }
205
+ : { selected: origin, version: 1 };
206
+ await writeOwnerOnly(paths.selection, `${JSON.stringify(body, null, 2)}\n`);
207
+ },
208
+ async write(origin, credential) {
209
+ const file = await readCredentials();
210
+ await writeOwnerOnly(paths.credentials, `${JSON.stringify({ origins: { ...file.origins, [origin]: credential }, version: 1 }, null, 2)}\n`);
211
+ },
212
+ };
213
+ }
214
+ /**
215
+ * Whether the configuration directory itself is owner-only.
216
+ *
217
+ * Reported by `doctor` rather than refused on every read: a directory a group
218
+ * can list is not a directory a group can read a `0600` file out of, so this is
219
+ * a warning about the next file somebody puts there rather than a reason to
220
+ * refuse the one already checked.
221
+ */
222
+ export async function directoryConcerns(paths, uid) {
223
+ if (uid === undefined)
224
+ return [];
225
+ try {
226
+ const entry = await lstat(paths.directory);
227
+ if ((entry.mode & 0o077) !== 0) {
228
+ return [
229
+ `${paths.directory} is reachable by somebody other than its owner (mode ${(entry.mode & 0o777).toString(8).padStart(3, "0")}); chmod 700 it`,
230
+ ];
231
+ }
232
+ await readdir(paths.directory);
233
+ return [];
234
+ }
235
+ catch (error) {
236
+ if (error.code === "ENOENT")
237
+ return [];
238
+ return [`${paths.directory} could not be read: ${String(error)}`];
239
+ }
240
+ }
241
+ export const CONFIG_FILE_MODE = OWNER_ONLY_FILE;
242
+ export const CONFIG_DIRECTORY_MODE = OWNER_ONLY_DIRECTORY;
package/dist/exit.d.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * What the shell learns when `greatwait` stops.
3
+ *
4
+ * **Classes rather than one code per failure.** A script branching on an exit
5
+ * status needs to know what to *do* — retry, fix the request, get a new
6
+ * credential, give up — and a code per problem makes that a lookup table the
7
+ * caller has to maintain against a list that grows. The public problem code is
8
+ * still there, in `--json` and in the human message; this is the coarse
9
+ * decision, and the two are published together so neither has to carry the
10
+ * other's job.
11
+ *
12
+ * The numbers are the contract. They may not be renumbered: a script that
13
+ * branches on `4` is the whole reason to have them.
14
+ */
15
+ export declare const EXIT_CLASSES: {
16
+ /** The command did what it was asked. */
17
+ readonly ok: 0;
18
+ /**
19
+ * The command could not do what it was asked, for a reason it did not
20
+ * anticipate. Reserved for a defect: every refusal the service publishes has
21
+ * a class of its own below.
22
+ */
23
+ readonly internal: 1;
24
+ /** The command line was wrong — an unknown command, a missing argument. */
25
+ readonly usage: 2;
26
+ /** No usable credential. `greatwait login`. */
27
+ readonly authentication: 3;
28
+ /** A credential that authenticated and may not do this. */
29
+ readonly authorization: 4;
30
+ /** The request was refused on its merits: invalid, missing, or conflicting. */
31
+ readonly validation: 5;
32
+ /** The service could not answer now. The identical request may work later. */
33
+ readonly retryable: 6;
34
+ /** A deadline this command was given passed before the answer arrived. */
35
+ readonly timeout: 7;
36
+ /**
37
+ * Interrupted.
38
+ *
39
+ * 130 rather than 8, because `128 + SIGINT` is what a shell reports for a
40
+ * process killed by Ctrl-C and what `$?` is compared against in every script
41
+ * that checks. A private number here would be a second answer to a question
42
+ * POSIX already answers.
43
+ */
44
+ readonly interrupted: 130;
45
+ };
46
+ export type ExitClass = keyof typeof EXIT_CLASSES;
47
+ /**
48
+ * The class an HTTP status falls in.
49
+ *
50
+ * **Status rather than problem code, and the two are checked in that order.**
51
+ * A refusal from an edge proxy carries no problem document at all — there is no
52
+ * code to read — and a client that branched on the code first would classify
53
+ * every one of those as internal. The code refines the answer where it
54
+ * disagrees with the status, which is exactly once: `scope_missing` is a `403`
55
+ * and belongs in `authorization`, and everything else in the 4xx range that is
56
+ * not authentication is the caller's request to fix.
57
+ */
58
+ export declare function classifyStatus(status: number, problemCode?: string): ExitClass;
package/dist/exit.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * What the shell learns when `greatwait` stops.
3
+ *
4
+ * **Classes rather than one code per failure.** A script branching on an exit
5
+ * status needs to know what to *do* — retry, fix the request, get a new
6
+ * credential, give up — and a code per problem makes that a lookup table the
7
+ * caller has to maintain against a list that grows. The public problem code is
8
+ * still there, in `--json` and in the human message; this is the coarse
9
+ * decision, and the two are published together so neither has to carry the
10
+ * other's job.
11
+ *
12
+ * The numbers are the contract. They may not be renumbered: a script that
13
+ * branches on `4` is the whole reason to have them.
14
+ */
15
+ export const EXIT_CLASSES = {
16
+ /** The command did what it was asked. */
17
+ ok: 0,
18
+ /**
19
+ * The command could not do what it was asked, for a reason it did not
20
+ * anticipate. Reserved for a defect: every refusal the service publishes has
21
+ * a class of its own below.
22
+ */
23
+ internal: 1,
24
+ /** The command line was wrong — an unknown command, a missing argument. */
25
+ usage: 2,
26
+ /** No usable credential. `greatwait login`. */
27
+ authentication: 3,
28
+ /** A credential that authenticated and may not do this. */
29
+ authorization: 4,
30
+ /** The request was refused on its merits: invalid, missing, or conflicting. */
31
+ validation: 5,
32
+ /** The service could not answer now. The identical request may work later. */
33
+ retryable: 6,
34
+ /** A deadline this command was given passed before the answer arrived. */
35
+ timeout: 7,
36
+ /**
37
+ * Interrupted.
38
+ *
39
+ * 130 rather than 8, because `128 + SIGINT` is what a shell reports for a
40
+ * process killed by Ctrl-C and what `$?` is compared against in every script
41
+ * that checks. A private number here would be a second answer to a question
42
+ * POSIX already answers.
43
+ */
44
+ interrupted: 130,
45
+ };
46
+ /**
47
+ * The class an HTTP status falls in.
48
+ *
49
+ * **Status rather than problem code, and the two are checked in that order.**
50
+ * A refusal from an edge proxy carries no problem document at all — there is no
51
+ * code to read — and a client that branched on the code first would classify
52
+ * every one of those as internal. The code refines the answer where it
53
+ * disagrees with the status, which is exactly once: `scope_missing` is a `403`
54
+ * and belongs in `authorization`, and everything else in the 4xx range that is
55
+ * not authentication is the caller's request to fix.
56
+ */
57
+ export function classifyStatus(status, problemCode) {
58
+ if (problemCode === "scope_missing")
59
+ return "authorization";
60
+ if (status === 401)
61
+ return "authentication";
62
+ if (status === 403)
63
+ return "authorization";
64
+ if (status === 408 || status === 504)
65
+ return "timeout";
66
+ if (status === 429)
67
+ return "retryable";
68
+ if (status >= 500)
69
+ return "retryable";
70
+ if (status >= 400)
71
+ return "validation";
72
+ return "ok";
73
+ }
@@ -0,0 +1,9 @@
1
+ export { run, USAGE } from "./cli.js";
2
+ export { COMMANDS } from "./commands.js";
3
+ export { configDirectory, configLocations, createCredentialStore, originKey, UnsafeConfigError, type CredentialStore, type StoredCredential, } from "./config.js";
4
+ export { EXIT_CLASSES, classifyStatus, type ExitClass } from "./exit.js";
5
+ export { nodeRuntime, openLoopback } from "./node-runtime.js";
6
+ export { OUTPUT_VERSION, redactSecret, renderFailure, renderResult, } from "./output.js";
7
+ export { parseArguments, parseDuration } from "./args.js";
8
+ export { publishedWait, renderJourney, STEP_COMMANDS } from "./journey.js";
9
+ export { CliError, InterruptedError, type CliRuntime, type LoopbackListener, } from "./runtime.js";
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export { run, USAGE } from "./cli.js";
2
+ export { COMMANDS } from "./commands.js";
3
+ export { configDirectory, configLocations, createCredentialStore, originKey, UnsafeConfigError, } from "./config.js";
4
+ export { EXIT_CLASSES, classifyStatus } from "./exit.js";
5
+ export { nodeRuntime, openLoopback } from "./node-runtime.js";
6
+ export { OUTPUT_VERSION, redactSecret, renderFailure, renderResult, } from "./output.js";
7
+ export { parseArguments, parseDuration } from "./args.js";
8
+ export { publishedWait, renderJourney, STEP_COMMANDS } from "./journey.js";
9
+ export { CliError, InterruptedError, } from "./runtime.js";
@@ -0,0 +1,49 @@
1
+ import { type JourneyStep } from "@greatwait/contracts";
2
+ /**
3
+ * The published first-value sequence, rendered for a terminal.
4
+ *
5
+ * **Rendered, not restated.** The steps, their purposes, what to look at, and
6
+ * what to do when each one goes wrong are all in the journey declaration that
7
+ * ships in `@greatwait/contracts`, because the website, the curl page, the
8
+ * JavaScript page and this command all need the same sentences and none of them
9
+ * may own them. A second description here would be a fourth answer to a
10
+ * question that has one, and it would drift on the first contract change
11
+ * nobody remembered to mirror.
12
+ *
13
+ * The one thing this adds is the command that performs each step, which is a
14
+ * fact about this surface and about no other.
15
+ */
16
+ /** The command in this CLI that performs each journey step. */
17
+ export declare const STEP_COMMANDS: Readonly<Record<string, string>>;
18
+ export interface RenderedStep {
19
+ readonly command: string | undefined;
20
+ readonly key: string;
21
+ readonly needs: readonly string[];
22
+ readonly observe: string;
23
+ readonly purpose: string;
24
+ readonly title: string;
25
+ }
26
+ export declare function renderStep(step: JourneyStep): RenderedStep;
27
+ export declare function renderJourney(): {
28
+ readonly prerequisites: readonly {
29
+ readonly action: string;
30
+ readonly because: string;
31
+ readonly title: string;
32
+ }[];
33
+ readonly steps: readonly RenderedStep[];
34
+ };
35
+ /**
36
+ * The wait the journey publishes, for the one command that waits.
37
+ *
38
+ * **Read rather than chosen.** `await-occurrence` declares the interval and the
39
+ * deadline, and the website, the curl page and the JavaScript page all wait
40
+ * exactly that long. A number here would be a fourth answer to a question with
41
+ * one, and the first person to notice would be the one whose command gave up
42
+ * before the documentation said it would.
43
+ */
44
+ export declare function publishedWait(): {
45
+ readonly intervalMs: number;
46
+ readonly timeoutMs: number;
47
+ };
48
+ /** The recovery the journey publishes for a named condition, if it has one. */
49
+ export declare function recoveryFor(stepKey: string, when: string): string | undefined;
@@ -0,0 +1,76 @@
1
+ import { FIRST_VALUE_JOURNEY, journeyStepVariables, } from "@greatwait/contracts";
2
+ /**
3
+ * The published first-value sequence, rendered for a terminal.
4
+ *
5
+ * **Rendered, not restated.** The steps, their purposes, what to look at, and
6
+ * what to do when each one goes wrong are all in the journey declaration that
7
+ * ships in `@greatwait/contracts`, because the website, the curl page, the
8
+ * JavaScript page and this command all need the same sentences and none of them
9
+ * may own them. A second description here would be a fourth answer to a
10
+ * question that has one, and it would drift on the first contract change
11
+ * nobody remembered to mirror.
12
+ *
13
+ * The one thing this adds is the command that performs each step, which is a
14
+ * fact about this surface and about no other.
15
+ */
16
+ /** The command in this CLI that performs each journey step. */
17
+ export const STEP_COMMANDS = {
18
+ "await-occurrence": "greatwait occurrences wait <schedule> <occurrence>",
19
+ "check-credential": "greatwait whoami",
20
+ "check-service": "greatwait doctor",
21
+ "clean-up": "greatwait destinations disable <destination>",
22
+ "create-destination": "greatwait destinations create --url <your receiver>",
23
+ "create-one-shot": "greatwait schedules create --endpoint <destination> --in 30s",
24
+ "read-attempt": "greatwait attempts list <schedule> <occurrence>",
25
+ "read-schedule": "greatwait schedules read <schedule>",
26
+ "replay-one-shot": "greatwait schedules create --endpoint <destination> --in 30s --idempotency-key <the same key>",
27
+ };
28
+ export function renderStep(step) {
29
+ return {
30
+ command: STEP_COMMANDS[step.key],
31
+ key: step.key,
32
+ // What the step must be given, as the journey names it — the path
33
+ // parameters an earlier step captured, and the references in its body and
34
+ // headers. The join is published rather than re-derived here.
35
+ needs: [...journeyStepVariables(step)],
36
+ observe: step.observe,
37
+ purpose: step.purpose,
38
+ title: step.title,
39
+ };
40
+ }
41
+ export function renderJourney() {
42
+ return {
43
+ prerequisites: FIRST_VALUE_JOURNEY.prerequisites.map((prerequisite) => ({
44
+ action: prerequisite.action,
45
+ because: prerequisite.because,
46
+ title: prerequisite.title,
47
+ })),
48
+ steps: FIRST_VALUE_JOURNEY.steps.map(renderStep),
49
+ };
50
+ }
51
+ /**
52
+ * The wait the journey publishes, for the one command that waits.
53
+ *
54
+ * **Read rather than chosen.** `await-occurrence` declares the interval and the
55
+ * deadline, and the website, the curl page and the JavaScript page all wait
56
+ * exactly that long. A number here would be a fourth answer to a question with
57
+ * one, and the first person to notice would be the one whose command gave up
58
+ * before the documentation said it would.
59
+ */
60
+ export function publishedWait() {
61
+ const step = FIRST_VALUE_JOURNEY.steps.find((candidate) => candidate.key === "await-occurrence");
62
+ const poll = step?.poll;
63
+ if (poll === undefined) {
64
+ throw new Error("the published journey no longer declares a poll for await-occurrence");
65
+ }
66
+ return {
67
+ intervalMs: poll.intervalSeconds * 1000,
68
+ timeoutMs: poll.timeoutSeconds * 1000,
69
+ };
70
+ }
71
+ /** The recovery the journey publishes for a named condition, if it has one. */
72
+ export function recoveryFor(stepKey, when) {
73
+ return FIRST_VALUE_JOURNEY.steps
74
+ .find((step) => step.key === stepKey)
75
+ ?.recovery.find((entry) => entry.when === when)?.nextMove;
76
+ }
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "./cli.js";
3
+ import { nodeRuntime } from "./node-runtime.js";
4
+ /**
5
+ * The installed executable.
6
+ *
7
+ * **The whole file is the part the repository's own test suite cannot reach**,
8
+ * which is why criterion 7 asks for a regression against a `bin` that does
9
+ * nothing: every case here drives `run` directly, so a package whose source is
10
+ * perfect and whose `bin` entry points at a file that does not exist passes all
11
+ * of them. `scripts/verify-clean-install.ts` installs the packed tarball and
12
+ * executes this path.
13
+ *
14
+ * `process.exitCode` rather than `process.exit`: the second truncates whatever
15
+ * is still buffered on stdout, and this command's last act is to print.
16
+ */
17
+ const controller = new AbortController();
18
+ process.on("SIGINT", () => {
19
+ controller.abort();
20
+ });
21
+ process.on("SIGTERM", () => {
22
+ controller.abort();
23
+ });
24
+ process.exitCode = await run(nodeRuntime(controller.signal));
@@ -0,0 +1,35 @@
1
+ import { type CliRuntime, type LoopbackListener } from "./runtime.js";
2
+ /**
3
+ * The one file that knows there is a process.
4
+ *
5
+ * Everything above this takes its surroundings as a parameter, so the shape of
6
+ * this file is the list of things a test has to be able to fake: a browser, a
7
+ * listener, a terminal, a clock, a signal.
8
+ */
9
+ /**
10
+ * A listener for exactly one authorization redirect.
11
+ *
12
+ * **Only the redirect satisfies it.** A browser sent to a loopback address will
13
+ * happily make other requests to it first — a speculative preconnect, a
14
+ * `/favicon.ico` once the page renders — and a listener that resolved on the
15
+ * first request would resolve with one of those, carrying no `state`, killing
16
+ * the sign-in with a message about a state mismatch while the real redirect
17
+ * lands a moment later on a closed socket. Anything that is not the redirect is
18
+ * answered and the wait continues.
19
+ *
20
+ * Port zero: the operating system picks, and the port is known before the
21
+ * client is registered for it, which RFC 8252's exact-match rule requires.
22
+ */
23
+ export declare function openLoopback(): Promise<LoopbackListener>;
24
+ /**
25
+ * One line, not echoed.
26
+ *
27
+ * `readline` with the terminal's own echo turned off rather than a library:
28
+ * what has to be true is that the characters never reach the screen or the
29
+ * scrollback, and that is a property of the terminal rather than of the prompt.
30
+ */
31
+ export declare function promptSecret(question: string, streams?: {
32
+ readonly input: NodeJS.ReadStream;
33
+ readonly output: NodeJS.WriteStream;
34
+ }): Promise<string>;
35
+ export declare function nodeRuntime(signal: AbortSignal): CliRuntime;