@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/args.d.ts +2 -2
- package/dist/args.js +33 -16
- package/dist/catalog.d.ts +196 -0
- package/dist/catalog.js +1453 -0
- package/dist/cli.d.ts +11 -2
- package/dist/cli.js +42 -31
- package/dist/commands.d.ts +33 -1
- package/dist/commands.js +199 -144
- package/dist/confirm.d.ts +33 -0
- package/dist/confirm.js +17 -0
- package/dist/exit.d.ts +9 -0
- package/dist/exit.js +19 -0
- package/dist/gw.d.ts +2 -0
- package/dist/gw.js +15 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/journey.d.ts +2 -2
- package/dist/journey.js +20 -4
- package/dist/main.js +6 -16
- package/dist/node-runtime.d.ts +15 -1
- package/dist/node-runtime.js +31 -1
- package/dist/oauth.d.ts +1 -1
- package/dist/oauth.js +15 -15
- package/dist/operate.d.ts +39 -0
- package/dist/operate.js +1394 -0
- package/dist/remediation.d.ts +35 -0
- package/dist/remediation.js +73 -0
- package/dist/runtime.d.ts +21 -1
- package/dist/session.js +5 -5
- package/dist/start.d.ts +15 -0
- package/dist/start.js +26 -0
- package/dist/support.d.ts +133 -0
- package/dist/support.js +270 -0
- package/package.json +5 -4
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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
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" |
|
|
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,
|
|
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
|
-
?
|
|
108
|
-
: `${described.reason}\n\nNext:
|
|
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
|
-
? [...
|
|
114
|
-
:
|
|
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",
|
|
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
|
-
?
|
|
197
|
-
: `${described.reason}\n\nNext:
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
510
|
+
throw new CliError(`${runtime.programName} context use needs an origin`, "usage");
|
|
524
511
|
}
|
|
525
|
-
expectNoExtraArguments(args, 2,
|
|
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:
|
|
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",
|
|
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,
|
|
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(
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
660
|
-
if (endpoint === undefined) {
|
|
661
|
-
throw new CliError(
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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(
|
|
674
|
+
throw new CliError(`${runtime.programName} schedules read needs a schedule`, "usage", `Usage: ${runtime.programName} schedules read <schedule>`);
|
|
714
675
|
}
|
|
715
|
-
expectNoExtraArguments(args, 1,
|
|
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(
|
|
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,
|
|
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:
|
|
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(
|
|
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,
|
|
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
|
-
/**
|
|
823
|
-
|
|
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>;
|
package/dist/confirm.js
ADDED
|
@@ -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
|
*
|