@greatwait/cli 0.3.0 → 0.3.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
@@ -43,6 +43,13 @@ directory, readable by you and nobody else. A staging credential is never
43
43
  presented to production, because the two are separate entries rather than one
44
44
  current context.
45
45
 
46
+ For a short script that needs a bearer token, `greatwait token` prints the
47
+ credential for the selected API origin, refreshing an expiring OAuth sign-in
48
+ first. Add `--api-url <origin>` to choose an origin explicitly. `--json` prints
49
+ `{token, origin, expiresAt}`; an API key is returned unchanged with `null` expiry.
50
+ This output is a secret: keep it out of logs, shell tracing and shared files.
51
+ Use a scoped API key for unattended automation.
52
+
46
53
  ## First callback
47
54
 
48
55
  Use a public HTTPS receiver you control. Once signed in, schedule directly to
@@ -99,6 +106,8 @@ grant and remove local credentials.
99
106
  ## Output and exit codes
100
107
 
101
108
  `--json` is a versioned machine shape, not a serialization of the human text.
109
+ The deliberate secret-output command `token --json` returns the credential object
110
+ described above.
102
111
  The exit status is a class rather than a code per failure:
103
112
 
104
113
  | code | meaning |
package/dist/catalog.js CHANGED
@@ -493,6 +493,14 @@ export const COMMAND_CATALOG = [
493
493
  summary: "Sign out, and forget the credential stored on this machine.",
494
494
  words: "logout",
495
495
  },
496
+ {
497
+ group: "Session",
498
+ operations: [],
499
+ options: [API_URL],
500
+ positionals: [],
501
+ summary: "Print the credential for the selected origin, refreshing OAuth first when needed. Output is a secret: keep it out of logs and shell tracing. --json prints token, origin and expiresAt (null for API keys).",
502
+ words: "token",
503
+ },
496
504
  {
497
505
  group: "Session",
498
506
  operations: ["context.read"],
@@ -831,10 +839,20 @@ export const COMMAND_CATALOG = [
831
839
  summary: "JSON your receiver gets with the call. Inline, @file.json, or - for standard input.",
832
840
  value: "<json>",
833
841
  },
842
+ {
843
+ flag: "--completion",
844
+ value: "<response|callback>",
845
+ summary: "How work finishes: response or callback. Defaults to response on create; replace keeps the stored value when omitted.",
846
+ },
847
+ {
848
+ flag: "--on-overlap",
849
+ value: "<skip|queue>",
850
+ summary: "When the next run is due before work finishes: skip or queue. Defaults to skip on create; replace keeps the stored value when omitted.",
851
+ },
834
852
  {
835
853
  flag: "--delivery-timeout",
836
854
  value: "<duration>",
837
- summary: "How long the receiver may take, such as 20s or 5m (whole seconds). Defaults to 5s on create; replace keeps the stored timeout. Response completion allows up to 30m; longer needs existing callback completion. Separate from --timeout for --wait.",
855
+ summary: "How long the receiver may take, such as 20s or 5m (whole seconds). Defaults to 5s on create; replace keeps the stored timeout. Response completion allows up to 30m; callback completion allows up to 24h. Separate from --timeout for --wait.",
838
856
  },
839
857
  IDEMPOTENCY,
840
858
  {
@@ -918,6 +936,16 @@ export const COMMAND_CATALOG = [
918
936
  summary: "JSON your receiver gets with the call. Inline, @file.json, or - for standard input.",
919
937
  value: "<json>",
920
938
  },
939
+ {
940
+ flag: "--completion",
941
+ value: "<response|callback>",
942
+ summary: "How work finishes: response or callback. Defaults to response on create; replace keeps the stored value when omitted.",
943
+ },
944
+ {
945
+ flag: "--on-overlap",
946
+ value: "<skip|queue>",
947
+ summary: "When the next run is due before work finishes: skip or queue. Defaults to skip on create; replace keeps the stored value when omitted.",
948
+ },
921
949
  {
922
950
  flag: "--delivery-timeout",
923
951
  value: "<duration>",
package/dist/commands.js CHANGED
@@ -10,7 +10,7 @@ import { awaitRedirect, buildAuthorization, codeFrom, discover, exchangeCode, re
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";
13
- import { anonymousClient, ENVIRONMENT_NAMES, openSession, resolveOrigin, storeFor, } from "./session.js";
13
+ import { anonymousClient, ENVIRONMENT_NAMES, openCredential, openSession, resolveOrigin, storeFor, } from "./session.js";
14
14
  import { OPERATE_COMMANDS, destinationLines, scheduleRequest } from "./operate.js";
15
15
  import { apiUrlOption, expectNoExtraArguments, parseResponse, positionals, } from "./support.js";
16
16
  /* -------------------------------------------------------------------------- */
@@ -317,6 +317,13 @@ const CREDENTIAL_LABELS = {
317
317
  "environment-key": `an API credential from ${ENVIRONMENT_NAMES.apiKey}`,
318
318
  oauth: "an OAuth grant",
319
319
  };
320
+ const token = async (runtime, args) => {
321
+ expectNoExtraArguments(args, 0, `${runtime.programName} token [--api-url <origin>]`);
322
+ const { token, origin, expiresAt } = await openCredential(runtime, {
323
+ apiUrl: apiUrlOption(args),
324
+ });
325
+ return { human: token, data: { token, origin, expiresAt } };
326
+ };
320
327
  const whoami = async (runtime, args) => {
321
328
  expectNoExtraArguments(args, 0, `${runtime.programName} whoami [--api-url <origin>]`);
322
329
  const session = await openSession(runtime, { apiUrl: apiUrlOption(args) });
@@ -677,6 +684,7 @@ const schedulesCreate = async (runtime, args) => {
677
684
  const schedule = created.schedule.id;
678
685
  const occurrence = created.occurrence.id;
679
686
  const data = {
687
+ delivery: created.schedule.delivery,
680
688
  ...(created.endpointSigningSecret === undefined
681
689
  ? {}
682
690
  : {
@@ -1052,6 +1060,7 @@ const IMPLEMENTATIONS = {
1052
1060
  "occurrences wait": occurrencesWait,
1053
1061
  "schedules create": schedulesCreate,
1054
1062
  "schedules read": schedulesRead,
1063
+ token,
1055
1064
  whoami,
1056
1065
  };
1057
1066
  /**
package/dist/operate.d.ts CHANGED
@@ -34,7 +34,7 @@ type Command = (runtime: CliRuntime, args: ParsedArguments) => Promise<CommandRe
34
34
  * machine's would make the same expression mean different things on a laptop
35
35
  * and on a build runner.
36
36
  */
37
- declare function scheduleRequest(runtime: CliRuntime, args: ParsedArguments, usage: string): Promise<Readonly<Record<string, unknown>>>;
37
+ declare function scheduleRequest(runtime: CliRuntime, args: ParsedArguments, usage: string, mode?: "create" | "replace"): Promise<Readonly<Record<string, unknown>>>;
38
38
  /**
39
39
  * Where a schedule written with `--url` delivers, and where its signing
40
40
  * secret is.
package/dist/operate.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { BULK_COMMANDS } from "./bulk.js";
2
2
  import { writeFile } from "node:fs/promises";
3
- import { projectNotificationSettingsUpdateRequestSchema, statusSigningKeyRequestSchema, autoPauseAfterFailuresSchema, deliveryDurationSecondsSchema, httpRequestSchema, externalIdentifierSchema, scheduleTagsSchema, scheduleTagSchema, scheduleSelectionSchema, normalizeScheduleSelection, projectAttemptSearchRequestSchema, normalizeAttemptSelection, sanitizedDiagnosticText, diagnosticBodyText, responseBodySentence, requestCaptureSentence, attemptStatusGuidance, endpointHeaderChangesSchema, billingAutoTopUpApplyRequestSchema, billingAutoTopUpPreviewRequestSchema, billingAutoTopUpPreviewSchema, billingCeilingUpdateRequestSchema, billingFundingQuoteRequestSchema, billingFundingQuoteSchema, billingNotificationSettingsUpdateRequestSchema, billingRefundRequestCreateSchema, billingRefundResolutionRequestSchema, billingRedeemRequestSchema, projectBillingQuoteSchema, projectBillingQuoteRequestSchema, } from "@greatwait/contracts";
3
+ import { projectNotificationSettingsUpdateRequestSchema, statusSigningKeyRequestSchema, autoPauseAfterFailuresSchema, deliveryDurationSecondsSchema, deliveryCompletionModeSchema, deliveryOverlapPolicySchema, scheduleRequestSchema, httpRequestSchema, externalIdentifierSchema, scheduleTagsSchema, scheduleTagSchema, scheduleSelectionSchema, normalizeScheduleSelection, projectAttemptSearchRequestSchema, normalizeAttemptSelection, sanitizedDiagnosticText, diagnosticBodyText, responseBodySentence, requestCaptureSentence, attemptStatusGuidance, endpointHeaderChangesSchema, billingAutoTopUpApplyRequestSchema, billingAutoTopUpPreviewRequestSchema, billingAutoTopUpPreviewSchema, billingCeilingUpdateRequestSchema, billingFundingQuoteRequestSchema, billingFundingQuoteSchema, billingNotificationSettingsUpdateRequestSchema, billingRefundRequestCreateSchema, billingRefundResolutionRequestSchema, billingRedeemRequestSchema, projectBillingQuoteSchema, projectBillingQuoteRequestSchema, } from "@greatwait/contracts";
4
4
  import { parseDuration } from "./args.js";
5
5
  import { confirmChange } from "./confirm.js";
6
6
  import { redactSecret } from "./output.js";
@@ -80,7 +80,7 @@ const RECURRENCE_FLAGS = ["--in", "--at", "--every", "--cron"];
80
80
  * machine's would make the same expression mean different things on a laptop
81
81
  * and on a build runner.
82
82
  */
83
- async function scheduleRequest(runtime, args, usage) {
83
+ async function scheduleRequest(runtime, args, usage, mode = "create") {
84
84
  const given = RECURRENCE_FLAGS.filter((flag) => args.options[flag] !== undefined);
85
85
  if (given.length !== 1) {
86
86
  throw new CliError(given.length === 0
@@ -132,6 +132,27 @@ async function scheduleRequest(runtime, args, usage) {
132
132
  : deliveryDurationSecondsSchema.safeParse(parseDuration(timeoutInput));
133
133
  if (timeout !== undefined && !timeout.success)
134
134
  throw new CliError("--delivery-timeout is invalid", "usage", timeout.error.issues.map((issue) => issue.message).join("; "));
135
+ const completionInput = args.options["--completion"];
136
+ const completion = completionInput === undefined
137
+ ? undefined
138
+ : deliveryCompletionModeSchema.safeParse(completionInput);
139
+ if (completion !== undefined && !completion.success)
140
+ throw new CliError('--completion (delivery.completion) must be "response" or "callback"', "usage");
141
+ const overlapInput = args.options["--on-overlap"];
142
+ const overlap = overlapInput === undefined
143
+ ? undefined
144
+ : deliveryOverlapPolicySchema.safeParse(overlapInput);
145
+ if (overlap !== undefined && !overlap.success)
146
+ throw new CliError('--on-overlap (delivery.onOverlap) must be "skip" or "queue"', "usage");
147
+ const delivery = {
148
+ ...(timeout?.success ? { maxDurationSeconds: timeout.data } : {}),
149
+ ...(completion?.success ? { completion: completion.data } : {}),
150
+ ...(overlap?.success ? { onOverlap: overlap.data } : {}),
151
+ };
152
+ // Replacement validates the resulting pair after reading the stored settings.
153
+ // An omitted completion there can mean a retained callback, not response.
154
+ if (mode === "create")
155
+ validateDelivery(delivery);
135
156
  const external_identifier = args.options["--external-identifier"];
136
157
  if (external_identifier !== undefined &&
137
158
  !externalIdentifierSchema.safeParse(external_identifier).success)
@@ -145,9 +166,7 @@ async function scheduleRequest(runtime, args, usage) {
145
166
  ...(external_identifier === undefined ? {} : { external_identifier }),
146
167
  ...(parsedTags?.success ? { tags: parsedTags.data } : {}),
147
168
  ...(http?.success ? { http: http.data } : {}),
148
- ...(timeout?.success
149
- ? { delivery: { maxDurationSeconds: timeout.data } }
150
- : {}),
169
+ ...(Object.keys(delivery).length === 0 ? {} : { delivery }),
151
170
  ...(endpoint === undefined ? {} : { endpoint }),
152
171
  ...(url === undefined ? {} : { url }),
153
172
  ...(latencyClass === undefined ? {} : { latencyClass }),
@@ -157,6 +176,14 @@ async function scheduleRequest(runtime, args, usage) {
157
176
  ...recurrence,
158
177
  };
159
178
  }
179
+ /** Use the canonical contract's relation between completion and timeout. */
180
+ function validateDelivery(delivery) {
181
+ const parsed = scheduleRequestSchema.safeParse({ delaySeconds: 1, delivery });
182
+ if (!parsed.success)
183
+ throw new CliError(parsed.error.issues
184
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
185
+ .join("; "), "usage");
186
+ }
160
187
  /**
161
188
  * Where a schedule written with `--url` delivers, and where its signing
162
189
  * secret is.
@@ -518,35 +545,30 @@ async function scheduleChange(runtime, args, options) {
518
545
  const schedulesReplace = async (runtime, args) => {
519
546
  const usage = `${runtime.programName} schedules replace <schedule> (--endpoint <destination> | --url <https url>) (--in <duration> | --at <instant> | --every <duration> | --cron <expression>)`;
520
547
  const [id] = positionals(args, ["<schedule>"], usage);
521
- const request = await scheduleRequest(runtime, args, usage);
548
+ const request = await scheduleRequest(runtime, args, usage, "replace");
522
549
  return scheduleChange(runtime, args, {
523
550
  act: (active, scheduleId, current) => {
524
551
  if (current === undefined)
525
552
  throw new CliError("Replacement needs its schedule read", "internal");
526
- const delivery = current.schedule.delivery;
527
- const timeout = args.options["--delivery-timeout"];
528
- if (timeout !== undefined &&
553
+ const delivery = {
554
+ ...current.schedule.delivery,
555
+ ...request["delivery"],
556
+ };
557
+ const changesDelivery = request["delivery"] !== undefined;
558
+ validateDelivery(delivery);
559
+ if (changesDelivery &&
529
560
  current.schedule.endpoint === undefined &&
530
561
  request["endpoint"] === undefined &&
531
562
  request["url"] === undefined)
532
- throw new CliError("--delivery-timeout needs a callback destination", "usage", "Give --endpoint or --url when attaching a callback to a timer-only schedule.");
563
+ throw new CliError("Delivery settings need a callback destination", "usage", "Give --endpoint or --url when attaching a callback to a timer-only schedule.");
533
564
  // The API replaces delivery settings with defaults when callback fields
534
565
  // are supplied. Carry all stored settings, including completion/overlap.
535
- // Naming the retained endpoint also makes a timeout-only change reach
566
+ // Naming the retained endpoint also makes a delivery-only change reach
536
567
  // that callback replacement path. Fence the read against concurrent edits.
537
568
  return active.client.requestDetailed("schedules_replace", scheduleId, {
538
569
  ...request,
539
- ...(delivery === undefined
540
- ? {}
541
- : {
542
- delivery: {
543
- ...delivery,
544
- ...(timeout === undefined
545
- ? {}
546
- : { maxDurationSeconds: parseDuration(timeout) }),
547
- },
548
- }),
549
- ...(timeout !== undefined &&
570
+ ...(Object.keys(delivery).length === 0 ? {} : { delivery }),
571
+ ...(changesDelivery &&
550
572
  request["endpoint"] === undefined &&
551
573
  request["url"] === undefined &&
552
574
  current.schedule.endpoint !== undefined
package/dist/output.js CHANGED
@@ -27,6 +27,9 @@ export function renderResult(command, result, json) {
27
27
  ? `${result.human}\n`
28
28
  : `${result.human}\n\n${result.nextMove}\n`;
29
29
  }
30
+ // The token command is a shell credential primitive with a bare JSON shape.
31
+ if (command === "token")
32
+ return `${JSON.stringify(result.data)}\n`;
30
33
  return `${JSON.stringify({
31
34
  command,
32
35
  data: result.data,
package/dist/session.d.ts CHANGED
@@ -49,9 +49,20 @@ export declare function anonymousClient(runtime: CliRuntime, origin: string): De
49
49
  * reason nothing on their machine explains. Persisting first costs one write
50
50
  * and removes the whole failure.
51
51
  */
52
+ export interface SessionCredential {
53
+ readonly token: string;
54
+ readonly expiresAt: string | null;
55
+ readonly credentialKind: Session["credentialKind"];
56
+ readonly origin: string;
57
+ readonly originSource: ResolvedOrigin["source"];
58
+ }
52
59
  export declare function openSession(runtime: CliRuntime, options: {
53
60
  readonly apiUrl?: string | undefined;
54
61
  }): Promise<Session>;
62
+ /** Resolve the exact credential used by commands, persisting refresh before use. */
63
+ export declare function openCredential(runtime: CliRuntime, options: {
64
+ readonly apiUrl?: string | undefined;
65
+ }): Promise<SessionCredential>;
55
66
  /**
56
67
  * How long before expiry a grant is refreshed rather than presented.
57
68
  *
package/dist/session.js CHANGED
@@ -69,31 +69,28 @@ export function anonymousClient(runtime, origin) {
69
69
  fetch: runtime.fetch,
70
70
  });
71
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
72
  export async function openSession(runtime, options) {
73
+ const credential = await openCredential(runtime, options);
74
+ return {
75
+ client: createGreatwaitClient({
76
+ baseUrl: credential.origin,
77
+ credential: credential.token,
78
+ fetch: runtime.fetch,
79
+ }),
80
+ credentialKind: credential.credentialKind,
81
+ origin: credential.origin,
82
+ originSource: credential.originSource,
83
+ };
84
+ }
85
+ /** Resolve the exact credential used by commands, persisting refresh before use. */
86
+ export async function openCredential(runtime, options) {
83
87
  const store = storeFor(runtime);
84
88
  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
89
  const fromEnvironment = runtime.env[ENVIRONMENT_NAMES.apiKey];
90
90
  if (fromEnvironment !== undefined && fromEnvironment !== "") {
91
91
  return {
92
- client: createGreatwaitClient({
93
- baseUrl: resolved.origin,
94
- credential: fromEnvironment,
95
- fetch: runtime.fetch,
96
- }),
92
+ token: fromEnvironment,
93
+ expiresAt: null,
97
94
  credentialKind: "environment-key",
98
95
  origin: resolved.origin,
99
96
  originSource: resolved.source,
@@ -101,15 +98,11 @@ export async function openSession(runtime, options) {
101
98
  }
102
99
  const stored = await store.read(resolved.origin);
103
100
  if (stored === undefined) {
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.`);
101
+ throw new CliError(`no credential is stored for ${resolved.origin}`, "authentication", `Sign in with ${runtime.programName} login --api-url ${resolved.origin} — or export ${ENVIRONMENT_NAMES.apiKey} for a non-interactive run.`);
105
102
  }
106
- const credential = await presentableSecret(runtime, store, resolved.origin, stored);
103
+ const secret = await presentableSecret(runtime, store, resolved.origin, stored);
107
104
  return {
108
- client: createGreatwaitClient({
109
- baseUrl: resolved.origin,
110
- credential,
111
- fetch: runtime.fetch,
112
- }),
105
+ ...secret,
113
106
  credentialKind: stored.kind,
114
107
  origin: resolved.origin,
115
108
  originSource: resolved.source,
@@ -132,26 +125,35 @@ async function presentableSecret(runtime, store, origin, stored) {
132
125
  if (stored.secret === undefined) {
133
126
  throw new CliError(`the stored credential for ${origin} carries no secret`, "authentication", `Sign in again: ${runtime.programName} login --api-url ${origin} --api-key`);
134
127
  }
135
- return stored.secret;
128
+ return { token: stored.secret, expiresAt: null };
136
129
  }
137
130
  const { accessToken, clientId, expiresAtMs, refreshToken } = stored;
138
131
  if (accessToken === undefined || clientId === undefined) {
139
132
  throw new CliError(`the stored grant for ${origin} is incomplete`, "authentication", `Sign in again: ${runtime.programName} login --api-url ${origin}`);
140
133
  }
141
134
  if (expiresAtMs === undefined ||
142
- expiresAtMs - REFRESH_MARGIN_MS > runtime.now() ||
143
- refreshToken === undefined) {
144
- return accessToken;
135
+ expiresAtMs - REFRESH_MARGIN_MS > runtime.now()) {
136
+ return {
137
+ token: accessToken,
138
+ expiresAt: expiresAtMs === undefined ? null : new Date(expiresAtMs).toISOString(),
139
+ };
140
+ }
141
+ if (refreshToken === undefined) {
142
+ throw new CliError(`the stored grant for ${origin} needs refreshing but carries no refresh token`, "authentication", `Sign in with ${runtime.programName} login --api-url ${origin}`);
145
143
  }
146
144
  const server = await discover(runtime, origin);
147
145
  const rotated = await refresh(runtime, server, { clientId, refreshToken });
146
+ const refreshedExpiresAtMs = runtime.now() + rotated.expiresInSeconds * 1000;
148
147
  await store.write(origin, {
149
148
  accessToken: rotated.accessToken,
150
149
  clientId,
151
- expiresAtMs: runtime.now() + rotated.expiresInSeconds * 1000,
150
+ expiresAtMs: refreshedExpiresAtMs,
152
151
  kind: "oauth",
153
152
  refreshToken: rotated.refreshToken,
154
153
  scope: rotated.scope,
155
154
  });
156
- return rotated.accessToken;
155
+ return {
156
+ token: rotated.accessToken,
157
+ expiresAt: new Date(refreshedExpiresAtMs).toISOString(),
158
+ };
157
159
  }
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.3.0",
11
- "@greatwait/contracts": "0.3.0"
10
+ "@greatwait/api-client": "0.3.1",
11
+ "@greatwait/contracts": "0.3.1"
12
12
  },
13
13
  "description": "The GreatWait command line: sign in, schedule a call to a URL, and read its delivery outcome over the public API.",
14
14
  "engines": {
@@ -48,5 +48,5 @@
48
48
  },
49
49
  "sideEffects": false,
50
50
  "type": "module",
51
- "version": "0.3.0"
51
+ "version": "0.3.1"
52
52
  }