@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.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/args.d.ts +46 -0
- package/dist/args.js +118 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +191 -0
- package/dist/commands.d.ts +14 -0
- package/dist/commands.js +836 -0
- package/dist/config.d.ts +74 -0
- package/dist/config.js +242 -0
- package/dist/exit.d.ts +58 -0
- package/dist/exit.js +73 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/journey.d.ts +49 -0
- package/dist/journey.js +76 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +24 -0
- package/dist/node-runtime.d.ts +35 -0
- package/dist/node-runtime.js +210 -0
- package/dist/oauth.d.ts +96 -0
- package/dist/oauth.js +205 -0
- package/dist/output.d.ts +66 -0
- package/dist/output.js +76 -0
- package/dist/runtime.d.ts +63 -0
- package/dist/runtime.js +32 -0
- package/dist/session.d.ts +66 -0
- package/dist/session.js +157 -0
- package/package.json +51 -0
package/dist/commands.js
ADDED
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
import { GreatwaitClientError, createGreatwaitClient, } from "@greatwait/api-client";
|
|
2
|
+
import { isTerminalOccurrenceState, responseSchemaFor, } from "@greatwait/contracts";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { SECRET_ARGUMENT_ADVICE, parseDuration, } from "./args.js";
|
|
5
|
+
import { directoryConcerns, originKey } from "./config.js";
|
|
6
|
+
import { buildAuthorization, codeFrom, discover, exchangeCode, registerClient, revoke, } from "./oauth.js";
|
|
7
|
+
import { redactSecret } from "./output.js";
|
|
8
|
+
import { CliError, sleepOrAbort } from "./runtime.js";
|
|
9
|
+
import { publishedWait, recoveryFor, renderJourney, STEP_COMMANDS, } from "./journey.js";
|
|
10
|
+
import { anonymousClient, ENVIRONMENT_NAMES, openSession, resolveOrigin, storeFor, } from "./session.js";
|
|
11
|
+
/**
|
|
12
|
+
* The commands, each bounded and each useful on its own.
|
|
13
|
+
*
|
|
14
|
+
* **There is no `greatwait demo`.** The project specification excludes one by
|
|
15
|
+
* name, and the reason is that a single command whose success proves everything
|
|
16
|
+
* teaches nothing: somebody who ran it has watched a callback arrive and still
|
|
17
|
+
* cannot make a second one. Every command here is a step somebody would take
|
|
18
|
+
* again on their own work, and together they are the published first-value
|
|
19
|
+
* journey — which `greatwait journey` renders rather than restates.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* The scopes `login` asks for.
|
|
23
|
+
*
|
|
24
|
+
* Exactly the ones the published journey's steps declare, taken from the
|
|
25
|
+
* authorization server's own `scopes_supported` where it publishes a narrower
|
|
26
|
+
* set. Asking for everything a server offers would be asking a person to grant
|
|
27
|
+
* a command line permissions it has no code to use.
|
|
28
|
+
*/
|
|
29
|
+
const REQUESTED_SCOPES = [
|
|
30
|
+
"endpoints:read",
|
|
31
|
+
"endpoints:write",
|
|
32
|
+
"occurrences:read",
|
|
33
|
+
"schedules:read",
|
|
34
|
+
"schedules:write",
|
|
35
|
+
];
|
|
36
|
+
function apiUrlOption(args) {
|
|
37
|
+
return args.options["--api-url"];
|
|
38
|
+
}
|
|
39
|
+
function expectNoExtraArguments(args, used, usage) {
|
|
40
|
+
if (args.command.length <= used)
|
|
41
|
+
return;
|
|
42
|
+
// **A refusal never names an argument when `--api-key` is on the line.**
|
|
43
|
+
// The parser refuses a value immediately after that flag, but it cannot
|
|
44
|
+
// refuse one further along — `greatwait --api-key login <secret>` parses
|
|
45
|
+
// `login` as the command and leaves the secret as a positional — and naming
|
|
46
|
+
// it here is how the credential reached standard error and the `--json`
|
|
47
|
+
// envelope in the first place. Two layers, because either alone leaves a
|
|
48
|
+
// spelling uncovered.
|
|
49
|
+
if (args.booleans.has("--api-key")) {
|
|
50
|
+
throw new CliError("unexpected argument after --api-key", "usage", SECRET_ARGUMENT_ADVICE);
|
|
51
|
+
}
|
|
52
|
+
throw new CliError(`unexpected argument ${args.command[used]}`, "usage", `Usage: ${usage}`);
|
|
53
|
+
}
|
|
54
|
+
/* -------------------------------------------------------------------------- */
|
|
55
|
+
/* Signing in */
|
|
56
|
+
/* -------------------------------------------------------------------------- */
|
|
57
|
+
/**
|
|
58
|
+
* Reads an API credential without letting it reach the command line.
|
|
59
|
+
*
|
|
60
|
+
* Three sources, in the order that keeps a secret out of a process listing:
|
|
61
|
+
* anything piped in, then a hidden prompt on a terminal, and — for automation
|
|
62
|
+
* that has already put the value in its environment — the journey's own
|
|
63
|
+
* variable. There is deliberately no fourth.
|
|
64
|
+
*/
|
|
65
|
+
async function readApiKey(runtime) {
|
|
66
|
+
if (!runtime.interactive) {
|
|
67
|
+
const piped = (await runtime.readStdin()).trim();
|
|
68
|
+
if (piped !== "")
|
|
69
|
+
return piped;
|
|
70
|
+
const fromEnvironment = runtime.env[ENVIRONMENT_NAMES.apiKey];
|
|
71
|
+
if (fromEnvironment !== undefined && fromEnvironment !== "") {
|
|
72
|
+
return fromEnvironment;
|
|
73
|
+
}
|
|
74
|
+
throw new CliError("no credential was supplied and there is no terminal to ask on", "usage", `Pipe it in — printf %s "$KEY" | greatwait login --api-key — or export ${ENVIRONMENT_NAMES.apiKey}.`);
|
|
75
|
+
}
|
|
76
|
+
const typed = (await runtime.promptSecret("Paste the API credential (it will not echo): ")).trim();
|
|
77
|
+
if (typed === "") {
|
|
78
|
+
throw new CliError("no credential was entered", "usage");
|
|
79
|
+
}
|
|
80
|
+
return typed;
|
|
81
|
+
}
|
|
82
|
+
const login = async (runtime, args) => {
|
|
83
|
+
expectNoExtraArguments(args, 0, "greatwait login [--api-url <origin>] [--api-key] [--no-browser]");
|
|
84
|
+
const store = storeFor(runtime);
|
|
85
|
+
const requested = apiUrlOption(args);
|
|
86
|
+
const origin = requested === undefined
|
|
87
|
+
? (await resolveOrigin(runtime, store, undefined)).origin
|
|
88
|
+
: originKey(requested);
|
|
89
|
+
if (args.booleans.has("--api-key")) {
|
|
90
|
+
const secret = await readApiKey(runtime);
|
|
91
|
+
// **Proved before it is stored.** A credential written to disk and refused
|
|
92
|
+
// on the next command is a credential somebody has to debug; this way the
|
|
93
|
+
// refusal names the fault while the person is still looking at the prompt.
|
|
94
|
+
const described = await describeCredential(runtime, origin, secret);
|
|
95
|
+
await store.write(origin, { kind: "api-key", secret });
|
|
96
|
+
await store.select(origin);
|
|
97
|
+
return {
|
|
98
|
+
data: {
|
|
99
|
+
...(described.known ? { context: described.context } : {}),
|
|
100
|
+
credential: "api-key",
|
|
101
|
+
origin,
|
|
102
|
+
},
|
|
103
|
+
human: described.known
|
|
104
|
+
? `Signed in to ${origin} with an API credential, in project ${described.context.project} (${described.context.environmentKind}).`
|
|
105
|
+
: `Stored an API credential for ${origin} without checking it.`,
|
|
106
|
+
nextMove: described.known
|
|
107
|
+
? "Next: greatwait journey — the published first-value sequence, with the command for each step."
|
|
108
|
+
: `${described.reason}\n\nNext: greatwait journey — the published first-value sequence, with the command for each step.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const server = await discover(runtime, origin);
|
|
112
|
+
const scopes = server.scopesSupported.length === 0
|
|
113
|
+
? [...REQUESTED_SCOPES]
|
|
114
|
+
: REQUESTED_SCOPES.filter((scope) => server.scopesSupported.includes(scope));
|
|
115
|
+
if (scopes.length === 0) {
|
|
116
|
+
throw new CliError(`${origin} grants none of the permissions this command needs`, "authorization", "Sign in with an API credential instead: greatwait login --api-key");
|
|
117
|
+
}
|
|
118
|
+
// Bound first: the redirect URI is matched exactly, port included, so the
|
|
119
|
+
// port has to exist before the client is registered for it.
|
|
120
|
+
const listener = await runtime.listen();
|
|
121
|
+
try {
|
|
122
|
+
const clientId = await registerClient(runtime, server, {
|
|
123
|
+
redirectUri: listener.redirectUri,
|
|
124
|
+
scopes,
|
|
125
|
+
});
|
|
126
|
+
const authorization = await buildAuthorization(runtime, server, {
|
|
127
|
+
clientId,
|
|
128
|
+
redirectUri: listener.redirectUri,
|
|
129
|
+
scopes,
|
|
130
|
+
});
|
|
131
|
+
const noBrowser = args.booleans.has("--no-browser");
|
|
132
|
+
let opened = false;
|
|
133
|
+
if (!noBrowser) {
|
|
134
|
+
opened = await runtime
|
|
135
|
+
.openBrowser(authorization.authorizationUrl)
|
|
136
|
+
.then(() => true)
|
|
137
|
+
.catch(() => false);
|
|
138
|
+
}
|
|
139
|
+
// **Printed whether or not the browser opened.** A URL shown only when
|
|
140
|
+
// launching failed is a URL nobody on a remote shell ever sees, because
|
|
141
|
+
// `xdg-open` there exits zero having opened nothing.
|
|
142
|
+
runtime.stderr(`${opened ? "Opened your browser. If nothing appeared, open" : "Open"} this address to approve the request:\n\n ${authorization.authorizationUrl}\n\nWaiting for the answer on ${listener.redirectUri} …\n`);
|
|
143
|
+
const returned = await listener.received;
|
|
144
|
+
const code = codeFrom(returned, authorization.state);
|
|
145
|
+
const tokens = await exchangeCode(runtime, server, {
|
|
146
|
+
clientId,
|
|
147
|
+
code,
|
|
148
|
+
redirectUri: listener.redirectUri,
|
|
149
|
+
verifier: authorization.verifier,
|
|
150
|
+
});
|
|
151
|
+
// **Described before it is stored, like the API-key branch.** It used to
|
|
152
|
+
// write and select first, so a `500` from the description probe reported a
|
|
153
|
+
// failed sign-in while a live, selected grant sat on the disk — and a
|
|
154
|
+
// person or script that read the refusal as "nothing happened" had no
|
|
155
|
+
// reason to run `logout`. Whatever this command says, the disk now
|
|
156
|
+
// matches: a grant it refuses is a grant it does not keep, and a grant it
|
|
157
|
+
// keeps is one it reported keeping.
|
|
158
|
+
const described = await describeCredential(runtime, origin, tokens.accessToken).catch(async (error) => {
|
|
159
|
+
// **Only a refusal, and the narrowing is the whole of it.** The
|
|
160
|
+
// credential was minted seconds ago by this very exchange, so a `401` or
|
|
161
|
+
// a `403` is not a stale token — it is a service that will not let this
|
|
162
|
+
// grant act, and keeping it would leave somebody holding something that
|
|
163
|
+
// authenticated once and never again. Anything else reaching here is a
|
|
164
|
+
// defect in this command rather than an answer about the grant, and
|
|
165
|
+
// revoking on it would cost a person another consent for a fault that
|
|
166
|
+
// was never theirs.
|
|
167
|
+
if (error instanceof GreatwaitClientError &&
|
|
168
|
+
(error.status === 401 || error.status === 403)) {
|
|
169
|
+
await revoke(runtime, server, {
|
|
170
|
+
clientId,
|
|
171
|
+
token: tokens.refreshToken,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
throw error;
|
|
175
|
+
});
|
|
176
|
+
await store.write(origin, {
|
|
177
|
+
accessToken: tokens.accessToken,
|
|
178
|
+
clientId,
|
|
179
|
+
expiresAtMs: runtime.now() + tokens.expiresInSeconds * 1000,
|
|
180
|
+
kind: "oauth",
|
|
181
|
+
refreshToken: tokens.refreshToken,
|
|
182
|
+
scope: tokens.scope,
|
|
183
|
+
});
|
|
184
|
+
await store.select(origin);
|
|
185
|
+
return {
|
|
186
|
+
data: {
|
|
187
|
+
...(described.known ? { context: described.context } : {}),
|
|
188
|
+
credential: "oauth",
|
|
189
|
+
origin,
|
|
190
|
+
scopes: tokens.scope.split(" ").filter((scope) => scope !== ""),
|
|
191
|
+
},
|
|
192
|
+
human: described.known
|
|
193
|
+
? `Signed in to ${origin}, in project ${described.context.project} (${described.context.environmentKind}).`
|
|
194
|
+
: `Signed in to ${origin}. The grant is real — the service issued it — but it cannot say what it reaches.`,
|
|
195
|
+
nextMove: described.known
|
|
196
|
+
? "Next: greatwait journey — the published first-value sequence, with the command for each step."
|
|
197
|
+
: `${described.reason}\n\nNext: greatwait journey — the published first-value sequence, with the command for each step.`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
listener.close();
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
async function describeCredential(runtime, origin, credential) {
|
|
205
|
+
return describeThrough(createGreatwaitClient({
|
|
206
|
+
baseUrl: origin,
|
|
207
|
+
credential,
|
|
208
|
+
fetch: runtime.fetch,
|
|
209
|
+
}), origin);
|
|
210
|
+
}
|
|
211
|
+
async function describeThrough(client, origin) {
|
|
212
|
+
try {
|
|
213
|
+
return {
|
|
214
|
+
context: responseSchemaFor("context.read")?.parse(await client.request("context_read")),
|
|
215
|
+
known: true,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (!(error instanceof GreatwaitClientError)) {
|
|
220
|
+
// **A transport failure is not an answer about the credential.** The
|
|
221
|
+
// request never reached the service — no DNS, no TLS, a reset — so it
|
|
222
|
+
// says nothing about what was just issued, and it does not carry a
|
|
223
|
+
// status to classify. It used to be rethrown, which sent it to the
|
|
224
|
+
// caller's revoke-on-refusal path and destroyed a perfectly good grant
|
|
225
|
+
// over a network blip: the exact harm the reasoning below exists to
|
|
226
|
+
// avoid, skipped because the failure never reached the reasoning.
|
|
227
|
+
return {
|
|
228
|
+
known: false,
|
|
229
|
+
reason: `${origin} could not be reached to ask what this credential acts in (${error instanceof Error ? error.message : String(error)}). Run greatwait whoami when it is back.`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
// **Only a refusal of the credential itself is fatal.** That is the whole
|
|
233
|
+
// reason to make this call at sign-in, and it is the one answer that means
|
|
234
|
+
// the thing being stored will never work.
|
|
235
|
+
if (error.status === 401 || error.status === 403)
|
|
236
|
+
throw error;
|
|
237
|
+
if (error.status === 404) {
|
|
238
|
+
return {
|
|
239
|
+
known: false,
|
|
240
|
+
reason: `${origin} does not publish GET /v1/context, so it cannot say which project this credential acts in. It is running a build from before that operation existed.`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// **Anything else is the service having a moment, not a bad credential.**
|
|
244
|
+
// A `500` or a `503` from this one read says nothing about what was just
|
|
245
|
+
// issued, and throwing the credential away over it would cost a person
|
|
246
|
+
// another consent — or another visit to the dashboard — for a fault that
|
|
247
|
+
// has already passed. It is kept, and the report says what could not be
|
|
248
|
+
// learned rather than claiming the sign-in failed.
|
|
249
|
+
return {
|
|
250
|
+
known: false,
|
|
251
|
+
reason: `${origin} answered ${String(error.status)} when asked what this credential reaches, so it cannot be described yet. Run greatwait whoami again in a moment.`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const logout = async (runtime, args) => {
|
|
256
|
+
expectNoExtraArguments(args, 0, "greatwait logout [--api-url <origin>]");
|
|
257
|
+
const store = storeFor(runtime);
|
|
258
|
+
const { origin } = await resolveOrigin(runtime, store, apiUrlOption(args));
|
|
259
|
+
const stored = await store.read(origin);
|
|
260
|
+
if (stored === undefined) {
|
|
261
|
+
return {
|
|
262
|
+
data: { origin, removed: false, revoked: false },
|
|
263
|
+
human: `Nothing was stored for ${origin}.`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
let revoked = false;
|
|
267
|
+
if (stored.kind === "oauth" &&
|
|
268
|
+
stored.refreshToken !== undefined &&
|
|
269
|
+
stored.clientId !== undefined) {
|
|
270
|
+
// Best effort, and the local material goes either way: a logout that kept
|
|
271
|
+
// a credential on disk because the network was down would be the opposite
|
|
272
|
+
// of what was asked for.
|
|
273
|
+
const server = await discover(runtime, origin).catch(() => undefined);
|
|
274
|
+
if (server !== undefined) {
|
|
275
|
+
revoked = await revoke(runtime, server, {
|
|
276
|
+
clientId: stored.clientId,
|
|
277
|
+
token: stored.refreshToken,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
await store.forget(origin);
|
|
282
|
+
if ((await store.readSelection()) === origin) {
|
|
283
|
+
await store.select(undefined);
|
|
284
|
+
}
|
|
285
|
+
// **A signed-out shell that still authenticates is worth saying out loud.**
|
|
286
|
+
// The environment credential is the non-interactive path and `logout` cannot
|
|
287
|
+
// unset a variable in the caller's shell — so a person who signs out and
|
|
288
|
+
// finds the next command working has been given no reason for it. Found by
|
|
289
|
+
// this command's own hosted harness, which inherited the variable and
|
|
290
|
+
// therefore proved nothing when it checked that signing out had worked.
|
|
291
|
+
const fromEnvironment = runtime.env[ENVIRONMENT_NAMES.apiKey];
|
|
292
|
+
const environmentStillSignsIn = fromEnvironment !== undefined && fromEnvironment !== "";
|
|
293
|
+
return {
|
|
294
|
+
data: {
|
|
295
|
+
environmentCredentialRemains: environmentStillSignsIn,
|
|
296
|
+
origin,
|
|
297
|
+
removed: true,
|
|
298
|
+
revoked,
|
|
299
|
+
},
|
|
300
|
+
human: stored.kind === "oauth"
|
|
301
|
+
? revoked
|
|
302
|
+
? `Signed out of ${origin}. The grant was revoked and the local tokens are gone.`
|
|
303
|
+
: `Signed out of ${origin}. The local tokens are gone; the service did not confirm the grant was revoked.`
|
|
304
|
+
: `Signed out of ${origin}. The stored credential is gone — revoke it in the dashboard if it should stop working everywhere.`,
|
|
305
|
+
...(environmentStillSignsIn
|
|
306
|
+
? {
|
|
307
|
+
nextMove: `${ENVIRONMENT_NAMES.apiKey} is still set in this shell, so commands here will keep authenticating with it. Unset it if you meant to stop.`,
|
|
308
|
+
}
|
|
309
|
+
: stored.kind === "oauth" && !revoked
|
|
310
|
+
? {
|
|
311
|
+
nextMove: "Check the connected applications list in the dashboard if you meant to end the grant everywhere.",
|
|
312
|
+
}
|
|
313
|
+
: {}),
|
|
314
|
+
};
|
|
315
|
+
};
|
|
316
|
+
/* -------------------------------------------------------------------------- */
|
|
317
|
+
/* Where am I, and is anything wrong */
|
|
318
|
+
/* -------------------------------------------------------------------------- */
|
|
319
|
+
/** How each kind of credential is named in output a person reads. */
|
|
320
|
+
const CREDENTIAL_LABELS = {
|
|
321
|
+
"api-key": "a stored API credential",
|
|
322
|
+
"environment-key": `an API credential from ${ENVIRONMENT_NAMES.apiKey}`,
|
|
323
|
+
oauth: "an OAuth grant",
|
|
324
|
+
};
|
|
325
|
+
const whoami = async (runtime, args) => {
|
|
326
|
+
expectNoExtraArguments(args, 0, "greatwait whoami [--api-url <origin>]");
|
|
327
|
+
const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
|
|
328
|
+
const described = await describeThrough(session.client, session.origin);
|
|
329
|
+
if (!described.known) {
|
|
330
|
+
// **Reported rather than guessed at.** The values could be inferred from a
|
|
331
|
+
// destination or a key the project already owns, and a project that owns
|
|
332
|
+
// neither is exactly the one somebody has just made — so an inference would
|
|
333
|
+
// be absent when it is first wanted and confidently wrong the rest of the
|
|
334
|
+
// time.
|
|
335
|
+
return {
|
|
336
|
+
data: {
|
|
337
|
+
credential: session.credentialKind,
|
|
338
|
+
origin: session.origin,
|
|
339
|
+
projectKnown: false,
|
|
340
|
+
},
|
|
341
|
+
human: [
|
|
342
|
+
`Service ${session.origin}`,
|
|
343
|
+
`Credential ${CREDENTIAL_LABELS[session.credentialKind]}`,
|
|
344
|
+
`Project not published by this service`,
|
|
345
|
+
`Environment not published by this service`,
|
|
346
|
+
].join("\n"),
|
|
347
|
+
nextMove: described.reason,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const context = described.context;
|
|
351
|
+
return {
|
|
352
|
+
data: {
|
|
353
|
+
credential: session.credentialKind,
|
|
354
|
+
environmentKind: context.environmentKind,
|
|
355
|
+
origin: session.origin,
|
|
356
|
+
principal: context.principal,
|
|
357
|
+
project: context.project,
|
|
358
|
+
scopes: context.scopes,
|
|
359
|
+
},
|
|
360
|
+
human: [
|
|
361
|
+
`Service ${session.origin}`,
|
|
362
|
+
`Credential ${CREDENTIAL_LABELS[session.credentialKind]}`,
|
|
363
|
+
`Project ${context.project}`,
|
|
364
|
+
`Environment ${context.environmentKind}`,
|
|
365
|
+
`Permissions ${context.scopes.join(", ")}`,
|
|
366
|
+
].join("\n"),
|
|
367
|
+
...(context.environmentKind === "live"
|
|
368
|
+
? {
|
|
369
|
+
nextMove: "This is the live environment: anything you create here is delivered for real.",
|
|
370
|
+
}
|
|
371
|
+
: {}),
|
|
372
|
+
};
|
|
373
|
+
};
|
|
374
|
+
const doctor = async (runtime, args) => {
|
|
375
|
+
expectNoExtraArguments(args, 0, "greatwait doctor [--api-url <origin>]");
|
|
376
|
+
const store = storeFor(runtime);
|
|
377
|
+
const checks = [];
|
|
378
|
+
let origin;
|
|
379
|
+
try {
|
|
380
|
+
origin = (await resolveOrigin(runtime, store, apiUrlOption(args))).origin;
|
|
381
|
+
checks.push({
|
|
382
|
+
detail: origin,
|
|
383
|
+
name: "service",
|
|
384
|
+
outcome: "passed",
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
checks.push({
|
|
389
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
390
|
+
name: "service",
|
|
391
|
+
outcome: "failed",
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
// Reachability and identity, with no credential — the journey's own first
|
|
395
|
+
// step, and the check that separates "wrong URL" from "wrong key".
|
|
396
|
+
let environment;
|
|
397
|
+
if (origin !== undefined) {
|
|
398
|
+
try {
|
|
399
|
+
const version = await anonymousClient(runtime, origin).getVersion();
|
|
400
|
+
environment = version.environment;
|
|
401
|
+
checks.push({
|
|
402
|
+
detail: `${version.service} in ${version.environment}, build ${version.buildSha.slice(0, 12)}`,
|
|
403
|
+
name: "reachable",
|
|
404
|
+
outcome: "passed",
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
checks.push({
|
|
409
|
+
detail: error instanceof Error
|
|
410
|
+
? error.message
|
|
411
|
+
: "the origin did not answer as a Greatwait API",
|
|
412
|
+
name: "reachable",
|
|
413
|
+
outcome: "failed",
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const locations = store.locations();
|
|
418
|
+
const concerns = await directoryConcerns(locations, runtime.uid);
|
|
419
|
+
checks.push(concerns.length === 0
|
|
420
|
+
? {
|
|
421
|
+
detail: `${locations.directory} is readable by its owner only`,
|
|
422
|
+
name: "configuration",
|
|
423
|
+
outcome: "passed",
|
|
424
|
+
}
|
|
425
|
+
: {
|
|
426
|
+
detail: concerns.join("; "),
|
|
427
|
+
name: "configuration",
|
|
428
|
+
outcome: "failed",
|
|
429
|
+
});
|
|
430
|
+
if (origin === undefined) {
|
|
431
|
+
checks.push({
|
|
432
|
+
detail: "no service was chosen, so no credential was checked",
|
|
433
|
+
name: "credential",
|
|
434
|
+
outcome: "skipped",
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
try {
|
|
439
|
+
const session = await openSession(runtime, { apiUrl: origin });
|
|
440
|
+
const described = await describeThrough(session.client, origin);
|
|
441
|
+
// **`skipped`, not `passed`, when the service cannot say.** A `404` from
|
|
442
|
+
// `/v1/context` arrives before anything looks at the credential, so a
|
|
443
|
+
// check that reported success from one would be reporting that a
|
|
444
|
+
// credential works on the evidence that a route does not exist.
|
|
445
|
+
checks.push(described.known
|
|
446
|
+
? {
|
|
447
|
+
detail: `${session.credentialKind} in project ${described.context.project} (${described.context.environmentKind}), holding ${described.context.scopes.join(", ")}`,
|
|
448
|
+
name: "credential",
|
|
449
|
+
outcome: "passed",
|
|
450
|
+
}
|
|
451
|
+
: {
|
|
452
|
+
detail: `${CREDENTIAL_LABELS[session.credentialKind]} is stored and was not checked — ${described.reason}`,
|
|
453
|
+
name: "credential",
|
|
454
|
+
outcome: "skipped",
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
checks.push({
|
|
459
|
+
detail: error instanceof GreatwaitClientError
|
|
460
|
+
? `${error.problem?.code ?? String(error.status)}: ${error.problem?.title ?? error.message}`
|
|
461
|
+
: error instanceof Error
|
|
462
|
+
? error.message
|
|
463
|
+
: String(error),
|
|
464
|
+
name: "credential",
|
|
465
|
+
outcome: "failed",
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const failed = checks.filter((check) => check.outcome === "failed");
|
|
470
|
+
return {
|
|
471
|
+
data: {
|
|
472
|
+
checks,
|
|
473
|
+
...(environment === undefined ? {} : { environment }),
|
|
474
|
+
ok: failed.length === 0,
|
|
475
|
+
},
|
|
476
|
+
// **A failing `doctor` is an answer, not a raised error.** Its machine
|
|
477
|
+
// shape is a check per name with an outcome, and that shape is the same
|
|
478
|
+
// whichever way the answer went; raising put the rendered table into an
|
|
479
|
+
// error message, so a script asking which check failed got a paragraph to
|
|
480
|
+
// parse.
|
|
481
|
+
...(failed.length === 0 ? {} : { exit: "validation" }),
|
|
482
|
+
human: checks
|
|
483
|
+
.map((check) => `${check.outcome === "passed" ? "ok " : check.outcome === "failed" ? "fail" : "skip"} ${check.name.padEnd(14)}${check.detail}`)
|
|
484
|
+
.join("\n"),
|
|
485
|
+
...(failed.length === 0
|
|
486
|
+
? {}
|
|
487
|
+
: {
|
|
488
|
+
nextMove: `${String(failed.length)} of ${String(checks.length)} checks failed.`,
|
|
489
|
+
}),
|
|
490
|
+
};
|
|
491
|
+
};
|
|
492
|
+
const context = async (runtime, args) => {
|
|
493
|
+
const store = storeFor(runtime);
|
|
494
|
+
const action = args.command[0];
|
|
495
|
+
if (action === undefined) {
|
|
496
|
+
const { origin, source } = await resolveOrigin(runtime, store, apiUrlOption(args));
|
|
497
|
+
return {
|
|
498
|
+
data: { origin, source },
|
|
499
|
+
human: `${origin} (${{
|
|
500
|
+
argument: "from --api-url",
|
|
501
|
+
environment: `from ${ENVIRONMENT_NAMES.apiUrl}`,
|
|
502
|
+
selection: "selected",
|
|
503
|
+
sole: "the only one signed in",
|
|
504
|
+
}[source]})`,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
if (action === "list") {
|
|
508
|
+
expectNoExtraArguments(args, 1, "greatwait context list");
|
|
509
|
+
const origins = await store.origins();
|
|
510
|
+
const selected = await store.readSelection();
|
|
511
|
+
return {
|
|
512
|
+
data: { origins, ...(selected === undefined ? {} : { selected }) },
|
|
513
|
+
human: origins.length === 0
|
|
514
|
+
? "No service is signed in."
|
|
515
|
+
: origins
|
|
516
|
+
.map((entry) => `${entry === selected ? "*" : " "} ${entry}`)
|
|
517
|
+
.join("\n"),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
if (action === "use") {
|
|
521
|
+
const wanted = args.command[1];
|
|
522
|
+
if (wanted === undefined) {
|
|
523
|
+
throw new CliError("greatwait context use needs an origin", "usage");
|
|
524
|
+
}
|
|
525
|
+
expectNoExtraArguments(args, 2, "greatwait context use <origin>");
|
|
526
|
+
const origin = originKey(wanted);
|
|
527
|
+
if ((await store.read(origin)) === undefined) {
|
|
528
|
+
throw new CliError(`no credential is stored for ${origin}`, "usage", `Sign in to it first: greatwait login --api-url ${origin}`);
|
|
529
|
+
}
|
|
530
|
+
await store.select(origin);
|
|
531
|
+
return {
|
|
532
|
+
data: { origin, selected: true },
|
|
533
|
+
human: `Commands now go to ${origin} unless --api-url says otherwise.`,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
throw new CliError(`unknown context command ${action}`, "usage", "Usage: greatwait context [list | use <origin>]");
|
|
537
|
+
};
|
|
538
|
+
/* -------------------------------------------------------------------------- */
|
|
539
|
+
/* The journey */
|
|
540
|
+
/* -------------------------------------------------------------------------- */
|
|
541
|
+
const journey = async (runtime, args) => {
|
|
542
|
+
expectNoExtraArguments(args, 0, "greatwait journey");
|
|
543
|
+
const rendered = renderJourney();
|
|
544
|
+
// Named where it is known, because the journey's own first step is "confirm
|
|
545
|
+
// which service and environment you reached" and a reader following this
|
|
546
|
+
// list should not have to go and find out separately. Not knowing is not a
|
|
547
|
+
// failure here: the sequence is worth reading before signing in to anything.
|
|
548
|
+
const chosen = await resolveOrigin(runtime, storeFor(runtime), apiUrlOption(args)).then((resolved) => resolved.origin, () => undefined);
|
|
549
|
+
const lines = [
|
|
550
|
+
...(chosen === undefined
|
|
551
|
+
? ["No service is chosen yet. Sign in when you reach step 2."]
|
|
552
|
+
: [`Against ${chosen}.`]),
|
|
553
|
+
``,
|
|
554
|
+
"Before you start",
|
|
555
|
+
...rendered.prerequisites.flatMap((prerequisite) => [
|
|
556
|
+
``,
|
|
557
|
+
` ${prerequisite.title}`,
|
|
558
|
+
` ${prerequisite.action}`,
|
|
559
|
+
` Why: ${prerequisite.because}`,
|
|
560
|
+
]),
|
|
561
|
+
``,
|
|
562
|
+
`First value, in ${String(rendered.steps.length)} steps`,
|
|
563
|
+
...rendered.steps.flatMap((step, index) => [
|
|
564
|
+
``,
|
|
565
|
+
` ${String(index + 1)}. ${step.title}`,
|
|
566
|
+
` ${step.purpose}`,
|
|
567
|
+
` Look for: ${step.observe}`,
|
|
568
|
+
...(step.command === undefined ? [] : [` Run: ${step.command}`]),
|
|
569
|
+
]),
|
|
570
|
+
];
|
|
571
|
+
return {
|
|
572
|
+
data: { ...(chosen === undefined ? {} : { origin: chosen }), ...rendered },
|
|
573
|
+
human: lines.join("\n"),
|
|
574
|
+
nextMove: `Start with: ${STEP_COMMANDS["check-service"]}`,
|
|
575
|
+
};
|
|
576
|
+
};
|
|
577
|
+
/* -------------------------------------------------------------------------- */
|
|
578
|
+
/* Destinations */
|
|
579
|
+
/* -------------------------------------------------------------------------- */
|
|
580
|
+
const destinationsCreate = async (runtime, args) => {
|
|
581
|
+
const url = args.options["--url"];
|
|
582
|
+
if (url === undefined) {
|
|
583
|
+
throw new CliError("greatwait destinations create needs --url", "usage", "Usage: greatwait destinations create --url https://your-receiver.example.com/greatwait");
|
|
584
|
+
}
|
|
585
|
+
const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
|
|
586
|
+
const created = responseSchemaFor("endpoints.create")?.parse(await session.client.request("endpoints_create", { url }));
|
|
587
|
+
return {
|
|
588
|
+
// **The secret is here and in no later output.** The service returns it
|
|
589
|
+
// from this call and from no other, which is the product behaviour rather
|
|
590
|
+
// than a limitation of this command — so printing it once, with the warning,
|
|
591
|
+
// is the only place it can honestly appear.
|
|
592
|
+
data: { id: created.id, secret: created.secret, status: created.status },
|
|
593
|
+
human: [
|
|
594
|
+
`Destination ${created.id}`,
|
|
595
|
+
`Status ${created.status}`,
|
|
596
|
+
``,
|
|
597
|
+
`Signing key ${created.secret}`,
|
|
598
|
+
``,
|
|
599
|
+
`This key is shown once and is not recoverable. Store it now — your receiver`,
|
|
600
|
+
`verifies every callback with it, and reading this destination back will show`,
|
|
601
|
+
`${redactSecret(created.secret)} instead.`,
|
|
602
|
+
].join("\n"),
|
|
603
|
+
nextMove: `Next: greatwait schedules create --endpoint ${created.id} --in 30s`,
|
|
604
|
+
};
|
|
605
|
+
};
|
|
606
|
+
const destinationsDisable = async (runtime, args) => {
|
|
607
|
+
const id = args.command[0];
|
|
608
|
+
if (id === undefined) {
|
|
609
|
+
throw new CliError("greatwait destinations disable needs a destination", "usage", "Usage: greatwait destinations disable <destination>");
|
|
610
|
+
}
|
|
611
|
+
expectNoExtraArguments(args, 1, "greatwait destinations disable <destination>");
|
|
612
|
+
const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
|
|
613
|
+
const disabled = responseSchemaFor("endpoints.disable")?.parse(await session.client.request("endpoints_disable", id));
|
|
614
|
+
return {
|
|
615
|
+
data: { id: disabled.id, status: disabled.status },
|
|
616
|
+
human: `Destination ${disabled.id} is ${disabled.status}. Nothing further is delivered to it, and the attempts already recorded against it stay readable.`,
|
|
617
|
+
};
|
|
618
|
+
};
|
|
619
|
+
/* -------------------------------------------------------------------------- */
|
|
620
|
+
/* Schedules */
|
|
621
|
+
/* -------------------------------------------------------------------------- */
|
|
622
|
+
/** The payload, from an argument, a file, or standard input. */
|
|
623
|
+
async function readPayload(runtime, value) {
|
|
624
|
+
if (value === undefined)
|
|
625
|
+
return undefined;
|
|
626
|
+
const text = value === "-"
|
|
627
|
+
? await runtime.readStdin()
|
|
628
|
+
: value.startsWith("@")
|
|
629
|
+
? await readFile(value.slice(1), "utf8")
|
|
630
|
+
: value;
|
|
631
|
+
try {
|
|
632
|
+
return JSON.parse(text);
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
throw new CliError("--data is not JSON", "usage", "Pass a JSON object, @a-file.json, or - to read it from standard input.");
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* An instant, in the exact spelling the contract publishes.
|
|
640
|
+
*
|
|
641
|
+
* **Refused here rather than by the service, and normalised on the way.**
|
|
642
|
+
* `--in` was already read before anything was sent and `--at` was not, so
|
|
643
|
+
* `--at "next tuesday"` cost a round trip and came back as `invalid_request` —
|
|
644
|
+
* which calls a mistake in the command line a fault in the request. And the
|
|
645
|
+
* ordinary ISO spelling somebody types, `2030-01-01T00:00:00Z`, is refused by
|
|
646
|
+
* the contract for having no milliseconds; that is a real instant written a
|
|
647
|
+
* normal way, so it is formatted rather than rejected. Nothing is guessed: a
|
|
648
|
+
* value `Date` cannot read is refused, and the refusal says what a good one
|
|
649
|
+
* looks like.
|
|
650
|
+
*/
|
|
651
|
+
function parseInstant(value) {
|
|
652
|
+
const parsed = Date.parse(value);
|
|
653
|
+
if (Number.isNaN(parsed)) {
|
|
654
|
+
throw new CliError(`${value} is not an instant`, "usage", "Write it as UTC, such as 2030-01-01T09:00:00.000Z. For a delay from now, use --in 30s instead.");
|
|
655
|
+
}
|
|
656
|
+
return new Date(parsed).toISOString();
|
|
657
|
+
}
|
|
658
|
+
const schedulesCreate = async (runtime, args) => {
|
|
659
|
+
const endpoint = args.options["--endpoint"];
|
|
660
|
+
if (endpoint === undefined) {
|
|
661
|
+
throw new CliError("greatwait schedules create needs --endpoint", "usage", "Usage: greatwait schedules create --endpoint <destination> --in 30s");
|
|
662
|
+
}
|
|
663
|
+
const inValue = args.options["--in"];
|
|
664
|
+
const atValue = args.options["--at"];
|
|
665
|
+
if ((inValue === undefined) === (atValue === undefined)) {
|
|
666
|
+
throw new CliError("give exactly one of --in and --at", "usage", "Either --in 30s, or --at 2026-09-04T09:00:00.000Z.");
|
|
667
|
+
}
|
|
668
|
+
const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
|
|
669
|
+
const data = await readPayload(runtime, args.options["--data"]);
|
|
670
|
+
// **Generated when it is not given, and printed either way.** A create that
|
|
671
|
+
// silently invented a fresh key on every run would make a retry a second
|
|
672
|
+
// schedule, which is the failure the key exists to prevent; a create that
|
|
673
|
+
// hid the key would leave the caller unable to make that retry at all.
|
|
674
|
+
const idempotencyKey = args.options["--idempotency-key"] ??
|
|
675
|
+
`greatwait-cli-${runtime.randomUuid()}`;
|
|
676
|
+
const request = {
|
|
677
|
+
...(data === undefined ? {} : { data }),
|
|
678
|
+
...(inValue === undefined
|
|
679
|
+
? { at: parseInstant(atValue) }
|
|
680
|
+
: { delaySeconds: parseDuration(inValue) }),
|
|
681
|
+
endpoint,
|
|
682
|
+
};
|
|
683
|
+
const answer = await session.client.requestDetailed("schedules_create", request, { "Idempotency-Key": idempotencyKey });
|
|
684
|
+
const created = responseSchemaFor("schedules.create")?.parse(answer.body);
|
|
685
|
+
const replayed = answer.headers["Idempotency-Replayed"] === "true";
|
|
686
|
+
return {
|
|
687
|
+
data: {
|
|
688
|
+
idempotencyKey,
|
|
689
|
+
occurrence: created.occurrence.id,
|
|
690
|
+
plannedFor: created.occurrence.plannedFor,
|
|
691
|
+
replayed,
|
|
692
|
+
schedule: created.schedule.id,
|
|
693
|
+
},
|
|
694
|
+
human: [
|
|
695
|
+
`Schedule ${created.schedule.id}`,
|
|
696
|
+
`Occurrence ${created.occurrence.id}`,
|
|
697
|
+
`Planned for ${created.occurrence.plannedFor}`,
|
|
698
|
+
`Retry key ${idempotencyKey}`,
|
|
699
|
+
...(replayed
|
|
700
|
+
? [
|
|
701
|
+
``,
|
|
702
|
+
`This key had already been used for this exact request, so nothing was`,
|
|
703
|
+
`created a second time and no second callback will be delivered.`,
|
|
704
|
+
]
|
|
705
|
+
: []),
|
|
706
|
+
].join("\n"),
|
|
707
|
+
nextMove: `Next: greatwait occurrences wait ${created.schedule.id} ${created.occurrence.id}`,
|
|
708
|
+
};
|
|
709
|
+
};
|
|
710
|
+
const schedulesRead = async (runtime, args) => {
|
|
711
|
+
const id = args.command[0];
|
|
712
|
+
if (id === undefined) {
|
|
713
|
+
throw new CliError("greatwait schedules read needs a schedule", "usage", "Usage: greatwait schedules read <schedule>");
|
|
714
|
+
}
|
|
715
|
+
expectNoExtraArguments(args, 1, "greatwait schedules read <schedule>");
|
|
716
|
+
const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
|
|
717
|
+
const answer = await session.client.requestDetailed("schedules_read", id);
|
|
718
|
+
const view = responseSchemaFor("schedules.read")?.parse(answer.body);
|
|
719
|
+
const etag = answer.headers["ETag"];
|
|
720
|
+
return {
|
|
721
|
+
data: {
|
|
722
|
+
...(etag === undefined ? {} : { etag }),
|
|
723
|
+
recurrence: view.schedule.recurrence.kind,
|
|
724
|
+
schedule: view.schedule.id,
|
|
725
|
+
status: view.schedule.status,
|
|
726
|
+
version: view.schedule.version,
|
|
727
|
+
},
|
|
728
|
+
human: [
|
|
729
|
+
`Schedule ${view.schedule.id}`,
|
|
730
|
+
`Status ${view.schedule.status}`,
|
|
731
|
+
`Recurrence ${view.schedule.recurrence.kind}`,
|
|
732
|
+
`Version ${String(view.schedule.version)}`,
|
|
733
|
+
...(etag === undefined ? [] : [`ETag ${etag}`]),
|
|
734
|
+
].join("\n"),
|
|
735
|
+
...(etag === undefined
|
|
736
|
+
? {}
|
|
737
|
+
: {
|
|
738
|
+
nextMove: "Send that ETag back in If-Match to make a later pause, replace or cancel conditional on nothing having changed in between.",
|
|
739
|
+
}),
|
|
740
|
+
};
|
|
741
|
+
};
|
|
742
|
+
/* -------------------------------------------------------------------------- */
|
|
743
|
+
/* Waiting */
|
|
744
|
+
/* -------------------------------------------------------------------------- */
|
|
745
|
+
const occurrencesWait = async (runtime, args) => {
|
|
746
|
+
const schedule = args.command[0];
|
|
747
|
+
const occurrence = args.command[1];
|
|
748
|
+
if (schedule === undefined || occurrence === undefined) {
|
|
749
|
+
throw new CliError("greatwait occurrences wait needs a schedule and an occurrence", "usage", "Usage: greatwait occurrences wait <schedule> <occurrence>");
|
|
750
|
+
}
|
|
751
|
+
expectNoExtraArguments(args, 2, "greatwait occurrences wait <schedule> <occurrence>");
|
|
752
|
+
const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
|
|
753
|
+
// The interval and the deadline come from the published journey. A number
|
|
754
|
+
// here would be a fourth answer to a question the website, the curl page and
|
|
755
|
+
// the JavaScript page already answer identically.
|
|
756
|
+
const { intervalMs, timeoutMs } = publishedWait();
|
|
757
|
+
const override = args.options["--timeout"];
|
|
758
|
+
const deadline = runtime.now() +
|
|
759
|
+
(override === undefined ? timeoutMs : parseDuration(override) * 1000);
|
|
760
|
+
const seen = [];
|
|
761
|
+
for (;;) {
|
|
762
|
+
const view = responseSchemaFor("occurrences.read")?.parse(await session.client.request("occurrences_read", schedule, occurrence));
|
|
763
|
+
const state = view.occurrence.state;
|
|
764
|
+
if (seen[seen.length - 1] !== state) {
|
|
765
|
+
seen.push(state);
|
|
766
|
+
runtime.stderr(` ${state}\n`);
|
|
767
|
+
}
|
|
768
|
+
if (isTerminalOccurrenceState(state)) {
|
|
769
|
+
return {
|
|
770
|
+
data: {
|
|
771
|
+
attempts: view.occurrence.attemptCount,
|
|
772
|
+
occurrence,
|
|
773
|
+
schedule,
|
|
774
|
+
settled: true,
|
|
775
|
+
state,
|
|
776
|
+
states: seen,
|
|
777
|
+
},
|
|
778
|
+
human: `Occurrence ${occurrence} settled ${state} after ${String(view.occurrence.attemptCount)} ${view.occurrence.attemptCount === 1 ? "attempt" : "attempts"}.`,
|
|
779
|
+
nextMove: `Next: greatwait attempts list ${schedule} ${occurrence}`,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
if (runtime.now() >= deadline) {
|
|
783
|
+
throw new CliError(`occurrence ${occurrence} was still ${state} when the deadline passed`, "timeout", recoveryFor("await-occurrence", "not-settled"));
|
|
784
|
+
}
|
|
785
|
+
await sleepOrAbort(runtime, intervalMs);
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
const attemptsList = async (runtime, args) => {
|
|
789
|
+
const schedule = args.command[0];
|
|
790
|
+
const occurrence = args.command[1];
|
|
791
|
+
if (schedule === undefined || occurrence === undefined) {
|
|
792
|
+
throw new CliError("greatwait attempts list needs a schedule and an occurrence", "usage", "Usage: greatwait attempts list <schedule> <occurrence>");
|
|
793
|
+
}
|
|
794
|
+
expectNoExtraArguments(args, 2, "greatwait attempts list <schedule> <occurrence>");
|
|
795
|
+
const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
|
|
796
|
+
const page = responseSchemaFor("attempts.list")?.parse(await session.client.request("attempts_list", schedule, occurrence));
|
|
797
|
+
const failed = page.data.find((attempt) => attempt.state !== "succeeded");
|
|
798
|
+
return {
|
|
799
|
+
data: { attempts: page.data, occurrence, schedule },
|
|
800
|
+
human: page.data.length === 0
|
|
801
|
+
? "No attempt has been made yet."
|
|
802
|
+
: page.data
|
|
803
|
+
.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}`}`)
|
|
804
|
+
.join("\n"),
|
|
805
|
+
// **The journey's own sentence, not a paraphrase of the code.**
|
|
806
|
+
// `diagnostic` and `remediation` are closed vocabularies rather than prose
|
|
807
|
+
// — the step's `observe` says to read them, and its recovery entry is the
|
|
808
|
+
// sentence written for a person meeting a failed attempt. Inventing a
|
|
809
|
+
// second one here would be this command maintaining its own copy of every
|
|
810
|
+
// delivery failure the service can classify.
|
|
811
|
+
...(failed === undefined
|
|
812
|
+
? {}
|
|
813
|
+
: (() => {
|
|
814
|
+
const nextMove = recoveryFor("read-attempt", "attempt-failed");
|
|
815
|
+
return nextMove === undefined ? {} : { nextMove };
|
|
816
|
+
})()),
|
|
817
|
+
};
|
|
818
|
+
};
|
|
819
|
+
/* -------------------------------------------------------------------------- */
|
|
820
|
+
/* Dispatch */
|
|
821
|
+
/* -------------------------------------------------------------------------- */
|
|
822
|
+
/** Every command, keyed by the words that select it. */
|
|
823
|
+
export const COMMANDS = {
|
|
824
|
+
"attempts list": attemptsList,
|
|
825
|
+
context,
|
|
826
|
+
"destinations create": destinationsCreate,
|
|
827
|
+
"destinations disable": destinationsDisable,
|
|
828
|
+
doctor,
|
|
829
|
+
journey,
|
|
830
|
+
login,
|
|
831
|
+
logout,
|
|
832
|
+
"occurrences wait": occurrencesWait,
|
|
833
|
+
"schedules create": schedulesCreate,
|
|
834
|
+
"schedules read": schedulesRead,
|
|
835
|
+
whoami,
|
|
836
|
+
};
|