@greatwait/cli 0.2.0 → 0.2.2
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/catalog.js +63 -5
- package/dist/commands.js +13 -9
- package/dist/node-runtime.d.ts +10 -0
- package/dist/node-runtime.js +34 -2
- package/dist/oauth.d.ts +15 -1
- package/dist/oauth.js +40 -1
- package/dist/operate.js +126 -3
- package/dist/start.d.ts +0 -14
- package/dist/start.js +34 -7
- package/package.json +3 -3
package/dist/catalog.js
CHANGED
|
@@ -83,6 +83,30 @@ const API_URL = {
|
|
|
83
83
|
summary: "The GreatWait API to use, such as https://api.greatwait.dev. Defaults to the one you signed in to.",
|
|
84
84
|
value: "<origin>",
|
|
85
85
|
};
|
|
86
|
+
const CORRELATION_FILTER_OPTIONS = [
|
|
87
|
+
{
|
|
88
|
+
flag: "--external-identifier",
|
|
89
|
+
value: "<id>",
|
|
90
|
+
summary: "Exact host application identifier, such as a request ID.",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
flag: "--tag",
|
|
94
|
+
value: "<tag>",
|
|
95
|
+
summary: "Exact membership of one schedule tag.",
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
const CORRELATION_WRITE_OPTIONS = [
|
|
99
|
+
{
|
|
100
|
+
flag: "--external-identifier",
|
|
101
|
+
value: "<id>",
|
|
102
|
+
summary: "Searchable host application identifier; at most 256 UTF-8 bytes. Do not include secrets.",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
flag: "--tags",
|
|
106
|
+
value: "<json>",
|
|
107
|
+
summary: "JSON array of up to 16 distinct tags, each at most 64 UTF-8 bytes. Inline or @file.json.",
|
|
108
|
+
},
|
|
109
|
+
];
|
|
86
110
|
const PAGE_OPTIONS = [
|
|
87
111
|
{
|
|
88
112
|
flag: "--limit",
|
|
@@ -525,12 +549,21 @@ export const COMMAND_CATALOG = [
|
|
|
525
549
|
summary: "Start calling a disabled destination again.",
|
|
526
550
|
words: "destinations enable",
|
|
527
551
|
},
|
|
552
|
+
{
|
|
553
|
+
group: "Schedules",
|
|
554
|
+
operations: ["executions.list"],
|
|
555
|
+
options: [API_URL, ...CORRELATION_FILTER_OPTIONS, ...PAGE_OPTIONS],
|
|
556
|
+
positionals: [],
|
|
557
|
+
summary: "Find retained runs, newest first, by host identifier or tag.",
|
|
558
|
+
words: "executions list",
|
|
559
|
+
},
|
|
528
560
|
/* --- Schedules -------------------------------------------------------- */
|
|
529
561
|
{
|
|
530
562
|
group: "Schedules",
|
|
531
563
|
operations: ["schedules.list"],
|
|
532
564
|
options: [
|
|
533
565
|
API_URL,
|
|
566
|
+
...CORRELATION_FILTER_OPTIONS,
|
|
534
567
|
{
|
|
535
568
|
flag: "--endpoint",
|
|
536
569
|
summary: "Only the schedules that call this destination. Its id starts ep_.",
|
|
@@ -560,6 +593,7 @@ export const COMMAND_CATALOG = [
|
|
|
560
593
|
operations: ["schedules.create"],
|
|
561
594
|
options: [
|
|
562
595
|
API_URL,
|
|
596
|
+
...CORRELATION_WRITE_OPTIONS,
|
|
563
597
|
{
|
|
564
598
|
flag: "--endpoint",
|
|
565
599
|
summary: "The destination to call, by id, starting ep_. Or use --url instead.",
|
|
@@ -641,6 +675,7 @@ export const COMMAND_CATALOG = [
|
|
|
641
675
|
operations: ["schedules.replace"],
|
|
642
676
|
options: [
|
|
643
677
|
API_URL,
|
|
678
|
+
...CORRELATION_WRITE_OPTIONS,
|
|
644
679
|
{
|
|
645
680
|
flag: "--endpoint",
|
|
646
681
|
summary: "The destination to call, by id, starting ep_. Or use --url instead.",
|
|
@@ -1372,6 +1407,34 @@ export const COMMAND_CATALOG = [
|
|
|
1372
1407
|
summary: "Remove your saved card. Project fees stop renewing when the paid period ends, and automatic top-up turns off.",
|
|
1373
1408
|
words: "billing remove-card",
|
|
1374
1409
|
},
|
|
1410
|
+
{
|
|
1411
|
+
group: "Planning",
|
|
1412
|
+
operations: ["billing.promotions"],
|
|
1413
|
+
options: [API_URL],
|
|
1414
|
+
positionals: [],
|
|
1415
|
+
summary: "List your promotions: promotional credit, complimentary projects, perks and discounts, with their end dates.",
|
|
1416
|
+
words: "billing promotions",
|
|
1417
|
+
},
|
|
1418
|
+
{
|
|
1419
|
+
confirms: true,
|
|
1420
|
+
group: "Planning",
|
|
1421
|
+
operations: ["billing.redeem"],
|
|
1422
|
+
options: [
|
|
1423
|
+
API_URL,
|
|
1424
|
+
{
|
|
1425
|
+
flag: "--target-project",
|
|
1426
|
+
summary: "The project a complimentary-project code should cover.",
|
|
1427
|
+
value: "<project-id>",
|
|
1428
|
+
},
|
|
1429
|
+
IDEMPOTENCY,
|
|
1430
|
+
CONFIRM,
|
|
1431
|
+
],
|
|
1432
|
+
positionals: [
|
|
1433
|
+
{ name: "<code>", summary: "The promotion code, as you received it." },
|
|
1434
|
+
],
|
|
1435
|
+
summary: "Redeem a promotion code. Nothing is charged.",
|
|
1436
|
+
words: "billing redeem",
|
|
1437
|
+
},
|
|
1375
1438
|
{
|
|
1376
1439
|
group: "Planning",
|
|
1377
1440
|
operations: ["billing.ledger"],
|
|
@@ -1626,11 +1689,6 @@ export const OPERATION_OMISSIONS = [
|
|
|
1626
1689
|
ground: "declared",
|
|
1627
1690
|
operation: "projects.delete",
|
|
1628
1691
|
},
|
|
1629
|
-
{
|
|
1630
|
-
because: "The Executions page is the accepted project-wide history surface in this story. The generated SDK and MCP expose the operation to automation; adding a second interactive CLI design is outside it.",
|
|
1631
|
-
ground: "declared",
|
|
1632
|
-
operation: "executions.list",
|
|
1633
|
-
},
|
|
1634
1692
|
{
|
|
1635
1693
|
because: "Grants are what a person gave to an application, and only that person may read them.",
|
|
1636
1694
|
ground: "principal-unreachable",
|
package/dist/commands.js
CHANGED
|
@@ -6,7 +6,7 @@ import { parseDuration } from "./args.js";
|
|
|
6
6
|
import { COMMAND_CATALOG, REQUESTED_SCOPES } from "./catalog.js";
|
|
7
7
|
import { confirmChange } from "./confirm.js";
|
|
8
8
|
import { directoryConcerns, originKey } from "./config.js";
|
|
9
|
-
import { buildAuthorization, codeFrom, discover, exchangeCode, registerClient, revoke, } from "./oauth.js";
|
|
9
|
+
import { awaitRedirect, buildAuthorization, codeFrom, discover, exchangeCode, registerClient, revoke, } from "./oauth.js";
|
|
10
10
|
import { redactSecret } from "./output.js";
|
|
11
11
|
import { CliError, sleepOrAbort } from "./runtime.js";
|
|
12
12
|
import { publishedWait, recoveryFor, renderJourney, STEP_COMMANDS, } from "./journey.js";
|
|
@@ -65,9 +65,10 @@ const login = async (runtime, args) => {
|
|
|
65
65
|
human: described.known
|
|
66
66
|
? `Signed in to ${origin} with an API credential, in project ${described.context.project} (${described.context.environmentKind}).`
|
|
67
67
|
: `Stored an API credential for ${origin} without checking it.`,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
// **No onward step.** Signing in is not enrolment in the first-value
|
|
69
|
+
// journey, and a person who asked only to sign in was being told where to
|
|
70
|
+
// go next (FXO-3054). `greatwait --help` still names the journey.
|
|
71
|
+
...(described.known ? {} : { nextMove: described.reason }),
|
|
71
72
|
};
|
|
72
73
|
}
|
|
73
74
|
const server = await discover(runtime, origin);
|
|
@@ -128,8 +129,13 @@ const login = async (runtime, args) => {
|
|
|
128
129
|
// **Printed whether or not the browser opened.** A URL shown only when
|
|
129
130
|
// launching failed is a URL nobody on a remote shell ever sees, because
|
|
130
131
|
// `xdg-open` there exits zero having opened nothing.
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
//
|
|
133
|
+
// The loopback address is not printed. It is how the browser answers this
|
|
134
|
+
// process, not something a person acts on, and it read as a second address
|
|
135
|
+
// to visit (FXO-3054). It is still in the authorization address, as
|
|
136
|
+
// `redirect_uri`, for anyone diagnosing a sign-in.
|
|
137
|
+
runtime.stderr(`${opened ? "Opened your browser. If nothing appeared, open" : "Open"} this address to approve the request:\n\n ${authorization.authorizationUrl}\n\nWaiting for you to approve it …\n`);
|
|
138
|
+
const returned = await awaitRedirect(runtime, listener);
|
|
133
139
|
const code = codeFrom(returned, authorization.state, runtime.programName);
|
|
134
140
|
const tokens = await exchangeCode(runtime, server, {
|
|
135
141
|
clientId,
|
|
@@ -181,9 +187,7 @@ const login = async (runtime, args) => {
|
|
|
181
187
|
human: described.known
|
|
182
188
|
? `Signed in to ${origin}, in project ${described.context.project} (${described.context.environmentKind}).`
|
|
183
189
|
: `Signed in to ${origin}. The grant is real — the service issued it — but it cannot say what it reaches.`,
|
|
184
|
-
nextMove: described.
|
|
185
|
-
? `Next: ${runtime.programName} journey — the published first-value sequence, with the command for each step.`
|
|
186
|
-
: `${described.reason}\n\nNext: ${runtime.programName} journey — the published first-value sequence, with the command for each step.`,
|
|
190
|
+
...(described.known ? {} : { nextMove: described.reason }),
|
|
187
191
|
};
|
|
188
192
|
}
|
|
189
193
|
finally {
|
package/dist/node-runtime.d.ts
CHANGED
|
@@ -19,6 +19,16 @@ import { type CliRuntime, type LoopbackListener } from "./runtime.js";
|
|
|
19
19
|
*
|
|
20
20
|
* Port zero: the operating system picks, and the port is known before the
|
|
21
21
|
* client is registered for it, which RFC 8252's exact-match rule requires.
|
|
22
|
+
*
|
|
23
|
+
* **Closing it ends every connection a browser opened, not just the listening
|
|
24
|
+
* socket.** `server.close()` stops accepting and then waits for open
|
|
25
|
+
* connections to finish, and a browser keeps them open: Chrome preconnects a
|
|
26
|
+
* socket it never sends a request on, and holds the redirect's own connection
|
|
27
|
+
* alive for reuse. Either one kept the process running after `login` had
|
|
28
|
+
* printed its success — and because Ctrl-C is caught to end waits cleanly, it
|
|
29
|
+
* could not be stopped short of closing the terminal (FXO-3054). Every socket
|
|
30
|
+
* is tracked from the moment it connects and destroyed on close, and every
|
|
31
|
+
* answer says `Connection: close` so a browser does not hold one for reuse.
|
|
22
32
|
*/
|
|
23
33
|
export declare function openLoopback(): Promise<LoopbackListener>;
|
|
24
34
|
/**
|
package/dist/node-runtime.js
CHANGED
|
@@ -23,6 +23,16 @@ import { InterruptedError, } from "./runtime.js";
|
|
|
23
23
|
*
|
|
24
24
|
* Port zero: the operating system picks, and the port is known before the
|
|
25
25
|
* client is registered for it, which RFC 8252's exact-match rule requires.
|
|
26
|
+
*
|
|
27
|
+
* **Closing it ends every connection a browser opened, not just the listening
|
|
28
|
+
* socket.** `server.close()` stops accepting and then waits for open
|
|
29
|
+
* connections to finish, and a browser keeps them open: Chrome preconnects a
|
|
30
|
+
* socket it never sends a request on, and holds the redirect's own connection
|
|
31
|
+
* alive for reuse. Either one kept the process running after `login` had
|
|
32
|
+
* printed its success — and because Ctrl-C is caught to end waits cleanly, it
|
|
33
|
+
* could not be stopped short of closing the terminal (FXO-3054). Every socket
|
|
34
|
+
* is tracked from the moment it connects and destroyed on close, and every
|
|
35
|
+
* answer says `Connection: close` so a browser does not hold one for reuse.
|
|
26
36
|
*/
|
|
27
37
|
export async function openLoopback() {
|
|
28
38
|
let deliver;
|
|
@@ -39,19 +49,29 @@ export async function openLoopback() {
|
|
|
39
49
|
// class the refusal earned. A handler attached here is never the *only* one:
|
|
40
50
|
// a caller that does await it still sees the rejection.
|
|
41
51
|
received.catch(() => undefined);
|
|
52
|
+
// `IncomingMessage["socket"]` rather than an import of `node:net`, which the
|
|
53
|
+
// architecture gate would count as one more runtime dependency.
|
|
54
|
+
const sockets = new Set();
|
|
42
55
|
const server = createServer((request, response) => {
|
|
43
56
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
44
57
|
const isRedirect = url.pathname === "/callback" &&
|
|
45
58
|
(url.searchParams.has("code") || url.searchParams.has("error"));
|
|
46
59
|
if (!isRedirect) {
|
|
47
|
-
response.writeHead(204);
|
|
60
|
+
response.writeHead(204, { connection: "close" });
|
|
48
61
|
response.end();
|
|
49
62
|
return;
|
|
50
63
|
}
|
|
51
|
-
response.writeHead(200, {
|
|
64
|
+
response.writeHead(200, {
|
|
65
|
+
connection: "close",
|
|
66
|
+
"content-type": "text/plain; charset=utf-8",
|
|
67
|
+
});
|
|
52
68
|
response.end("GreatWait received the answer. You can close this tab.\n");
|
|
53
69
|
deliver?.(url.searchParams);
|
|
54
70
|
});
|
|
71
|
+
server.on("connection", (socket) => {
|
|
72
|
+
sockets.add(socket);
|
|
73
|
+
socket.once("close", () => sockets.delete(socket));
|
|
74
|
+
});
|
|
55
75
|
await new Promise((resolve, reject) => {
|
|
56
76
|
server.once("error", reject);
|
|
57
77
|
// `127.0.0.1` rather than every interface: this socket receives an
|
|
@@ -70,6 +90,18 @@ export async function openLoopback() {
|
|
|
70
90
|
close: () => {
|
|
71
91
|
fail?.(new Error("the sign-in was abandoned"));
|
|
72
92
|
server.close();
|
|
93
|
+
// After `close`, so nothing new is accepted while these are destroyed.
|
|
94
|
+
// A refusal can close within a tick of answering the redirect, so a
|
|
95
|
+
// socket still flushing that answer is ended first and destroyed once it
|
|
96
|
+
// has, and the browser still shows the page.
|
|
97
|
+
for (const socket of sockets) {
|
|
98
|
+
if (socket.writableLength > 0) {
|
|
99
|
+
socket.end(() => socket.destroy());
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
socket.destroy();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
73
105
|
},
|
|
74
106
|
received,
|
|
75
107
|
redirectUri: `http://127.0.0.1:${String(address.port)}/callback`,
|
package/dist/oauth.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type CliRuntime } from "./runtime.js";
|
|
1
|
+
import { type CliRuntime, type LoopbackListener } from "./runtime.js";
|
|
2
2
|
/**
|
|
3
3
|
* The delegated-authorization half of `login`, spoken as a public client.
|
|
4
4
|
*
|
|
@@ -59,6 +59,20 @@ export declare function buildAuthorization(runtime: CliRuntime, server: Authoriz
|
|
|
59
59
|
readonly redirectUri: string;
|
|
60
60
|
readonly scopes: readonly string[];
|
|
61
61
|
}): Promise<AuthorizationRequest>;
|
|
62
|
+
/**
|
|
63
|
+
* The redirect, or the reason there will not be one.
|
|
64
|
+
*
|
|
65
|
+
* **Bounded by Ctrl-C and by the authorization request's own lifetime.** It
|
|
66
|
+
* used to await the listener alone, so Ctrl-C aborted a signal nothing here was
|
|
67
|
+
* watching and a browser closed without answering held the terminal for ever.
|
|
68
|
+
* The deadline is the service's: past it the request a person would approve
|
|
69
|
+
* has expired, and waiting longer could only end in a refusal.
|
|
70
|
+
*
|
|
71
|
+
* The timer is cancelled however the wait ends. A ten-minute timer left armed
|
|
72
|
+
* after a successful sign-in is ten minutes of a process that has finished and
|
|
73
|
+
* will not exit.
|
|
74
|
+
*/
|
|
75
|
+
export declare function awaitRedirect(runtime: CliRuntime, listener: LoopbackListener): Promise<URLSearchParams>;
|
|
62
76
|
/**
|
|
63
77
|
* Turns the redirect into a code, refusing anything that is not this run's.
|
|
64
78
|
*
|
package/dist/oauth.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AUTHORIZATION_REQUEST_TTL_SECONDS } from "@greatwait/contracts";
|
|
2
|
+
import { CliError, InterruptedError, } from "./runtime.js";
|
|
2
3
|
function base64Url(bytes) {
|
|
3
4
|
return Buffer.from(bytes).toString("base64url");
|
|
4
5
|
}
|
|
@@ -109,6 +110,44 @@ export async function buildAuthorization(runtime, server, options) {
|
|
|
109
110
|
url.searchParams.set("state", state);
|
|
110
111
|
return { authorizationUrl: url.toString(), state, verifier };
|
|
111
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* The redirect, or the reason there will not be one.
|
|
115
|
+
*
|
|
116
|
+
* **Bounded by Ctrl-C and by the authorization request's own lifetime.** It
|
|
117
|
+
* used to await the listener alone, so Ctrl-C aborted a signal nothing here was
|
|
118
|
+
* watching and a browser closed without answering held the terminal for ever.
|
|
119
|
+
* The deadline is the service's: past it the request a person would approve
|
|
120
|
+
* has expired, and waiting longer could only end in a refusal.
|
|
121
|
+
*
|
|
122
|
+
* The timer is cancelled however the wait ends. A ten-minute timer left armed
|
|
123
|
+
* after a successful sign-in is ten minutes of a process that has finished and
|
|
124
|
+
* will not exit.
|
|
125
|
+
*/
|
|
126
|
+
export async function awaitRedirect(runtime, listener) {
|
|
127
|
+
if (runtime.signal.aborted)
|
|
128
|
+
throw new InterruptedError();
|
|
129
|
+
const stop = new AbortController();
|
|
130
|
+
const onAbort = () => {
|
|
131
|
+
stop.abort();
|
|
132
|
+
};
|
|
133
|
+
runtime.signal.addEventListener("abort", onAbort, { once: true });
|
|
134
|
+
const deadline = runtime
|
|
135
|
+
.sleep(AUTHORIZATION_REQUEST_TTL_SECONDS * 1_000, stop.signal)
|
|
136
|
+
.then(() => {
|
|
137
|
+
if (runtime.signal.aborted)
|
|
138
|
+
throw new InterruptedError();
|
|
139
|
+
throw new CliError(`nobody approved the request within ${String(AUTHORIZATION_REQUEST_TTL_SECONDS / 60)} minutes, so this command stopped waiting`, "timeout", `Run ${runtime.programName} login again, or use an API key: ${runtime.programName} login --api-key`);
|
|
140
|
+
});
|
|
141
|
+
try {
|
|
142
|
+
return await Promise.race([listener.received, deadline]);
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
runtime.signal.removeEventListener("abort", onAbort);
|
|
146
|
+
stop.abort();
|
|
147
|
+
// The losing side settles after the race and nobody awaits it.
|
|
148
|
+
deadline.catch(() => undefined);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
112
151
|
/**
|
|
113
152
|
* Turns the redirect into a code, refusing anything that is not this run's.
|
|
114
153
|
*
|
package/dist/operate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { writeFile } from "node:fs/promises";
|
|
2
|
-
import { projectNotificationSettingsUpdateRequestSchema, statusSigningKeyRequestSchema, autoPauseAfterFailuresSchema, httpRequestSchema, sanitizedDiagnosticText, diagnosticBodyText, responseBodySentence, requestCaptureSentence, attemptStatusGuidance, endpointHeaderChangesSchema, billingAutoTopUpApplyRequestSchema, billingAutoTopUpPreviewRequestSchema, billingAutoTopUpPreviewSchema, billingCeilingUpdateRequestSchema, billingFundingQuoteRequestSchema, billingFundingQuoteSchema, billingNotificationSettingsUpdateRequestSchema, billingRefundRequestCreateSchema, billingRefundResolutionRequestSchema, projectBillingQuoteSchema, projectBillingQuoteRequestSchema, } from "@greatwait/contracts";
|
|
2
|
+
import { projectNotificationSettingsUpdateRequestSchema, statusSigningKeyRequestSchema, autoPauseAfterFailuresSchema, httpRequestSchema, externalIdentifierSchema, scheduleTagsSchema, scheduleTagSchema, sanitizedDiagnosticText, diagnosticBodyText, responseBodySentence, requestCaptureSentence, attemptStatusGuidance, endpointHeaderChangesSchema, billingAutoTopUpApplyRequestSchema, billingAutoTopUpPreviewRequestSchema, billingAutoTopUpPreviewSchema, billingCeilingUpdateRequestSchema, billingFundingQuoteRequestSchema, billingFundingQuoteSchema, billingNotificationSettingsUpdateRequestSchema, billingRefundRequestCreateSchema, billingRefundResolutionRequestSchema, billingRedeemRequestSchema, projectBillingQuoteSchema, projectBillingQuoteRequestSchema, } from "@greatwait/contracts";
|
|
3
3
|
import { parseDuration } from "./args.js";
|
|
4
4
|
import { confirmChange } from "./confirm.js";
|
|
5
5
|
import { redactSecret } from "./output.js";
|
|
@@ -125,8 +125,18 @@ async function scheduleRequest(runtime, args, usage) {
|
|
|
125
125
|
throw new CliError('--auto-pause-after-failures must be a whole number from 1 to 100, or "never"', "usage");
|
|
126
126
|
const latencyClass = args.options["--latency-class"];
|
|
127
127
|
const partitionKey = args.options["--partition-key"];
|
|
128
|
+
const external_identifier = args.options["--external-identifier"];
|
|
129
|
+
if (external_identifier !== undefined &&
|
|
130
|
+
!externalIdentifierSchema.safeParse(external_identifier).success)
|
|
131
|
+
throw new CliError("--external-identifier must contain 1–256 UTF-8 bytes and no NUL characters", "usage");
|
|
132
|
+
const tags = await readJsonOption(runtime, "--tags", args.options["--tags"]);
|
|
133
|
+
const parsedTags = tags === undefined ? undefined : scheduleTagsSchema.safeParse(tags);
|
|
134
|
+
if (parsedTags !== undefined && !parsedTags.success)
|
|
135
|
+
throw new CliError("--tags must be up to 16 distinct strings, each 1–64 UTF-8 bytes", "usage");
|
|
128
136
|
return {
|
|
129
137
|
...(data === undefined ? {} : { data }),
|
|
138
|
+
...(external_identifier === undefined ? {} : { external_identifier }),
|
|
139
|
+
...(parsedTags?.success ? { tags: parsedTags.data } : {}),
|
|
130
140
|
...(http?.success ? { http: http.data } : {}),
|
|
131
141
|
...(endpoint === undefined ? {} : { endpoint }),
|
|
132
142
|
...(url === undefined ? {} : { url }),
|
|
@@ -311,18 +321,51 @@ const destinationsRotateKey = async (runtime, args) => {
|
|
|
311
321
|
/* -------------------------------------------------------------------------- */
|
|
312
322
|
/* Schedules */
|
|
313
323
|
/* -------------------------------------------------------------------------- */
|
|
324
|
+
function correlationFilters(args) {
|
|
325
|
+
const external_identifier = args.options["--external-identifier"];
|
|
326
|
+
const tag = args.options["--tag"];
|
|
327
|
+
if (external_identifier !== undefined &&
|
|
328
|
+
!externalIdentifierSchema.safeParse(external_identifier).success)
|
|
329
|
+
throw new CliError("--external-identifier must contain 1–256 UTF-8 bytes", "usage");
|
|
330
|
+
if (tag !== undefined && !scheduleTagSchema.safeParse(tag).success)
|
|
331
|
+
throw new CliError("--tag must contain 1–64 UTF-8 bytes", "usage");
|
|
332
|
+
return {
|
|
333
|
+
...(external_identifier === undefined ? {} : { external_identifier }),
|
|
334
|
+
...(tag === undefined ? {} : { tag }),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function correlationCommand(args) {
|
|
338
|
+
return ["--external-identifier", "--tag"]
|
|
339
|
+
.flatMap((flag) => args.options[flag] === undefined
|
|
340
|
+
? []
|
|
341
|
+
: [` ${flag} ${`'${args.options[flag].replaceAll("'", "'\"'\"'")}'`}`])
|
|
342
|
+
.join("");
|
|
343
|
+
}
|
|
344
|
+
const executionsList = async (runtime, args) => {
|
|
345
|
+
const filters = correlationFilters(args);
|
|
346
|
+
const active = await session(runtime, args);
|
|
347
|
+
return listing(runtime, args, {
|
|
348
|
+
columns: ["STATE", "PLANNED FOR", "SCHEDULE", "RUN"],
|
|
349
|
+
command: `${runtime.programName} executions list${correlationCommand(args)}`,
|
|
350
|
+
empty: "No retained runs match these filters.",
|
|
351
|
+
read: (request) => page("executions.list", () => active.client.request("executions_list", { ...request, ...filters })),
|
|
352
|
+
row: (run) => [run.state, run.plannedFor, run.schedule, run.id],
|
|
353
|
+
});
|
|
354
|
+
};
|
|
314
355
|
const schedulesList = async (runtime, args) => {
|
|
356
|
+
const filters = correlationFilters(args);
|
|
315
357
|
const active = await session(runtime, args);
|
|
316
358
|
const endpoint = args.options["--endpoint"];
|
|
317
359
|
return listing(runtime, args, {
|
|
318
360
|
columns: ["STATUS", "NAME", "RECURRENCE", "DELIVERS TO", "SCHEDULE"],
|
|
319
|
-
command: `${runtime.programName} schedules list${endpoint === undefined ? "" : ` --endpoint ${endpoint}`}`,
|
|
361
|
+
command: `${runtime.programName} schedules list${endpoint === undefined ? "" : ` --endpoint ${endpoint}`}${correlationCommand(args)}`,
|
|
320
362
|
empty: endpoint === undefined
|
|
321
363
|
? "This project has no schedules yet."
|
|
322
364
|
: "No schedule delivers to that destination.",
|
|
323
365
|
read: (request) => page("schedules.list", () => active.client.request("schedules_list", {
|
|
324
366
|
...request,
|
|
325
367
|
...(endpoint === undefined ? {} : { endpoint }),
|
|
368
|
+
...filters,
|
|
326
369
|
})),
|
|
327
370
|
row: (schedule) => [
|
|
328
371
|
schedule.status,
|
|
@@ -735,6 +778,16 @@ const billing = async (runtime, args) => {
|
|
|
735
778
|
...(view.trialExpiresAt === undefined
|
|
736
779
|
? []
|
|
737
780
|
: [`Trial ends ${view.trialExpiresAt}`]),
|
|
781
|
+
...(view.credit.byKind === undefined
|
|
782
|
+
? []
|
|
783
|
+
: ["purchased", "trial", "promotional"].flatMap((kind) => {
|
|
784
|
+
const balance = view.credit.byKind[kind];
|
|
785
|
+
return balance.granted.microdollars === "0"
|
|
786
|
+
? []
|
|
787
|
+
: [
|
|
788
|
+
`${kind.padEnd(12)} ${money(balance.available)} available, ${money(balance.reserved)} reserved, ${money(balance.consumed)} used${balance.forfeited.microdollars === "0" ? "" : `, ${money(balance.forfeited)} expired`}${balance.expiresAt === undefined ? "" : `; ends ${balance.expiresAt}`}`,
|
|
789
|
+
];
|
|
790
|
+
})),
|
|
738
791
|
...(view.activation.mode === "shadow" || view.blockingReasons.length === 0
|
|
739
792
|
? []
|
|
740
793
|
: [`Blocked ${view.blockingReasons.join(", ")}`]),
|
|
@@ -1050,6 +1103,63 @@ const billingAutoTopUpApply = async (runtime, args) => {
|
|
|
1050
1103
|
data: { confirmation, idempotencyKey: key, ...view },
|
|
1051
1104
|
};
|
|
1052
1105
|
};
|
|
1106
|
+
function promotionGrantLine(grant) {
|
|
1107
|
+
const benefit = grant.benefit;
|
|
1108
|
+
const what = benefit.kind === "credit"
|
|
1109
|
+
? `${money(benefit.amount)} promotional credit`
|
|
1110
|
+
: benefit.kind === "project-slot"
|
|
1111
|
+
? `complimentary project ${benefit.project}`
|
|
1112
|
+
: benefit.kind === "perk"
|
|
1113
|
+
? `perk ${benefit.perk}`
|
|
1114
|
+
: `${benefit.percentOff !== undefined ? `${String(benefit.percentOff)}%` : money(benefit.amountOff)} off project fees, ${benefit.duration === "once" ? "one invoice" : `${String(benefit.durationInMonths)} invoices`}`;
|
|
1115
|
+
return `${grant.id}: ${what} (${grant.state}, from ${grant.source}${grant.endsAt === undefined ? "" : `, ends ${grant.endsAt}`}${grant.revokedAt === undefined ? "" : `, revoked ${grant.revokedAt}`})`;
|
|
1116
|
+
}
|
|
1117
|
+
function promotionsLines(view) {
|
|
1118
|
+
return [
|
|
1119
|
+
`Projects: ${String(view.projectCapacity.included)} included, ${String(view.projectCapacity.paid)} paid, ${String(view.projectCapacity.complimentary)} complimentary`,
|
|
1120
|
+
...(view.grants.length === 0
|
|
1121
|
+
? ["No promotions."]
|
|
1122
|
+
: view.grants.map(promotionGrantLine)),
|
|
1123
|
+
...view.restrictions,
|
|
1124
|
+
];
|
|
1125
|
+
}
|
|
1126
|
+
const billingPromotions = async (runtime, args) => {
|
|
1127
|
+
const active = await session(runtime, args);
|
|
1128
|
+
const view = parseResponse("billing.promotions", await active.client.request("billing_promotions"));
|
|
1129
|
+
return { data: view, human: promotionsLines(view).join("\n") };
|
|
1130
|
+
};
|
|
1131
|
+
const billingRedeem = async (runtime, args) => {
|
|
1132
|
+
const parsed = billingRedeemRequestSchema.safeParse({
|
|
1133
|
+
code: positionals(args, ["<code>"], `${runtime.programName} billing redeem <code> [--target-project <project-id>]`)[0],
|
|
1134
|
+
confirmed: true,
|
|
1135
|
+
...(args.options["--target-project"] === undefined
|
|
1136
|
+
? {}
|
|
1137
|
+
: { project: args.options["--target-project"] }),
|
|
1138
|
+
});
|
|
1139
|
+
if (!parsed.success)
|
|
1140
|
+
throw new CliError("Provide the code, and --target-project when the code covers a project.", "usage");
|
|
1141
|
+
const confirmation = await confirmChange(runtime, args, {
|
|
1142
|
+
action: "Redeem this promotion code for your organization.",
|
|
1143
|
+
effect: [
|
|
1144
|
+
...(parsed.data.project === undefined
|
|
1145
|
+
? []
|
|
1146
|
+
: [`project ${parsed.data.project}`]),
|
|
1147
|
+
"Nothing is charged. Promotional value expires, has no cash value and cannot be refunded.",
|
|
1148
|
+
],
|
|
1149
|
+
});
|
|
1150
|
+
const active = await session(runtime, args);
|
|
1151
|
+
const key = idempotencyKey(runtime, args);
|
|
1152
|
+
const view = parseResponse("billing.redeem", await active.client.request("billing_redeem", parsed.data, {
|
|
1153
|
+
"Idempotency-Key": key,
|
|
1154
|
+
}));
|
|
1155
|
+
return {
|
|
1156
|
+
data: { confirmation, idempotencyKey: key, ...view },
|
|
1157
|
+
human: [
|
|
1158
|
+
`Redeemed ${promotionGrantLine(view.grant)}`,
|
|
1159
|
+
...promotionsLines(view.promotions),
|
|
1160
|
+
].join("\n"),
|
|
1161
|
+
};
|
|
1162
|
+
};
|
|
1053
1163
|
const billingRefundRequest = async (runtime, args) => {
|
|
1054
1164
|
const parsed = billingRefundRequestCreateSchema.safeParse({
|
|
1055
1165
|
amount: budgetMoney(args, "--amount"),
|
|
@@ -1139,8 +1249,13 @@ function projectBillingResult(view) {
|
|
|
1139
1249
|
human: [
|
|
1140
1250
|
`Monthly project fees ${money(view.recurringProjectFees)}`,
|
|
1141
1251
|
`Policy desired r${String(view.desiredRevision)}, effective r${String(view.effectiveRevision)}${view.pending ? " (pending)" : ""}`,
|
|
1142
|
-
...view.projects.map((project) => `${project.name} (${project.project}): ${project.access}${project.paidThroughAt ? `, paid through ${project.paidThroughAt}` : ""}${project.cancellationAt ? `, cancels ${project.cancellationAt}` : ""}${project.renewalGraceUntil ? `, renewal grace until ${project.renewalGraceUntil}` : ""}`),
|
|
1252
|
+
...view.projects.map((project) => `${project.name} (${project.project}): ${project.complimentary ? `complimentary until ${project.complimentary.endsAt}` : project.access}${project.paidThroughAt ? `, paid through ${project.paidThroughAt}` : ""}${project.cancellationAt ? `, cancels ${project.cancellationAt}` : ""}${project.renewalGraceUntil ? `, renewal grace until ${project.renewalGraceUntil}` : ""}`),
|
|
1143
1253
|
...view.pendingOperations.map((operation) => `${operation.project}: ${operation.state}${operation.reason ? ` (${operation.reason})` : ""}${operation.paymentUrl ? ` ${operation.paymentUrl}` : ""}`),
|
|
1254
|
+
...(view.discount === undefined
|
|
1255
|
+
? []
|
|
1256
|
+
: [
|
|
1257
|
+
`Discount ${view.discount.percentOff !== undefined ? `${String(view.discount.percentOff)}% off` : `${money(view.discount.amountOff)} off`} ${view.discount.duration === "once" ? "one invoice" : `${String(view.discount.durationInMonths)} monthly invoices`} (${view.discount.state})`,
|
|
1258
|
+
]),
|
|
1144
1259
|
].join("\n"),
|
|
1145
1260
|
};
|
|
1146
1261
|
}
|
|
@@ -1151,6 +1266,11 @@ const billingProjects = async (runtime, args) => {
|
|
|
1151
1266
|
function projectQuoteLines(quote) {
|
|
1152
1267
|
return [
|
|
1153
1268
|
`Project ${quote.project}${quote.fromProject ? `; former included ${quote.fromProject}` : ""}`,
|
|
1269
|
+
...(quote.discount === undefined
|
|
1270
|
+
? []
|
|
1271
|
+
: [
|
|
1272
|
+
`Before discount ${money(quote.discount.gross)}; discount ${money(quote.discount.amount)} for ${quote.discount.duration === "once" ? "this invoice" : `${String(quote.discount.durationInMonths)} monthly invoices`}`,
|
|
1273
|
+
]),
|
|
1154
1274
|
`Charge ${money(quote.charge)}; monthly project fees ${money(quote.monthlyProjectFees)}`,
|
|
1155
1275
|
`Effective ${quote.effectiveAt}; quote expires ${quote.expiresAt}`,
|
|
1156
1276
|
...(quote.formerIncludedWillBlock
|
|
@@ -1730,6 +1850,8 @@ export const OPERATE_COMMANDS = {
|
|
|
1730
1850
|
"billing notification-settings": billingNotificationSettings,
|
|
1731
1851
|
"billing update-notifications": billingNotificationSettingsUpdate,
|
|
1732
1852
|
"billing ledger": billingLedger,
|
|
1853
|
+
"billing promotions": billingPromotions,
|
|
1854
|
+
"billing redeem": billingRedeem,
|
|
1733
1855
|
"billing projects": billingProjects,
|
|
1734
1856
|
"billing purchase": billingPurchase,
|
|
1735
1857
|
"billing quote-funding": billingQuoteFunding,
|
|
@@ -1760,6 +1882,7 @@ export const OPERATE_COMMANDS = {
|
|
|
1760
1882
|
"schedules archive": schedulesArchive,
|
|
1761
1883
|
"schedules cancel": schedulesCancel,
|
|
1762
1884
|
"schedules list": schedulesList,
|
|
1885
|
+
"executions list": executionsList,
|
|
1763
1886
|
"schedules pause": schedulesPause,
|
|
1764
1887
|
"schedules replace": schedulesReplace,
|
|
1765
1888
|
"schedules resume": schedulesResume,
|
package/dist/start.d.ts
CHANGED
|
@@ -1,15 +1 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The body both installed executables share.
|
|
3
|
-
*
|
|
4
|
-
* **Two entry files rather than one that works out what it was called.**
|
|
5
|
-
* `process.argv[1]` is the *resolved* path of the main module, and npm links a
|
|
6
|
-
* bin as a symlink into `node_modules/.bin`, so a program deriving its own name
|
|
7
|
-
* from argv would read `main.js` — and the version that guesses correctly on
|
|
8
|
-
* macOS is the version that guesses differently under a shell wrapper, on
|
|
9
|
-
* Windows, or with `--preserve-symlinks-main`. The name is a property of the
|
|
10
|
-
* entry point, so the entry point states it.
|
|
11
|
-
*
|
|
12
|
-
* `process.exitCode` rather than `process.exit`: the second truncates whatever
|
|
13
|
-
* is still buffered on stdout, and this command's last act is to print.
|
|
14
|
-
*/
|
|
15
1
|
export declare function start(programName: string): Promise<void>;
|
package/dist/start.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { run } from "./cli.js";
|
|
2
|
+
import { EXIT_CLASSES } from "./exit.js";
|
|
2
3
|
import { nodeRuntime } from "./node-runtime.js";
|
|
3
4
|
/**
|
|
4
5
|
* The body both installed executables share.
|
|
@@ -13,14 +14,40 @@ import { nodeRuntime } from "./node-runtime.js";
|
|
|
13
14
|
*
|
|
14
15
|
* `process.exitCode` rather than `process.exit`: the second truncates whatever
|
|
15
16
|
* is still buffered on stdout, and this command's last act is to print.
|
|
17
|
+
*
|
|
18
|
+
* **Catching a signal must never make the process unstoppable.** The first
|
|
19
|
+
* Ctrl-C aborts, so a wait can end with its own sentence and exit class. A
|
|
20
|
+
* second one a moment later exits at once with 130, and once the command has
|
|
21
|
+
* returned the handlers are removed, so a signal regains its default meaning.
|
|
22
|
+
* "A moment later" because one keypress under `npx` arrives twice — from the
|
|
23
|
+
* terminal to the process group, and again forwarded by npm — and treating the
|
|
24
|
+
* echo as a second Ctrl-C would skip the interruption's own sentence. Before
|
|
25
|
+
* FXO-3054 neither held: a socket left open after a successful `login` kept the
|
|
26
|
+
* process alive, every Ctrl-C was absorbed by an abort nothing was waiting on,
|
|
27
|
+
* and the only way out was closing the terminal.
|
|
16
28
|
*/
|
|
29
|
+
/** How far apart two signals must be to count as a person asking twice. */
|
|
30
|
+
const REPEATED_SIGNAL_MS = 500;
|
|
17
31
|
export async function start(programName) {
|
|
18
32
|
const controller = new AbortController();
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
let firstSignalAt;
|
|
34
|
+
const onSignal = (signal) => {
|
|
35
|
+
if (firstSignalAt === undefined) {
|
|
36
|
+
firstSignalAt = Date.now();
|
|
37
|
+
controller.abort();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (Date.now() - firstSignalAt >= REPEATED_SIGNAL_MS) {
|
|
41
|
+
process.exit(signal === "SIGINT" ? EXIT_CLASSES.interrupted : 143);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
process.on("SIGINT", onSignal);
|
|
45
|
+
process.on("SIGTERM", onSignal);
|
|
46
|
+
try {
|
|
47
|
+
process.exitCode = await run(nodeRuntime(controller.signal, programName));
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
process.off("SIGINT", onSignal);
|
|
51
|
+
process.off("SIGTERM", onSignal);
|
|
52
|
+
}
|
|
26
53
|
}
|
package/package.json
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
"url": "https://github.com/flowxo/greatwait/issues"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@greatwait/api-client": "0.2.
|
|
11
|
-
"@greatwait/contracts": "0.2.
|
|
10
|
+
"@greatwait/api-client": "0.2.2",
|
|
11
|
+
"@greatwait/contracts": "0.2.2"
|
|
12
12
|
},
|
|
13
13
|
"description": "The GreatWait command line: sign in, schedule a call to a URL, and read its delivery outcome over the public API.",
|
|
14
14
|
"engines": {
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
},
|
|
49
49
|
"sideEffects": false,
|
|
50
50
|
"type": "module",
|
|
51
|
-
"version": "0.2.
|
|
51
|
+
"version": "0.2.2"
|
|
52
52
|
}
|