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

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")),
@@ -226,7 +213,7 @@ async function describeThrough(client, origin) {
226
213
  // avoid, skipped because the failure never reached the reasoning.
227
214
  return {
228
215
  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.`,
216
+ 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
217
  };
231
218
  }
232
219
  // **Only a refusal of the credential itself is fatal.** That is the whole
@@ -248,12 +235,12 @@ async function describeThrough(client, origin) {
248
235
  // learned rather than claiming the sign-in failed.
249
236
  return {
250
237
  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.`,
238
+ 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
239
  };
253
240
  }
254
241
  }
255
242
  const logout = async (runtime, args) => {
256
- expectNoExtraArguments(args, 0, "greatwait logout [--api-url <origin>]");
243
+ expectNoExtraArguments(args, 0, `${runtime.programName} logout [--api-url <origin>]`);
257
244
  const store = storeFor(runtime);
258
245
  const { origin } = await resolveOrigin(runtime, store, apiUrlOption(args));
259
246
  const stored = await store.read(origin);
@@ -323,9 +310,9 @@ const CREDENTIAL_LABELS = {
323
310
  oauth: "an OAuth grant",
324
311
  };
325
312
  const whoami = async (runtime, args) => {
326
- expectNoExtraArguments(args, 0, "greatwait whoami [--api-url <origin>]");
313
+ expectNoExtraArguments(args, 0, `${runtime.programName} whoami [--api-url <origin>]`);
327
314
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
328
- const described = await describeThrough(session.client, session.origin);
315
+ const described = await describeThrough(session.client, session.origin, runtime.programName);
329
316
  if (!described.known) {
330
317
  // **Reported rather than guessed at.** The values could be inferred from a
331
318
  // destination or a key the project already owns, and a project that owns
@@ -372,7 +359,7 @@ const whoami = async (runtime, args) => {
372
359
  };
373
360
  };
374
361
  const doctor = async (runtime, args) => {
375
- expectNoExtraArguments(args, 0, "greatwait doctor [--api-url <origin>]");
362
+ expectNoExtraArguments(args, 0, `${runtime.programName} doctor [--api-url <origin>]`);
376
363
  const store = storeFor(runtime);
377
364
  const checks = [];
378
365
  let origin;
@@ -437,7 +424,7 @@ const doctor = async (runtime, args) => {
437
424
  else {
438
425
  try {
439
426
  const session = await openSession(runtime, { apiUrl: origin });
440
- const described = await describeThrough(session.client, origin);
427
+ const described = await describeThrough(session.client, origin, runtime.programName);
441
428
  // **`skipped`, not `passed`, when the service cannot say.** A `404` from
442
429
  // `/v1/context` arrives before anything looks at the credential, so a
443
430
  // check that reported success from one would be reporting that a
@@ -505,7 +492,7 @@ const context = async (runtime, args) => {
505
492
  };
506
493
  }
507
494
  if (action === "list") {
508
- expectNoExtraArguments(args, 1, "greatwait context list");
495
+ expectNoExtraArguments(args, 1, `${runtime.programName} context list`);
509
496
  const origins = await store.origins();
510
497
  const selected = await store.readSelection();
511
498
  return {
@@ -520,12 +507,12 @@ const context = async (runtime, args) => {
520
507
  if (action === "use") {
521
508
  const wanted = args.command[1];
522
509
  if (wanted === undefined) {
523
- throw new CliError("greatwait context use needs an origin", "usage");
510
+ throw new CliError(`${runtime.programName} context use needs an origin`, "usage");
524
511
  }
525
- expectNoExtraArguments(args, 2, "greatwait context use <origin>");
512
+ expectNoExtraArguments(args, 2, `${runtime.programName} context use <origin>`);
526
513
  const origin = originKey(wanted);
527
514
  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}`);
515
+ throw new CliError(`no credential is stored for ${origin}`, "usage", `Sign in to it first: ${runtime.programName} login --api-url ${origin}`);
529
516
  }
530
517
  await store.select(origin);
531
518
  return {
@@ -533,14 +520,14 @@ const context = async (runtime, args) => {
533
520
  human: `Commands now go to ${origin} unless --api-url says otherwise.`,
534
521
  };
535
522
  }
536
- throw new CliError(`unknown context command ${action}`, "usage", "Usage: greatwait context [list | use <origin>]");
523
+ throw new CliError(`unknown context command ${action}`, "usage", `Usage: ${runtime.programName} context [list | use <origin>]`);
537
524
  };
538
525
  /* -------------------------------------------------------------------------- */
539
526
  /* The journey */
540
527
  /* -------------------------------------------------------------------------- */
541
528
  const journey = async (runtime, args) => {
542
- expectNoExtraArguments(args, 0, "greatwait journey");
543
- const rendered = renderJourney();
529
+ expectNoExtraArguments(args, 0, `${runtime.programName} journey`);
530
+ const rendered = renderJourney(runtime.programName);
544
531
  // Named where it is known, because the journey's own first step is "confirm
545
532
  // which service and environment you reached" and a reader following this
546
533
  // list should not have to go and find out separately. Not knowing is not a
@@ -580,7 +567,7 @@ const journey = async (runtime, args) => {
580
567
  const destinationsCreate = async (runtime, args) => {
581
568
  const url = args.options["--url"];
582
569
  if (url === undefined) {
583
- throw new CliError("greatwait destinations create needs --url", "usage", "Usage: greatwait destinations create --url https://your-receiver.example.com/greatwait");
570
+ throw new CliError(`${runtime.programName} destinations create needs --url`, "usage", `Usage: ${runtime.programName} destinations create --url https://your-receiver.example.com/greatwait`);
584
571
  }
585
572
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
586
573
  const created = responseSchemaFor("endpoints.create")?.parse(await session.client.request("endpoints_create", { url }));
@@ -600,88 +587,62 @@ const destinationsCreate = async (runtime, args) => {
600
587
  `verifies every callback with it, and reading this destination back will show`,
601
588
  `${redactSecret(created.secret)} instead.`,
602
589
  ].join("\n"),
603
- nextMove: `Next: greatwait schedules create --endpoint ${created.id} --in 30s`,
590
+ nextMove: `Next: ${runtime.programName} schedules create --endpoint ${created.id} --in 30s`,
591
+ };
592
+ };
593
+ const destinationsEnable = async (runtime, args) => {
594
+ const [id] = positionals(args, ["<destination>"], `${runtime.programName} destinations enable <destination>`);
595
+ const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
596
+ const enabled = parseResponse("endpoints.enable", await session.client.request("endpoints_enable", id));
597
+ return {
598
+ data: { id: enabled.id, status: enabled.status, url: enabled.url },
599
+ human: `Destination ${enabled.id} is ${enabled.status}. Schedules pointed at ${enabled.url} deliver again from their next occurrence.`,
604
600
  };
605
601
  };
606
602
  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>");
603
+ const [id] = positionals(args, ["<destination>"], `${runtime.programName} destinations disable <destination>`);
612
604
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
613
- const disabled = responseSchemaFor("endpoints.disable")?.parse(await session.client.request("endpoints_disable", id));
605
+ // Read first, so the preview names the receiver that stops hearing from us
606
+ // rather than repeating back the identifier that was typed.
607
+ const current = parseResponse("endpoints.read", await session.client.request("endpoints_read", id));
608
+ const confirmation = await confirmChange(runtime, args, {
609
+ action: `Disable destination ${id}.`,
610
+ effect: [
611
+ `receiver ${current.url}`,
612
+ `status ${current.status}`,
613
+ `Nothing further is delivered there. Schedules pointing at it deliver`,
614
+ `nothing until it is enabled again or they are moved.`,
615
+ ],
616
+ });
617
+ const disabled = parseResponse("endpoints.disable", await session.client.request("endpoints_disable", id));
614
618
  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.`,
619
+ data: { confirmation, id: disabled.id, status: disabled.status },
620
+ 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
621
  };
618
622
  };
619
623
  /* -------------------------------------------------------------------------- */
620
624
  /* Schedules */
621
625
  /* -------------------------------------------------------------------------- */
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
626
  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.");
627
+ const usage = `${runtime.programName} schedules create --endpoint <destination> (--in <duration> | --at <instant> | --every <duration> | --cron <expression>)`;
628
+ if (args.options["--endpoint"] === undefined) {
629
+ throw new CliError(`${runtime.programName} schedules create needs --endpoint`, "usage", `Usage: ${usage}`);
667
630
  }
631
+ expectNoExtraArguments(args, 0, usage);
668
632
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
669
- const data = await readPayload(runtime, args.options["--data"]);
633
+ // **The same builder `schedules replace` uses.** The two send the identical
634
+ // definition — a replace is a create with an identifier — and writing the
635
+ // recurrence rules out twice is how one of them comes to accept a cron
636
+ // expression the other does not.
637
+ const request = await scheduleRequest(runtime, args, usage);
670
638
  // **Generated when it is not given, and printed either way.** A create that
671
639
  // silently invented a fresh key on every run would make a retry a second
672
640
  // schedule, which is the failure the key exists to prevent; a create that
673
641
  // hid the key would leave the caller unable to make that retry at all.
674
642
  const idempotencyKey = args.options["--idempotency-key"] ??
675
643
  `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
644
  const answer = await session.client.requestDetailed("schedules_create", request, { "Idempotency-Key": idempotencyKey });
684
- const created = responseSchemaFor("schedules.create")?.parse(answer.body);
645
+ const created = parseResponse("schedules.create", answer.body);
685
646
  const replayed = answer.headers["Idempotency-Replayed"] === "true";
686
647
  return {
687
648
  data: {
@@ -704,15 +665,15 @@ const schedulesCreate = async (runtime, args) => {
704
665
  ]
705
666
  : []),
706
667
  ].join("\n"),
707
- nextMove: `Next: greatwait occurrences wait ${created.schedule.id} ${created.occurrence.id}`,
668
+ nextMove: `Next: ${runtime.programName} occurrences wait ${created.schedule.id} ${created.occurrence.id}`,
708
669
  };
709
670
  };
710
671
  const schedulesRead = async (runtime, args) => {
711
672
  const id = args.command[0];
712
673
  if (id === undefined) {
713
- throw new CliError("greatwait schedules read needs a schedule", "usage", "Usage: greatwait schedules read <schedule>");
674
+ throw new CliError(`${runtime.programName} schedules read needs a schedule`, "usage", `Usage: ${runtime.programName} schedules read <schedule>`);
714
675
  }
715
- expectNoExtraArguments(args, 1, "greatwait schedules read <schedule>");
676
+ expectNoExtraArguments(args, 1, `${runtime.programName} schedules read <schedule>`);
716
677
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
717
678
  const answer = await session.client.requestDetailed("schedules_read", id);
718
679
  const view = responseSchemaFor("schedules.read")?.parse(answer.body);
@@ -739,6 +700,66 @@ const schedulesRead = async (runtime, args) => {
739
700
  }),
740
701
  };
741
702
  };
703
+ const exportsCreate = async (runtime, args) => {
704
+ expectNoExtraArguments(args, 0, `${runtime.programName} exports create`);
705
+ const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
706
+ const view = parseResponse("exports.create", await session.client.request("exports_create", {}));
707
+ return {
708
+ data: view,
709
+ human: `Export ${view.id} is ${view.state}; its consistency cutoff is ${view.cutoffAt}.`,
710
+ nextMove: `Next: ${runtime.programName} exports read ${view.id}`,
711
+ };
712
+ };
713
+ const exportsRead = async (runtime, args) => {
714
+ const [exportId] = positionals(args, ["<export>"], `${runtime.programName} exports read <export>`);
715
+ const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
716
+ const view = parseResponse("exports.read", await session.client.request("exports_read", exportId));
717
+ return {
718
+ data: view,
719
+ human: `Export ${view.id} is ${view.state}; its consistency cutoff is ${view.cutoffAt}.`,
720
+ ...(view.state === "complete"
721
+ ? {
722
+ nextMove: `Save the first bounded part: ${runtime.programName} exports part ${view.id} > ${view.id}.ndjson`,
723
+ }
724
+ : {}),
725
+ };
726
+ };
727
+ const exportsPart = async (runtime, args) => {
728
+ const [exportId] = positionals(args, ["<export>"], `${runtime.programName} exports part <export> [--cursor <cursor>]`);
729
+ const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
730
+ const cursor = args.options["--cursor"];
731
+ const part = parseResponse("exports.part", await session.client.request("exports_part", exportId, cursor === undefined ? {} : { cursor }));
732
+ if (part.nextCursor !== undefined && !args.booleans.has("--json")) {
733
+ runtime.stderr(`More to export. Append the next bounded part with: ${runtime.programName} exports part ${exportId} --cursor '${part.nextCursor}' >> ${part.fileName}\n`);
734
+ }
735
+ return {
736
+ data: part,
737
+ // The human form is the exact bounded file bytes, so shell redirection is
738
+ // safe. Metadata remains available through --json without interpreting
739
+ // customer payload fields.
740
+ human: part.content.endsWith("\n")
741
+ ? part.content.slice(0, -1)
742
+ : part.content,
743
+ };
744
+ };
745
+ const exportsCancel = async (runtime, args) => {
746
+ const [exportId] = positionals(args, ["<export>"], `${runtime.programName} exports cancel <export> [--yes]`);
747
+ const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
748
+ const current = parseResponse("exports.read", await session.client.request("exports_read", exportId));
749
+ const confirmation = await confirmChange(runtime, args, {
750
+ action: `Cancel export ${current.id}.`,
751
+ effect: [
752
+ `state ${current.state}`,
753
+ `cutoff ${current.cutoffAt}`,
754
+ "No further file parts can be downloaded from this job.",
755
+ ],
756
+ });
757
+ const answer = parseResponse("exports.cancel", await session.client.request("exports_cancel", exportId));
758
+ return {
759
+ data: { ...answer, confirmation },
760
+ human: `Export ${answer.export.id} is ${answer.export.state}.`,
761
+ };
762
+ };
742
763
  /* -------------------------------------------------------------------------- */
743
764
  /* Waiting */
744
765
  /* -------------------------------------------------------------------------- */
@@ -746,9 +767,9 @@ const occurrencesWait = async (runtime, args) => {
746
767
  const schedule = args.command[0];
747
768
  const occurrence = args.command[1];
748
769
  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>");
770
+ throw new CliError(`${runtime.programName} occurrences wait needs a schedule and an occurrence`, "usage", `Usage: ${runtime.programName} occurrences wait <schedule> <occurrence>`);
750
771
  }
751
- expectNoExtraArguments(args, 2, "greatwait occurrences wait <schedule> <occurrence>");
772
+ expectNoExtraArguments(args, 2, `${runtime.programName} occurrences wait <schedule> <occurrence>`);
752
773
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
753
774
  // The interval and the deadline come from the published journey. A number
754
775
  // here would be a fourth answer to a question the website, the curl page and
@@ -776,7 +797,7 @@ const occurrencesWait = async (runtime, args) => {
776
797
  states: seen,
777
798
  },
778
799
  human: `Occurrence ${occurrence} settled ${state} after ${String(view.occurrence.attemptCount)} ${view.occurrence.attemptCount === 1 ? "attempt" : "attempts"}.`,
779
- nextMove: `Next: greatwait attempts list ${schedule} ${occurrence}`,
800
+ nextMove: `Next: ${runtime.programName} attempts list ${schedule} ${occurrence}`,
780
801
  };
781
802
  }
782
803
  if (runtime.now() >= deadline) {
@@ -789,9 +810,9 @@ const attemptsList = async (runtime, args) => {
789
810
  const schedule = args.command[0];
790
811
  const occurrence = args.command[1];
791
812
  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>");
813
+ throw new CliError(`${runtime.programName} attempts list needs a schedule and an occurrence`, "usage", `Usage: ${runtime.programName} attempts list <schedule> <occurrence>`);
793
814
  }
794
- expectNoExtraArguments(args, 2, "greatwait attempts list <schedule> <occurrence>");
815
+ expectNoExtraArguments(args, 2, `${runtime.programName} attempts list <schedule> <occurrence>`);
795
816
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
796
817
  const page = responseSchemaFor("attempts.list")?.parse(await session.client.request("attempts_list", schedule, occurrence));
797
818
  const failed = page.data.find((attempt) => attempt.state !== "succeeded");
@@ -819,13 +840,26 @@ const attemptsList = async (runtime, args) => {
819
840
  /* -------------------------------------------------------------------------- */
820
841
  /* Dispatch */
821
842
  /* -------------------------------------------------------------------------- */
822
- /** Every command, keyed by the words that select it. */
823
- export const COMMANDS = {
843
+ /**
844
+ * Every implementation, keyed by the words that select it.
845
+ *
846
+ * Not the dispatch table: that is built from the catalog below, so a command
847
+ * cannot be documented without existing and cannot exist without being
848
+ * documented. This half is the functions, and `packages/cli/src/catalog.ts` is
849
+ * what says they are the right set.
850
+ */
851
+ const IMPLEMENTATIONS = {
852
+ ...OPERATE_COMMANDS,
824
853
  "attempts list": attemptsList,
825
854
  context,
826
855
  "destinations create": destinationsCreate,
827
856
  "destinations disable": destinationsDisable,
857
+ "destinations enable": destinationsEnable,
828
858
  doctor,
859
+ "exports cancel": exportsCancel,
860
+ "exports create": exportsCreate,
861
+ "exports part": exportsPart,
862
+ "exports read": exportsRead,
829
863
  journey,
830
864
  login,
831
865
  logout,
@@ -834,3 +868,24 @@ export const COMMANDS = {
834
868
  "schedules read": schedulesRead,
835
869
  whoami,
836
870
  };
871
+ /**
872
+ * The dispatch table, assembled from the catalog.
873
+ *
874
+ * **A declaration with no function raises here, at import.** The alternative
875
+ * was a test that compared two lists, which is a check the published reference
876
+ * page can still contradict: the page is generated from the catalog, so a
877
+ * command documented and never implemented would ship as a promise the
878
+ * executable does not keep. Raising means the binary does not start.
879
+ *
880
+ * The other direction is a test rather than a raise — an implementation nobody
881
+ * declared is unreachable rather than dangerous, and `commands.test.ts` names
882
+ * it.
883
+ */
884
+ export const COMMANDS = Object.fromEntries(COMMAND_CATALOG.map((declaration) => {
885
+ const implementation = IMPLEMENTATIONS[declaration.words];
886
+ if (implementation === undefined) {
887
+ throw new Error(`${declaration.words} is declared in the catalog and implemented nowhere`);
888
+ }
889
+ return [declaration.words, implementation];
890
+ }));
891
+ 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
+ }
package/dist/exit.d.ts CHANGED
@@ -44,6 +44,15 @@ export declare const EXIT_CLASSES: {
44
44
  readonly interrupted: 130;
45
45
  };
46
46
  export type ExitClass = keyof typeof EXIT_CLASSES;
47
+ /**
48
+ * What each class means, in the words the reference publishes.
49
+ *
50
+ * **Beside the numbers rather than in the page**, because a table of exit codes
51
+ * written out in documentation is a table that describes last month's classes.
52
+ * The reference renders this, so adding a class without describing it does not
53
+ * compile.
54
+ */
55
+ export declare const EXIT_CLASS_MEANINGS: Readonly<Record<ExitClass, string>>;
47
56
  /**
48
57
  * The class an HTTP status falls in.
49
58
  *