@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/README.md +1 -1
- package/dist/args.d.ts +2 -2
- package/dist/args.js +33 -16
- package/dist/catalog.d.ts +196 -0
- package/dist/catalog.js +1521 -0
- package/dist/cli.d.ts +11 -2
- package/dist/cli.js +45 -32
- package/dist/commands.d.ts +33 -1
- package/dist/commands.js +207 -148
- package/dist/confirm.d.ts +33 -0
- package/dist/confirm.js +17 -0
- package/dist/exit.d.ts +24 -2
- package/dist/exit.js +44 -2
- 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 +32 -2
- package/dist/oauth.d.ts +1 -1
- package/dist/oauth.js +16 -16
- package/dist/operate.d.ts +39 -0
- package/dist/operate.js +1558 -0
- package/dist/remediation.d.ts +35 -0
- package/dist/remediation.js +81 -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 +129 -0
- package/dist/support.js +261 -0
- package/package.json +6 -5
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")),
|
|
@@ -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
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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(
|
|
512
|
+
throw new CliError(`${runtime.programName} context use needs an origin`, "usage");
|
|
524
513
|
}
|
|
525
|
-
expectNoExtraArguments(args, 2,
|
|
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:
|
|
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",
|
|
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,
|
|
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(
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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.");
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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(
|
|
678
|
+
throw new CliError(`${runtime.programName} schedules read needs a schedule`, "usage", `Usage: ${runtime.programName} schedules read <schedule>`);
|
|
714
679
|
}
|
|
715
|
-
expectNoExtraArguments(args, 1,
|
|
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(
|
|
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,
|
|
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:
|
|
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(
|
|
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,
|
|
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
|
-
/**
|
|
823
|
-
|
|
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>;
|
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
|
+
}
|