@greatwait/cli 0.1.0-alpha.3

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.
@@ -0,0 +1,63 @@
1
+ import type { FetchLike } from "@greatwait/api-client";
2
+ /**
3
+ * Everything this command touches that is not a pure function.
4
+ *
5
+ * **A parameter rather than an import, and the reason is criterion 7.** The
6
+ * cases this story has to answer are a browser that will not open, a callback
7
+ * that never arrives, a Ctrl-C halfway through a wait, a deadline passing, and
8
+ * a configuration directory on a platform the test is not running on. Every one
9
+ * of those is a fact about the surroundings, and a suite that could not
10
+ * construct them would be asserting the happy path and calling it coverage.
11
+ */
12
+ export interface CliRuntime {
13
+ readonly argv: readonly string[];
14
+ readonly env: Readonly<Record<string, string | undefined>>;
15
+ readonly fetch: FetchLike;
16
+ /** The user's home directory, for the configuration path. */
17
+ readonly home: string;
18
+ /** Whether standard input is a terminal, which decides prompting. */
19
+ readonly interactive: boolean;
20
+ /** Opens a loopback listener for one authorization redirect. */
21
+ readonly listen: () => Promise<LoopbackListener>;
22
+ readonly now: () => number;
23
+ /** Hands a URL to the platform's browser. Rejects when there is none. */
24
+ readonly openBrowser: (url: string) => Promise<void>;
25
+ readonly platform: string;
26
+ /** Reads one line without echoing it. Only ever used for a secret. */
27
+ readonly promptSecret: (question: string) => Promise<string>;
28
+ readonly randomBytes: (length: number) => Uint8Array;
29
+ readonly randomUuid: () => string;
30
+ /** Everything piped in, for a secret supplied non-interactively. */
31
+ readonly readStdin: () => Promise<string>;
32
+ /** Aborted on Ctrl-C. Every wait in this command is bound to it. */
33
+ readonly signal: AbortSignal;
34
+ readonly sleep: (milliseconds: number, signal: AbortSignal) => Promise<void>;
35
+ readonly stderr: (text: string) => void;
36
+ readonly stdout: (text: string) => void;
37
+ /** `undefined` where a file mode says nothing about who may read it. */
38
+ readonly uid: number | undefined;
39
+ }
40
+ export interface LoopbackListener {
41
+ /** Resolves with the redirect's query, or rejects if the wait is abandoned. */
42
+ readonly received: Promise<URLSearchParams>;
43
+ readonly close: () => void;
44
+ readonly redirectUri: string;
45
+ }
46
+ /** Raised where the reason is already a sentence a person can act on. */
47
+ export declare class CliError extends Error {
48
+ readonly exit: "authentication" | "authorization" | "internal" | "retryable" | "timeout" | "usage" | "validation";
49
+ readonly nextMove: string | undefined;
50
+ constructor(message: string, exit: CliError["exit"], nextMove?: string);
51
+ }
52
+ /** Raised when the abort signal fires, so every wait ends the same way. */
53
+ export declare class InterruptedError extends Error {
54
+ constructor();
55
+ }
56
+ /**
57
+ * Waits, and gives up when the signal fires rather than when the timer ends.
58
+ *
59
+ * The whole reason Ctrl-C is a criterion: a poll that only checks its signal
60
+ * between sleeps leaves a person holding a terminal for up to one interval
61
+ * after they asked it to stop, and the published interval here is five seconds.
62
+ */
63
+ export declare function sleepOrAbort(runtime: CliRuntime, milliseconds: number): Promise<void>;
@@ -0,0 +1,32 @@
1
+ /** Raised where the reason is already a sentence a person can act on. */
2
+ export class CliError extends Error {
3
+ exit;
4
+ nextMove;
5
+ constructor(message, exit, nextMove) {
6
+ super(message);
7
+ this.name = "CliError";
8
+ this.exit = exit;
9
+ this.nextMove = nextMove;
10
+ }
11
+ }
12
+ /** Raised when the abort signal fires, so every wait ends the same way. */
13
+ export class InterruptedError extends Error {
14
+ constructor() {
15
+ super("interrupted");
16
+ this.name = "InterruptedError";
17
+ }
18
+ }
19
+ /**
20
+ * Waits, and gives up when the signal fires rather than when the timer ends.
21
+ *
22
+ * The whole reason Ctrl-C is a criterion: a poll that only checks its signal
23
+ * between sleeps leaves a person holding a terminal for up to one interval
24
+ * after they asked it to stop, and the published interval here is five seconds.
25
+ */
26
+ export async function sleepOrAbort(runtime, milliseconds) {
27
+ if (runtime.signal.aborted)
28
+ throw new InterruptedError();
29
+ await runtime.sleep(milliseconds, runtime.signal);
30
+ if (runtime.signal.aborted)
31
+ throw new InterruptedError();
32
+ }
@@ -0,0 +1,66 @@
1
+ import { type DefaultGreatwaitClient } from "@greatwait/api-client";
2
+ import { type CredentialStore } from "./config.js";
3
+ import { type CliRuntime } from "./runtime.js";
4
+ /**
5
+ * Which service this command is talking to, and what it presents to it.
6
+ *
7
+ * **Two questions, resolved separately and both refusable.** Merging them is
8
+ * how a command ends up presenting a staging credential to production: a single
9
+ * "current context" that carried both would be one value to get wrong, and the
10
+ * value that must never be wrong is which origin a secret is sent to. So the
11
+ * origin is chosen first, and the credential is whatever that origin has.
12
+ */
13
+ /** Environment names, stated once so nothing else spells one. */
14
+ export declare const ENVIRONMENT_NAMES: {
15
+ readonly apiKey: "GREATWAIT_API_KEY";
16
+ readonly apiUrl: "GREATWAIT_API_URL";
17
+ };
18
+ export interface ResolvedOrigin {
19
+ readonly origin: string;
20
+ /** Where the origin came from, for `doctor` and for a refusal that explains. */
21
+ readonly source: "argument" | "environment" | "selection" | "sole";
22
+ }
23
+ /**
24
+ * The origin, or a refusal naming every way to give one.
25
+ *
26
+ * **A default only where exactly one choice exists**, which is the project's
27
+ * own CLI invariant. Two stored origins and no selection is not a case with an
28
+ * obvious answer — picking the most recent would be picking production about as
29
+ * often as staging — so it is a question, asked once, rather than a guess made
30
+ * silently on every command.
31
+ */
32
+ export declare function resolveOrigin(runtime: CliRuntime, store: CredentialStore, argumentValue: string | undefined): Promise<ResolvedOrigin>;
33
+ export interface Session {
34
+ readonly client: DefaultGreatwaitClient;
35
+ readonly credentialKind: "api-key" | "environment-key" | "oauth";
36
+ readonly origin: string;
37
+ readonly originSource: ResolvedOrigin["source"];
38
+ }
39
+ export declare function storeFor(runtime: CliRuntime): CredentialStore;
40
+ /** A client that presents no credential, for the reads that need none. */
41
+ export declare function anonymousClient(runtime: CliRuntime, origin: string): DefaultGreatwaitClient;
42
+ /**
43
+ * An authenticated session, refreshing an expired grant on the way.
44
+ *
45
+ * **The rotated pair is written back before the token is used.** A refresh
46
+ * exchange invalidates the refresh token it was given, so a process that
47
+ * refreshed, made its call, and crashed would leave a stored refresh token the
48
+ * service no longer honours — and the person would have to sign in again for a
49
+ * reason nothing on their machine explains. Persisting first costs one write
50
+ * and removes the whole failure.
51
+ */
52
+ export declare function openSession(runtime: CliRuntime, options: {
53
+ readonly apiUrl?: string | undefined;
54
+ }): Promise<Session>;
55
+ /**
56
+ * How long before expiry a grant is refreshed rather than presented.
57
+ *
58
+ * **Published, so a case can name it.** A margin pinned only by "refreshes at
59
+ * ten seconds, does not at an hour" is satisfied by anything between, and a
60
+ * thirty-minute margin would refresh on nearly every command — burning a
61
+ * rotation each time and making the stored pair a race between two terminals.
62
+ *
63
+ * A minute, because that is comfortably longer than any single call this
64
+ * command makes and far shorter than the hour a grant lasts.
65
+ */
66
+ export declare const REFRESH_MARGIN_MS = 60000;
@@ -0,0 +1,157 @@
1
+ import { createGreatwaitClient, } from "@greatwait/api-client";
2
+ import { createCredentialStore, originKey, } from "./config.js";
3
+ import { discover, refresh } from "./oauth.js";
4
+ import { CliError } from "./runtime.js";
5
+ /**
6
+ * Which service this command is talking to, and what it presents to it.
7
+ *
8
+ * **Two questions, resolved separately and both refusable.** Merging them is
9
+ * how a command ends up presenting a staging credential to production: a single
10
+ * "current context" that carried both would be one value to get wrong, and the
11
+ * value that must never be wrong is which origin a secret is sent to. So the
12
+ * origin is chosen first, and the credential is whatever that origin has.
13
+ */
14
+ /** Environment names, stated once so nothing else spells one. */
15
+ export const ENVIRONMENT_NAMES = {
16
+ apiKey: "GREATWAIT_API_KEY",
17
+ apiUrl: "GREATWAIT_API_URL",
18
+ };
19
+ /**
20
+ * The origin, or a refusal naming every way to give one.
21
+ *
22
+ * **A default only where exactly one choice exists**, which is the project's
23
+ * own CLI invariant. Two stored origins and no selection is not a case with an
24
+ * obvious answer — picking the most recent would be picking production about as
25
+ * often as staging — so it is a question, asked once, rather than a guess made
26
+ * silently on every command.
27
+ */
28
+ export async function resolveOrigin(runtime, store, argumentValue) {
29
+ if (argumentValue !== undefined) {
30
+ return { origin: normalize(argumentValue), source: "argument" };
31
+ }
32
+ const fromEnvironment = runtime.env[ENVIRONMENT_NAMES.apiUrl];
33
+ if (fromEnvironment !== undefined && fromEnvironment !== "") {
34
+ return { origin: normalize(fromEnvironment), source: "environment" };
35
+ }
36
+ const selected = await store.readSelection();
37
+ if (selected !== undefined) {
38
+ return { origin: selected, source: "selection" };
39
+ }
40
+ const known = await store.origins();
41
+ if (known.length === 1) {
42
+ return { origin: known[0], source: "sole" };
43
+ }
44
+ if (known.length === 0) {
45
+ throw new CliError("no Greatwait service has been chosen", "usage", `Sign in first — greatwait login --api-url https://api.greatwait.dev — or set ${ENVIRONMENT_NAMES.apiUrl}.`);
46
+ }
47
+ throw new CliError(`${String(known.length)} services are signed in and none is selected`, "usage", `Choose one — greatwait context use ${known[0]} — or pass --api-url on this command.`);
48
+ }
49
+ function normalize(value) {
50
+ try {
51
+ return originKey(value);
52
+ }
53
+ catch {
54
+ throw new CliError(`${value} is not an https origin`, "usage", "Give the API's origin, such as https://api.greatwait.dev.");
55
+ }
56
+ }
57
+ export function storeFor(runtime) {
58
+ return createCredentialStore({
59
+ env: runtime.env,
60
+ home: runtime.home,
61
+ platform: runtime.platform,
62
+ uid: runtime.uid,
63
+ });
64
+ }
65
+ /** A client that presents no credential, for the reads that need none. */
66
+ export function anonymousClient(runtime, origin) {
67
+ return createGreatwaitClient({
68
+ baseUrl: origin,
69
+ fetch: runtime.fetch,
70
+ });
71
+ }
72
+ /**
73
+ * An authenticated session, refreshing an expired grant on the way.
74
+ *
75
+ * **The rotated pair is written back before the token is used.** A refresh
76
+ * exchange invalidates the refresh token it was given, so a process that
77
+ * refreshed, made its call, and crashed would leave a stored refresh token the
78
+ * service no longer honours — and the person would have to sign in again for a
79
+ * reason nothing on their machine explains. Persisting first costs one write
80
+ * and removes the whole failure.
81
+ */
82
+ export async function openSession(runtime, options) {
83
+ const store = storeFor(runtime);
84
+ const resolved = await resolveOrigin(runtime, store, options.apiUrl);
85
+ // **The environment wins, and only for an API key.** Non-interactive
86
+ // automation is the case this exists for, and the journey's own variable is
87
+ // the one it already exports. A stored OAuth grant is a person's, and a cron
88
+ // job that silently used one would be acting as them.
89
+ const fromEnvironment = runtime.env[ENVIRONMENT_NAMES.apiKey];
90
+ if (fromEnvironment !== undefined && fromEnvironment !== "") {
91
+ return {
92
+ client: createGreatwaitClient({
93
+ baseUrl: resolved.origin,
94
+ credential: fromEnvironment,
95
+ fetch: runtime.fetch,
96
+ }),
97
+ credentialKind: "environment-key",
98
+ origin: resolved.origin,
99
+ originSource: resolved.source,
100
+ };
101
+ }
102
+ const stored = await store.read(resolved.origin);
103
+ if (stored === undefined) {
104
+ throw new CliError(`no credential is stored for ${resolved.origin}`, "authentication", `Sign in — greatwait login --api-url ${resolved.origin} — or export ${ENVIRONMENT_NAMES.apiKey} for a non-interactive run.`);
105
+ }
106
+ const credential = await presentableSecret(runtime, store, resolved.origin, stored);
107
+ return {
108
+ client: createGreatwaitClient({
109
+ baseUrl: resolved.origin,
110
+ credential,
111
+ fetch: runtime.fetch,
112
+ }),
113
+ credentialKind: stored.kind,
114
+ origin: resolved.origin,
115
+ originSource: resolved.source,
116
+ };
117
+ }
118
+ /**
119
+ * How long before expiry a grant is refreshed rather than presented.
120
+ *
121
+ * **Published, so a case can name it.** A margin pinned only by "refreshes at
122
+ * ten seconds, does not at an hour" is satisfied by anything between, and a
123
+ * thirty-minute margin would refresh on nearly every command — burning a
124
+ * rotation each time and making the stored pair a race between two terminals.
125
+ *
126
+ * A minute, because that is comfortably longer than any single call this
127
+ * command makes and far shorter than the hour a grant lasts.
128
+ */
129
+ export const REFRESH_MARGIN_MS = 60_000;
130
+ async function presentableSecret(runtime, store, origin, stored) {
131
+ if (stored.kind === "api-key") {
132
+ if (stored.secret === undefined) {
133
+ throw new CliError(`the stored credential for ${origin} carries no secret`, "authentication", `Sign in again: greatwait login --api-url ${origin} --api-key`);
134
+ }
135
+ return stored.secret;
136
+ }
137
+ const { accessToken, clientId, expiresAtMs, refreshToken } = stored;
138
+ if (accessToken === undefined || clientId === undefined) {
139
+ throw new CliError(`the stored grant for ${origin} is incomplete`, "authentication", `Sign in again: greatwait login --api-url ${origin}`);
140
+ }
141
+ if (expiresAtMs === undefined ||
142
+ expiresAtMs - REFRESH_MARGIN_MS > runtime.now() ||
143
+ refreshToken === undefined) {
144
+ return accessToken;
145
+ }
146
+ const server = await discover(runtime, origin);
147
+ const rotated = await refresh(runtime, server, { clientId, refreshToken });
148
+ await store.write(origin, {
149
+ accessToken: rotated.accessToken,
150
+ clientId,
151
+ expiresAtMs: runtime.now() + rotated.expiresInSeconds * 1000,
152
+ kind: "oauth",
153
+ refreshToken: rotated.refreshToken,
154
+ scope: rotated.scope,
155
+ });
156
+ return rotated.accessToken;
157
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "bin": {
3
+ "greatwait": "./dist/main.js"
4
+ },
5
+ "bugs": {
6
+ "url": "https://github.com/flowxo/greatwait/issues"
7
+ },
8
+ "dependencies": {
9
+ "@greatwait/api-client": "0.1.0-alpha.3",
10
+ "@greatwait/contracts": "0.1.0-alpha.3"
11
+ },
12
+ "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
+ "engines": {
14
+ "node": ">=22.13.0"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "LICENSE",
26
+ "README.md",
27
+ "dist"
28
+ ],
29
+ "homepage": "https://greatwait.dev",
30
+ "keywords": [
31
+ "greatwait",
32
+ "scheduling",
33
+ "webhooks",
34
+ "cli",
35
+ "command-line"
36
+ ],
37
+ "license": "MIT",
38
+ "name": "@greatwait/cli",
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "tag": "latest"
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/flowxo/greatwait.git",
46
+ "directory": "packages/cli"
47
+ },
48
+ "sideEffects": false,
49
+ "type": "module",
50
+ "version": "0.1.0-alpha.3"
51
+ }