@greatwait/cli 0.1.0-alpha.3 → 0.1.0-alpha.5

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/dist/commands.js CHANGED
@@ -1,56 +1,16 @@
1
1
  import { GreatwaitClientError, createGreatwaitClient, } from "@greatwait/api-client";
2
2
  import { isTerminalOccurrenceState, responseSchemaFor, } from "@greatwait/contracts";
3
- import { readFile } from "node:fs/promises";
4
- import { SECRET_ARGUMENT_ADVICE, parseDuration, } from "./args.js";
3
+ import { parseDuration } from "./args.js";
4
+ import { COMMAND_CATALOG, REQUESTED_SCOPES } from "./catalog.js";
5
+ import { confirmChange } from "./confirm.js";
5
6
  import { directoryConcerns, originKey } from "./config.js";
6
7
  import { buildAuthorization, codeFrom, discover, exchangeCode, registerClient, revoke, } from "./oauth.js";
7
8
  import { redactSecret } from "./output.js";
8
9
  import { CliError, sleepOrAbort } from "./runtime.js";
9
10
  import { publishedWait, recoveryFor, renderJourney, STEP_COMMANDS, } from "./journey.js";
10
11
  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
- }
12
+ import { OPERATE_COMMANDS, scheduleRequest } from "./operate.js";
13
+ import { apiUrlOption, expectNoExtraArguments, parseResponse, positionals, } from "./support.js";
54
14
  /* -------------------------------------------------------------------------- */
55
15
  /* Signing in */
56
16
  /* -------------------------------------------------------------------------- */
@@ -71,7 +31,7 @@ async function readApiKey(runtime) {
71
31
  if (fromEnvironment !== undefined && fromEnvironment !== "") {
72
32
  return fromEnvironment;
73
33
  }
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}.`);
34
+ throw new CliError("no credential was supplied and there is no terminal to ask on", "usage", `Pipe it in — printf %s "$KEY" | ${runtime.programName} login --api-key — or export ${ENVIRONMENT_NAMES.apiKey}.`);
75
35
  }
76
36
  const typed = (await runtime.promptSecret("Paste the API credential (it will not echo): ")).trim();
77
37
  if (typed === "") {
@@ -80,7 +40,7 @@ async function readApiKey(runtime) {
80
40
  return typed;
81
41
  }
82
42
  const login = async (runtime, args) => {
83
- expectNoExtraArguments(args, 0, "greatwait login [--api-url <origin>] [--api-key] [--no-browser]");
43
+ expectNoExtraArguments(args, 0, `${runtime.programName} login [--api-url <origin>] [--api-key] [--no-browser] [--scope <scopes>]`);
84
44
  const store = storeFor(runtime);
85
45
  const requested = apiUrlOption(args);
86
46
  const origin = requested === undefined
@@ -104,16 +64,43 @@ const login = async (runtime, args) => {
104
64
  ? `Signed in to ${origin} with an API credential, in project ${described.context.project} (${described.context.environmentKind}).`
105
65
  : `Stored an API credential for ${origin} without checking it.`,
106
66
  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.`,
67
+ ? `Next: ${runtime.programName} journey — the published first-value sequence, with the command for each step.`
68
+ : `${described.reason}\n\nNext: ${runtime.programName} journey — the published first-value sequence, with the command for each step.`,
109
69
  };
110
70
  }
111
71
  const server = await discover(runtime, origin);
72
+ // **Asked for, then narrowed to what the server publishes.** The default is
73
+ // the union of every scope the commands can use, derived from the catalog
74
+ // rather than listed — so a command added for an operation needing a new
75
+ // permission widens this request instead of failing at the point of use with
76
+ // a `scope_missing` on a grant nobody can widen without signing in again.
77
+ const wanted = args.options["--scope"];
78
+ const asked = wanted === undefined
79
+ ? REQUESTED_SCOPES
80
+ : wanted.split(/[\s,]+/u).filter((scope) => scope !== "");
81
+ if (wanted !== undefined) {
82
+ // **An empty value is the caller's mistake, not the server's.** It arrives
83
+ // from an unset shell variable — `--scope "$SCOPES"` — and without this the
84
+ // filtered list is empty, which falls through to "this service grants none
85
+ // of the permissions this command needs" and blames the service for a
86
+ // request nobody made.
87
+ if (asked.length === 0) {
88
+ throw new CliError("--scope was given with no permissions in it", "usage", `Name the ones you want, separated by spaces, or leave --scope off to ask for all of: ${REQUESTED_SCOPES.join(", ")}.`);
89
+ }
90
+ const unknown = asked.filter((scope) => !REQUESTED_SCOPES.includes(scope));
91
+ if (unknown.length > 0) {
92
+ // Refused rather than passed through: a scope no command uses is either a
93
+ // typo or a permission this program has no code to exercise, and granting
94
+ // one to a command line that cannot use it is the opposite of least
95
+ // privilege.
96
+ throw new CliError(`no command uses ${unknown.join(", ")}`, "usage", `The permissions these commands can use are: ${REQUESTED_SCOPES.join(", ")}.`);
97
+ }
98
+ }
112
99
  const scopes = server.scopesSupported.length === 0
113
- ? [...REQUESTED_SCOPES]
114
- : REQUESTED_SCOPES.filter((scope) => server.scopesSupported.includes(scope));
100
+ ? [...asked]
101
+ : asked.filter((scope) => server.scopesSupported.includes(scope));
115
102
  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");
103
+ throw new CliError(`${origin} grants none of the permissions this command needs`, "authorization", `Sign in with an API credential instead: ${runtime.programName} login --api-key`);
117
104
  }
118
105
  // Bound first: the redirect URI is matched exactly, port included, so the
119
106
  // port has to exist before the client is registered for it.
@@ -141,7 +128,7 @@ const login = async (runtime, args) => {
141
128
  // `xdg-open` there exits zero having opened nothing.
142
129
  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
130
  const returned = await listener.received;
144
- const code = codeFrom(returned, authorization.state);
131
+ const code = codeFrom(returned, authorization.state, runtime.programName);
145
132
  const tokens = await exchangeCode(runtime, server, {
146
133
  clientId,
147
134
  code,
@@ -193,8 +180,8 @@ const login = async (runtime, args) => {
193
180
  ? `Signed in to ${origin}, in project ${described.context.project} (${described.context.environmentKind}).`
194
181
  : `Signed in to ${origin}. The grant is real — the service issued it — but it cannot say what it reaches.`,
195
182
  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.`,
183
+ ? `Next: ${runtime.programName} journey — the published first-value sequence, with the command for each step.`
184
+ : `${described.reason}\n\nNext: ${runtime.programName} journey — the published first-value sequence, with the command for each step.`,
198
185
  };
199
186
  }
200
187
  finally {
@@ -206,9 +193,9 @@ async function describeCredential(runtime, origin, credential) {
206
193
  baseUrl: origin,
207
194
  credential,
208
195
  fetch: runtime.fetch,
209
- }), origin);
196
+ }), origin, runtime.programName);
210
197
  }
211
- async function describeThrough(client, origin) {
198
+ async function describeThrough(client, origin, programName) {
212
199
  try {
213
200
  return {
214
201
  context: responseSchemaFor("context.read")?.parse(await client.request("context_read")),
@@ -216,7 +203,9 @@ async function describeThrough(client, origin) {
216
203
  };
217
204
  }
218
205
  catch (error) {
219
- if (!(error instanceof GreatwaitClientError)) {
206
+ if (!(error instanceof GreatwaitClientError) || error.status === 0) {
207
+ // The SDK uses status 0 when its transport retries are exhausted; older
208
+ // adapters may still throw the original network error.
220
209
  // **A transport failure is not an answer about the credential.** The
221
210
  // request never reached the service — no DNS, no TLS, a reset — so it
222
211
  // says nothing about what was just issued, and it does not carry a
@@ -226,7 +215,7 @@ async function describeThrough(client, origin) {
226
215
  // avoid, skipped because the failure never reached the reasoning.
227
216
  return {
228
217
  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.`,
218
+ reason: `${origin} could not be reached to ask what this credential acts in (${error instanceof Error ? error.message : String(error)}). Run ${programName} whoami when it is back.`,
230
219
  };
231
220
  }
232
221
  // **Only a refusal of the credential itself is fatal.** That is the whole
@@ -248,12 +237,12 @@ async function describeThrough(client, origin) {
248
237
  // learned rather than claiming the sign-in failed.
249
238
  return {
250
239
  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.`,
240
+ reason: `${origin} answered ${String(error.status)} when asked what this credential reaches, so it cannot be described yet. Run ${programName} whoami again in a moment.`,
252
241
  };
253
242
  }
254
243
  }
255
244
  const logout = async (runtime, args) => {
256
- expectNoExtraArguments(args, 0, "greatwait logout [--api-url <origin>]");
245
+ expectNoExtraArguments(args, 0, `${runtime.programName} logout [--api-url <origin>]`);
257
246
  const store = storeFor(runtime);
258
247
  const { origin } = await resolveOrigin(runtime, store, apiUrlOption(args));
259
248
  const stored = await store.read(origin);
@@ -323,9 +312,9 @@ const CREDENTIAL_LABELS = {
323
312
  oauth: "an OAuth grant",
324
313
  };
325
314
  const whoami = async (runtime, args) => {
326
- expectNoExtraArguments(args, 0, "greatwait whoami [--api-url <origin>]");
315
+ expectNoExtraArguments(args, 0, `${runtime.programName} whoami [--api-url <origin>]`);
327
316
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
328
- const described = await describeThrough(session.client, session.origin);
317
+ const described = await describeThrough(session.client, session.origin, runtime.programName);
329
318
  if (!described.known) {
330
319
  // **Reported rather than guessed at.** The values could be inferred from a
331
320
  // destination or a key the project already owns, and a project that owns
@@ -372,7 +361,7 @@ const whoami = async (runtime, args) => {
372
361
  };
373
362
  };
374
363
  const doctor = async (runtime, args) => {
375
- expectNoExtraArguments(args, 0, "greatwait doctor [--api-url <origin>]");
364
+ expectNoExtraArguments(args, 0, `${runtime.programName} doctor [--api-url <origin>]`);
376
365
  const store = storeFor(runtime);
377
366
  const checks = [];
378
367
  let origin;
@@ -408,7 +397,7 @@ const doctor = async (runtime, args) => {
408
397
  checks.push({
409
398
  detail: error instanceof Error
410
399
  ? error.message
411
- : "the origin did not answer as a Greatwait API",
400
+ : "the origin did not answer as a GreatWait API",
412
401
  name: "reachable",
413
402
  outcome: "failed",
414
403
  });
@@ -437,7 +426,7 @@ const doctor = async (runtime, args) => {
437
426
  else {
438
427
  try {
439
428
  const session = await openSession(runtime, { apiUrl: origin });
440
- const described = await describeThrough(session.client, origin);
429
+ const described = await describeThrough(session.client, origin, runtime.programName);
441
430
  // **`skipped`, not `passed`, when the service cannot say.** A `404` from
442
431
  // `/v1/context` arrives before anything looks at the credential, so a
443
432
  // check that reported success from one would be reporting that a
@@ -505,7 +494,7 @@ const context = async (runtime, args) => {
505
494
  };
506
495
  }
507
496
  if (action === "list") {
508
- expectNoExtraArguments(args, 1, "greatwait context list");
497
+ expectNoExtraArguments(args, 1, `${runtime.programName} context list`);
509
498
  const origins = await store.origins();
510
499
  const selected = await store.readSelection();
511
500
  return {
@@ -520,12 +509,12 @@ const context = async (runtime, args) => {
520
509
  if (action === "use") {
521
510
  const wanted = args.command[1];
522
511
  if (wanted === undefined) {
523
- throw new CliError("greatwait context use needs an origin", "usage");
512
+ throw new CliError(`${runtime.programName} context use needs an origin`, "usage");
524
513
  }
525
- expectNoExtraArguments(args, 2, "greatwait context use <origin>");
514
+ expectNoExtraArguments(args, 2, `${runtime.programName} context use <origin>`);
526
515
  const origin = originKey(wanted);
527
516
  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}`);
517
+ throw new CliError(`no credential is stored for ${origin}`, "usage", `Sign in to it first: ${runtime.programName} login --api-url ${origin}`);
529
518
  }
530
519
  await store.select(origin);
531
520
  return {
@@ -533,14 +522,14 @@ const context = async (runtime, args) => {
533
522
  human: `Commands now go to ${origin} unless --api-url says otherwise.`,
534
523
  };
535
524
  }
536
- throw new CliError(`unknown context command ${action}`, "usage", "Usage: greatwait context [list | use <origin>]");
525
+ throw new CliError(`unknown context command ${action}`, "usage", `Usage: ${runtime.programName} context [list | use <origin>]`);
537
526
  };
538
527
  /* -------------------------------------------------------------------------- */
539
528
  /* The journey */
540
529
  /* -------------------------------------------------------------------------- */
541
530
  const journey = async (runtime, args) => {
542
- expectNoExtraArguments(args, 0, "greatwait journey");
543
- const rendered = renderJourney();
531
+ expectNoExtraArguments(args, 0, `${runtime.programName} journey`);
532
+ const rendered = renderJourney(runtime.programName);
544
533
  // Named where it is known, because the journey's own first step is "confirm
545
534
  // which service and environment you reached" and a reader following this
546
535
  // list should not have to go and find out separately. Not knowing is not a
@@ -580,7 +569,7 @@ const journey = async (runtime, args) => {
580
569
  const destinationsCreate = async (runtime, args) => {
581
570
  const url = args.options["--url"];
582
571
  if (url === undefined) {
583
- throw new CliError("greatwait destinations create needs --url", "usage", "Usage: greatwait destinations create --url https://your-receiver.example.com/greatwait");
572
+ throw new CliError(`${runtime.programName} destinations create needs --url`, "usage", `Usage: ${runtime.programName} destinations create --url https://your-receiver.example.com/greatwait`);
584
573
  }
585
574
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
586
575
  const created = responseSchemaFor("endpoints.create")?.parse(await session.client.request("endpoints_create", { url }));
@@ -596,92 +585,68 @@ const destinationsCreate = async (runtime, args) => {
596
585
  ``,
597
586
  `Signing key ${created.secret}`,
598
587
  ``,
599
- `This key is shown once and is not recoverable. Store it now — your receiver`,
588
+ `This is the only call that returns the key. Store it now — your receiver`,
600
589
  `verifies every callback with it, and reading this destination back will show`,
601
- `${redactSecret(created.secret)} instead.`,
590
+ `${redactSecret(created.secret)} instead. If you lose it, a signed-in member`,
591
+ `can read it again on this destination's page in the dashboard; no command`,
592
+ `here can, because the reveal accepts a member session and not a grant.`,
602
593
  ].join("\n"),
603
- nextMove: `Next: greatwait schedules create --endpoint ${created.id} --in 30s`,
594
+ nextMove: `Next: ${runtime.programName} schedules create --endpoint ${created.id} --in 30s`,
595
+ };
596
+ };
597
+ const destinationsEnable = async (runtime, args) => {
598
+ const [id] = positionals(args, ["<destination>"], `${runtime.programName} destinations enable <destination>`);
599
+ const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
600
+ const enabled = parseResponse("endpoints.enable", await session.client.request("endpoints_enable", id));
601
+ return {
602
+ data: { id: enabled.id, status: enabled.status, url: enabled.url },
603
+ human: `Destination ${enabled.id} is ${enabled.status}. Schedules pointed at ${enabled.url} deliver again from their next occurrence.`,
604
604
  };
605
605
  };
606
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>");
607
+ const [id] = positionals(args, ["<destination>"], `${runtime.programName} destinations disable <destination>`);
612
608
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
613
- const disabled = responseSchemaFor("endpoints.disable")?.parse(await session.client.request("endpoints_disable", id));
609
+ // Read first, so the preview names the receiver that stops hearing from us
610
+ // rather than repeating back the identifier that was typed.
611
+ const current = parseResponse("endpoints.read", await session.client.request("endpoints_read", id));
612
+ const confirmation = await confirmChange(runtime, args, {
613
+ action: `Disable destination ${id}.`,
614
+ effect: [
615
+ `receiver ${current.url}`,
616
+ `status ${current.status}`,
617
+ `Nothing further is delivered there. Schedules pointing at it deliver`,
618
+ `nothing until it is enabled again or they are moved.`,
619
+ ],
620
+ });
621
+ const disabled = parseResponse("endpoints.disable", await session.client.request("endpoints_disable", id));
614
622
  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.`,
623
+ data: { confirmation, id: disabled.id, status: disabled.status },
624
+ human: `Destination ${disabled.id} is ${disabled.status}. Nothing further is delivered to it until it is enabled again, and the attempts already recorded against it stay readable.`,
617
625
  };
618
626
  };
619
627
  /* -------------------------------------------------------------------------- */
620
628
  /* Schedules */
621
629
  /* -------------------------------------------------------------------------- */
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
630
  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.");
631
+ const usage = `${runtime.programName} schedules create --endpoint <destination> (--in <duration> | --at <instant> | --every <duration> | --cron <expression>)`;
632
+ if (args.options["--endpoint"] === undefined) {
633
+ throw new CliError(`${runtime.programName} schedules create needs --endpoint`, "usage", `Usage: ${usage}`);
667
634
  }
635
+ expectNoExtraArguments(args, 0, usage);
668
636
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
669
- const data = await readPayload(runtime, args.options["--data"]);
637
+ // **The same builder `schedules replace` uses.** The two send the identical
638
+ // definition — a replace is a create with an identifier — and writing the
639
+ // recurrence rules out twice is how one of them comes to accept a cron
640
+ // expression the other does not.
641
+ const request = await scheduleRequest(runtime, args, usage);
670
642
  // **Generated when it is not given, and printed either way.** A create that
671
643
  // silently invented a fresh key on every run would make a retry a second
672
644
  // schedule, which is the failure the key exists to prevent; a create that
673
645
  // hid the key would leave the caller unable to make that retry at all.
674
646
  const idempotencyKey = args.options["--idempotency-key"] ??
675
647
  `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
648
  const answer = await session.client.requestDetailed("schedules_create", request, { "Idempotency-Key": idempotencyKey });
684
- const created = responseSchemaFor("schedules.create")?.parse(answer.body);
649
+ const created = parseResponse("schedules.create", answer.body);
685
650
  const replayed = answer.headers["Idempotency-Replayed"] === "true";
686
651
  return {
687
652
  data: {
@@ -704,15 +669,15 @@ const schedulesCreate = async (runtime, args) => {
704
669
  ]
705
670
  : []),
706
671
  ].join("\n"),
707
- nextMove: `Next: greatwait occurrences wait ${created.schedule.id} ${created.occurrence.id}`,
672
+ nextMove: `Next: ${runtime.programName} occurrences wait ${created.schedule.id} ${created.occurrence.id}`,
708
673
  };
709
674
  };
710
675
  const schedulesRead = async (runtime, args) => {
711
676
  const id = args.command[0];
712
677
  if (id === undefined) {
713
- throw new CliError("greatwait schedules read needs a schedule", "usage", "Usage: greatwait schedules read <schedule>");
678
+ throw new CliError(`${runtime.programName} schedules read needs a schedule`, "usage", `Usage: ${runtime.programName} schedules read <schedule>`);
714
679
  }
715
- expectNoExtraArguments(args, 1, "greatwait schedules read <schedule>");
680
+ expectNoExtraArguments(args, 1, `${runtime.programName} schedules read <schedule>`);
716
681
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
717
682
  const answer = await session.client.requestDetailed("schedules_read", id);
718
683
  const view = responseSchemaFor("schedules.read")?.parse(answer.body);
@@ -739,6 +704,66 @@ const schedulesRead = async (runtime, args) => {
739
704
  }),
740
705
  };
741
706
  };
707
+ const exportsCreate = async (runtime, args) => {
708
+ expectNoExtraArguments(args, 0, `${runtime.programName} exports create`);
709
+ const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
710
+ const view = parseResponse("exports.create", await session.client.request("exports_create", {}));
711
+ return {
712
+ data: view,
713
+ human: `Export ${view.id} is ${view.state}; its consistency cutoff is ${view.cutoffAt}.`,
714
+ nextMove: `Next: ${runtime.programName} exports read ${view.id}`,
715
+ };
716
+ };
717
+ const exportsRead = async (runtime, args) => {
718
+ const [exportId] = positionals(args, ["<export>"], `${runtime.programName} exports read <export>`);
719
+ const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
720
+ const view = parseResponse("exports.read", await session.client.request("exports_read", exportId));
721
+ return {
722
+ data: view,
723
+ human: `Export ${view.id} is ${view.state}; its consistency cutoff is ${view.cutoffAt}.`,
724
+ ...(view.state === "complete"
725
+ ? {
726
+ nextMove: `Save the first bounded part: ${runtime.programName} exports part ${view.id} > ${view.id}.ndjson`,
727
+ }
728
+ : {}),
729
+ };
730
+ };
731
+ const exportsPart = async (runtime, args) => {
732
+ const [exportId] = positionals(args, ["<export>"], `${runtime.programName} exports part <export> [--cursor <cursor>]`);
733
+ const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
734
+ const cursor = args.options["--cursor"];
735
+ const part = parseResponse("exports.part", await session.client.request("exports_part", exportId, cursor === undefined ? {} : { cursor }));
736
+ if (part.nextCursor !== undefined && !args.booleans.has("--json")) {
737
+ runtime.stderr(`More to export. Append the next bounded part with: ${runtime.programName} exports part ${exportId} --cursor '${part.nextCursor}' >> ${part.fileName}\n`);
738
+ }
739
+ return {
740
+ data: part,
741
+ // The human form is the exact bounded file bytes, so shell redirection is
742
+ // safe. Metadata remains available through --json without interpreting
743
+ // customer payload fields.
744
+ human: part.content.endsWith("\n")
745
+ ? part.content.slice(0, -1)
746
+ : part.content,
747
+ };
748
+ };
749
+ const exportsCancel = async (runtime, args) => {
750
+ const [exportId] = positionals(args, ["<export>"], `${runtime.programName} exports cancel <export> [--yes]`);
751
+ const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
752
+ const current = parseResponse("exports.read", await session.client.request("exports_read", exportId));
753
+ const confirmation = await confirmChange(runtime, args, {
754
+ action: `Cancel export ${current.id}.`,
755
+ effect: [
756
+ `state ${current.state}`,
757
+ `cutoff ${current.cutoffAt}`,
758
+ "No further file parts can be downloaded from this job.",
759
+ ],
760
+ });
761
+ const answer = parseResponse("exports.cancel", await session.client.request("exports_cancel", exportId));
762
+ return {
763
+ data: { ...answer, confirmation },
764
+ human: `Export ${answer.export.id} is ${answer.export.state}.`,
765
+ };
766
+ };
742
767
  /* -------------------------------------------------------------------------- */
743
768
  /* Waiting */
744
769
  /* -------------------------------------------------------------------------- */
@@ -746,9 +771,9 @@ const occurrencesWait = async (runtime, args) => {
746
771
  const schedule = args.command[0];
747
772
  const occurrence = args.command[1];
748
773
  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>");
774
+ throw new CliError(`${runtime.programName} occurrences wait needs a schedule and an occurrence`, "usage", `Usage: ${runtime.programName} occurrences wait <schedule> <occurrence>`);
750
775
  }
751
- expectNoExtraArguments(args, 2, "greatwait occurrences wait <schedule> <occurrence>");
776
+ expectNoExtraArguments(args, 2, `${runtime.programName} occurrences wait <schedule> <occurrence>`);
752
777
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
753
778
  // The interval and the deadline come from the published journey. A number
754
779
  // here would be a fourth answer to a question the website, the curl page and
@@ -776,7 +801,7 @@ const occurrencesWait = async (runtime, args) => {
776
801
  states: seen,
777
802
  },
778
803
  human: `Occurrence ${occurrence} settled ${state} after ${String(view.occurrence.attemptCount)} ${view.occurrence.attemptCount === 1 ? "attempt" : "attempts"}.`,
779
- nextMove: `Next: greatwait attempts list ${schedule} ${occurrence}`,
804
+ nextMove: `Next: ${runtime.programName} attempts list ${schedule} ${occurrence}`,
780
805
  };
781
806
  }
782
807
  if (runtime.now() >= deadline) {
@@ -789,9 +814,9 @@ const attemptsList = async (runtime, args) => {
789
814
  const schedule = args.command[0];
790
815
  const occurrence = args.command[1];
791
816
  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>");
817
+ throw new CliError(`${runtime.programName} attempts list needs a schedule and an occurrence`, "usage", `Usage: ${runtime.programName} attempts list <schedule> <occurrence>`);
793
818
  }
794
- expectNoExtraArguments(args, 2, "greatwait attempts list <schedule> <occurrence>");
819
+ expectNoExtraArguments(args, 2, `${runtime.programName} attempts list <schedule> <occurrence>`);
795
820
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
796
821
  const page = responseSchemaFor("attempts.list")?.parse(await session.client.request("attempts_list", schedule, occurrence));
797
822
  const failed = page.data.find((attempt) => attempt.state !== "succeeded");
@@ -819,13 +844,26 @@ const attemptsList = async (runtime, args) => {
819
844
  /* -------------------------------------------------------------------------- */
820
845
  /* Dispatch */
821
846
  /* -------------------------------------------------------------------------- */
822
- /** Every command, keyed by the words that select it. */
823
- export const COMMANDS = {
847
+ /**
848
+ * Every implementation, keyed by the words that select it.
849
+ *
850
+ * Not the dispatch table: that is built from the catalog below, so a command
851
+ * cannot be documented without existing and cannot exist without being
852
+ * documented. This half is the functions, and `packages/cli/src/catalog.ts` is
853
+ * what says they are the right set.
854
+ */
855
+ const IMPLEMENTATIONS = {
856
+ ...OPERATE_COMMANDS,
824
857
  "attempts list": attemptsList,
825
858
  context,
826
859
  "destinations create": destinationsCreate,
827
860
  "destinations disable": destinationsDisable,
861
+ "destinations enable": destinationsEnable,
828
862
  doctor,
863
+ "exports cancel": exportsCancel,
864
+ "exports create": exportsCreate,
865
+ "exports part": exportsPart,
866
+ "exports read": exportsRead,
829
867
  journey,
830
868
  login,
831
869
  logout,
@@ -834,3 +872,24 @@ export const COMMANDS = {
834
872
  "schedules read": schedulesRead,
835
873
  whoami,
836
874
  };
875
+ /**
876
+ * The dispatch table, assembled from the catalog.
877
+ *
878
+ * **A declaration with no function raises here, at import.** The alternative
879
+ * was a test that compared two lists, which is a check the published reference
880
+ * page can still contradict: the page is generated from the catalog, so a
881
+ * command documented and never implemented would ship as a promise the
882
+ * executable does not keep. Raising means the binary does not start.
883
+ *
884
+ * The other direction is a test rather than a raise — an implementation nobody
885
+ * declared is unreachable rather than dangerous, and `commands.test.ts` names
886
+ * it.
887
+ */
888
+ export const COMMANDS = Object.fromEntries(COMMAND_CATALOG.map((declaration) => {
889
+ const implementation = IMPLEMENTATIONS[declaration.words];
890
+ if (implementation === undefined) {
891
+ throw new Error(`${declaration.words} is declared in the catalog and implemented nowhere`);
892
+ }
893
+ return [declaration.words, implementation];
894
+ }));
895
+ export { IMPLEMENTATIONS };
@@ -0,0 +1,33 @@
1
+ import type { ParsedArguments } from "./args.js";
2
+ import { type CliRuntime } from "./runtime.js";
3
+ /**
4
+ * The one policy for "are you sure", so no command invents its own.
5
+ *
6
+ * **The preview is printed whichever way the answer comes.** A confirmation
7
+ * that only appears on a terminal leaves an automated run with no record of
8
+ * what it agreed to, and the first question after an unexpected cancellation is
9
+ * "what exactly did it act on". So the scope goes to standard error always, and
10
+ * only the *asking* depends on there being somebody to ask.
11
+ *
12
+ * Automation is served by `--yes` rather than by a terminal check alone. A
13
+ * command that simply skipped the prompt when standard input was not a terminal
14
+ * would be a command that behaves differently inside a pipeline than in front
15
+ * of a person, silently, in the destructive direction — which is the shape of
16
+ * every "it deleted everything in CI" story. Without a terminal and without the
17
+ * flag, this refuses.
18
+ */
19
+ export interface ChangePreview {
20
+ /** One line naming the act: "Cancel schedule sch_01…". */
21
+ readonly action: string;
22
+ /**
23
+ * The exact scope, as a person needs to see it before agreeing.
24
+ *
25
+ * Read from the service immediately before asking wherever there is
26
+ * something to read: the point is to show what *this* object is now, not to
27
+ * restate the arguments back.
28
+ */
29
+ readonly effect: readonly string[];
30
+ }
31
+ /** How the change came to be agreed, for the machine shape. */
32
+ export type Confirmation = "flag" | "prompt";
33
+ export declare function confirmChange(runtime: CliRuntime, args: ParsedArguments, preview: ChangePreview): Promise<Confirmation>;
@@ -0,0 +1,17 @@
1
+ import { CliError } from "./runtime.js";
2
+ export async function confirmChange(runtime, args, preview) {
3
+ runtime.stderr(`${[preview.action, ...preview.effect.map((line) => ` ${line}`)].join("\n")}\n`);
4
+ if (args.booleans.has("--yes"))
5
+ return "flag";
6
+ if (!runtime.interactive) {
7
+ throw new CliError("this changes something that already exists, and there is no terminal to confirm on", "usage", "Pass --yes to confirm it without being asked.");
8
+ }
9
+ const answer = (await runtime.promptLine("Continue? [y/N] ")).trim();
10
+ // **Anything but an explicit yes is a no.** A blank line is what somebody
11
+ // types when they meant to read the preview first, and a prompt that took it
12
+ // as agreement would be a prompt that exists to be dismissed.
13
+ if (answer !== "y" && answer !== "yes") {
14
+ throw new CliError("not confirmed, so nothing was changed", "interrupted", "Nothing was sent to the service.");
15
+ }
16
+ return "prompt";
17
+ }