@greatwait/cli 0.1.0-alpha.8 → 0.2.1

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 CHANGED
@@ -6,7 +6,6 @@ against a receiver you control.
6
6
  ```sh
7
7
  npm install --global @greatwait/cli
8
8
  greatwait login --api-url https://api.greatwait.dev
9
- greatwait journey
10
9
  ```
11
10
 
12
11
  ## What it is
@@ -44,18 +43,59 @@ directory, readable by you and nobody else. A staging credential is never
44
43
  presented to production, because the two are separate entries rather than one
45
44
  current context.
46
45
 
47
- ## First value
46
+ ## First callback
48
47
 
49
- `greatwait journey` prints the published first-value sequence — the same nine
50
- steps the website and the quickstarts walk — with the command for each one.
48
+ Use a public HTTPS receiver you control. Once signed in, schedule directly to
49
+ its URL and wait for the first delivery:
51
50
 
52
51
  ```sh
53
- greatwait destinations create --url https://your-receiver.example.com/greatwait
54
- greatwait schedules create --endpoint ep_… --in 30s --data '{"greeting":"first value"}'
55
- greatwait occurrences wait sch_… occ_…
56
- greatwait attempts list sch_… occ_…
52
+ greatwait schedules create --url https://your-receiver.example.com/greatwait --in 30s --data '{"hello":"world"}' --wait
57
53
  ```
58
54
 
55
+ GreatWait uses your project's destination at that URL or creates one. The
56
+ answer includes the schedule and occurrence ids; copy them into the read:
57
+
58
+ ```sh
59
+ greatwait attempts list <schedule> <occurrence>
60
+ ```
61
+
62
+ Look for `state: succeeded` and the status your receiver returned. `--wait`
63
+ stops after three minutes by default; a settled failure is also an outcome,
64
+ so inspect the attempt. If it times out, use
65
+ `greatwait occurrences wait <schedule> <occurrence>` and read the attempts
66
+ again. The timeout does not cancel the scheduled call.
67
+
68
+ For a signed receiver, first add your URL in
69
+ [Settings → Endpoints](https://app.greatwait.dev/settings/endpoints), reveal its
70
+ signing secret, and configure the receiver. The direct-URL schedule reuses that
71
+ destination. If the schedule already created it, reveal the secret and configure
72
+ the receiver before its due time; use a longer `--in` if needed. Direct-URL
73
+ scheduling never prints the signing secret. Verify the signature over the exact request bytes
74
+ before parsing, as described in the
75
+ [CLI quickstart](https://docs.greatwait.dev/guides/cli).
76
+
77
+ ## Clean up
78
+
79
+ Archive the finished schedule. Read it to find its destination id:
80
+
81
+ ```sh
82
+ greatwait schedules read <schedule>
83
+ greatwait schedules archive <schedule>
84
+ ```
85
+
86
+ If this walkthrough created the destination and no other schedule uses it,
87
+ disable it:
88
+
89
+ ```sh
90
+ greatwait destinations disable <destination> --yes
91
+ ```
92
+
93
+ If you stop before delivery, cancel the schedule with
94
+ `greatwait schedules cancel <schedule> --yes` first. Revoke a walkthrough API
95
+ key in the dashboard, or use `greatwait credentials revoke <credential> --yes`
96
+ while signed in through OAuth. Finish with `greatwait logout` to revoke the CLI
97
+ grant and remove local credentials.
98
+
59
99
  ## Output and exit codes
60
100
 
61
101
  `--json` is a versioned machine shape, not a serialization of the human text.
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
- nextMove: described.known
69
- ? `Next: ${runtime.programName} journey — the published first-value sequence, with the command for each step.`
70
- : `${described.reason}\n\nNext: ${runtime.programName} journey — the published first-value sequence, with the command for each step.`,
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
- 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`);
132
- const returned = await listener.received;
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.known
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 {
@@ -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
  /**
@@ -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, { "content-type": "text/plain; charset=utf-8" });
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 { CliError } from "./runtime.js";
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/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
- process.on("SIGINT", () => {
20
- controller.abort();
21
- });
22
- process.on("SIGTERM", () => {
23
- controller.abort();
24
- });
25
- process.exitCode = await run(nodeRuntime(controller.signal, programName));
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,10 +7,10 @@
7
7
  "url": "https://github.com/flowxo/greatwait/issues"
8
8
  },
9
9
  "dependencies": {
10
- "@greatwait/api-client": "0.1.0-alpha.8",
11
- "@greatwait/contracts": "0.1.0-alpha.8"
10
+ "@greatwait/api-client": "0.2.1",
11
+ "@greatwait/contracts": "0.2.1"
12
12
  },
13
- "description": "The GreatWait command line: sign in, create a destination and a one-shot schedule, and wait for the callback, over the public API.",
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": {
15
15
  "node": ">=22.13.0"
16
16
  },
@@ -48,5 +48,5 @@
48
48
  },
49
49
  "sideEffects": false,
50
50
  "type": "module",
51
- "version": "0.1.0-alpha.8"
51
+ "version": "0.2.1"
52
52
  }