@bli-cockpit/cli 0.2.242 → 0.2.244
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/adapters/git-state.js +1 -0
- package/dist/adapters/raw-evidence-budget-admission.js +63 -0
- package/dist/adapters/raw-evidence-collection-state.js +6 -50
- package/dist/adapters/raw-evidence-git-diff.js +1 -0
- package/dist/agent-rules.js +8 -0
- package/dist/autostart-contract.js +12 -0
- package/dist/autostart-darwin-plist.js +2 -2
- package/dist/autostart-darwin.js +1 -1
- package/dist/autostart-remind.js +245 -0
- package/dist/autostart-self-heal.js +17 -1
- package/dist/autostart-windows-scripts.js +7 -7
- package/dist/autostart.js +16 -15
- package/dist/commands/approvals-check-where.js +14 -0
- package/dist/commands/approvals-render.js +8 -1
- package/dist/commands/approvals.js +3 -2
- package/dist/commands/autostart-command.js +10 -0
- package/dist/commands/autostart-heal.js +36 -1
- package/dist/commands/brain.js +1 -1
- package/dist/commands/cal-agenda.js +44 -0
- package/dist/commands/cal-availability-share.js +97 -0
- package/dist/commands/cal-booking.js +147 -0
- package/dist/commands/cal-join.js +62 -0
- package/dist/commands/cal-links.js +68 -0
- package/dist/commands/cal-people.js +136 -0
- package/dist/commands/cal-remind.js +176 -0
- package/dist/commands/cal-reminders.js +33 -0
- package/dist/commands/cal-suggest.js +89 -0
- package/dist/commands/cal.js +41 -1
- package/dist/commands/cycle-args.js +127 -0
- package/dist/commands/cycles.js +117 -0
- package/dist/commands/docs-comments-args.js +85 -0
- package/dist/commands/docs-comments.js +128 -0
- package/dist/commands/docs-files-args.js +49 -0
- package/dist/commands/docs-files.js +179 -0
- package/dist/commands/docs-formatting.js +45 -0
- package/dist/commands/docs-inbox-args.js +93 -0
- package/dist/commands/docs-inbox.js +126 -0
- package/dist/commands/docs-notion.js +5 -0
- package/dist/commands/docs-read.js +2 -1
- package/dist/commands/docs-recovery.js +173 -0
- package/dist/commands/docs-refs-args.js +104 -0
- package/dist/commands/docs-refs.js +153 -0
- package/dist/commands/docs-search-args.js +79 -0
- package/dist/commands/docs-search.js +64 -0
- package/dist/commands/docs-sync-args.js +82 -0
- package/dist/commands/docs-sync.js +197 -0
- package/dist/commands/docs-tree.js +136 -0
- package/dist/commands/docs.js +85 -51
- package/dist/commands/doctor-update.js +4 -1
- package/dist/commands/inbox-args.js +81 -0
- package/dist/commands/inbox.js +88 -0
- package/dist/commands/issue-batch.js +61 -0
- package/dist/commands/issue-relations.js +151 -0
- package/dist/commands/issue-write.js +38 -5
- package/dist/commands/issue.js +32 -1
- package/dist/commands/local-args-collector-sources.js +2 -1
- package/dist/commands/local-args-tower-approvals.js +16 -4
- package/dist/commands/local-args-tower-cal-booking.js +81 -0
- package/dist/commands/local-args-tower-cal-links.js +42 -0
- package/dist/commands/local-args-tower-cal-remind.js +74 -0
- package/dist/commands/local-args-tower-cal-share.js +41 -0
- package/dist/commands/local-args-tower-cal.js +81 -8
- package/dist/commands/local-args-tower-docs-msg.js +98 -9
- package/dist/commands/local-args-tower-msg.js +222 -10
- package/dist/commands/local-args-tower-search.js +32 -5
- package/dist/commands/local-args-tower-work.js +110 -34
- package/dist/commands/local-auth.js +1 -1
- package/dist/commands/local-help-commands-tower-ops.js +6 -4
- package/dist/commands/local-help-commands-tower.js +47 -31
- package/dist/commands/local-help-commands.js +3 -1
- package/dist/commands/local-help-cycles.js +9 -0
- package/dist/commands/local-help-docs-files.js +10 -0
- package/dist/commands/local-help-docs-formatting.js +7 -0
- package/dist/commands/local-help-docs-inbox.js +10 -0
- package/dist/commands/local-help-docs-recovery.js +10 -0
- package/dist/commands/local-help-docs-refs.js +11 -0
- package/dist/commands/local-help-docs-sync.js +9 -0
- package/dist/commands/local-help-inbox.js +9 -0
- package/dist/commands/local-help-project.js +21 -0
- package/dist/commands/local-help-templates.js +9 -0
- package/dist/commands/local-help-views.js +10 -0
- package/dist/commands/local-help-work-notify.js +6 -0
- package/dist/commands/local-help.js +3 -3
- package/dist/commands/manager-hints.js +4 -2
- package/dist/commands/manager-limits.json +3 -1
- package/dist/commands/manager-read-reasons.js +1 -0
- package/dist/commands/manager-render-home.js +13 -5
- package/dist/commands/manager-render.js +3 -0
- package/dist/commands/manager.js +3 -1
- package/dist/commands/mcp-stdio-probe.js +1 -0
- package/dist/commands/msg-activity.js +134 -0
- package/dist/commands/msg-channel-args.js +92 -0
- package/dist/commands/msg-channel.js +125 -0
- package/dist/commands/msg-context-args.js +67 -0
- package/dist/commands/msg-context.js +65 -0
- package/dist/commands/msg-dm.js +28 -0
- package/dist/commands/msg-file.js +185 -0
- package/dist/commands/msg-mark-args.js +78 -0
- package/dist/commands/msg-mark.js +148 -0
- package/dist/commands/msg-notify-args.js +100 -0
- package/dist/commands/msg-notify.js +122 -0
- package/dist/commands/msg-schedule-args.js +114 -0
- package/dist/commands/msg-schedule.js +226 -0
- package/dist/commands/msg-search-args.js +71 -0
- package/dist/commands/msg-search.js +48 -0
- package/dist/commands/msg-write.js +15 -1
- package/dist/commands/msg.js +62 -39
- package/dist/commands/project-args.js +82 -0
- package/dist/commands/project.js +71 -7
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/search.js +23 -0
- package/dist/commands/session-sync-failures.js +17 -4
- package/dist/commands/session-sync-health.js +7 -2
- package/dist/commands/session-sync-hold.js +42 -1
- package/dist/commands/session-sync-outside-roots.js +72 -14
- package/dist/commands/session-sync-upload.js +2 -0
- package/dist/commands/session-sync.js +1 -0
- package/dist/commands/sources-check.js +63 -2
- package/dist/commands/sources-discovery.js +1 -1
- package/dist/commands/sources-reconciliation.js +87 -0
- package/dist/commands/sync-doctor.js +14 -1
- package/dist/commands/sync-report.js +1 -1
- package/dist/commands/template-args.js +122 -0
- package/dist/commands/templates.js +162 -0
- package/dist/commands/view-args.js +131 -0
- package/dist/commands/views.js +138 -0
- package/dist/commands/work-notify-args.js +67 -0
- package/dist/commands/work-notify.js +63 -0
- package/dist/delivery-hold-notice.js +34 -0
- package/dist/evidence-transcripts-client.js +1 -0
- package/dist/log-rotation.js +2 -2
- package/dist/notifier.js +83 -0
- package/dist/process-runner.js +4 -0
- package/dist/repo-identity-git.js +1 -0
- package/package.json +4 -4
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
2
|
+
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
3
|
+
const FAIL_TAG = "[cal cli]";
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 30000;
|
|
5
|
+
/** `cal people`, `cal availability` and `cal overlays` (BLI-21522): the same doors the browser's People box uses. */
|
|
6
|
+
export async function runCalPeople(command, door) {
|
|
7
|
+
if (command.action === "people")
|
|
8
|
+
return people(command, door);
|
|
9
|
+
if (command.action === "availability")
|
|
10
|
+
return availability(command, door);
|
|
11
|
+
return overlays(command, door);
|
|
12
|
+
}
|
|
13
|
+
async function search(door, label) {
|
|
14
|
+
const answer = await askAgentDoor(door, { method: "GET", path: `/api/cal/people?q=${encodeURIComponent(label)}`, label: "cal people", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
15
|
+
if (!answer.ok)
|
|
16
|
+
return { ok: false, code: failAgentDoor(door, FAIL_TAG, answer.reason, answer.detail) };
|
|
17
|
+
return { ok: true, people: answer.body.people ?? [] };
|
|
18
|
+
}
|
|
19
|
+
async function people(command, door) {
|
|
20
|
+
const found = await search(door, command.query ?? "");
|
|
21
|
+
if (!found.ok)
|
|
22
|
+
return found.code;
|
|
23
|
+
if (door.json)
|
|
24
|
+
return emitAgentDoor(door, { query: command.query, people: found.people });
|
|
25
|
+
if (!found.people.length)
|
|
26
|
+
writeLine(door.io.stdout, `Nobody on the team list is exactly "${command.query}". Names must match exactly; an email address works too.`);
|
|
27
|
+
for (const person of found.people)
|
|
28
|
+
writeLine(door.io.stdout, `${person.display_name} <${person.email}>${person.kind === "email" ? " (not on the team list)" : ""}`);
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
/** One label to one exact address, or a named failure. Ambiguity is never guessed. */
|
|
32
|
+
export async function resolveLabel(door, label) {
|
|
33
|
+
const found = await search(door, label);
|
|
34
|
+
if (!found.ok)
|
|
35
|
+
return found;
|
|
36
|
+
if (found.people.length === 1)
|
|
37
|
+
return { ok: true, email: found.people[0].email };
|
|
38
|
+
const detail = found.people.length === 0
|
|
39
|
+
? `Nobody on the team list is exactly "${label}". Use their full name as listed, or an email address.`
|
|
40
|
+
: `"${label}" fits more than one person (${found.people.map((p) => p.email).join(", ")}). Use an email address.`;
|
|
41
|
+
return { ok: false, code: failAgentDoor(door, FAIL_TAG, found.people.length === 0 ? "unknown_person" : "ambiguous_person", detail) };
|
|
42
|
+
}
|
|
43
|
+
export async function readPipedBody(door) {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(await readPipedText(door.io.stdin, { maxChars: 20000, overflowMessage: "The request is too large." }));
|
|
46
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function availability(command, door) {
|
|
53
|
+
let labels = command.peopleWith ?? [];
|
|
54
|
+
let from = command.from;
|
|
55
|
+
let to = command.to;
|
|
56
|
+
let tz = command.timeZone;
|
|
57
|
+
let account = command.accountName;
|
|
58
|
+
if (!labels.length && !isInteractiveStdin(door.io)) {
|
|
59
|
+
const piped = await readPipedBody(door);
|
|
60
|
+
if (piped === null)
|
|
61
|
+
return failAgentDoor(door, FAIL_TAG, "invalid_body", "Pipe a JSON object like {\"with\":[\"vee@x.ca\"],\"from\":\"2026-10-19\",\"to\":\"2026-10-23\"}, or use --with.");
|
|
62
|
+
const listed = piped["with"] ?? piped["emails"];
|
|
63
|
+
labels = Array.isArray(listed) ? listed.filter((v) => typeof v === "string") : [];
|
|
64
|
+
from ??= typeof piped["from"] === "string" ? piped["from"] : undefined;
|
|
65
|
+
to ??= typeof piped["to"] === "string" ? piped["to"] : undefined;
|
|
66
|
+
tz ??= typeof piped["tz"] === "string" ? piped["tz"] : undefined;
|
|
67
|
+
account ??= typeof piped["account_id"] === "string" ? piped["account_id"] : undefined;
|
|
68
|
+
}
|
|
69
|
+
if (!labels.length)
|
|
70
|
+
return failAgentDoor(door, FAIL_TAG, "invalid_body", "Name who to check: --with vee@bluepearl.ca (repeat it), or a name as on the team list.");
|
|
71
|
+
if (!from || !to)
|
|
72
|
+
return failAgentDoor(door, FAIL_TAG, "invalid_body", "Give the window: --from 2026-10-19 --to 2026-10-23 (a date or an ISO instant).");
|
|
73
|
+
const emails = [];
|
|
74
|
+
for (const label of labels) {
|
|
75
|
+
const resolved = await resolveLabel(door, label);
|
|
76
|
+
if (!resolved.ok)
|
|
77
|
+
return resolved.code;
|
|
78
|
+
emails.push(resolved.email);
|
|
79
|
+
}
|
|
80
|
+
const body = { emails: [...new Set(emails)], from, to, tz: tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone, ...(account ? { account_id: account } : {}) };
|
|
81
|
+
const answer = await askAgentDoor(door, { method: "POST", path: "/api/cal/availability", body, label: "cal availability", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
82
|
+
if (!answer.ok)
|
|
83
|
+
return failAgentDoor(door, FAIL_TAG, answer.reason, answer.detail);
|
|
84
|
+
if (door.json)
|
|
85
|
+
return emitAgentDoor(door, answer.body);
|
|
86
|
+
const result = answer.body;
|
|
87
|
+
const zone = result.window.time_zone;
|
|
88
|
+
const time = (iso) => new Intl.DateTimeFormat("en-GB", { timeZone: zone, weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(iso));
|
|
89
|
+
for (const person of result.people) {
|
|
90
|
+
if (person.status === "unknown")
|
|
91
|
+
writeLine(door.io.stdout, `${person.email}: unknown (${person.reason ?? "no answer"}). Not shown as free.`);
|
|
92
|
+
else if (!person.busy.length)
|
|
93
|
+
writeLine(door.io.stdout, `${person.email}: no busy time in this window`);
|
|
94
|
+
else {
|
|
95
|
+
writeLine(door.io.stdout, `${person.email}: busy`);
|
|
96
|
+
for (const block of person.busy)
|
|
97
|
+
writeLine(door.io.stdout, ` ${time(block.start)} to ${time(block.end)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const c = result.coverage;
|
|
101
|
+
writeLine(door.io.stdout, `${c.answered} of ${c.requested} answered, ${c.unknown} unknown · times in ${zone} · as of ${c.as_of}`);
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
async function overlays(command, door) {
|
|
105
|
+
let answer;
|
|
106
|
+
if (command.pinLabel || command.removeEmail) {
|
|
107
|
+
let body;
|
|
108
|
+
if (command.pinLabel) {
|
|
109
|
+
const found = await search(door, command.pinLabel);
|
|
110
|
+
if (!found.ok)
|
|
111
|
+
return found.code;
|
|
112
|
+
if (found.people.length !== 1) {
|
|
113
|
+
return failAgentDoor(door, FAIL_TAG, found.people.length ? "ambiguous_person" : "unknown_person", found.people.length ? `"${command.pinLabel}" fits more than one person (${found.people.map((p) => p.email).join(", ")}). Pin by email address.` : `Nobody on the team list is exactly "${command.pinLabel}". Pin by email address.`);
|
|
114
|
+
}
|
|
115
|
+
const person = found.people[0];
|
|
116
|
+
body = { pin: { email: person.email, person_id: person.person_id, label: person.display_name } };
|
|
117
|
+
}
|
|
118
|
+
else
|
|
119
|
+
body = { remove: command.removeEmail };
|
|
120
|
+
answer = await askAgentDoor(door, { method: "PATCH", path: "/api/cal/overlays", body, label: "cal overlays", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
answer = await askAgentDoor(door, { method: "GET", path: "/api/cal/overlays", label: "cal overlays", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
124
|
+
}
|
|
125
|
+
if (!answer.ok)
|
|
126
|
+
return failAgentDoor(door, FAIL_TAG, answer.reason, answer.detail);
|
|
127
|
+
if (door.json)
|
|
128
|
+
return emitAgentDoor(door, answer.body);
|
|
129
|
+
const result = answer.body;
|
|
130
|
+
if (result.changed)
|
|
131
|
+
writeLine(door.io.stdout, `Overlay ${result.changed}.`);
|
|
132
|
+
writeLine(door.io.stdout, `${result.overlays.length} of ${result.max} people pinned on your calendar`);
|
|
133
|
+
for (const pin of result.overlays)
|
|
134
|
+
writeLine(door.io.stdout, ` ${pin.label ?? pin.email} <${pin.email}>`);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit cal remind` - ONE tick of the laptop meeting reminder (BLI-21564, calendar slice 11).
|
|
3
|
+
*
|
|
4
|
+
* The OS scheduler runs this every minute (`com.bli.cockpit.remind` / `BLI Cockpit Remind`); there is no
|
|
5
|
+
* always-on process. Each tick is cheap and bounded:
|
|
6
|
+
*
|
|
7
|
+
* 1. Read the local cache `reminders.json` (collector state dir).
|
|
8
|
+
* 2. Handle every cached reminder whose time has come, BEFORE refreshing. A laptop that slept over the
|
|
9
|
+
* boundary wakes with a stale cache, and this order is what lets the 5-minute grace decide `shown`
|
|
10
|
+
* versus `missed` instead of the nudge silently vanishing.
|
|
11
|
+
* 3. Refresh from `/api/cal/reminders/due` at most every 5 minutes when reminders are on, every 15 when off.
|
|
12
|
+
*
|
|
13
|
+
* Handling one reminder: claim it (the server answers 409 `already_claimed` when another laptop won), then
|
|
14
|
+
* show it, then post the outcome. A claim after `start + 5 min` is recorded `missed` and shows nothing.
|
|
15
|
+
* Nothing here is silent: every branch writes one reason line to stderr (ids and counts, never a title).
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { getCollectorRuntimePaths } from "../local-state-paths.js";
|
|
20
|
+
import { showNotification } from "../notifier.js";
|
|
21
|
+
import { askAgentDoor, emitAgentDoor, openAgentDoor } from "./agent-door.js";
|
|
22
|
+
import { writeLine } from "./cli-io.js";
|
|
23
|
+
const TAG = "[cal remind]";
|
|
24
|
+
const GRACE_MS = 5 * 60_000;
|
|
25
|
+
const ENABLED_REFRESH_MS = 5 * 60_000;
|
|
26
|
+
const DISABLED_REFRESH_MS = 15 * 60_000;
|
|
27
|
+
const KEEP_AFTER_START_MS = 30 * 60_000;
|
|
28
|
+
const REQUEST_TIMEOUT_MS = 20_000;
|
|
29
|
+
export function cachePathFor(homeDir) {
|
|
30
|
+
return path.join(getCollectorRuntimePaths(homeDir).state_dir, "reminders.json");
|
|
31
|
+
}
|
|
32
|
+
function isReminder(value) {
|
|
33
|
+
const row = value;
|
|
34
|
+
return !!row && typeof row === "object" && typeof row.event_id === "string" && typeof row.occurrence_start === "string"
|
|
35
|
+
&& Number.isFinite(Date.parse(row.occurrence_start)) && Number.isFinite(Date.parse(row.deliver_at))
|
|
36
|
+
&& typeof row.lead_minutes === "number" && typeof row.title === "string";
|
|
37
|
+
}
|
|
38
|
+
export async function readReminderCache(cachePath, log = () => undefined) {
|
|
39
|
+
let text;
|
|
40
|
+
try {
|
|
41
|
+
text = await fs.readFile(cachePath, "utf8");
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code !== "ENOENT")
|
|
45
|
+
log(`${TAG} cache unreadable ${JSON.stringify({ reason: "read_failed", code: error.code ?? null })}`);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(text);
|
|
50
|
+
if (!Number.isFinite(Date.parse(parsed.checked_at)) || typeof parsed.settings?.enabled !== "boolean" || !Array.isArray(parsed.reminders))
|
|
51
|
+
throw new Error("shape");
|
|
52
|
+
return { ...parsed, reminders: parsed.reminders.filter(isReminder) };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
log(`${TAG} cache discarded ${JSON.stringify({ reason: "unparseable" })}`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function writeReminderCache(cachePath, cache) {
|
|
60
|
+
await fs.mkdir(path.dirname(cachePath), { recursive: true, mode: 0o700 });
|
|
61
|
+
const temp = `${cachePath}.${process.pid}.tmp`;
|
|
62
|
+
await fs.writeFile(temp, JSON.stringify(cache), { mode: 0o600 });
|
|
63
|
+
await fs.rename(temp, cachePath);
|
|
64
|
+
}
|
|
65
|
+
function bannerText(reminder, now) {
|
|
66
|
+
const minutes = Math.round((Date.parse(reminder.occurrence_start) - now.getTime()) / 60_000);
|
|
67
|
+
if (minutes > 0)
|
|
68
|
+
return `${reminder.title} starts in ${minutes} min`;
|
|
69
|
+
return minutes === 0 ? `${reminder.title} starts now` : `${reminder.title} started ${-minutes} min ago`;
|
|
70
|
+
}
|
|
71
|
+
export async function remindTick(input) {
|
|
72
|
+
const log = input.log ?? ((line) => console.error(line));
|
|
73
|
+
const notify = input.notify ?? ((request) => showNotification(request));
|
|
74
|
+
const now = input.now;
|
|
75
|
+
const report = { refreshed: false, refresh_reason: "fresh_enough", enabled: false, due_handled: 0, shown: 0, missed: 0, permission_denied: 0, notifier_failed: 0, claimed_elsewhere: 0, retry_later: 0, cached: 0 };
|
|
76
|
+
let cache = await readReminderCache(input.cachePath, log);
|
|
77
|
+
// What this tick already handled, so a refresh that still lists it cannot nudge twice in one run.
|
|
78
|
+
const handled = new Set();
|
|
79
|
+
async function handleDue(current) {
|
|
80
|
+
const keep = [];
|
|
81
|
+
for (const reminder of current.reminders) {
|
|
82
|
+
const start = Date.parse(reminder.occurrence_start);
|
|
83
|
+
if (now.getTime() > start + KEEP_AFTER_START_MS) {
|
|
84
|
+
log(`${TAG} dropped ${JSON.stringify({ reason: "long_past", lead_minutes: reminder.lead_minutes })}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (Date.parse(reminder.deliver_at) > now.getTime()) {
|
|
88
|
+
keep.push(reminder);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const handledKey = `${reminder.event_id}|${Date.parse(reminder.occurrence_start)}|${reminder.lead_minutes}`;
|
|
92
|
+
if (handled.has(handledKey))
|
|
93
|
+
continue;
|
|
94
|
+
handled.add(handledKey);
|
|
95
|
+
report.due_handled += 1;
|
|
96
|
+
const claim = await input.ask("POST", "/api/cal/reminders/claim", { event_id: reminder.event_id, occurrence_start: reminder.occurrence_start, lead_minutes: reminder.lead_minutes, device_label: input.deviceLabel ?? process.platform });
|
|
97
|
+
if (!claim.ok) {
|
|
98
|
+
if (claim.httpStatus === 409 && claim.reason === "already_claimed") {
|
|
99
|
+
report.claimed_elsewhere += 1;
|
|
100
|
+
log(`${TAG} skipped ${JSON.stringify({ reason: "already_claimed" })}`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (claim.httpStatus === 409) {
|
|
104
|
+
current.checked_at = new Date(0).toISOString();
|
|
105
|
+
log(`${TAG} skipped ${JSON.stringify({ reason: claim.reason })}`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (claim.httpStatus === 404) {
|
|
109
|
+
log(`${TAG} dropped ${JSON.stringify({ reason: claim.reason })}`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
report.retry_later += 1;
|
|
113
|
+
keep.push(reminder);
|
|
114
|
+
log(`${TAG} claim failed, will retry ${JSON.stringify({ reason: claim.reason, http_status: claim.httpStatus ?? null })}`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
let outcome;
|
|
118
|
+
if (now.getTime() > start + GRACE_MS) {
|
|
119
|
+
outcome = { outcome: "missed", reason: `late_${Math.round((now.getTime() - start) / 60_000)}m` };
|
|
120
|
+
report.missed += 1;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
outcome = await notify({ title: "TOWER", message: bannerText(reminder, now), sound: current.settings.sound });
|
|
124
|
+
report[outcome.outcome === "shown" ? "shown" : outcome.outcome] += 1;
|
|
125
|
+
}
|
|
126
|
+
const posted = await input.ask("POST", "/api/cal/reminders/outcome", { event_id: reminder.event_id, occurrence_start: reminder.occurrence_start, lead_minutes: reminder.lead_minutes, outcome: outcome.outcome, ...(outcome.reason ? { reason: outcome.reason } : {}) });
|
|
127
|
+
log(`${TAG} ${outcome.outcome} ${JSON.stringify({ reason: outcome.reason, lead_minutes: reminder.lead_minutes, outcome_recorded: posted.ok, ...(posted.ok ? {} : { record_error: posted.reason }) })}`);
|
|
128
|
+
}
|
|
129
|
+
current.reminders = keep;
|
|
130
|
+
}
|
|
131
|
+
if (cache)
|
|
132
|
+
await handleDue(cache);
|
|
133
|
+
const interval = cache?.settings.enabled ? ENABLED_REFRESH_MS : DISABLED_REFRESH_MS;
|
|
134
|
+
const age = cache ? now.getTime() - Date.parse(cache.checked_at) : Number.POSITIVE_INFINITY;
|
|
135
|
+
if (!cache || age >= interval || age < 0) {
|
|
136
|
+
report.refresh_reason = cache ? "interval_elapsed" : "no_cache";
|
|
137
|
+
const due = await input.ask("GET", "/api/cal/reminders/due?within_minutes=15");
|
|
138
|
+
if (due.ok) {
|
|
139
|
+
const body = due.body;
|
|
140
|
+
cache = { checked_at: now.toISOString(), settings: { enabled: body.settings?.enabled === true, lead_minutes: body.settings?.lead_minutes ?? 10, sound: body.settings?.sound !== false }, reminders: (body.reminders ?? []).filter(isReminder) };
|
|
141
|
+
report.refreshed = true;
|
|
142
|
+
await handleDue(cache);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
report.refresh_reason = `refresh_failed:${due.reason}`;
|
|
146
|
+
log(`${TAG} refresh failed ${JSON.stringify({ reason: due.reason, http_status: due.httpStatus ?? null, had_cache: cache !== null })}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (cache) {
|
|
150
|
+
report.enabled = cache.settings.enabled;
|
|
151
|
+
report.cached = cache.reminders.length;
|
|
152
|
+
await writeReminderCache(input.cachePath, cache);
|
|
153
|
+
}
|
|
154
|
+
// An idle tick (nothing due, cache still fresh) stays out of the log: the job runs every minute. Anything that
|
|
155
|
+
// decided something, refreshed, or failed says so.
|
|
156
|
+
const idle = !report.refreshed && report.due_handled === 0 && report.refresh_reason === "fresh_enough";
|
|
157
|
+
if (!idle)
|
|
158
|
+
log(`${TAG} tick ${JSON.stringify(report)}`);
|
|
159
|
+
return report;
|
|
160
|
+
}
|
|
161
|
+
/** `cockpit cal remind [--json] [--quiet]`: one tick. An unpaired machine says so and exits 0, so the scheduler is not marked failing. */
|
|
162
|
+
export async function runCalRemind(command, io) {
|
|
163
|
+
let door;
|
|
164
|
+
try {
|
|
165
|
+
door = await openAgentDoor("cal remind", command, io);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
writeLine(io.stderr, `${TAG} skipped ${JSON.stringify({ reason: "not_paired" })}`);
|
|
169
|
+
if (command.json)
|
|
170
|
+
writeLine(io.stdout, JSON.stringify({ ok: false, error: "not_paired", detail: error instanceof Error ? error.message : "no paired session" }));
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
const ask = (method, requestPath, body) => askAgentDoor(door, { method, path: requestPath, label: `cal remind ${method}`, timeoutMs: REQUEST_TIMEOUT_MS, ...(body === undefined ? {} : { body }) });
|
|
174
|
+
const report = await remindTick({ ask, cachePath: cachePathFor(command.homeDir), now: new Date(), log: (line) => writeLine(io.stderr, line) });
|
|
175
|
+
return command.json ? emitAgentDoor(door, { ok: true, ...report }) : 0;
|
|
176
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** `cockpit cal reminders [--on|--off] [--lead <min>] [--sound on|off]`, `cal snooze` and `cal dismiss` (BLI-21564): the same doors the web switch uses. */
|
|
2
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
3
|
+
import { writeLine } from "./cli-io.js";
|
|
4
|
+
const TAG = "[cal cli]";
|
|
5
|
+
const TIMEOUT_MS = 30000;
|
|
6
|
+
export async function runCalReminders(command, door) {
|
|
7
|
+
const patch = command.reminderPatch;
|
|
8
|
+
const answer = await askAgentDoor(door, patch
|
|
9
|
+
? { method: "PATCH", path: "/api/cal/reminders/settings", body: patch, label: "cal reminders", timeoutMs: TIMEOUT_MS }
|
|
10
|
+
: { method: "GET", path: "/api/cal/reminders/settings", label: "cal reminders", timeoutMs: TIMEOUT_MS });
|
|
11
|
+
if (!answer.ok)
|
|
12
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
13
|
+
if (door.json)
|
|
14
|
+
return emitAgentDoor(door, answer.body);
|
|
15
|
+
const settings = answer.body;
|
|
16
|
+
writeLine(door.io.stdout, `${patch ? "Reminders saved. " : ""}Laptop meeting reminders are ${settings.enabled ? "ON" : "OFF"}: ${settings.lead_minutes} min before, sound ${settings.sound ? "on" : "off"}${settings.saved ? "" : " (defaults, never saved)"}.`);
|
|
17
|
+
if (!settings.enabled)
|
|
18
|
+
writeLine(door.io.stdout, "Turn them on with: cockpit cal reminders --on");
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
/** `cal snooze <event id> --occurrence <iso> --minutes 10` and `cal dismiss <event id> --occurrence <iso>`. */
|
|
22
|
+
export async function runCalReminderChange(command, door) {
|
|
23
|
+
const snooze = command.action === "snooze";
|
|
24
|
+
const body = { event_id: command.subjectId, occurrence_start: command.occurrenceStart, ...(snooze ? { minutes: command.snoozeMinutes } : {}) };
|
|
25
|
+
const answer = await askAgentDoor(door, { method: "POST", path: `/api/cal/reminders/${snooze ? "snooze" : "dismiss"}`, body, label: `cal ${command.action}`, timeoutMs: TIMEOUT_MS });
|
|
26
|
+
if (!answer.ok)
|
|
27
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
28
|
+
if (door.json)
|
|
29
|
+
return emitAgentDoor(door, answer.body);
|
|
30
|
+
const done = answer.body;
|
|
31
|
+
writeLine(door.io.stdout, snooze ? `Snoozed until ${done.snoozed_until ?? "later"}.` : "Dismissed: no more reminders for this meeting.");
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
2
|
+
import { resolveLabel, readPipedBody } from "./cal-people.js";
|
|
3
|
+
import { isInteractiveStdin, writeLine } from "./cli-io.js";
|
|
4
|
+
const FAIL_TAG = "[cal cli]";
|
|
5
|
+
const REQUEST_TIMEOUT_MS = 60000;
|
|
6
|
+
const DEFAULT_WORDS = { duration_minutes: "30 minutes", hours: "09:00 to 17:00 in your zone", include_weekends: "weekends off" };
|
|
7
|
+
function fitWords(slot) {
|
|
8
|
+
if (slot.fit === "all_free")
|
|
9
|
+
return "everyone free";
|
|
10
|
+
const parts = [];
|
|
11
|
+
if (slot.soft.length)
|
|
12
|
+
parts.push(`outside working hours for ${slot.soft.join(", ")}`);
|
|
13
|
+
if (slot.unconfirmed.length)
|
|
14
|
+
parts.push(`unknown, not shared with you: ${slot.unconfirmed.join(", ")}`);
|
|
15
|
+
if (slot.own_unconfirmed)
|
|
16
|
+
parts.push("your own calendars were not fully read");
|
|
17
|
+
return parts.join("; ");
|
|
18
|
+
}
|
|
19
|
+
/** `cal suggest` (BLI-21542): ranked common free slots, the same door the web "Find a time" form calls. */
|
|
20
|
+
export async function runCalSuggest(command, door) {
|
|
21
|
+
let labels = command.peopleWith ?? [];
|
|
22
|
+
let from = command.from;
|
|
23
|
+
let to = command.to;
|
|
24
|
+
let tz = command.timeZone;
|
|
25
|
+
let account = command.accountName;
|
|
26
|
+
let duration = command.duration;
|
|
27
|
+
let hours = command.hoursRange;
|
|
28
|
+
let weekends = command.weekends;
|
|
29
|
+
let limit = command.limit;
|
|
30
|
+
if (!labels.length && !isInteractiveStdin(door.io)) {
|
|
31
|
+
const piped = await readPipedBody(door);
|
|
32
|
+
if (piped === null)
|
|
33
|
+
return failAgentDoor(door, FAIL_TAG, "invalid_body", "Pipe a JSON object like {\"with\":[\"vee@x.ca\"],\"from\":\"2026-10-19\",\"to\":\"2026-10-23\"}, or use --with.");
|
|
34
|
+
const listed = piped["with"] ?? piped["emails"];
|
|
35
|
+
labels = Array.isArray(listed) ? listed.filter((v) => typeof v === "string") : [];
|
|
36
|
+
from ??= typeof piped["from"] === "string" ? piped["from"] : undefined;
|
|
37
|
+
to ??= typeof piped["to"] === "string" ? piped["to"] : undefined;
|
|
38
|
+
tz ??= typeof piped["tz"] === "string" ? piped["tz"] : undefined;
|
|
39
|
+
account ??= typeof piped["account_id"] === "string" ? piped["account_id"] : undefined;
|
|
40
|
+
duration ??= typeof piped["duration_minutes"] === "number" ? piped["duration_minutes"] : undefined;
|
|
41
|
+
limit ??= typeof piped["limit"] === "number" ? piped["limit"] : undefined;
|
|
42
|
+
weekends ??= typeof piped["include_weekends"] === "boolean" ? piped["include_weekends"] : undefined;
|
|
43
|
+
const h = piped["hours"];
|
|
44
|
+
if (!hours && h && typeof h.start === "string" && typeof h.end === "string")
|
|
45
|
+
hours = { start: h.start, end: h.end };
|
|
46
|
+
}
|
|
47
|
+
if (!labels.length)
|
|
48
|
+
return failAgentDoor(door, FAIL_TAG, "invalid_body", "Name who to meet: --with vee@bluepearl.ca (repeat it), or a name as on the team list.");
|
|
49
|
+
if (!from || !to)
|
|
50
|
+
return failAgentDoor(door, FAIL_TAG, "invalid_body", "Give the window: --from 2026-10-19 --to 2026-10-23 (a date or an ISO instant).");
|
|
51
|
+
const emails = [];
|
|
52
|
+
for (const label of labels) {
|
|
53
|
+
const resolved = await resolveLabel(door, label);
|
|
54
|
+
if (!resolved.ok)
|
|
55
|
+
return resolved.code;
|
|
56
|
+
emails.push(resolved.email);
|
|
57
|
+
}
|
|
58
|
+
const body = {
|
|
59
|
+
emails: [...new Set(emails)], from, to, tz: tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
60
|
+
...(account ? { account_id: account } : {}), ...(duration === undefined ? {} : { duration_minutes: duration }),
|
|
61
|
+
...(hours ? { hours } : {}), ...(weekends === undefined ? {} : { include_weekends: weekends }), ...(limit === undefined ? {} : { limit }),
|
|
62
|
+
};
|
|
63
|
+
const answer = await askAgentDoor(door, { method: "POST", path: "/api/cal/suggestions", body, label: "cal suggest", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
64
|
+
if (!answer.ok)
|
|
65
|
+
return failAgentDoor(door, FAIL_TAG, answer.reason, answer.detail);
|
|
66
|
+
if (door.json)
|
|
67
|
+
return emitAgentDoor(door, answer.body);
|
|
68
|
+
const result = answer.body;
|
|
69
|
+
const zone = result.window.time_zone;
|
|
70
|
+
const out = door.io.stdout;
|
|
71
|
+
if (!result.slots.length)
|
|
72
|
+
writeLine(out, "No common free slot in this window. Widen the dates or the hours.");
|
|
73
|
+
for (const slot of result.slots) {
|
|
74
|
+
const when = new Intl.DateTimeFormat("en-GB", { timeZone: zone, weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(slot.start));
|
|
75
|
+
const others = slot.local.map((l) => `${l.email} ${l.start_local.slice(11)} (${l.time_zone})`).join(" · ");
|
|
76
|
+
writeLine(out, `${when} ${zone} · ${fitWords(slot)}${others ? ` · ${others}` : ""}`);
|
|
77
|
+
writeLine(out, ` slot: ${slot.id}`);
|
|
78
|
+
}
|
|
79
|
+
for (const person of result.people)
|
|
80
|
+
if (person.status === "unknown")
|
|
81
|
+
writeLine(out, `${person.email}: unknown (${person.reason ?? "no answer"}). Never shown as free.`);
|
|
82
|
+
const c = result.coverage;
|
|
83
|
+
if (result.defaults_used.length)
|
|
84
|
+
writeLine(out, `Tower defaults used: ${result.defaults_used.map((d) => DEFAULT_WORDS[d] ?? d).join(", ")}`);
|
|
85
|
+
for (const note of c.notes)
|
|
86
|
+
writeLine(out, note);
|
|
87
|
+
writeLine(out, `${c.slots_returned} of ${c.slots_found} slots · ${c.answered} of ${c.requested} answered, ${c.unknown} unknown · ${c.own_calendars_checked} of your calendars checked${c.own_incomplete ? " (incomplete)" : ""} · times in ${zone} · as of ${c.as_of}`);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
package/dist/commands/cal.js
CHANGED
|
@@ -20,11 +20,20 @@
|
|
|
20
20
|
* own zone from `Intl`, because a window computed server-side would be
|
|
21
21
|
* computed in UTC and "today" would end at 5pm in Vancouver.
|
|
22
22
|
*/
|
|
23
|
-
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor
|
|
23
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
|
|
24
24
|
import { runCalAddIcal, runCalDetach, runCalShare, runCalSync } from "./cal-calendars-write.js";
|
|
25
25
|
import { runCalDetail } from "./cal-detail.js";
|
|
26
|
+
import { runCalJoin } from "./cal-join.js";
|
|
27
|
+
import { runCalContext } from "./cal-links.js";
|
|
28
|
+
import { runCalRemind } from "./cal-remind.js";
|
|
29
|
+
import { runCalReminderChange, runCalReminders } from "./cal-reminders.js";
|
|
26
30
|
import { runCalOverlay } from "./cal-overlay.js";
|
|
27
31
|
import { runCalViewSettings } from "./cal-view-settings.js";
|
|
32
|
+
import { runCalPeople } from "./cal-people.js";
|
|
33
|
+
import { runCalAvailabilityRevoke, runCalAvailabilityShare } from "./cal-availability-share.js";
|
|
34
|
+
import { runCalBookingGuest, runCalBookingPage } from "./cal-booking.js";
|
|
35
|
+
import { BOOKING_GUEST_ACTIONS } from "./local-args-tower-cal-booking.js";
|
|
36
|
+
import { runCalSuggest } from "./cal-suggest.js";
|
|
28
37
|
import { runCalDelete } from "./cal-delete.js";
|
|
29
38
|
import { detailFields, runCalRsvp, scopeFields, writeScopeFacts } from "./cal-scope.js";
|
|
30
39
|
import { runCalDirect } from "./cal-direct.js";
|
|
@@ -35,8 +44,28 @@ const TAG = "[cal cli]";
|
|
|
35
44
|
const READ_DEADLINE_MS = 30_000;
|
|
36
45
|
const WRITE_DEADLINE_MS = 60_000;
|
|
37
46
|
export async function runCal(command, io) {
|
|
47
|
+
if (BOOKING_GUEST_ACTIONS.includes(command.action))
|
|
48
|
+
return runCalBookingGuest(command, io);
|
|
49
|
+
if (command.action === "remind")
|
|
50
|
+
return runCalRemind(command, io);
|
|
38
51
|
const door = await openAgentDoor("cal", command, io);
|
|
39
52
|
switch (command.action) {
|
|
53
|
+
case "join":
|
|
54
|
+
return runCalJoin(command, door);
|
|
55
|
+
case "links":
|
|
56
|
+
case "agenda": return runCalContext(command, door);
|
|
57
|
+
case "reminders":
|
|
58
|
+
return runCalReminders(command, door);
|
|
59
|
+
case "snooze":
|
|
60
|
+
case "dismiss":
|
|
61
|
+
return runCalReminderChange(command, door);
|
|
62
|
+
case "booking-page":
|
|
63
|
+
return runCalBookingPage(command, door);
|
|
64
|
+
case "booking-slots":
|
|
65
|
+
case "book":
|
|
66
|
+
case "booking-change":
|
|
67
|
+
case "booking-cancel":
|
|
68
|
+
return runCalBookingGuest(command, io);
|
|
40
69
|
case "event":
|
|
41
70
|
case "export":
|
|
42
71
|
return runCalDetail(command, door);
|
|
@@ -50,6 +79,16 @@ export async function runCal(command, io) {
|
|
|
50
79
|
case "view-settings":
|
|
51
80
|
case "view-settings-change":
|
|
52
81
|
return runCalViewSettings(command, door);
|
|
82
|
+
case "people":
|
|
83
|
+
case "availability":
|
|
84
|
+
case "overlays":
|
|
85
|
+
return runCalPeople(command, door);
|
|
86
|
+
case "suggest":
|
|
87
|
+
return runCalSuggest(command, door);
|
|
88
|
+
case "availability-share":
|
|
89
|
+
return runCalAvailabilityShare(command, door);
|
|
90
|
+
case "availability-revoke":
|
|
91
|
+
return runCalAvailabilityRevoke(command, door);
|
|
53
92
|
case "today":
|
|
54
93
|
case "week":
|
|
55
94
|
case "next":
|
|
@@ -311,6 +350,7 @@ async function create(command, door) {
|
|
|
311
350
|
...(command.allDay ? { all_day: true } : {}),
|
|
312
351
|
...(command.attendees.length > 0 ? { attendees: command.attendees } : {}),
|
|
313
352
|
...(command.meet ? { meet: true } : {}),
|
|
353
|
+
...(command.recheck ? { recheck_availability: true } : {}),
|
|
314
354
|
...detailFields(command),
|
|
315
355
|
...(command.idempotencyKey ? { idempotency_key: command.idempotencyKey } : {}),
|
|
316
356
|
...(command.reason ? { reason: command.reason } : {}),
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit ticket cycles [list|show|create|update|map]` argument parsing (BLI-21809, P12), plus the
|
|
3
|
+
* `--cycle <ref|none>` field of `ticket create|update`. `cycles` is an `issue` action; its flags are
|
|
4
|
+
* read here so `local-args-tower-work.ts` only names them, the way `template-args.ts` does.
|
|
5
|
+
*
|
|
6
|
+
* A cycle ref is a uuid or an exact, case-insensitive name; two cycles with one name are refused by the
|
|
7
|
+
* door (`cycle_ref_ambiguous`) with their ids, never guessed.
|
|
8
|
+
*/
|
|
9
|
+
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl } from "./local-arg-values.js";
|
|
10
|
+
export const CYCLE_VALUE_FLAGS = ["--starts", "--ends", "--timezone", "--phase", "--cycle"];
|
|
11
|
+
export const CYCLE_BOOLEAN_FLAGS = ["--close", "--reopen"];
|
|
12
|
+
export const CYCLE_VERBS = ["list", "show", "create", "update", "map"];
|
|
13
|
+
export const CYCLE_PHASES = ["upcoming", "active", "ended", "closed"];
|
|
14
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
15
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
16
|
+
function validDay(flag, value) {
|
|
17
|
+
if (value === undefined)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (!DAY.test(value) || new Date(`${value}T12:00:00Z`).toISOString().slice(0, 10) !== value) {
|
|
20
|
+
throw new Error(`${flag} must be a calendar date as YYYY-MM-DD.`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
export function parseCycleArgs(values, rest) {
|
|
25
|
+
const first = rest[0];
|
|
26
|
+
const verb = (first === undefined ? "list" : first);
|
|
27
|
+
if (!CYCLE_VERBS.includes(verb))
|
|
28
|
+
throw new Error(`Unknown cycles command: ${first}. Try list, show, create, update or map.`);
|
|
29
|
+
const positional = rest.slice(first === undefined ? 0 : 1);
|
|
30
|
+
const needsRef = verb === "show" || verb === "update" || verb === "map";
|
|
31
|
+
if (needsRef && positional.length !== 1) {
|
|
32
|
+
throw new Error(`ticket cycles ${verb} takes exactly one ${verb === "map" ? "Linear cycle id" : "cycle id or name"}.`);
|
|
33
|
+
}
|
|
34
|
+
if (!needsRef && positional.length > 0)
|
|
35
|
+
throw new Error(`ticket cycles ${verb} does not take "${positional[0]}".`);
|
|
36
|
+
const given = (flag) => values.flags.has(flag) || values.booleans.has(flag);
|
|
37
|
+
const writes = verb === "create" || verb === "update";
|
|
38
|
+
for (const flag of ["--name", "--description", "--starts", "--ends", "--timezone"]) {
|
|
39
|
+
if (given(flag) && !writes)
|
|
40
|
+
throw new Error(`${flag} belongs to \`cockpit ticket cycles create\` and \`update\`.`);
|
|
41
|
+
}
|
|
42
|
+
if (verb !== "update" && (given("--close") || given("--reopen") || given("--expected-updated-at"))) {
|
|
43
|
+
throw new Error("--close, --reopen and --expected-updated-at belong to `cockpit ticket cycles update`.");
|
|
44
|
+
}
|
|
45
|
+
if (verb !== "list" && (given("--phase") || given("--limit") || given("--cursor"))) {
|
|
46
|
+
throw new Error("--phase, --limit and --cursor belong to `cockpit ticket cycles list`.");
|
|
47
|
+
}
|
|
48
|
+
if (given("--cycle"))
|
|
49
|
+
throw new Error("--cycle belongs to `cockpit ticket create` and `update`.");
|
|
50
|
+
if (given("--request-id") && verb !== "create" && verb !== "update" && verb !== "map") {
|
|
51
|
+
throw new Error("--request-id belongs to `cockpit ticket cycles create`, `update` and `map`.");
|
|
52
|
+
}
|
|
53
|
+
if (given("--close") && given("--reopen"))
|
|
54
|
+
throw new Error("Give --close or --reopen, not both.");
|
|
55
|
+
const name = optionalNonEmpty(values.flags.get("--name"));
|
|
56
|
+
const startsOn = validDay("--starts", optionalNonEmpty(values.flags.get("--starts")));
|
|
57
|
+
const endsOn = validDay("--ends", optionalNonEmpty(values.flags.get("--ends")));
|
|
58
|
+
const timezone = optionalNonEmpty(values.flags.get("--timezone"));
|
|
59
|
+
const rawDescription = values.flags.get("--description");
|
|
60
|
+
if (verb === "create" && (!name || !startsOn || !endsOn)) {
|
|
61
|
+
throw new Error('ticket cycles create needs --name "<name>" --starts YYYY-MM-DD --ends YYYY-MM-DD.');
|
|
62
|
+
}
|
|
63
|
+
if (startsOn && endsOn && endsOn < startsOn)
|
|
64
|
+
throw new Error("--ends must not be before --starts.");
|
|
65
|
+
const phase = optionalNonEmpty(values.flags.get("--phase"));
|
|
66
|
+
if (phase && !CYCLE_PHASES.includes(phase))
|
|
67
|
+
throw new Error(`--phase must be one of: ${CYCLE_PHASES.join(", ")}.`);
|
|
68
|
+
const closing = given("--close") || given("--reopen");
|
|
69
|
+
const fieldGiven = name !== undefined || rawDescription !== undefined || startsOn !== undefined || endsOn !== undefined || timezone !== undefined;
|
|
70
|
+
if (verb === "update" && closing && fieldGiven)
|
|
71
|
+
throw new Error("--close and --reopen travel alone; change the fields in a separate update.");
|
|
72
|
+
if (verb === "update" && !closing && !fieldGiven) {
|
|
73
|
+
throw new Error("ticket cycles update needs a field to change: --name, --description, --starts, --ends, --timezone, --close or --reopen.");
|
|
74
|
+
}
|
|
75
|
+
const expectedUpdatedAt = optionalNonEmpty(values.flags.get("--expected-updated-at"));
|
|
76
|
+
if (expectedUpdatedAt && Number.isNaN(Date.parse(expectedUpdatedAt))) {
|
|
77
|
+
throw new Error("--expected-updated-at must be the updated_at timestamp you last read.");
|
|
78
|
+
}
|
|
79
|
+
const requestId = optionalNonEmpty(values.flags.get("--request-id"));
|
|
80
|
+
if (requestId && !UUID.test(requestId))
|
|
81
|
+
throw new Error("--request-id must be a UUID.");
|
|
82
|
+
const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
|
|
83
|
+
const cursor = optionalNonEmpty(values.flags.get("--cursor"));
|
|
84
|
+
const cycles = {
|
|
85
|
+
verb,
|
|
86
|
+
...(needsRef ? { ref: positional[0] } : {}),
|
|
87
|
+
...(name ? { name } : {}),
|
|
88
|
+
...(rawDescription === undefined ? {} : { description: verb === "update" && rawDescription.trim().toLowerCase() === "none" ? null : rawDescription }),
|
|
89
|
+
...(startsOn ? { startsOn } : {}),
|
|
90
|
+
...(endsOn ? { endsOn } : {}),
|
|
91
|
+
...(timezone ? { timezone } : {}),
|
|
92
|
+
...(phase ? { phase } : {}),
|
|
93
|
+
...(limit === undefined ? {} : { limit }),
|
|
94
|
+
...(cursor ? { cursor } : {}),
|
|
95
|
+
...(given("--close") ? { closed: true } : given("--reopen") ? { closed: false } : {}),
|
|
96
|
+
...(expectedUpdatedAt ? { expectedUpdatedAt } : {}),
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
kind: "issue",
|
|
100
|
+
action: "cycles",
|
|
101
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
102
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
103
|
+
...(requestId ? { requestId } : {}),
|
|
104
|
+
json: values.booleans.has("--json"),
|
|
105
|
+
cycles,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* `ticket create|update --cycle <name|id|none>`: `none` clears (update only). Any cycle-only flag on another
|
|
110
|
+
* ticket verb is refused here, so nothing is silently ignored.
|
|
111
|
+
*/
|
|
112
|
+
export function readCycleField(action, values) {
|
|
113
|
+
const stray = [...CYCLE_VALUE_FLAGS.filter((f) => f !== "--cycle"), ...CYCLE_BOOLEAN_FLAGS].find((f) => values.flags.has(f) || values.booleans.has(f));
|
|
114
|
+
if (stray)
|
|
115
|
+
throw new Error(`${stray} belongs to \`cockpit ticket cycles\`.`);
|
|
116
|
+
const cycle = optionalNonEmpty(values.flags.get("--cycle"));
|
|
117
|
+
if (cycle === undefined)
|
|
118
|
+
return {};
|
|
119
|
+
if (action !== "create" && action !== "update")
|
|
120
|
+
throw new Error("--cycle belongs to `cockpit ticket create` and `update`.");
|
|
121
|
+
if (cycle.toLowerCase() === "none") {
|
|
122
|
+
if (action === "create")
|
|
123
|
+
throw new Error("--cycle none clears a cycle on `cockpit ticket update`; leave --cycle off to create without one.");
|
|
124
|
+
return { cycle: null };
|
|
125
|
+
}
|
|
126
|
+
return { cycle };
|
|
127
|
+
}
|