@greatwait/cli 0.2.3 → 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 +9 -0
- package/dist/bulk.d.ts +6 -0
- package/dist/bulk.js +178 -0
- package/dist/catalog.js +217 -2
- package/dist/commands.js +27 -2
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +166 -11
- package/dist/output.js +3 -0
- package/dist/session.d.ts +11 -0
- package/dist/session.js +34 -32
- package/package.json +3 -3
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/bulk.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ParsedArguments } from "./args.js";
|
|
2
|
+
import { type CliRuntime } from "./runtime.js";
|
|
3
|
+
import type { CommandResult } from "./output.js";
|
|
4
|
+
type Command = (runtime: CliRuntime, args: ParsedArguments) => Promise<CommandResult>;
|
|
5
|
+
export declare const BULK_COMMANDS: Readonly<Record<string, Command>>;
|
|
6
|
+
export {};
|
package/dist/bulk.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { bulkPreviewRequestSchema, bulkConfirmRequestSchema, bulkControlRequestSchema, bulkJobSchema, bulkListRequestSchema, bulkItemListRequestSchema, } from "@greatwait/contracts";
|
|
2
|
+
import { confirmChange } from "./confirm.js";
|
|
3
|
+
import { CliError } from "./runtime.js";
|
|
4
|
+
import { openSession } from "./session.js";
|
|
5
|
+
import { apiUrlOption, readJsonOption, parseResponse, positionals, pageRequest, drainPages, continuationNote, table, } from "./support.js";
|
|
6
|
+
const session = (runtime, args) => openSession(runtime, { apiUrl: apiUrlOption(args) });
|
|
7
|
+
function jobId(runtime, args, command) {
|
|
8
|
+
const [id] = positionals(args, ["<job>"], `${runtime.programName} bulk ${command} <job>`);
|
|
9
|
+
const parsed = bulkJobSchema.shape.id.safeParse(id);
|
|
10
|
+
if (!parsed.success)
|
|
11
|
+
throw new CliError("Give a valid bjo_ job identity from bulk preview", "usage");
|
|
12
|
+
return parsed.data;
|
|
13
|
+
}
|
|
14
|
+
function renderJob(job) {
|
|
15
|
+
const counts = job.counts;
|
|
16
|
+
return {
|
|
17
|
+
data: job,
|
|
18
|
+
human: [
|
|
19
|
+
`Job ${job.id}`,
|
|
20
|
+
`Operation ${job.intent.operation} ${job.intent.target}`,
|
|
21
|
+
`State ${job.state}`,
|
|
22
|
+
`Version ${String(job.version)}`,
|
|
23
|
+
`Selection ${job.intent.selection.kind === "all_matching" ? "all matching results" : "explicit selected IDs"}`,
|
|
24
|
+
`Counts ${job.complete ? "exact" : "incomplete preparation; lower bounds"}`,
|
|
25
|
+
`Matched ${String(counts.matched)}; eligible ${String(counts.eligible)}; ineligible ${String(counts.ineligible)}`,
|
|
26
|
+
`Progress pending ${String(counts.pending)}; running ${String(counts.running)}; succeeded ${String(counts.succeeded)}; no-op ${String(counts.noOp)}; skipped ${String(counts.skipped)}; conflicted ${String(counts.conflicted)}; failed ${String(counts.failed)}; cancelled ${String(counts.cancelled)}`,
|
|
27
|
+
...(job.intentDigest === undefined
|
|
28
|
+
? []
|
|
29
|
+
: [`Intent ${job.intentDigest}`]),
|
|
30
|
+
...(job.previewExpiresAt === undefined
|
|
31
|
+
? []
|
|
32
|
+
: [`Confirm by ${job.previewExpiresAt}`]),
|
|
33
|
+
...(job.executionExpiresAt === undefined
|
|
34
|
+
? []
|
|
35
|
+
: [`Deadline ${job.executionExpiresAt}`]),
|
|
36
|
+
...job.warnings.map((warning) => `Warning ${warning}`),
|
|
37
|
+
`Billing ${job.billing.note}`,
|
|
38
|
+
...(job.billing.currentLimits === undefined
|
|
39
|
+
? []
|
|
40
|
+
: [`Admission ${JSON.stringify(job.billing.currentLimits)}`]),
|
|
41
|
+
].join("\n"),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const preview = async (runtime, args) => {
|
|
45
|
+
const parsed = bulkPreviewRequestSchema.safeParse(await readJsonOption(runtime, "--intent", args.options["--intent"]));
|
|
46
|
+
if (!parsed.success)
|
|
47
|
+
throw new CliError("--intent must match the canonical bulk operation request", "usage", parsed.error.issues
|
|
48
|
+
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
49
|
+
.join("; "));
|
|
50
|
+
const active = await session(runtime, args);
|
|
51
|
+
const job = parseResponse("bulk.preview", await active.client.request("bulk_preview", parsed.data));
|
|
52
|
+
return {
|
|
53
|
+
...renderJob(job),
|
|
54
|
+
nextMove: `Inspect this frozen selection: ${runtime.programName} bulk read ${job.id}; ${runtime.programName} bulk items ${job.id} --all`,
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
const read = async (runtime, args) => {
|
|
58
|
+
const id = jobId(runtime, args, "read");
|
|
59
|
+
const active = await session(runtime, args);
|
|
60
|
+
return renderJob(parseResponse("bulk.read", await active.client.request("bulk_read", id)));
|
|
61
|
+
};
|
|
62
|
+
const confirm = async (runtime, args) => {
|
|
63
|
+
const id = jobId(runtime, args, "confirm");
|
|
64
|
+
const parsed = bulkConfirmRequestSchema.safeParse({
|
|
65
|
+
intentDigest: args.options["--intent-digest"],
|
|
66
|
+
confirmed: true,
|
|
67
|
+
acknowledgeDuplicateEffects: args.booleans.has("--acknowledge-duplicate-effects"),
|
|
68
|
+
});
|
|
69
|
+
if (!parsed.success)
|
|
70
|
+
throw new CliError("Give the exact --intent-digest from bulk read", "usage");
|
|
71
|
+
const active = await session(runtime, args);
|
|
72
|
+
const current = parseResponse("bulk.read", await active.client.request("bulk_read", id));
|
|
73
|
+
if (!current.complete || current.intentDigest !== parsed.data.intentDigest)
|
|
74
|
+
throw new CliError("The job is incomplete or the intent digest differs; refresh the preview", "validation");
|
|
75
|
+
if (current.intent.operation === "retry" &&
|
|
76
|
+
!parsed.data.acknowledgeDuplicateEffects)
|
|
77
|
+
throw new CliError("Retry requires --acknowledge-duplicate-effects", "usage", "A receiver may already have acted; explicit redelivery can repeat external effects.");
|
|
78
|
+
const confirmation = await confirmChange(runtime, args, {
|
|
79
|
+
action: `Confirm ${current.intent.operation} for frozen bulk job ${id}.`,
|
|
80
|
+
effect: [
|
|
81
|
+
`authority ${JSON.stringify(current.authority)}`,
|
|
82
|
+
`intent ${parsed.data.intentDigest}`,
|
|
83
|
+
`selection ${JSON.stringify(current.intent.selection)}`,
|
|
84
|
+
`parameters ${JSON.stringify(current.intent.parameters)}`,
|
|
85
|
+
`matched ${String(current.counts.matched)}; eligible ${String(current.counts.eligible)}; ineligible ${String(current.counts.ineligible)}`,
|
|
86
|
+
...current.warnings,
|
|
87
|
+
"Already-sent requests cannot be recalled and ordinary quotas and per-attempt billing apply.",
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
const key = args.options["--idempotency-key"] ??
|
|
91
|
+
`greatwait-bulk-${id}-${parsed.data.intentDigest}`;
|
|
92
|
+
runtime.stderr(`Retry key ${key}; retain it with this intent if the response is lost.\n`);
|
|
93
|
+
const job = parseResponse("bulk.confirm", await active.client.request("bulk_confirm", id, parsed.data, {
|
|
94
|
+
"Idempotency-Key": key,
|
|
95
|
+
}));
|
|
96
|
+
return {
|
|
97
|
+
...renderJob(job),
|
|
98
|
+
data: { job, confirmation, idempotencyKey: key },
|
|
99
|
+
nextMove: `Monitor: ${runtime.programName} bulk read ${id}`,
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
function control(action) {
|
|
103
|
+
return async (runtime, args) => {
|
|
104
|
+
const id = jobId(runtime, args, action);
|
|
105
|
+
const raw = args.options["--expected-version"];
|
|
106
|
+
const parsed = bulkControlRequestSchema.safeParse({
|
|
107
|
+
expectedVersion: raw === undefined ? undefined : Number(raw),
|
|
108
|
+
});
|
|
109
|
+
if (!parsed.success)
|
|
110
|
+
throw new CliError("Give --expected-version from bulk read", "usage");
|
|
111
|
+
const active = await session(runtime, args);
|
|
112
|
+
const current = parseResponse("bulk.read", await active.client.request("bulk_read", id));
|
|
113
|
+
const confirmation = await confirmChange(runtime, args, {
|
|
114
|
+
action: `${action} remaining work in bulk job ${id}.`,
|
|
115
|
+
effect: [
|
|
116
|
+
`operation ${current.intent.operation}; state ${current.state}; expected version ${String(parsed.data.expectedVersion)}`,
|
|
117
|
+
"This controls the job, not selected schedules. Already-started items may finish; committed effects remain.",
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
const job = parseResponse(`bulk.${action}`, await active.client.request(`bulk_${action}`, id, parsed.data));
|
|
121
|
+
return { ...renderJob(job), data: { job, confirmation } };
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function list(items) {
|
|
125
|
+
return async (runtime, args) => {
|
|
126
|
+
const id = items ? jobId(runtime, args, "items") : undefined;
|
|
127
|
+
const active = await session(runtime, args);
|
|
128
|
+
const operation = items ? "bulk.items" : "bulk.list";
|
|
129
|
+
const state = args.options["--state"];
|
|
130
|
+
const parsed = (items ? bulkItemListRequestSchema : bulkListRequestSchema).safeParse({
|
|
131
|
+
...pageRequest(args),
|
|
132
|
+
...(state === undefined ? {} : { state }),
|
|
133
|
+
});
|
|
134
|
+
if (!parsed.success)
|
|
135
|
+
throw new CliError("Invalid bulk listing page or state", "usage");
|
|
136
|
+
const command = `${runtime.programName} bulk ${items ? `items ${id}` : "list"}${state === undefined ? "" : ` --state ${state}`}`;
|
|
137
|
+
const pages = await drainPages(runtime, args, pageRequest(args), async (request) => {
|
|
138
|
+
const query = { ...request, ...(state === undefined ? {} : { state }) };
|
|
139
|
+
const response = items
|
|
140
|
+
? await active.client.request("bulk_items", id, query)
|
|
141
|
+
: await active.client.request("bulk_list", query);
|
|
142
|
+
const page = parseResponse(operation, response);
|
|
143
|
+
return { ...page, freshness: { kind: "authoritative" } };
|
|
144
|
+
});
|
|
145
|
+
const note = continuationNote(pages, command);
|
|
146
|
+
return {
|
|
147
|
+
data: {
|
|
148
|
+
rows: pages.rows,
|
|
149
|
+
pages: pages.pages,
|
|
150
|
+
ceilingReached: pages.ceilingReached,
|
|
151
|
+
...(pages.nextCursor === undefined
|
|
152
|
+
? {}
|
|
153
|
+
: { nextCursor: pages.nextCursor }),
|
|
154
|
+
},
|
|
155
|
+
human: table(items
|
|
156
|
+
? ["STATE", "REASON", "SCHEDULE", "ITEM"]
|
|
157
|
+
: ["STATE", "OPERATION", "MATCHED", "JOB"], pages.rows.map((row) => "intent" in row
|
|
158
|
+
? [
|
|
159
|
+
row.state,
|
|
160
|
+
row.intent.operation,
|
|
161
|
+
String(row.counts.matched),
|
|
162
|
+
row.id,
|
|
163
|
+
]
|
|
164
|
+
: [row.state, row.reason, row.scheduleId, row.id]), items ? "No items match this page." : "No authorized bulk jobs match."),
|
|
165
|
+
...(note === undefined ? {} : { nextMove: note }),
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export const BULK_COMMANDS = {
|
|
170
|
+
"bulk preview": preview,
|
|
171
|
+
"bulk confirm": confirm,
|
|
172
|
+
"bulk read": read,
|
|
173
|
+
"bulk list": list(false),
|
|
174
|
+
"bulk items": list(true),
|
|
175
|
+
"bulk pause": control("pause"),
|
|
176
|
+
"bulk resume": control("resume"),
|
|
177
|
+
"bulk cancel": control("cancel"),
|
|
178
|
+
};
|
package/dist/catalog.js
CHANGED
|
@@ -279,6 +279,111 @@ export const MEMBER_COMMANDS = [
|
|
|
279
279
|
},
|
|
280
280
|
];
|
|
281
281
|
export const COMMAND_CATALOG = [
|
|
282
|
+
{
|
|
283
|
+
group: "Bulk operations",
|
|
284
|
+
words: "bulk preview",
|
|
285
|
+
summary: "Prepare an exact all-matching or selected-ID preview for retry, cancel, reschedule, pause or resume.",
|
|
286
|
+
operations: ["bulk.preview"],
|
|
287
|
+
positionals: [],
|
|
288
|
+
options: [
|
|
289
|
+
API_URL,
|
|
290
|
+
{
|
|
291
|
+
flag: "--intent",
|
|
292
|
+
value: "<json|@file|->",
|
|
293
|
+
summary: "Canonical bulk request: operation, target, selection and operation-specific parameters. Preview never sends deliveries or changes schedules.",
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
group: "Bulk operations",
|
|
299
|
+
words: "bulk confirm",
|
|
300
|
+
summary: "Confirm the exact frozen preview and start its bounded asynchronous job.",
|
|
301
|
+
operations: ["bulk.read", "bulk.confirm"],
|
|
302
|
+
confirms: true,
|
|
303
|
+
positionals: [
|
|
304
|
+
{ name: "<job>", summary: "The bjo_ identity returned by bulk preview." },
|
|
305
|
+
],
|
|
306
|
+
options: [
|
|
307
|
+
API_URL,
|
|
308
|
+
CONFIRM,
|
|
309
|
+
IDEMPOTENCY,
|
|
310
|
+
{
|
|
311
|
+
flag: "--intent-digest",
|
|
312
|
+
value: "<digest>",
|
|
313
|
+
summary: "Exact digest from the complete unexpired preview.",
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
flag: "--acknowledge-duplicate-effects",
|
|
317
|
+
summary: "Explicitly acknowledge retry's potential duplicate receiver effects; required for retry.",
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
group: "Bulk operations",
|
|
323
|
+
words: "bulk read",
|
|
324
|
+
summary: "Read preview progress, job counts, limits, warnings and results.",
|
|
325
|
+
operations: ["bulk.read"],
|
|
326
|
+
positionals: [
|
|
327
|
+
{ name: "<job>", summary: "The bjo_ identity returned by bulk preview." },
|
|
328
|
+
],
|
|
329
|
+
options: [API_URL],
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
group: "Bulk operations",
|
|
333
|
+
words: "bulk list",
|
|
334
|
+
summary: "List authorized retained bulk jobs in this project and environment.",
|
|
335
|
+
operations: ["bulk.list"],
|
|
336
|
+
positionals: [],
|
|
337
|
+
options: [
|
|
338
|
+
API_URL,
|
|
339
|
+
...PAGE_OPTIONS,
|
|
340
|
+
{
|
|
341
|
+
flag: "--state",
|
|
342
|
+
value: "<state>",
|
|
343
|
+
summary: "Only jobs in this lifecycle state.",
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
group: "Bulk operations",
|
|
349
|
+
words: "bulk items",
|
|
350
|
+
summary: "Inspect every frozen target and its durable outcome.",
|
|
351
|
+
operations: ["bulk.items"],
|
|
352
|
+
positionals: [
|
|
353
|
+
{ name: "<job>", summary: "The bjo_ identity returned by bulk preview." },
|
|
354
|
+
],
|
|
355
|
+
options: [
|
|
356
|
+
API_URL,
|
|
357
|
+
...PAGE_OPTIONS,
|
|
358
|
+
{
|
|
359
|
+
flag: "--state",
|
|
360
|
+
value: "<state>",
|
|
361
|
+
summary: "Only items in this state.",
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
},
|
|
365
|
+
...["pause", "resume", "cancel"].map((action) => ({
|
|
366
|
+
group: "Bulk operations",
|
|
367
|
+
words: `bulk ${action}`,
|
|
368
|
+
summary: `${action[0].toUpperCase()}${action.slice(1)} remaining bulk-job work; already-started items may finish.`,
|
|
369
|
+
operations: ["bulk.read", `bulk.${action}`],
|
|
370
|
+
confirms: true,
|
|
371
|
+
positionals: [
|
|
372
|
+
{
|
|
373
|
+
name: "<job>",
|
|
374
|
+
summary: "The bjo_ identity returned by bulk preview.",
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
options: [
|
|
378
|
+
API_URL,
|
|
379
|
+
CONFIRM,
|
|
380
|
+
{
|
|
381
|
+
flag: "--expected-version",
|
|
382
|
+
value: "<version>",
|
|
383
|
+
summary: "Mandatory job version from bulk read; stale controls conflict.",
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
})),
|
|
282
387
|
{
|
|
283
388
|
group: "Planning",
|
|
284
389
|
words: "notifications show",
|
|
@@ -388,6 +493,14 @@ export const COMMAND_CATALOG = [
|
|
|
388
493
|
summary: "Sign out, and forget the credential stored on this machine.",
|
|
389
494
|
words: "logout",
|
|
390
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
|
+
},
|
|
391
504
|
{
|
|
392
505
|
group: "Session",
|
|
393
506
|
operations: ["context.read"],
|
|
@@ -557,6 +670,58 @@ export const COMMAND_CATALOG = [
|
|
|
557
670
|
summary: "Find retained runs, newest first, by host identifier or tag.",
|
|
558
671
|
words: "executions list",
|
|
559
672
|
},
|
|
673
|
+
{
|
|
674
|
+
group: "Schedules",
|
|
675
|
+
operations: ["attempts.search"],
|
|
676
|
+
options: [
|
|
677
|
+
API_URL,
|
|
678
|
+
{
|
|
679
|
+
flag: "--started-from",
|
|
680
|
+
value: "<instant>",
|
|
681
|
+
summary: "Required UTC attempt start, inclusive (RFC 3339).",
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
flag: "--started-before",
|
|
685
|
+
value: "<instant>",
|
|
686
|
+
summary: "Required UTC attempt end, exclusive (RFC 3339).",
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
flag: "--http-status",
|
|
690
|
+
value: "<status>",
|
|
691
|
+
summary: "Only attempts with this HTTP response status, such as 503.",
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
flag: "--status-class",
|
|
695
|
+
value: "<class>",
|
|
696
|
+
summary: "Only one HTTP status class: 1xx through 5xx.",
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
flag: "--outcome",
|
|
700
|
+
value: "<outcome>",
|
|
701
|
+
summary: "The matched attempt's outcome, independently of later recovery.",
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
flag: "--diagnostic",
|
|
705
|
+
value: "<category>",
|
|
706
|
+
summary: "A classified attempt diagnostic, including transport failures without a response.",
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
flag: "--endpoint",
|
|
710
|
+
value: "<destination>",
|
|
711
|
+
summary: "Only attempts for this destination (ep_).",
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
flag: "--schedule",
|
|
715
|
+
value: "<schedule>",
|
|
716
|
+
summary: "Only attempts for this schedule (sch_).",
|
|
717
|
+
},
|
|
718
|
+
...CORRELATION_FILTER_OPTIONS,
|
|
719
|
+
...PAGE_OPTIONS,
|
|
720
|
+
],
|
|
721
|
+
positionals: [],
|
|
722
|
+
summary: "Search retained attempts by actual UTC start time, including failures that later recovered.",
|
|
723
|
+
words: "attempts search",
|
|
724
|
+
},
|
|
560
725
|
/* --- Schedules -------------------------------------------------------- */
|
|
561
726
|
{
|
|
562
727
|
group: "Schedules",
|
|
@@ -566,9 +731,29 @@ export const COMMAND_CATALOG = [
|
|
|
566
731
|
...CORRELATION_FILTER_OPTIONS,
|
|
567
732
|
{
|
|
568
733
|
flag: "--endpoint",
|
|
569
|
-
summary: "Only
|
|
734
|
+
summary: "Only schedules that call this destination (ep_), or none for timer-only.",
|
|
570
735
|
value: "<destination>",
|
|
571
736
|
},
|
|
737
|
+
{
|
|
738
|
+
flag: "--status",
|
|
739
|
+
value: "<status>",
|
|
740
|
+
summary: "Active, paused, cancelled or completed schedules.",
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
flag: "--recurrence",
|
|
744
|
+
value: "<kind>",
|
|
745
|
+
summary: "One recurrence kind: at, every or cron.",
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
flag: "--text",
|
|
749
|
+
value: "<text>",
|
|
750
|
+
summary: "Case-insensitive text in a schedule name, address or identifier.",
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
flag: "--due-before",
|
|
754
|
+
value: "<instant>",
|
|
755
|
+
summary: "Only schedules with an unsettled run before this RFC 3339 instant.",
|
|
756
|
+
},
|
|
572
757
|
...PAGE_OPTIONS,
|
|
573
758
|
],
|
|
574
759
|
positionals: [],
|
|
@@ -654,6 +839,21 @@ export const COMMAND_CATALOG = [
|
|
|
654
839
|
summary: "JSON your receiver gets with the call. Inline, @file.json, or - for standard input.",
|
|
655
840
|
value: "<json>",
|
|
656
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
|
+
},
|
|
852
|
+
{
|
|
853
|
+
flag: "--delivery-timeout",
|
|
854
|
+
value: "<duration>",
|
|
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.",
|
|
856
|
+
},
|
|
657
857
|
IDEMPOTENCY,
|
|
658
858
|
{
|
|
659
859
|
flag: "--wait",
|
|
@@ -736,6 +936,21 @@ export const COMMAND_CATALOG = [
|
|
|
736
936
|
summary: "JSON your receiver gets with the call. Inline, @file.json, or - for standard input.",
|
|
737
937
|
value: "<json>",
|
|
738
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
|
+
},
|
|
949
|
+
{
|
|
950
|
+
flag: "--delivery-timeout",
|
|
951
|
+
value: "<duration>",
|
|
952
|
+
summary: "Override only the receiver timeout, such as 20s or 5m (whole seconds). Keeps completion and overlap settings. Response completion allows up to 30m; existing callback completion allows up to 1d. Omit to preserve the timeout.",
|
|
953
|
+
},
|
|
739
954
|
IF_MATCH,
|
|
740
955
|
CONFIRM,
|
|
741
956
|
],
|
|
@@ -745,7 +960,7 @@ export const COMMAND_CATALOG = [
|
|
|
745
960
|
summary: "The schedule's id, starting sch_, from schedules create or schedules list.",
|
|
746
961
|
},
|
|
747
962
|
],
|
|
748
|
-
summary: "Change when a schedule runs.
|
|
963
|
+
summary: "Change when a schedule runs. Destination, data, HTTP and delivery settings stay unless you give new ones. Timing is replaced; latency class and lateness policy are re-derived; pending occurrences are discarded. A concurrent edit can refuse this change.",
|
|
749
964
|
words: "schedules replace",
|
|
750
965
|
},
|
|
751
966
|
{
|
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
|
: {
|
|
@@ -803,12 +811,20 @@ const schedulesRead = async (runtime, args) => {
|
|
|
803
811
|
const answer = await session.client.requestDetailed("schedules_read", id);
|
|
804
812
|
const view = responseSchemaFor("schedules.read")?.parse(answer.body);
|
|
805
813
|
const etag = answer.headers["ETag"];
|
|
814
|
+
const failedOneShot = view.schedule.status === "active" &&
|
|
815
|
+
view.schedule.recurrence.kind === "at" &&
|
|
816
|
+
view.occurrence.generation === view.schedule.generation &&
|
|
817
|
+
view.occurrence.state === "terminal_failed";
|
|
806
818
|
return {
|
|
807
819
|
data: {
|
|
808
820
|
...(etag === undefined ? {} : { etag }),
|
|
809
821
|
recurrence: view.schedule.recurrence.kind,
|
|
810
822
|
schedule: view.schedule.id,
|
|
811
823
|
status: view.schedule.status,
|
|
824
|
+
...(view.schedule.delivery === undefined
|
|
825
|
+
? {}
|
|
826
|
+
: { delivery: view.schedule.delivery }),
|
|
827
|
+
...(failedOneShot ? { runState: view.occurrence.state } : {}),
|
|
812
828
|
autoPauseAfterFailures: view.schedule.autoPauseAfterFailures,
|
|
813
829
|
consecutiveFailedRuns: view.schedule.consecutiveFailedRuns,
|
|
814
830
|
autoPauseReason: view.schedule.autoPauseReason,
|
|
@@ -820,7 +836,15 @@ const schedulesRead = async (runtime, args) => {
|
|
|
820
836
|
},
|
|
821
837
|
human: [
|
|
822
838
|
`Schedule ${view.schedule.id}`,
|
|
823
|
-
`Status ${view.schedule.status}`,
|
|
839
|
+
`Status ${failedOneShot ? "failed (schedule active)" : view.schedule.status}`,
|
|
840
|
+
...(failedOneShot
|
|
841
|
+
? ["Next The only run failed. Nothing more is planned."]
|
|
842
|
+
: []),
|
|
843
|
+
...(view.schedule.delivery === undefined
|
|
844
|
+
? []
|
|
845
|
+
: [
|
|
846
|
+
`Delivery ${String(view.schedule.delivery.maxDurationSeconds)}s timeout (${view.schedule.delivery.completion}, overlap ${view.schedule.delivery.onOverlap})`,
|
|
847
|
+
]),
|
|
824
848
|
`Auto-pause ${String(view.schedule.autoPauseAfterFailures ?? 5)}`,
|
|
825
849
|
`Failed runs ${String(view.schedule.consecutiveFailedRuns ?? 0)}`,
|
|
826
850
|
...(view.schedule.autoPauseReason == null
|
|
@@ -1036,6 +1060,7 @@ const IMPLEMENTATIONS = {
|
|
|
1036
1060
|
"occurrences wait": occurrencesWait,
|
|
1037
1061
|
"schedules create": schedulesCreate,
|
|
1038
1062
|
"schedules read": schedulesRead,
|
|
1063
|
+
token,
|
|
1039
1064
|
whoami,
|
|
1040
1065
|
};
|
|
1041
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,5 +1,6 @@
|
|
|
1
|
+
import { BULK_COMMANDS } from "./bulk.js";
|
|
1
2
|
import { writeFile } from "node:fs/promises";
|
|
2
|
-
import { projectNotificationSettingsUpdateRequestSchema, statusSigningKeyRequestSchema, autoPauseAfterFailuresSchema, httpRequestSchema, externalIdentifierSchema, scheduleTagsSchema, scheduleTagSchema, 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";
|
|
3
4
|
import { parseDuration } from "./args.js";
|
|
4
5
|
import { confirmChange } from "./confirm.js";
|
|
5
6
|
import { redactSecret } from "./output.js";
|
|
@@ -79,7 +80,7 @@ const RECURRENCE_FLAGS = ["--in", "--at", "--every", "--cron"];
|
|
|
79
80
|
* machine's would make the same expression mean different things on a laptop
|
|
80
81
|
* and on a build runner.
|
|
81
82
|
*/
|
|
82
|
-
async function scheduleRequest(runtime, args, usage) {
|
|
83
|
+
async function scheduleRequest(runtime, args, usage, mode = "create") {
|
|
83
84
|
const given = RECURRENCE_FLAGS.filter((flag) => args.options[flag] !== undefined);
|
|
84
85
|
if (given.length !== 1) {
|
|
85
86
|
throw new CliError(given.length === 0
|
|
@@ -125,6 +126,33 @@ async function scheduleRequest(runtime, args, usage) {
|
|
|
125
126
|
throw new CliError('--auto-pause-after-failures must be a whole number from 1 to 100, or "never"', "usage");
|
|
126
127
|
const latencyClass = args.options["--latency-class"];
|
|
127
128
|
const partitionKey = args.options["--partition-key"];
|
|
129
|
+
const timeoutInput = args.options["--delivery-timeout"];
|
|
130
|
+
const timeout = timeoutInput === undefined
|
|
131
|
+
? undefined
|
|
132
|
+
: deliveryDurationSecondsSchema.safeParse(parseDuration(timeoutInput));
|
|
133
|
+
if (timeout !== undefined && !timeout.success)
|
|
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);
|
|
128
156
|
const external_identifier = args.options["--external-identifier"];
|
|
129
157
|
if (external_identifier !== undefined &&
|
|
130
158
|
!externalIdentifierSchema.safeParse(external_identifier).success)
|
|
@@ -138,6 +166,7 @@ async function scheduleRequest(runtime, args, usage) {
|
|
|
138
166
|
...(external_identifier === undefined ? {} : { external_identifier }),
|
|
139
167
|
...(parsedTags?.success ? { tags: parsedTags.data } : {}),
|
|
140
168
|
...(http?.success ? { http: http.data } : {}),
|
|
169
|
+
...(Object.keys(delivery).length === 0 ? {} : { delivery }),
|
|
141
170
|
...(endpoint === undefined ? {} : { endpoint }),
|
|
142
171
|
...(url === undefined ? {} : { url }),
|
|
143
172
|
...(latencyClass === undefined ? {} : { latencyClass }),
|
|
@@ -147,6 +176,14 @@ async function scheduleRequest(runtime, args, usage) {
|
|
|
147
176
|
...recurrence,
|
|
148
177
|
};
|
|
149
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
|
+
}
|
|
150
187
|
/**
|
|
151
188
|
* Where a schedule written with `--url` delivers, and where its signing
|
|
152
189
|
* secret is.
|
|
@@ -352,16 +389,92 @@ const executionsList = async (runtime, args) => {
|
|
|
352
389
|
row: (run) => [run.state, run.plannedFor, run.schedule, run.id],
|
|
353
390
|
});
|
|
354
391
|
};
|
|
392
|
+
const attemptsSearch = async (runtime, args) => {
|
|
393
|
+
const from = args.options["--started-from"];
|
|
394
|
+
const before = args.options["--started-before"];
|
|
395
|
+
if (from === undefined || before === undefined)
|
|
396
|
+
throw new CliError("--started-from and --started-before are required UTC timestamps", "usage");
|
|
397
|
+
const parsed = projectAttemptSearchRequestSchema.safeParse({
|
|
398
|
+
...correlationFilters(args),
|
|
399
|
+
startedFrom: parseInstant(from, "--started-from"),
|
|
400
|
+
startedBefore: parseInstant(before, "--started-before"),
|
|
401
|
+
responseStatus: args.options["--http-status"] === undefined
|
|
402
|
+
? undefined
|
|
403
|
+
: Number(args.options["--http-status"]),
|
|
404
|
+
statusClass: args.options["--status-class"],
|
|
405
|
+
outcome: args.options["--outcome"],
|
|
406
|
+
diagnostic: args.options["--diagnostic"],
|
|
407
|
+
endpoint: args.options["--endpoint"],
|
|
408
|
+
schedule: args.options["--schedule"],
|
|
409
|
+
});
|
|
410
|
+
if (!parsed.success)
|
|
411
|
+
throw new CliError("The attempt filters are invalid", "usage", parsed.error.issues
|
|
412
|
+
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
413
|
+
.join("; "));
|
|
414
|
+
const active = await session(runtime, args);
|
|
415
|
+
const flags = [
|
|
416
|
+
"--started-from",
|
|
417
|
+
"--started-before",
|
|
418
|
+
"--http-status",
|
|
419
|
+
"--status-class",
|
|
420
|
+
"--outcome",
|
|
421
|
+
"--diagnostic",
|
|
422
|
+
"--endpoint",
|
|
423
|
+
"--schedule",
|
|
424
|
+
];
|
|
425
|
+
return listing(runtime, args, {
|
|
426
|
+
columns: [
|
|
427
|
+
"STARTED (UTC)",
|
|
428
|
+
"ATTEMPT OUTCOME",
|
|
429
|
+
"HTTP / DIAGNOSTIC",
|
|
430
|
+
"RUN OUTCOME",
|
|
431
|
+
"SCHEDULE",
|
|
432
|
+
"RUN",
|
|
433
|
+
"ATTEMPT",
|
|
434
|
+
],
|
|
435
|
+
command: `${runtime.programName} attempts search${flags.flatMap((flag) => (args.options[flag] === undefined ? [] : [` ${flag} '${args.options[flag].replaceAll("'", "'\"'\"'")}'`])).join("")}${correlationCommand(args)}`,
|
|
436
|
+
empty: "No retained attempts match these filters. Older evidence may have expired.",
|
|
437
|
+
read: (request) => page("attempts.search", () => active.client.request("attempts_search", {
|
|
438
|
+
...normalizeAttemptSelection(parsed.data),
|
|
439
|
+
...request,
|
|
440
|
+
})),
|
|
441
|
+
row: (result) => [
|
|
442
|
+
result.attempt.startedAt,
|
|
443
|
+
result.attempt.state ?? "in_flight",
|
|
444
|
+
result.attempt.responseStatus?.toString() ??
|
|
445
|
+
result.attempt.diagnostic ??
|
|
446
|
+
"pending",
|
|
447
|
+
result.occurrence.state,
|
|
448
|
+
result.schedule.id,
|
|
449
|
+
result.occurrence.id,
|
|
450
|
+
result.attempt.id,
|
|
451
|
+
],
|
|
452
|
+
});
|
|
453
|
+
};
|
|
355
454
|
const schedulesList = async (runtime, args) => {
|
|
356
|
-
const
|
|
455
|
+
const parsed = scheduleSelectionSchema.safeParse({
|
|
456
|
+
...correlationFilters(args),
|
|
457
|
+
endpoint: args.options["--endpoint"],
|
|
458
|
+
status: args.options["--status"],
|
|
459
|
+
recurrence: args.options["--recurrence"],
|
|
460
|
+
text: args.options["--text"],
|
|
461
|
+
dueBefore: args.options["--due-before"] === undefined
|
|
462
|
+
? undefined
|
|
463
|
+
: parseInstant(args.options["--due-before"], "--due-before"),
|
|
464
|
+
});
|
|
465
|
+
if (!parsed.success)
|
|
466
|
+
throw new CliError("The schedule filters are invalid", "usage", parsed.error.issues
|
|
467
|
+
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
468
|
+
.join("; "));
|
|
469
|
+
const filters = normalizeScheduleSelection(parsed.data);
|
|
357
470
|
const active = await session(runtime, args);
|
|
358
471
|
const endpoint = args.options["--endpoint"];
|
|
359
472
|
return listing(runtime, args, {
|
|
360
473
|
columns: ["STATUS", "NAME", "RECURRENCE", "DELIVERS TO", "SCHEDULE"],
|
|
361
|
-
command: `${runtime.programName} schedules list${endpoint === undefined ? "" : ` --endpoint ${endpoint}`}${correlationCommand(args)}`,
|
|
474
|
+
command: `${runtime.programName} schedules list${endpoint === undefined ? "" : ` --endpoint ${endpoint}`}${correlationCommand(args)}${["--status", "--recurrence", "--text", "--due-before"].flatMap((flag) => (args.options[flag] === undefined ? [] : [` ${flag} '${args.options[flag].replaceAll("'", "'\"'\"'")}'`])).join("")}`,
|
|
362
475
|
// An empty filtered page answers the filter, not the project: "no
|
|
363
476
|
// schedules yet" read as the project being empty when nothing matched.
|
|
364
|
-
empty: Object.keys(filters).
|
|
477
|
+
empty: Object.keys(filters).some((key) => key !== "endpoint")
|
|
365
478
|
? "No schedule matches these filters."
|
|
366
479
|
: endpoint === undefined
|
|
367
480
|
? "This project has no schedules yet."
|
|
@@ -390,11 +503,19 @@ const schedulesList = async (runtime, args) => {
|
|
|
390
503
|
async function scheduleChange(runtime, args, options) {
|
|
391
504
|
const active = await session(runtime, args);
|
|
392
505
|
let confirmation;
|
|
506
|
+
let currentView;
|
|
393
507
|
if (options.confirm !== undefined) {
|
|
394
|
-
const
|
|
508
|
+
const answer = await active.client.requestDetailed("schedules_read", options.id);
|
|
509
|
+
const current = parseResponse("schedules.read", answer.body).schedule;
|
|
510
|
+
currentView = {
|
|
511
|
+
schedule: current,
|
|
512
|
+
...(answer.headers["ETag"] === undefined
|
|
513
|
+
? {}
|
|
514
|
+
: { etag: answer.headers["ETag"] }),
|
|
515
|
+
};
|
|
395
516
|
confirmation = await confirmChange(runtime, args, options.confirm(current));
|
|
396
517
|
}
|
|
397
|
-
const answer = await options.act(active, options.id);
|
|
518
|
+
const answer = await options.act(active, options.id, currentView);
|
|
398
519
|
const view = parseResponse(options.operation, answer.body);
|
|
399
520
|
const nextMove = options.nextMove?.(view.schedule);
|
|
400
521
|
return {
|
|
@@ -424,15 +545,47 @@ async function scheduleChange(runtime, args, options) {
|
|
|
424
545
|
const schedulesReplace = async (runtime, args) => {
|
|
425
546
|
const usage = `${runtime.programName} schedules replace <schedule> (--endpoint <destination> | --url <https url>) (--in <duration> | --at <instant> | --every <duration> | --cron <expression>)`;
|
|
426
547
|
const [id] = positionals(args, ["<schedule>"], usage);
|
|
427
|
-
const request = await scheduleRequest(runtime, args, usage);
|
|
548
|
+
const request = await scheduleRequest(runtime, args, usage, "replace");
|
|
428
549
|
return scheduleChange(runtime, args, {
|
|
429
|
-
act: (active, scheduleId) =>
|
|
550
|
+
act: (active, scheduleId, current) => {
|
|
551
|
+
if (current === undefined)
|
|
552
|
+
throw new CliError("Replacement needs its schedule read", "internal");
|
|
553
|
+
const delivery = {
|
|
554
|
+
...current.schedule.delivery,
|
|
555
|
+
...request["delivery"],
|
|
556
|
+
};
|
|
557
|
+
const changesDelivery = request["delivery"] !== undefined;
|
|
558
|
+
validateDelivery(delivery);
|
|
559
|
+
if (changesDelivery &&
|
|
560
|
+
current.schedule.endpoint === undefined &&
|
|
561
|
+
request["endpoint"] === undefined &&
|
|
562
|
+
request["url"] === undefined)
|
|
563
|
+
throw new CliError("Delivery settings need a callback destination", "usage", "Give --endpoint or --url when attaching a callback to a timer-only schedule.");
|
|
564
|
+
// The API replaces delivery settings with defaults when callback fields
|
|
565
|
+
// are supplied. Carry all stored settings, including completion/overlap.
|
|
566
|
+
// Naming the retained endpoint also makes a delivery-only change reach
|
|
567
|
+
// that callback replacement path. Fence the read against concurrent edits.
|
|
568
|
+
return active.client.requestDetailed("schedules_replace", scheduleId, {
|
|
569
|
+
...request,
|
|
570
|
+
...(Object.keys(delivery).length === 0 ? {} : { delivery }),
|
|
571
|
+
...(changesDelivery &&
|
|
572
|
+
request["endpoint"] === undefined &&
|
|
573
|
+
request["url"] === undefined &&
|
|
574
|
+
current.schedule.endpoint !== undefined
|
|
575
|
+
? { endpoint: current.schedule.endpoint }
|
|
576
|
+
: {}),
|
|
577
|
+
}, {
|
|
578
|
+
...(current.etag === undefined ? {} : { "If-Match": current.etag }),
|
|
579
|
+
...conditionalHeaders(args),
|
|
580
|
+
});
|
|
581
|
+
},
|
|
430
582
|
confirm: (current) => ({
|
|
431
583
|
action: `Replace the definition of schedule ${current.id}.`,
|
|
432
584
|
effect: [
|
|
433
585
|
`now ${recurrenceOf(current)}, ${current.status}, version ${String(current.version)}`,
|
|
434
|
-
`after the timing on this command line. Destination, data and
|
|
435
|
-
` settings are kept unless this command line gives new ones.`,
|
|
586
|
+
`after the timing on this command line. Destination, data, HTTP and`,
|
|
587
|
+
` delivery settings are kept unless this command line gives new ones.`,
|
|
588
|
+
`Latency class and lateness policy are re-derived unless supplied.`,
|
|
436
589
|
`Occurrences already planned under the old definition are discarded.`,
|
|
437
590
|
],
|
|
438
591
|
}),
|
|
@@ -1769,6 +1922,7 @@ function notificationPreferenceResult(settings) {
|
|
|
1769
1922
|
/* -------------------------------------------------------------------------- */
|
|
1770
1923
|
/** The operate-and-diagnose commands, keyed by the words that select them. */
|
|
1771
1924
|
export const OPERATE_COMMANDS = {
|
|
1925
|
+
...BULK_COMMANDS,
|
|
1772
1926
|
"notifications show": async (runtime, args) => {
|
|
1773
1927
|
const [project] = positionals(args, ["<project>"], `${runtime.programName} notifications show <project>`);
|
|
1774
1928
|
const active = await session(runtime, args);
|
|
@@ -1887,6 +2041,7 @@ export const OPERATE_COMMANDS = {
|
|
|
1887
2041
|
"schedules cancel": schedulesCancel,
|
|
1888
2042
|
"schedules list": schedulesList,
|
|
1889
2043
|
"executions list": executionsList,
|
|
2044
|
+
"attempts search": attemptsSearch,
|
|
1890
2045
|
"schedules pause": schedulesPause,
|
|
1891
2046
|
"schedules replace": schedulesReplace,
|
|
1892
2047
|
"schedules resume": schedulesResume,
|
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
|
-
|
|
93
|
-
|
|
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
|
|
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
|
|
103
|
+
const secret = await presentableSecret(runtime, store, resolved.origin, stored);
|
|
107
104
|
return {
|
|
108
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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:
|
|
150
|
+
expiresAtMs: refreshedExpiresAtMs,
|
|
152
151
|
kind: "oauth",
|
|
153
152
|
refreshToken: rotated.refreshToken,
|
|
154
153
|
scope: rotated.scope,
|
|
155
154
|
});
|
|
156
|
-
return
|
|
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.
|
|
11
|
-
"@greatwait/contracts": "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.
|
|
51
|
+
"version": "0.3.1"
|
|
52
52
|
}
|