@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,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
+ };