@greatwait/cli 0.1.0-alpha.5 → 0.1.0-alpha.7

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/operate.js CHANGED
@@ -1,10 +1,11 @@
1
- import { billingAutoTopUpApplyRequestSchema, billingAutoTopUpPreviewRequestSchema, billingAutoTopUpPreviewSchema, billingCeilingUpdateRequestSchema, billingFundingQuoteRequestSchema, billingFundingQuoteSchema, billingNotificationSettingsUpdateRequestSchema, billingRefundRequestCreateSchema, billingRefundResolutionRequestSchema, projectBillingQuoteSchema, projectBillingQuoteRequestSchema, } from "@greatwait/contracts";
1
+ import { writeFile } from "node:fs/promises";
2
+ import { autoPauseAfterFailuresSchema, httpRequestSchema, sanitizedDiagnosticText, diagnosticBodyText, responseBodySentence, requestCaptureSentence, attemptStatusGuidance, endpointHeaderChangesSchema, billingAutoTopUpApplyRequestSchema, billingAutoTopUpPreviewRequestSchema, billingAutoTopUpPreviewSchema, billingCeilingUpdateRequestSchema, billingFundingQuoteRequestSchema, billingFundingQuoteSchema, billingNotificationSettingsUpdateRequestSchema, billingRefundRequestCreateSchema, billingRefundResolutionRequestSchema, projectBillingQuoteSchema, projectBillingQuoteRequestSchema, } from "@greatwait/contracts";
2
3
  import { parseDuration } from "./args.js";
3
4
  import { confirmChange } from "./confirm.js";
4
5
  import { redactSecret } from "./output.js";
5
6
  import { CliError } from "./runtime.js";
6
7
  import { openSession } from "./session.js";
7
- import { apiUrlOption, conditionalHeaders, continuationNote, drainPages, freshnessNote, pageRequest, parseInstant, parseResponse, positionals, readJsonOption, table, } from "./support.js";
8
+ import { apiUrlOption, conditionalHeaders, continuationNote, drainPages, freshnessNote, pageRequest, parseInstant, parseResponse, positionals, readJsonOption, readPrivateJsonOption, table, } from "./support.js";
8
9
  /* -------------------------------------------------------------------------- */
9
10
  /* Shared plumbing */
10
11
  /* -------------------------------------------------------------------------- */
@@ -102,18 +103,85 @@ async function scheduleRequest(runtime, args, usage) {
102
103
  ? { everySeconds: parseDuration(value) }
103
104
  : { cron: value };
104
105
  const endpoint = args.options["--endpoint"];
106
+ const url = args.options["--url"];
107
+ if (endpoint !== undefined && url !== undefined) {
108
+ // Refused here as well as by the service, because "you gave both" is a
109
+ // sentence about the line somebody typed. The service's own refusal says
110
+ // the same thing a round trip later.
111
+ throw new CliError("give --endpoint or --url, not both", "usage", "--url uses this project's destination at that address, or creates one. --endpoint names a destination that already exists.");
112
+ }
105
113
  const data = await readJsonOption(runtime, "--data", args.options["--data"]);
114
+ const httpInput = await readPrivateJsonOption(runtime, "--http", args.options["--http"]);
115
+ const http = httpInput === undefined
116
+ ? undefined
117
+ : httpRequestSchema.safeParse(httpInput);
118
+ if (http !== undefined && !http.success)
119
+ throw new CliError("--http settings are invalid", "usage", http.error.issues.map((issue) => issue.message).join("; "));
120
+ const pauseInput = args.options["--auto-pause-after-failures"];
121
+ const pause = pauseInput === undefined
122
+ ? undefined
123
+ : autoPauseAfterFailuresSchema.safeParse(pauseInput === "never" ? pauseInput : Number(pauseInput));
124
+ if (pause !== undefined && !pause.success)
125
+ throw new CliError('--auto-pause-after-failures must be a whole number from 1 to 100, or "never"', "usage");
106
126
  const latencyClass = args.options["--latency-class"];
107
127
  const partitionKey = args.options["--partition-key"];
108
128
  return {
109
129
  ...(data === undefined ? {} : { data }),
130
+ ...(http?.success ? { http: http.data } : {}),
110
131
  ...(endpoint === undefined ? {} : { endpoint }),
132
+ ...(url === undefined ? {} : { url }),
111
133
  ...(latencyClass === undefined ? {} : { latencyClass }),
134
+ ...(pause?.success ? { autoPauseAfterFailures: pause.data } : {}),
112
135
  ...(partitionKey === undefined ? {} : { partitionKey }),
113
136
  ...(timeZone === undefined ? {} : { timeZone }),
114
137
  ...recurrence,
115
138
  };
116
139
  }
140
+ /**
141
+ * Where a schedule written with `--url` delivers, and where its signing
142
+ * secret is.
143
+ *
144
+ * **Only the API's notice, never a secret.** A schedule write that names a
145
+ * `url` answers with `endpointSigningSecret` — `returned: false` and the
146
+ * dashboard page where a member reveals the secret — and nothing else about
147
+ * it, so there is nothing here that could print one. Absent when the write
148
+ * named `--endpoint`: that caller already holds the destination it chose.
149
+ */
150
+ export function destinationLines(schedule, notice,
151
+ /**
152
+ * The `--url` that was sent. A schedule write answers without
153
+ * `endpointUrl` (only reads carry it), and the address is the one the
154
+ * destination was found or made at.
155
+ */
156
+ requestedUrl) {
157
+ if (notice === undefined)
158
+ return [];
159
+ const address = schedule.endpointUrl ?? canonical(requestedUrl);
160
+ return [
161
+ ...(schedule.endpoint === undefined
162
+ ? []
163
+ : [
164
+ `Destination ${schedule.endpoint}${address === undefined ? "" : ` at ${address}`}`,
165
+ ]),
166
+ ``,
167
+ `GreatWait signs every call to this address. Your receiver checks the`,
168
+ `signature with the destination's signing secret, which isn't shown here.`,
169
+ notice.revealUrl === undefined
170
+ ? `A project member can reveal it on the destination's page in the dashboard.`
171
+ : `Reveal it on the destination's page: ${notice.revealUrl}`,
172
+ ];
173
+ }
174
+ /** An address as the service stores it, or as typed if it will not parse. */
175
+ function canonical(url) {
176
+ if (url === undefined)
177
+ return undefined;
178
+ try {
179
+ return new URL(url).href;
180
+ }
181
+ catch {
182
+ return url;
183
+ }
184
+ }
117
185
  /** How a schedule's recurrence reads in one column. */
118
186
  function recurrenceOf(schedule) {
119
187
  return schedule.recurrence.kind;
@@ -176,6 +244,12 @@ const destinationsUpdate = async (runtime, args) => {
176
244
  if (url === undefined) {
177
245
  throw new CliError(`${runtime.programName} destinations update needs --url`, "usage", `Usage: ${runtime.programName} destinations update <destination> --url https://your-receiver.example.com/greatwait`);
178
246
  }
247
+ const headerInput = await readPrivateJsonOption(runtime, "--headers", args.options["--headers"]);
248
+ const headers = headerInput === undefined
249
+ ? undefined
250
+ : endpointHeaderChangesSchema.safeParse(headerInput);
251
+ if (headers !== undefined && !headers.success)
252
+ throw new CliError("--headers settings are invalid", "usage", headers.error.issues.map((issue) => issue.message).join("; "));
179
253
  const active = await session(runtime, args);
180
254
  // Read first, so the preview is the destination as it stands rather than the
181
255
  // arguments read back.
@@ -188,7 +262,7 @@ const destinationsUpdate = async (runtime, args) => {
188
262
  `Deliveries in flight keep going to the receiver they started for.`,
189
263
  ],
190
264
  });
191
- const updated = parseResponse("endpoints.update", await active.client.request("endpoints_update", id, { url }, conditionalHeaders(args)));
265
+ const updated = parseResponse("endpoints.update", await active.client.request("endpoints_update", id, { url, ...(headers?.success ? { headers: headers.data } : {}) }, conditionalHeaders(args)));
192
266
  return {
193
267
  data: { confirmation, endpoint: updated },
194
268
  human: endpointBlock(updated).join("\n"),
@@ -279,6 +353,9 @@ async function scheduleChange(runtime, args, options) {
279
353
  return {
280
354
  data: {
281
355
  ...(confirmation === undefined ? {} : { confirmation }),
356
+ ...(view.endpointSigningSecret === undefined
357
+ ? {}
358
+ : { endpointSigningSecret: view.endpointSigningSecret }),
282
359
  ...(view.occurrence === undefined ? {} : { occurrence: view.occurrence }),
283
360
  schedule: view.schedule,
284
361
  },
@@ -292,12 +369,13 @@ async function scheduleChange(runtime, args, options) {
292
369
  : [
293
370
  `Next ${view.occurrence.id} planned for ${view.occurrence.plannedFor} (${view.occurrence.state})`,
294
371
  ]),
372
+ ...destinationLines(view.schedule, view.endpointSigningSecret, args.options["--url"]),
295
373
  ].join("\n"),
296
374
  ...(nextMove === undefined ? {} : { nextMove }),
297
375
  };
298
376
  }
299
377
  const schedulesReplace = async (runtime, args) => {
300
- const usage = `${runtime.programName} schedules replace <schedule> --endpoint <destination> (--in <duration> | --at <instant> | --every <duration> | --cron <expression>)`;
378
+ const usage = `${runtime.programName} schedules replace <schedule> (--endpoint <destination> | --url <https url>) (--in <duration> | --at <instant> | --every <duration> | --cron <expression>)`;
301
379
  const [id] = positionals(args, ["<schedule>"], usage);
302
380
  const request = await scheduleRequest(runtime, args, usage);
303
381
  return scheduleChange(runtime, args, {
@@ -306,7 +384,8 @@ const schedulesReplace = async (runtime, args) => {
306
384
  action: `Replace the definition of schedule ${current.id}.`,
307
385
  effect: [
308
386
  `now ${recurrenceOf(current)}, ${current.status}, version ${String(current.version)}`,
309
- `after the definition on this command line, and nothing of the old one`,
387
+ `after the timing on this command line. Destination, data and HTTP`,
388
+ ` settings are kept unless this command line gives new ones.`,
310
389
  `Occurrences already planned under the old definition are discarded.`,
311
390
  ],
312
391
  }),
@@ -423,9 +502,52 @@ const attemptsRead = async (runtime, args) => {
423
502
  const active = await session(runtime, args);
424
503
  const view = parseResponse("attempts.read", await active.client.request("attempts_read", schedule, occurrence, attempt));
425
504
  const found = view.attempt;
505
+ const responseFile = args.options["--response-file"];
506
+ if (responseFile !== undefined) {
507
+ if (found.response?.body === undefined)
508
+ throw new CliError(responseBodySentence(found.response?.bodyState ?? "not_captured", found.responseStatus === undefined), "validation");
509
+ const body = found.response.body;
510
+ try {
511
+ await writeFile(responseFile, body.encoding === "base64"
512
+ ? Buffer.from(body.content, "base64")
513
+ : Buffer.from(body.content, "utf8"), { mode: 0o600, flag: "wx" });
514
+ }
515
+ catch {
516
+ throw new CliError("The response file could not be created. Choose a path that does not already exist.", "usage");
517
+ }
518
+ }
519
+ const diagnosticLines = [];
520
+ if (found.response !== undefined) {
521
+ const response = found.response;
522
+ diagnosticLines.push(`Server response ${String(found.responseStatus ?? "")} ${response.statusText}`, ...response.headers.map((h) => `${h.name}: ${h.value}`));
523
+ if (response.body === undefined)
524
+ diagnosticLines.push(responseBodySentence(response.bodyState, found.responseStatus === undefined));
525
+ else {
526
+ const body = response.body;
527
+ if (body.incomplete)
528
+ diagnosticLines.push("The response stream stopped during capture; this is a partial answer.");
529
+ if (body.truncated)
530
+ diagnosticLines.push(`Response cut at 10 KiB (10,240 bytes)${body.originalBytes === undefined ? "" : `; original length ${String(body.originalBytes)} bytes`}.`);
531
+ diagnosticLines.push(diagnosticBodyText(body) !== undefined
532
+ ? diagnosticBodyText(body)
533
+ : `Binary response (${String(body.capturedBytes)} bytes). Download with --response-file <path>.`);
534
+ }
535
+ }
536
+ diagnosticLines.push("What we sent");
537
+ if (found.request === undefined)
538
+ diagnosticLines.push(requestCaptureSentence(found.requestState, found.state === undefined));
539
+ else
540
+ diagnosticLines.push(`${found.request.method} ${found.request.url}`, ...found.request.headers.map((h) => `${h.name}: ${h.value}`), found.request.body === "" ? "We sent no body." : found.request.body);
541
+ const guidance = attemptStatusGuidance(found.responseStatus);
542
+ if (guidance !== undefined)
543
+ diagnosticLines.push(guidance);
426
544
  return {
427
545
  data: { attempt: found, freshness: freshnessOf(view) },
428
546
  human: [
547
+ ...diagnosticLines.map(sanitizedDiagnosticText),
548
+ ...(responseFile === undefined
549
+ ? []
550
+ : [`Response saved to ${sanitizedDiagnosticText(responseFile)}`]),
429
551
  `Attempt ${found.id}`,
430
552
  `Number ${String(found.attemptNumber)}`,
431
553
  ...(found.state === undefined ? [] : [`State ${found.state}`]),
@@ -1387,7 +1509,9 @@ const sinksCreate = async (runtime, args) => {
1387
1509
  const program = await readJsonOption(runtime, "--program", args.options["--program"]);
1388
1510
  const sink = parseResponse("testsinks.create", await active.client.request("testsinks_create", {
1389
1511
  endpoint,
1390
- ...(program === undefined ? {} : { program }),
1512
+ // The program is passed as typed and validated by the service, which
1513
+ // names the field it refuses; the client's type cannot know it yet.
1514
+ ...(program === undefined ? {} : { program: program }),
1391
1515
  }));
1392
1516
  return {
1393
1517
  data: { sink },
@@ -30,6 +30,6 @@ export declare function remediationFor(problem: Problem | undefined, retryable:
30
30
  * A case asserting what the advice *says* wants one spelling; a case asserting
31
31
  * that it follows the invoked name passes its own.
32
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>>;
33
+ export declare const REMEDIATION: Readonly<Record<"authentication_required" | "billing_not_ready" | "capability_not_configured" | "credential_expired" | "credential_revoked" | "idempotency_key_reused" | "invalid_request" | "not_found" | "payload_too_large" | "plan_limit_reached" | "project_archived" | "rate_limited" | "revision_conflict" | "scope_missing" | "service_unavailable" | "state_conflict" | "test_to_live_escalation" | "version_conflict", string>>;
34
34
  /** Exposed so a case can assert the records cover the published vocabularies. */
35
35
  export { isRetryable, remediationTable };
package/dist/support.d.ts CHANGED
@@ -31,6 +31,8 @@ export declare function expectNoExtraArguments(args: ParsedArguments, used: numb
31
31
  * "unexpected argument" rule is applied from the same count.
32
32
  */
33
33
  export declare function positionals(args: ParsedArguments, names: readonly string[], usage: string): readonly string[];
34
+ /** Secret-bearing option documents are accepted through files/stdin only. */
35
+ export declare function readPrivateJsonOption(runtime: CliRuntime, flag: string, value: string | undefined): Promise<unknown>;
34
36
  /** The payload, from an argument, a file, or standard input. */
35
37
  export declare function readJsonOption(runtime: CliRuntime, flag: string, value: string | undefined): Promise<unknown>;
36
38
  /**
package/dist/support.js CHANGED
@@ -76,6 +76,13 @@ export function positionals(args, names, usage) {
76
76
  expectNoExtraArguments(args, names.length, usage);
77
77
  return found;
78
78
  }
79
+ /** Secret-bearing option documents are accepted through files/stdin only. */
80
+ export async function readPrivateJsonOption(runtime, flag, value) {
81
+ if (value !== undefined && value !== "-" && !value.startsWith("@")) {
82
+ throw new CliError(`${flag} must be @file.json or -`, "usage", "Read secret-bearing settings from a private file or standard input, never a command-line argument.");
83
+ }
84
+ return readJsonOption(runtime, flag, value);
85
+ }
79
86
  /** The payload, from an argument, a file, or standard input. */
80
87
  export async function readJsonOption(runtime, flag, value) {
81
88
  if (value === undefined)
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.1.0-alpha.5",
11
- "@greatwait/contracts": "0.1.0-alpha.5"
10
+ "@greatwait/api-client": "0.1.0-alpha.7",
11
+ "@greatwait/contracts": "0.1.0-alpha.7"
12
12
  },
13
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.",
14
14
  "engines": {
@@ -48,5 +48,5 @@
48
48
  },
49
49
  "sideEffects": false,
50
50
  "type": "module",
51
- "version": "0.1.0-alpha.5"
51
+ "version": "0.1.0-alpha.7"
52
52
  }