@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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { isRetryable, type Problem, type ProblemCode } from "@greatwait/contracts";
|
|
2
|
+
/**
|
|
3
|
+
* What to do about each refusal the service publishes.
|
|
4
|
+
*
|
|
5
|
+
* **One entry per public problem code, and the type makes it exhaustive.** A
|
|
6
|
+
* code added to the contract stops this file compiling until somebody says what
|
|
7
|
+
* a person should do about it, which is the only way a promise like "every
|
|
8
|
+
* problem code maps to a remediation" stays true past the week it was written.
|
|
9
|
+
*
|
|
10
|
+
* These are sentences for a person and they are deliberately *not* in the
|
|
11
|
+
* `--json` document: a machine branches on the code, which is published and
|
|
12
|
+
* stable, and a sentence rewritten because it read badly must not be a change
|
|
13
|
+
* to a versioned contract.
|
|
14
|
+
*
|
|
15
|
+
* The advice names a command wherever there is one to run. "Check your
|
|
16
|
+
* permissions" is not advice; `greatwait credentials list` is.
|
|
17
|
+
*/
|
|
18
|
+
declare function remediationTable(programName: string): Readonly<Record<ProblemCode, string>>;
|
|
19
|
+
/**
|
|
20
|
+
* The next move for a refusal, or nothing when there is nothing useful to say.
|
|
21
|
+
*
|
|
22
|
+
* A problem the client could not parse — an edge proxy's `503` carries no
|
|
23
|
+
* document at all — still gets the retryable sentence, because the status alone
|
|
24
|
+
* is enough to say whether repeating is safe.
|
|
25
|
+
*/
|
|
26
|
+
export declare function remediationFor(problem: Problem | undefined, retryable: boolean, programName: string): string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* The table under the canonical name, for the cases that read its sentences.
|
|
29
|
+
*
|
|
30
|
+
* A case asserting what the advice *says* wants one spelling; a case asserting
|
|
31
|
+
* that it follows the invoked name passes its own.
|
|
32
|
+
*/
|
|
33
|
+
export declare const REMEDIATION: Readonly<Record<"invalid_request" | "not_found" | "state_conflict" | "version_conflict" | "authentication_required" | "billing_not_ready" | "capability_not_configured" | "credential_expired" | "credential_revoked" | "idempotency_key_reused" | "payload_too_large" | "plan_limit_reached" | "project_archived" | "rate_limited" | "revision_conflict" | "scope_missing" | "service_unavailable" | "test_to_live_escalation", string>>;
|
|
34
|
+
/** Exposed so a case can assert the records cover the published vocabularies. */
|
|
35
|
+
export { isRetryable, remediationTable };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { isRetryable, } from "@greatwait/contracts";
|
|
2
|
+
import { CANONICAL_PROGRAM_NAME } from "./catalog.js";
|
|
3
|
+
/**
|
|
4
|
+
* What to do about each refusal the service publishes.
|
|
5
|
+
*
|
|
6
|
+
* **One entry per public problem code, and the type makes it exhaustive.** A
|
|
7
|
+
* code added to the contract stops this file compiling until somebody says what
|
|
8
|
+
* a person should do about it, which is the only way a promise like "every
|
|
9
|
+
* problem code maps to a remediation" stays true past the week it was written.
|
|
10
|
+
*
|
|
11
|
+
* These are sentences for a person and they are deliberately *not* in the
|
|
12
|
+
* `--json` document: a machine branches on the code, which is published and
|
|
13
|
+
* stable, and a sentence rewritten because it read badly must not be a change
|
|
14
|
+
* to a versioned contract.
|
|
15
|
+
*
|
|
16
|
+
* The advice names a command wherever there is one to run. "Check your
|
|
17
|
+
* permissions" is not advice; `greatwait credentials list` is.
|
|
18
|
+
*/
|
|
19
|
+
function remediationTable(programName) {
|
|
20
|
+
return {
|
|
21
|
+
authentication_required: `Sign in: ${programName} login`,
|
|
22
|
+
// Nothing to fix and nothing to do but wait a moment — so the advice says
|
|
23
|
+
// that, rather than sending somebody to look for a fault that is not there.
|
|
24
|
+
billing_not_ready: "Billing for this organization is still being set up. It finishes on its own within a minute; run the command again then. Nothing has been charged and no schedule is affected.",
|
|
25
|
+
// **Not "try again", because trying again is exactly what does not work.**
|
|
26
|
+
// The distinction from `service_unavailable` below is the whole of
|
|
27
|
+
// FXO-2497's second limb: both are 503, and only one of them clears by
|
|
28
|
+
// itself.
|
|
29
|
+
capability_not_configured: "This service is not configured for that. Repeating the command will not change it — somebody with access to the deployment has to turn the capability on. The detail above names which one.",
|
|
30
|
+
credential_expired: `This credential has passed its expiry. Sign in again — ${programName} login — or rotate the one your automation presents: ${programName} credentials rotate <credential>`,
|
|
31
|
+
credential_revoked: `This credential has been revoked and will not work again. Sign in again: ${programName} login`,
|
|
32
|
+
idempotency_key_reused: "This key was used for a different request. Use a new --idempotency-key, or repeat the original request exactly to get the original answer back.",
|
|
33
|
+
invalid_request: "The service refused the request itself. The detail above names the field.",
|
|
34
|
+
not_found: `Nothing here answers to that identifier — check the identifier, and check ${programName} whoami is the project and environment you meant.`,
|
|
35
|
+
payload_too_large: "The payload is over the published cap. Send a reference to the data rather than the data.",
|
|
36
|
+
// **Which ceiling is in the detail, and not every ceiling is occurrences.**
|
|
37
|
+
// This named `greatwait usage` alone, which reports terminal occurrences by
|
|
38
|
+
// day — so for the two ceilings that send no detail at all, the endpoint
|
|
39
|
+
// count and the test-sink count, the only guidance a person got pointed at a
|
|
40
|
+
// report that cannot show what is full.
|
|
41
|
+
plan_limit_reached: `A platform ceiling stopped this. Which one is in the detail above where the service sends one: ${programName} usage counts terminal occurrences, ${programName} destinations list and ${programName} sinks list count the objects.`,
|
|
42
|
+
project_archived: "This project is archived. Restore it from Settings > General before acting on its resources.",
|
|
43
|
+
rate_limited: "Too many requests for now. The identical request is safe to repeat after the wait the service asked for.",
|
|
44
|
+
revision_conflict: "The billing budget changed since the revision you read. Read billing again and retry with the current --expected-revision.",
|
|
45
|
+
scope_missing: "This credential does not carry the permission this call needs. Create one that does, rather than widening this one.",
|
|
46
|
+
service_unavailable: "The service could not answer. The identical request is safe to repeat.",
|
|
47
|
+
// **No command name, because this refusal is not about one resource.** It
|
|
48
|
+
// named `greatwait schedules read`, and the same code is raised for a
|
|
49
|
+
// disabled destination and for a test sink an endpoint already has — where
|
|
50
|
+
// that sentence sends somebody to the wrong object.
|
|
51
|
+
//
|
|
52
|
+
// It does not point at the detail either, which the first correction did:
|
|
53
|
+
// two of the raise sites — update and rotate on a *disabled*
|
|
54
|
+
// endpoint — send a title and no detail at all, so "the detail above names
|
|
55
|
+
// the state" would have described a line that was never printed.
|
|
56
|
+
state_conflict: "The object is in a state that does not allow this. Read it back and act on the state it is in.",
|
|
57
|
+
test_to_live_escalation: "This credential acts in the test environment and the request named the live one. Use a live credential to act in live.",
|
|
58
|
+
version_conflict: "Somebody else changed this since the version you read. Read it again, take the new ETag, and pass it as --if-match.",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The next move for a refusal, or nothing when there is nothing useful to say.
|
|
63
|
+
*
|
|
64
|
+
* A problem the client could not parse — an edge proxy's `503` carries no
|
|
65
|
+
* document at all — still gets the retryable sentence, because the status alone
|
|
66
|
+
* is enough to say whether repeating is safe.
|
|
67
|
+
*/
|
|
68
|
+
export function remediationFor(problem, retryable, programName) {
|
|
69
|
+
if (problem !== undefined)
|
|
70
|
+
return remediationTable(programName)[problem.code];
|
|
71
|
+
return retryable ? "The identical request is safe to repeat." : undefined;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The table under the canonical name, for the cases that read its sentences.
|
|
75
|
+
*
|
|
76
|
+
* A case asserting what the advice *says* wants one spelling; a case asserting
|
|
77
|
+
* that it follows the invoked name passes its own.
|
|
78
|
+
*/
|
|
79
|
+
export const REMEDIATION = remediationTable(CANONICAL_PROGRAM_NAME);
|
|
80
|
+
/** Exposed so a case can assert the records cover the published vocabularies. */
|
|
81
|
+
export { isRetryable, remediationTable };
|
package/dist/runtime.d.ts
CHANGED
|
@@ -23,6 +23,26 @@ export interface CliRuntime {
|
|
|
23
23
|
/** Hands a URL to the platform's browser. Rejects when there is none. */
|
|
24
24
|
readonly openBrowser: (url: string) => Promise<void>;
|
|
25
25
|
readonly platform: string;
|
|
26
|
+
/**
|
|
27
|
+
* The name this executable was invoked as — `greatwait`, or `gw`.
|
|
28
|
+
*
|
|
29
|
+
* **Every sentence written for a person uses it.** A command line installed
|
|
30
|
+
* under a short name and answering "Next: greatwait attempts list …" hands
|
|
31
|
+
* back exactly the typing the short name exists to save, and sends somebody
|
|
32
|
+
* to a command they may not have on their PATH at all.
|
|
33
|
+
*
|
|
34
|
+
* Not derived from `process.argv[1]`, which is the resolved module path
|
|
35
|
+
* behind npm's symlinked shim. The entry point states it.
|
|
36
|
+
*/
|
|
37
|
+
readonly programName: string;
|
|
38
|
+
/**
|
|
39
|
+
* Reads one line, echoing it. Only ever used for a confirmation.
|
|
40
|
+
*
|
|
41
|
+
* Separate from `promptSecret` because the two have opposite obligations:
|
|
42
|
+
* one must never show what is typed and this one must, so somebody can see
|
|
43
|
+
* they answered the question that was asked.
|
|
44
|
+
*/
|
|
45
|
+
readonly promptLine: (question: string) => Promise<string>;
|
|
26
46
|
/** Reads one line without echoing it. Only ever used for a secret. */
|
|
27
47
|
readonly promptSecret: (question: string) => Promise<string>;
|
|
28
48
|
readonly randomBytes: (length: number) => Uint8Array;
|
|
@@ -45,7 +65,7 @@ export interface LoopbackListener {
|
|
|
45
65
|
}
|
|
46
66
|
/** Raised where the reason is already a sentence a person can act on. */
|
|
47
67
|
export declare class CliError extends Error {
|
|
48
|
-
readonly exit: "authentication" | "authorization" | "internal" | "retryable" | "timeout" | "usage" | "validation";
|
|
68
|
+
readonly exit: "authentication" | "authorization" | "internal" | "interrupted" | "retryable" | "timeout" | "usage" | "validation";
|
|
49
69
|
readonly nextMove: string | undefined;
|
|
50
70
|
constructor(message: string, exit: CliError["exit"], nextMove?: string);
|
|
51
71
|
}
|
package/dist/session.js
CHANGED
|
@@ -42,9 +42,9 @@ export async function resolveOrigin(runtime, store, argumentValue) {
|
|
|
42
42
|
return { origin: known[0], source: "sole" };
|
|
43
43
|
}
|
|
44
44
|
if (known.length === 0) {
|
|
45
|
-
throw new CliError("no
|
|
45
|
+
throw new CliError("no GreatWait service has been chosen", "usage", `Sign in first — ${runtime.programName} login --api-url https://api.greatwait.dev — or set ${ENVIRONMENT_NAMES.apiUrl}.`);
|
|
46
46
|
}
|
|
47
|
-
throw new CliError(`${String(known.length)} services are signed in and none is selected`, "usage", `Choose one —
|
|
47
|
+
throw new CliError(`${String(known.length)} services are signed in and none is selected`, "usage", `Choose one — ${runtime.programName} context use ${known[0]} — or pass --api-url on this command.`);
|
|
48
48
|
}
|
|
49
49
|
function normalize(value) {
|
|
50
50
|
try {
|
|
@@ -101,7 +101,7 @@ export async function openSession(runtime, options) {
|
|
|
101
101
|
}
|
|
102
102
|
const stored = await store.read(resolved.origin);
|
|
103
103
|
if (stored === undefined) {
|
|
104
|
-
throw new CliError(`no credential is stored for ${resolved.origin}`, "authentication", `Sign in —
|
|
104
|
+
throw new CliError(`no credential is stored for ${resolved.origin}`, "authentication", `Sign in — ${runtime.programName} login --api-url ${resolved.origin} — or export ${ENVIRONMENT_NAMES.apiKey} for a non-interactive run.`);
|
|
105
105
|
}
|
|
106
106
|
const credential = await presentableSecret(runtime, store, resolved.origin, stored);
|
|
107
107
|
return {
|
|
@@ -130,13 +130,13 @@ export const REFRESH_MARGIN_MS = 60_000;
|
|
|
130
130
|
async function presentableSecret(runtime, store, origin, stored) {
|
|
131
131
|
if (stored.kind === "api-key") {
|
|
132
132
|
if (stored.secret === undefined) {
|
|
133
|
-
throw new CliError(`the stored credential for ${origin} carries no secret`, "authentication", `Sign in again:
|
|
133
|
+
throw new CliError(`the stored credential for ${origin} carries no secret`, "authentication", `Sign in again: ${runtime.programName} login --api-url ${origin} --api-key`);
|
|
134
134
|
}
|
|
135
135
|
return stored.secret;
|
|
136
136
|
}
|
|
137
137
|
const { accessToken, clientId, expiresAtMs, refreshToken } = stored;
|
|
138
138
|
if (accessToken === undefined || clientId === undefined) {
|
|
139
|
-
throw new CliError(`the stored grant for ${origin} is incomplete`, "authentication", `Sign in again:
|
|
139
|
+
throw new CliError(`the stored grant for ${origin} is incomplete`, "authentication", `Sign in again: ${runtime.programName} login --api-url ${origin}`);
|
|
140
140
|
}
|
|
141
141
|
if (expiresAtMs === undefined ||
|
|
142
142
|
expiresAtMs - REFRESH_MARGIN_MS > runtime.now() ||
|
package/dist/start.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
export declare function start(programName: string): Promise<void>;
|
package/dist/start.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { run } from "./cli.js";
|
|
2
|
+
import { nodeRuntime } from "./node-runtime.js";
|
|
3
|
+
/**
|
|
4
|
+
* The body both installed executables share.
|
|
5
|
+
*
|
|
6
|
+
* **Two entry files rather than one that works out what it was called.**
|
|
7
|
+
* `process.argv[1]` is the *resolved* path of the main module, and npm links a
|
|
8
|
+
* bin as a symlink into `node_modules/.bin`, so a program deriving its own name
|
|
9
|
+
* from argv would read `main.js` — and the version that guesses correctly on
|
|
10
|
+
* macOS is the version that guesses differently under a shell wrapper, on
|
|
11
|
+
* Windows, or with `--preserve-symlinks-main`. The name is a property of the
|
|
12
|
+
* entry point, so the entry point states it.
|
|
13
|
+
*
|
|
14
|
+
* `process.exitCode` rather than `process.exit`: the second truncates whatever
|
|
15
|
+
* is still buffered on stdout, and this command's last act is to print.
|
|
16
|
+
*/
|
|
17
|
+
export async function start(programName) {
|
|
18
|
+
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));
|
|
26
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { type ParsedArguments } from "./args.js";
|
|
2
|
+
import { type CliRuntime } from "./runtime.js";
|
|
3
|
+
/**
|
|
4
|
+
* What every command needs and no command should spell twice.
|
|
5
|
+
*
|
|
6
|
+
* Extracted when the command surface grew past first value: the alternative was
|
|
7
|
+
* `commands.ts` importing `operate.ts` and back again for the same six
|
|
8
|
+
* helpers, and a positional-argument refusal written slightly differently in
|
|
9
|
+
* thirty places.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* A response, validated against the shape the contract publishes for it.
|
|
13
|
+
*
|
|
14
|
+
* **A missing schema raises rather than yielding `undefined`.** The first
|
|
15
|
+
* spelling of this was `responseSchemaFor(id)?.parse(body) as T`, which turns
|
|
16
|
+
* an operation the registry does not describe into a successfully parsed
|
|
17
|
+
* `undefined` — and then into a `TypeError` several lines later, in the
|
|
18
|
+
* rendering, naming a property rather than the operation. There is no case
|
|
19
|
+
* where a command should carry on without knowing what it received.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseResponse<TView>(operation: string, body: unknown): TView;
|
|
22
|
+
export declare function apiUrlOption(args: ParsedArguments): string | undefined;
|
|
23
|
+
/** The program name a usage line opens with. */
|
|
24
|
+
export declare function programNameFrom(usage: string): string;
|
|
25
|
+
export declare function expectNoExtraArguments(args: ParsedArguments, used: number, usage: string): void;
|
|
26
|
+
/**
|
|
27
|
+
* The positional arguments a command needs, or a refusal naming the shape.
|
|
28
|
+
*
|
|
29
|
+
* Reads them all at once so a command missing its second of three says so
|
|
30
|
+
* rather than refusing the first thing it happens to check, and so the
|
|
31
|
+
* "unexpected argument" rule is applied from the same count.
|
|
32
|
+
*/
|
|
33
|
+
export declare function positionals(args: ParsedArguments, names: readonly string[], usage: string): readonly string[];
|
|
34
|
+
/** The payload, from an argument, a file, or standard input. */
|
|
35
|
+
export declare function readJsonOption(runtime: CliRuntime, flag: string, value: string | undefined): Promise<unknown>;
|
|
36
|
+
/**
|
|
37
|
+
* An instant, in the exact spelling the contract publishes.
|
|
38
|
+
*
|
|
39
|
+
* **Refused here rather than by the service, and normalised on the way.**
|
|
40
|
+
* `--in` was already read before anything was sent and `--at` was not, so
|
|
41
|
+
* `--at "next tuesday"` cost a round trip and came back as `invalid_request` —
|
|
42
|
+
* which calls a mistake in the command line a fault in the request. And the
|
|
43
|
+
* ordinary ISO spelling somebody types, `2030-01-01T00:00:00Z`, is refused by
|
|
44
|
+
* the contract for having no milliseconds; that is a real instant written a
|
|
45
|
+
* normal way, so it is formatted rather than rejected. Nothing is guessed: a
|
|
46
|
+
* value `Date` cannot read is refused, and the refusal says what a good one
|
|
47
|
+
* looks like.
|
|
48
|
+
*/
|
|
49
|
+
export declare function parseInstant(value: string, flag?: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* A whole number an option was given, refused here rather than by the service.
|
|
52
|
+
*
|
|
53
|
+
* The contract's own bounds, so `--limit 500` says what the maximum is instead
|
|
54
|
+
* of spending a round trip to be told `invalid_request`.
|
|
55
|
+
*/
|
|
56
|
+
export declare function parseCount(value: string, flag: string, bounds: {
|
|
57
|
+
readonly max: number;
|
|
58
|
+
readonly min: number;
|
|
59
|
+
}): number;
|
|
60
|
+
/**
|
|
61
|
+
* How many pages `--all` will follow before it stops and says so.
|
|
62
|
+
*
|
|
63
|
+
* A bounded invocation holds at most fifty response pages in memory. Its
|
|
64
|
+
* cursor can be used with `--all` again, so this limits one invocation's
|
|
65
|
+
* allocation and request burst without preventing access to later records.
|
|
66
|
+
*/
|
|
67
|
+
export declare const ALL_PAGES_CEILING = 50;
|
|
68
|
+
export interface PageRequest {
|
|
69
|
+
readonly cursor?: string;
|
|
70
|
+
readonly limit?: number;
|
|
71
|
+
}
|
|
72
|
+
/** The page arguments, validated against the contract's own bounds. */
|
|
73
|
+
export declare function pageRequest(args: ParsedArguments): PageRequest;
|
|
74
|
+
export interface Page<TRow> {
|
|
75
|
+
readonly data: readonly TRow[];
|
|
76
|
+
readonly freshness: Freshness;
|
|
77
|
+
readonly nextCursor?: string;
|
|
78
|
+
}
|
|
79
|
+
export type Freshness = {
|
|
80
|
+
readonly kind: "authoritative";
|
|
81
|
+
} | {
|
|
82
|
+
readonly asOf: string;
|
|
83
|
+
readonly kind: "projected";
|
|
84
|
+
readonly lagSeconds: number;
|
|
85
|
+
};
|
|
86
|
+
export interface DrainedPages<TRow> {
|
|
87
|
+
readonly ceilingReached: boolean;
|
|
88
|
+
readonly freshness: Freshness;
|
|
89
|
+
readonly nextCursor: string | undefined;
|
|
90
|
+
readonly pages: number;
|
|
91
|
+
readonly rows: readonly TRow[];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* One page, or every page, without either being a different command.
|
|
95
|
+
*
|
|
96
|
+
* The freshness reported is the *stalest* page seen rather than the last one:
|
|
97
|
+
* a listing assembled from several projected pages is only as fresh as its
|
|
98
|
+
* worst, and reporting the final page's age would describe a collection as
|
|
99
|
+
* current on the strength of the part that happened to be read last.
|
|
100
|
+
*/
|
|
101
|
+
export declare function drainPages<TRow>(runtime: CliRuntime, args: ParsedArguments, request: PageRequest, read: (request: PageRequest) => Promise<Page<TRow>>): Promise<DrainedPages<TRow>>;
|
|
102
|
+
/** The freshness of an answer, in a sentence, or nothing to say. */
|
|
103
|
+
export declare function freshnessNote(freshness: Freshness): string | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* What to say about a listing that stopped before the collection did.
|
|
106
|
+
*
|
|
107
|
+
* The cursor is handed back rather than swallowed: it is the only way to
|
|
108
|
+
* continue, and a command that knew where it stopped and did not say would
|
|
109
|
+
* make the ceiling indistinguishable from the end of the data.
|
|
110
|
+
*/
|
|
111
|
+
export declare function continuationNote(drained: DrainedPages<unknown>, command: string): string | undefined;
|
|
112
|
+
/**
|
|
113
|
+
* `If-Match`, from the ETag a read handed out.
|
|
114
|
+
*
|
|
115
|
+
* The header rather than a version number, because that is what the service
|
|
116
|
+
* publishes and what `schedules read` prints. Turning one into the other here
|
|
117
|
+
* would make this command the only place that knows the mapping.
|
|
118
|
+
*/
|
|
119
|
+
export declare function conditionalHeaders(args: ParsedArguments): Readonly<Record<string, string>>;
|
|
120
|
+
/**
|
|
121
|
+
* A column-aligned table, or a sentence when there is nothing in it.
|
|
122
|
+
*
|
|
123
|
+
* **Identifiers are present and are not the first thing read.** The story asks
|
|
124
|
+
* for output where identifiers are available without dominating it, and the
|
|
125
|
+
* answer is ordinary column alignment: the state somebody is looking for lines
|
|
126
|
+
* up down the left, and the identifier they will paste into the next command is
|
|
127
|
+
* where they can select it.
|
|
128
|
+
*/
|
|
129
|
+
export declare function table(columns: readonly string[], rows: readonly (readonly string[])[], empty: string): string;
|
package/dist/support.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { responseSchemaFor } from "@greatwait/contracts";
|
|
2
|
+
import { CANONICAL_PROGRAM_NAME } from "./catalog.js";
|
|
3
|
+
import { PAGE_SIZE } from "@greatwait/contracts/protocol";
|
|
4
|
+
import { secretArgumentAdvice } from "./args.js";
|
|
5
|
+
import { CliError } from "./runtime.js";
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
/**
|
|
8
|
+
* What every command needs and no command should spell twice.
|
|
9
|
+
*
|
|
10
|
+
* Extracted when the command surface grew past first value: the alternative was
|
|
11
|
+
* `commands.ts` importing `operate.ts` and back again for the same six
|
|
12
|
+
* helpers, and a positional-argument refusal written slightly differently in
|
|
13
|
+
* thirty places.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* A response, validated against the shape the contract publishes for it.
|
|
17
|
+
*
|
|
18
|
+
* **A missing schema raises rather than yielding `undefined`.** The first
|
|
19
|
+
* spelling of this was `responseSchemaFor(id)?.parse(body) as T`, which turns
|
|
20
|
+
* an operation the registry does not describe into a successfully parsed
|
|
21
|
+
* `undefined` — and then into a `TypeError` several lines later, in the
|
|
22
|
+
* rendering, naming a property rather than the operation. There is no case
|
|
23
|
+
* where a command should carry on without knowing what it received.
|
|
24
|
+
*/
|
|
25
|
+
export function parseResponse(operation, body) {
|
|
26
|
+
const schema = responseSchemaFor(operation);
|
|
27
|
+
if (schema === undefined) {
|
|
28
|
+
throw new CliError(`the contract publishes no response shape for ${operation}`, "internal");
|
|
29
|
+
}
|
|
30
|
+
return schema.parse(body);
|
|
31
|
+
}
|
|
32
|
+
export function apiUrlOption(args) {
|
|
33
|
+
return args.options["--api-url"];
|
|
34
|
+
}
|
|
35
|
+
/** The program name a usage line opens with. */
|
|
36
|
+
export function programNameFrom(usage) {
|
|
37
|
+
return usage.split(" ")[0] ?? CANONICAL_PROGRAM_NAME;
|
|
38
|
+
}
|
|
39
|
+
export 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
|
+
// **The name this was invoked as, taken from the usage line.** Every caller
|
|
51
|
+
// builds that line starting with it — the same string the refusal below
|
|
52
|
+
// prints — so there is one source rather than a second parameter threaded
|
|
53
|
+
// through thirty call sites, and a case pins the derivation.
|
|
54
|
+
throw new CliError("unexpected argument after --api-key", "usage", secretArgumentAdvice(programNameFrom(usage)));
|
|
55
|
+
}
|
|
56
|
+
throw new CliError(`unexpected argument ${args.command[used]}`, "usage", `Usage: ${usage}`);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The positional arguments a command needs, or a refusal naming the shape.
|
|
60
|
+
*
|
|
61
|
+
* Reads them all at once so a command missing its second of three says so
|
|
62
|
+
* rather than refusing the first thing it happens to check, and so the
|
|
63
|
+
* "unexpected argument" rule is applied from the same count.
|
|
64
|
+
*/
|
|
65
|
+
export function positionals(args, names, usage) {
|
|
66
|
+
const found = names.map((_, index) => args.command[index]);
|
|
67
|
+
const missing = names.filter((_, index) => found[index] === undefined);
|
|
68
|
+
if (missing.length > 0) {
|
|
69
|
+
// The command words alone, without the argument shapes: the usage line
|
|
70
|
+
// repeats them a line later, and "greatwait attempts read <schedule>
|
|
71
|
+
// <occurrence> <attempt> needs <occurrence> and <attempt>" reads as though
|
|
72
|
+
// the arguments were given and rejected.
|
|
73
|
+
const words = (usage.split(/\s[<[]/u)[0] ?? usage).trim();
|
|
74
|
+
throw new CliError(`${words} needs ${missing.join(" and ")}`, "usage", `Usage: ${usage}`);
|
|
75
|
+
}
|
|
76
|
+
expectNoExtraArguments(args, names.length, usage);
|
|
77
|
+
return found;
|
|
78
|
+
}
|
|
79
|
+
/** The payload, from an argument, a file, or standard input. */
|
|
80
|
+
export async function readJsonOption(runtime, flag, value) {
|
|
81
|
+
if (value === undefined)
|
|
82
|
+
return undefined;
|
|
83
|
+
const text = value === "-"
|
|
84
|
+
? await runtime.readStdin()
|
|
85
|
+
: value.startsWith("@")
|
|
86
|
+
? await readFile(value.slice(1), "utf8")
|
|
87
|
+
: value;
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(text);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new CliError(`${flag} is not JSON`, "usage", `Pass a JSON object, @a-file.json, or - to read it from standard input.`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* An instant, in the exact spelling the contract publishes.
|
|
97
|
+
*
|
|
98
|
+
* **Refused here rather than by the service, and normalised on the way.**
|
|
99
|
+
* `--in` was already read before anything was sent and `--at` was not, so
|
|
100
|
+
* `--at "next tuesday"` cost a round trip and came back as `invalid_request` —
|
|
101
|
+
* which calls a mistake in the command line a fault in the request. And the
|
|
102
|
+
* ordinary ISO spelling somebody types, `2030-01-01T00:00:00Z`, is refused by
|
|
103
|
+
* the contract for having no milliseconds; that is a real instant written a
|
|
104
|
+
* normal way, so it is formatted rather than rejected. Nothing is guessed: a
|
|
105
|
+
* value `Date` cannot read is refused, and the refusal says what a good one
|
|
106
|
+
* looks like.
|
|
107
|
+
*/
|
|
108
|
+
export function parseInstant(value, flag = "--at") {
|
|
109
|
+
const parsed = Date.parse(value);
|
|
110
|
+
if (Number.isNaN(parsed)) {
|
|
111
|
+
throw new CliError(`${value} is not an instant`, "usage", `Write ${flag} as UTC, such as 2030-01-01T09:00:00.000Z.`);
|
|
112
|
+
}
|
|
113
|
+
return new Date(parsed).toISOString();
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* A whole number an option was given, refused here rather than by the service.
|
|
117
|
+
*
|
|
118
|
+
* The contract's own bounds, so `--limit 500` says what the maximum is instead
|
|
119
|
+
* of spending a round trip to be told `invalid_request`.
|
|
120
|
+
*/
|
|
121
|
+
export function parseCount(value, flag, bounds) {
|
|
122
|
+
if (!/^\d+$/u.test(value)) {
|
|
123
|
+
throw new CliError(`${flag} must be a whole number`, "usage");
|
|
124
|
+
}
|
|
125
|
+
const count = Number(value);
|
|
126
|
+
if (count < bounds.min || count > bounds.max) {
|
|
127
|
+
throw new CliError(`${flag} must be between ${String(bounds.min)} and ${String(bounds.max)}`, "usage");
|
|
128
|
+
}
|
|
129
|
+
return count;
|
|
130
|
+
}
|
|
131
|
+
/* -------------------------------------------------------------------------- */
|
|
132
|
+
/* Pagination */
|
|
133
|
+
/* -------------------------------------------------------------------------- */
|
|
134
|
+
/**
|
|
135
|
+
* How many pages `--all` will follow before it stops and says so.
|
|
136
|
+
*
|
|
137
|
+
* A bounded invocation holds at most fifty response pages in memory. Its
|
|
138
|
+
* cursor can be used with `--all` again, so this limits one invocation's
|
|
139
|
+
* allocation and request burst without preventing access to later records.
|
|
140
|
+
*/
|
|
141
|
+
export const ALL_PAGES_CEILING = 50;
|
|
142
|
+
/** The page arguments, validated against the contract's own bounds. */
|
|
143
|
+
export function pageRequest(args) {
|
|
144
|
+
const limit = args.options["--limit"];
|
|
145
|
+
const cursor = args.options["--cursor"];
|
|
146
|
+
return {
|
|
147
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
148
|
+
...(limit === undefined
|
|
149
|
+
? {}
|
|
150
|
+
: { limit: parseCount(limit, "--limit", PAGE_SIZE) }),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* One page, or every page, without either being a different command.
|
|
155
|
+
*
|
|
156
|
+
* The freshness reported is the *stalest* page seen rather than the last one:
|
|
157
|
+
* a listing assembled from several projected pages is only as fresh as its
|
|
158
|
+
* worst, and reporting the final page's age would describe a collection as
|
|
159
|
+
* current on the strength of the part that happened to be read last.
|
|
160
|
+
*/
|
|
161
|
+
export async function drainPages(runtime, args, request, read) {
|
|
162
|
+
const all = args.booleans.has("--all");
|
|
163
|
+
const rows = [];
|
|
164
|
+
let freshness = { kind: "authoritative" };
|
|
165
|
+
let cursor = request.cursor;
|
|
166
|
+
let pages = 0;
|
|
167
|
+
for (;;) {
|
|
168
|
+
if (runtime.signal.aborted) {
|
|
169
|
+
throw new CliError("stopped while reading pages", "interrupted");
|
|
170
|
+
}
|
|
171
|
+
const page = await read({
|
|
172
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
173
|
+
...(request.limit === undefined ? {} : { limit: request.limit }),
|
|
174
|
+
});
|
|
175
|
+
pages += 1;
|
|
176
|
+
rows.push(...page.data);
|
|
177
|
+
freshness = stalest(freshness, page.freshness);
|
|
178
|
+
cursor = page.nextCursor;
|
|
179
|
+
if (!all || cursor === undefined) {
|
|
180
|
+
return {
|
|
181
|
+
ceilingReached: false,
|
|
182
|
+
freshness,
|
|
183
|
+
nextCursor: cursor,
|
|
184
|
+
pages,
|
|
185
|
+
rows,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (pages >= ALL_PAGES_CEILING) {
|
|
189
|
+
return {
|
|
190
|
+
ceilingReached: true,
|
|
191
|
+
freshness,
|
|
192
|
+
nextCursor: cursor,
|
|
193
|
+
pages,
|
|
194
|
+
rows,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function stalest(left, right) {
|
|
200
|
+
if (left.kind === "authoritative")
|
|
201
|
+
return right;
|
|
202
|
+
if (right.kind === "authoritative")
|
|
203
|
+
return left;
|
|
204
|
+
return right.lagSeconds > left.lagSeconds ? right : left;
|
|
205
|
+
}
|
|
206
|
+
/** The freshness of an answer, in a sentence, or nothing to say. */
|
|
207
|
+
export function freshnessNote(freshness) {
|
|
208
|
+
if (freshness.kind === "authoritative")
|
|
209
|
+
return undefined;
|
|
210
|
+
return `Read from a projection that was ${String(freshness.lagSeconds)}s behind at ${freshness.asOf}, so something newer than that may be missing.`;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* What to say about a listing that stopped before the collection did.
|
|
214
|
+
*
|
|
215
|
+
* The cursor is handed back rather than swallowed: it is the only way to
|
|
216
|
+
* continue, and a command that knew where it stopped and did not say would
|
|
217
|
+
* make the ceiling indistinguishable from the end of the data.
|
|
218
|
+
*/
|
|
219
|
+
export function continuationNote(drained, command) {
|
|
220
|
+
if (drained.nextCursor === undefined)
|
|
221
|
+
return undefined;
|
|
222
|
+
return drained.ceilingReached
|
|
223
|
+
? `Stopped after ${String(drained.pages)} pages; this result is incomplete. Continue with: ${command} --cursor ${drained.nextCursor} --all`
|
|
224
|
+
: `More to read. Continue with: ${command} --cursor ${drained.nextCursor}`;
|
|
225
|
+
}
|
|
226
|
+
/* -------------------------------------------------------------------------- */
|
|
227
|
+
/* Conditional writes */
|
|
228
|
+
/* -------------------------------------------------------------------------- */
|
|
229
|
+
/**
|
|
230
|
+
* `If-Match`, from the ETag a read handed out.
|
|
231
|
+
*
|
|
232
|
+
* The header rather than a version number, because that is what the service
|
|
233
|
+
* publishes and what `schedules read` prints. Turning one into the other here
|
|
234
|
+
* would make this command the only place that knows the mapping.
|
|
235
|
+
*/
|
|
236
|
+
export function conditionalHeaders(args) {
|
|
237
|
+
const etag = args.options["--if-match"];
|
|
238
|
+
return etag === undefined ? {} : { "If-Match": etag };
|
|
239
|
+
}
|
|
240
|
+
/* -------------------------------------------------------------------------- */
|
|
241
|
+
/* Human output */
|
|
242
|
+
/* -------------------------------------------------------------------------- */
|
|
243
|
+
/**
|
|
244
|
+
* A column-aligned table, or a sentence when there is nothing in it.
|
|
245
|
+
*
|
|
246
|
+
* **Identifiers are present and are not the first thing read.** The story asks
|
|
247
|
+
* for output where identifiers are available without dominating it, and the
|
|
248
|
+
* answer is ordinary column alignment: the state somebody is looking for lines
|
|
249
|
+
* up down the left, and the identifier they will paste into the next command is
|
|
250
|
+
* where they can select it.
|
|
251
|
+
*/
|
|
252
|
+
export function table(columns, rows, empty) {
|
|
253
|
+
if (rows.length === 0)
|
|
254
|
+
return empty;
|
|
255
|
+
const widths = columns.map((heading, index) => Math.max(heading.length, ...rows.map((row) => (row[index] ?? "").length)));
|
|
256
|
+
const render = (cells) => cells
|
|
257
|
+
.map((cell, index) => index === cells.length - 1 ? cell : cell.padEnd(widths[index]))
|
|
258
|
+
.join(" ")
|
|
259
|
+
.trimEnd();
|
|
260
|
+
return [render(columns), ...rows.map(render)].join("\n");
|
|
261
|
+
}
|
package/package.json
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bin": {
|
|
3
|
-
"greatwait": "./dist/main.js"
|
|
3
|
+
"greatwait": "./dist/main.js",
|
|
4
|
+
"gw": "./dist/gw.js"
|
|
4
5
|
},
|
|
5
6
|
"bugs": {
|
|
6
7
|
"url": "https://github.com/flowxo/greatwait/issues"
|
|
7
8
|
},
|
|
8
9
|
"dependencies": {
|
|
9
|
-
"@greatwait/api-client": "0.1.0-alpha.
|
|
10
|
-
"@greatwait/contracts": "0.1.0-alpha.
|
|
10
|
+
"@greatwait/api-client": "0.1.0-alpha.5",
|
|
11
|
+
"@greatwait/contracts": "0.1.0-alpha.5"
|
|
11
12
|
},
|
|
12
|
-
"description": "The
|
|
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
14
|
"engines": {
|
|
14
15
|
"node": ">=22.13.0"
|
|
15
16
|
},
|
|
@@ -47,5 +48,5 @@
|
|
|
47
48
|
},
|
|
48
49
|
"sideEffects": false,
|
|
49
50
|
"type": "module",
|
|
50
|
-
"version": "0.1.0-alpha.
|
|
51
|
+
"version": "0.1.0-alpha.5"
|
|
51
52
|
}
|