@bli-cockpit/cli 0.2.241 → 0.2.243
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-collection-state.js +22 -11
- 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-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 +279 -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-args-tower.js +1 -0
- package/dist/commands/local-args.js +2 -0
- package/dist/commands/local-auth.js +1 -1
- package/dist/commands/local-help-commands-tower-ops.js +2 -0
- package/dist/commands/local-help-commands-tower.js +48 -32
- package/dist/commands/local-help-commands.js +1 -0
- 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 +4 -2
- package/dist/commands/local.js +2 -0
- 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-draft.js +193 -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 +77 -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/push.js +50 -0
- 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-upload.js +2 -0
- package/dist/commands/session-sync.js +1 -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/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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { appendFile } from "node:fs/promises";
|
|
3
|
-
import { AUTOSTART_LABEL, autostartStatus, reregisterDarwinAgent, writeDarwinAutostartPlist, } from "../autostart.js";
|
|
3
|
+
import { AUTOSTART_LABEL, autostartStatus, REMIND_AUTOSTART_LABEL, reregisterDarwinAgent, writeDarwinAutostartPlist, } from "../autostart.js";
|
|
4
4
|
import { isAutostartOptedOut } from "../autostart-opt-out.js";
|
|
5
|
+
import { writeDarwinRemindPlist } from "../autostart-remind.js";
|
|
5
6
|
import { redactedHealthDetail } from "../health-detail.js";
|
|
6
7
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
|
|
7
8
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
@@ -110,6 +111,33 @@ async function writeAndVerifyRegistration(command, exec, platform, roots, before
|
|
|
110
111
|
detail: redactedHealthDetail(reregistered.detail ?? "").slice(0, DETAIL_MAX_CHARS),
|
|
111
112
|
};
|
|
112
113
|
}
|
|
114
|
+
// BLI-21564: the reminder job is healed with the sync agent, by the same two steps.
|
|
115
|
+
let remindPlist;
|
|
116
|
+
try {
|
|
117
|
+
remindPlist = await writeDarwinRemindPlist({
|
|
118
|
+
homeDir: command.homeDir,
|
|
119
|
+
repoRoot: roots[0],
|
|
120
|
+
repoRoots: roots,
|
|
121
|
+
dashboardUrl: command.dashboardUrl,
|
|
122
|
+
exec,
|
|
123
|
+
platform,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
return {
|
|
128
|
+
status: "fail",
|
|
129
|
+
reason: "remind_plist_write_failed",
|
|
130
|
+
detail: redactedHealthDetail(error instanceof Error ? error.message : String(error)).slice(0, DETAIL_MAX_CHARS),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const remindRegistered = await reregisterDarwinAgent({ exec, plistPath: remindPlist.plist_path, label: REMIND_AUTOSTART_LABEL });
|
|
134
|
+
if (!remindRegistered.ok) {
|
|
135
|
+
return {
|
|
136
|
+
status: "fail",
|
|
137
|
+
reason: `remind_${remindRegistered.reason}`,
|
|
138
|
+
detail: redactedHealthDetail(remindRegistered.detail ?? "").slice(0, DETAIL_MAX_CHARS),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
113
141
|
return verifyRegistration(command, exec, platform, roots, beforeStatus, written.node_path_reason);
|
|
114
142
|
}
|
|
115
143
|
async function verifyRegistration(command, exec, platform, roots, beforeStatus, nodePathReason) {
|
|
@@ -124,6 +152,13 @@ async function verifyRegistration(command, exec, platform, roots, beforeStatus,
|
|
|
124
152
|
exec,
|
|
125
153
|
platform,
|
|
126
154
|
});
|
|
155
|
+
if (after.status === "loaded" && after.remind && after.remind.status !== "loaded") {
|
|
156
|
+
return {
|
|
157
|
+
status: "fail",
|
|
158
|
+
reason: "remind_reregistered_but_still_broken",
|
|
159
|
+
detail: redactedHealthDetail(after.remind.message ?? after.remind.status).slice(0, DETAIL_MAX_CHARS),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
127
162
|
if (after.status !== "loaded") {
|
|
128
163
|
return {
|
|
129
164
|
status: "fail",
|
package/dist/commands/brain.js
CHANGED
|
@@ -62,7 +62,7 @@ export async function runBrain(command, io) {
|
|
|
62
62
|
if (!answer.ok)
|
|
63
63
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
64
64
|
const body = answer.body;
|
|
65
|
-
writeLine(io.stderr, `${TAG} status read ${JSON.stringify({ projects: body.projects.length, rows: body.coverage.rows, pages: body.coverage.pages, end: body.coverage.end })}`);
|
|
65
|
+
writeLine(io.stderr, `${TAG} status read ${JSON.stringify({ projects: body.projects.length, rows: body.coverage.rows, pages: body.coverage.pages, end: body.coverage.end, shown: body.projects.map((p) => ({ project: p.project, listed: p.incidents?.length ?? 0, ...p.incidents_coverage })) })}`);
|
|
66
66
|
if (command.json)
|
|
67
67
|
return emitAgentDoor(door, body);
|
|
68
68
|
writeLine(io.stdout, body.text);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit cal agenda [--from <d> --to <d>] [--tz <IANA>]` (BLI-21588): events plus tickets due in the window.
|
|
3
|
+
* `DUE` lines are all-day date markers, not meetings: they never block time. Coverage of both reads is printed.
|
|
4
|
+
*/
|
|
5
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
6
|
+
import { writeLine } from "./cli-io.js";
|
|
7
|
+
const TAG = "[cal cli]";
|
|
8
|
+
const dateIn = (instant, zone) => new Intl.DateTimeFormat("en-CA", { timeZone: zone }).format(new Date(instant));
|
|
9
|
+
const clock = (instant, zone) => new Intl.DateTimeFormat("en-GB", { timeZone: zone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(instant));
|
|
10
|
+
/** Lines grouped by day: events by time, then `DUE` markers. */
|
|
11
|
+
export function agendaLines(body) {
|
|
12
|
+
const zone = body.window.time_zone;
|
|
13
|
+
const days = new Map();
|
|
14
|
+
const add = (day, line) => { days.set(day, [...(days.get(day) ?? []), line]); };
|
|
15
|
+
for (const event of body.events) {
|
|
16
|
+
const when = event.all_day ? "all day" : `${clock(event.starts_at, zone)}-${clock(event.ends_at, zone)}`;
|
|
17
|
+
add(event.all_day ? event.starts_at.slice(0, 10) : dateIn(event.starts_at, zone), ` ${when.padEnd(11)} ${event.title}`);
|
|
18
|
+
}
|
|
19
|
+
for (const due of body.due)
|
|
20
|
+
add(due.date, ` DUE ${due.identifier} ${due.title} (${due.state})`);
|
|
21
|
+
const lines = [];
|
|
22
|
+
for (const day of [...days.keys()].sort())
|
|
23
|
+
lines.push(day, ...days.get(day));
|
|
24
|
+
const c = body.coverage;
|
|
25
|
+
if (lines.length === 0)
|
|
26
|
+
lines.push("Nothing on the calendar or due in this window.");
|
|
27
|
+
lines.push("", `${body.events.length} event(s) from ${c.events.calendars} calendar(s)${c.events.truncated ? " (more than fitted)" : ""}; ${c.due.unique} ticket(s) due (${c.due.returned} of ${c.due.requested} requested rows read${c.due.complete ? ", complete" : `, INCOMPLETE: ${c.due.reason}`}).`);
|
|
28
|
+
return lines;
|
|
29
|
+
}
|
|
30
|
+
export async function runCalAgenda(command, door) {
|
|
31
|
+
const query = new URLSearchParams({ tz: command.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone });
|
|
32
|
+
if (command.from)
|
|
33
|
+
query.set("from", command.from);
|
|
34
|
+
if (command.to)
|
|
35
|
+
query.set("to", command.to);
|
|
36
|
+
const answer = await askAgentDoor(door, { method: "GET", path: `/api/cal/agenda?${query}`, label: "cal agenda", timeoutMs: 30000 });
|
|
37
|
+
if (!answer.ok)
|
|
38
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
39
|
+
if (door.json)
|
|
40
|
+
return emitAgentDoor(door, answer.body);
|
|
41
|
+
for (const line of agendaLines(answer.body))
|
|
42
|
+
writeLine(door.io.stdout, line);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
3
|
+
import { readPipedBody } from "./cal-people.js";
|
|
4
|
+
import { isInteractiveStdin, writeLine } from "./cli-io.js";
|
|
5
|
+
const FAIL_TAG = "[cal cli]";
|
|
6
|
+
const REQUEST_TIMEOUT_MS = 60000;
|
|
7
|
+
const DOOR = "/api/cal/availability-shares";
|
|
8
|
+
const shareLine = (s) => `${s.id} ${s.status} ${s.label ?? "(no label)"} ${s.duration_minutes} min ${s.time_zone} opens ${s.open_count} expires ${s.expires_at}${s.single_use ? " single-use" : ""}`;
|
|
9
|
+
/** `cal availability-share` (BLI-21548): free time as text, a revocable link, a list, or a change. The same doors the web box calls. */
|
|
10
|
+
export async function runCalAvailabilityShare(command, door) {
|
|
11
|
+
const out = door.io.stdout;
|
|
12
|
+
if (command.shareMode === "list") {
|
|
13
|
+
const answer = await askAgentDoor(door, { method: "GET", path: DOOR, label: "cal availability-share --list", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
14
|
+
if (!answer.ok)
|
|
15
|
+
return failAgentDoor(door, FAIL_TAG, answer.reason, answer.detail);
|
|
16
|
+
if (door.json)
|
|
17
|
+
return emitAgentDoor(door, answer.body);
|
|
18
|
+
const { shares } = answer.body;
|
|
19
|
+
if (!shares.length)
|
|
20
|
+
writeLine(out, "No availability links yet.");
|
|
21
|
+
for (const share of shares)
|
|
22
|
+
writeLine(out, shareLine(share));
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
if (command.shareMode === "change") {
|
|
26
|
+
const body = {
|
|
27
|
+
...(command.shareExpires ? { expires_at: command.shareExpires } : {}), ...(command.shareLabel ? { label: command.shareLabel } : {}),
|
|
28
|
+
...(command.location ? { location: command.location } : {}), ...(command.meet ? { conference: "google_meet" } : {}),
|
|
29
|
+
};
|
|
30
|
+
if (!Object.keys(body).length)
|
|
31
|
+
return failAgentDoor(door, FAIL_TAG, "invalid_body", "Change at least one of --expires, --label, --location or --meet.");
|
|
32
|
+
const answer = await askAgentDoor(door, { method: "PATCH", path: `${DOOR}/${encodeURIComponent(command.shareId ?? "")}`, body, label: "cal availability-share --id", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
33
|
+
if (!answer.ok)
|
|
34
|
+
return failAgentDoor(door, FAIL_TAG, answer.reason, answer.detail);
|
|
35
|
+
if (door.json)
|
|
36
|
+
return emitAgentDoor(door, answer.body);
|
|
37
|
+
writeLine(out, `Changed: ${shareLine(answer.body.share)}`);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
let ranges = (command.shareRanges ?? []).map((range) => ({ start: range.split("/")[0].trim(), end: range.split("/")[1].trim() }));
|
|
41
|
+
const piped = {};
|
|
42
|
+
if (!ranges.length && !isInteractiveStdin(door.io)) {
|
|
43
|
+
const body = await readPipedBody(door);
|
|
44
|
+
if (body === null)
|
|
45
|
+
return failAgentDoor(door, FAIL_TAG, "invalid_body", "Pipe a JSON object like {\"ranges\":[{\"start\":\"2026-10-20T10:00\",\"end\":\"2026-10-20T17:00\"}]}, or use --range <start>/<end>.");
|
|
46
|
+
Object.assign(piped, body);
|
|
47
|
+
if (Array.isArray(body["ranges"]))
|
|
48
|
+
ranges = body["ranges"].filter((r) => Boolean(r) && typeof r.start === "string" && typeof r.end === "string");
|
|
49
|
+
}
|
|
50
|
+
if (!ranges.length)
|
|
51
|
+
return failAgentDoor(door, FAIL_TAG, "invalid_body", "Give at least one --range <start>/<end>, like 2026-10-20T10:00/2026-10-20T17:00.");
|
|
52
|
+
const link = command.shareMode === "link" || piped["mode"] === "link";
|
|
53
|
+
const pick = (flag, key, type) => flag ?? (typeof piped[key] === type ? piped[key] : undefined);
|
|
54
|
+
const duration = pick(command.duration, "duration_minutes", "number");
|
|
55
|
+
const expires = pick(command.shareExpires, "expires_at", "string");
|
|
56
|
+
const location = pick(command.location, "location", "string");
|
|
57
|
+
const label = pick(command.shareLabel, "label", "string");
|
|
58
|
+
const conference = command.meet ? "google_meet" : pick(undefined, "conference", "string");
|
|
59
|
+
const body = {
|
|
60
|
+
mode: link ? "link" : "text", ranges, time_zone: command.timeZone ?? pick(undefined, "time_zone", "string") ?? pick(undefined, "tz", "string") ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
61
|
+
...(duration === undefined ? {} : { duration_minutes: duration }), ...(expires ? { expires_at: expires } : {}), ...(location ? { location } : {}), ...(conference ? { conference } : {}),
|
|
62
|
+
...(command.singleUse || piped["single_use"] === true ? { single_use: true } : {}),
|
|
63
|
+
...(command.allowConflicts ? { avoid_conflicts: false } : typeof piped["avoid_conflicts"] === "boolean" ? { avoid_conflicts: piped["avoid_conflicts"] } : {}),
|
|
64
|
+
...(label ? { label } : {}),
|
|
65
|
+
...(link ? { request_key: command.idempotencyKey ?? (typeof piped["request_key"] === "string" ? piped["request_key"] : randomUUID()) } : {}),
|
|
66
|
+
};
|
|
67
|
+
const answer = await askAgentDoor(door, { method: "POST", path: DOOR, body, label: "cal availability-share", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
68
|
+
if (!answer.ok)
|
|
69
|
+
return failAgentDoor(door, FAIL_TAG, answer.reason, answer.detail);
|
|
70
|
+
if (door.json)
|
|
71
|
+
return emitAgentDoor(door, answer.body);
|
|
72
|
+
const result = answer.body;
|
|
73
|
+
writeLine(out, result.text);
|
|
74
|
+
if (link) {
|
|
75
|
+
if (result.token)
|
|
76
|
+
writeLine(out, `\nLink (shown once, ${result.token_rotated ? "new token for your repeat request" : "copy it now"}): ${result.url}\nId: ${result.share.id} expires ${result.share.expires_at}${result.share.single_use ? " single-use (enforced when booking arrives)" : ""}`);
|
|
77
|
+
else
|
|
78
|
+
writeLine(out, `\nNo new link: ${result.reason === "link_revoked" ? "that one was revoked" : "that link was already opened, so its token is not shown again"}. Id: ${result.share.id}`);
|
|
79
|
+
}
|
|
80
|
+
else
|
|
81
|
+
writeLine(out, "\nCopy the text above; nothing was saved or sent.");
|
|
82
|
+
if (result.coverage.own_incomplete)
|
|
83
|
+
writeLine(out, "Your own calendars were not fully read, so these times are not confirmed free.");
|
|
84
|
+
if (result.defaults_used.length)
|
|
85
|
+
writeLine(out, `Tower defaults used: ${result.defaults_used.map((d) => (d === "duration_minutes" ? "30 minutes" : "skip your busy time")).join(", ")}`);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
/** `cal availability-revoke <id>`: turn a link off. */
|
|
89
|
+
export async function runCalAvailabilityRevoke(command, door) {
|
|
90
|
+
const answer = await askAgentDoor(door, { method: "DELETE", path: `${DOOR}/${encodeURIComponent(command.subjectId ?? "")}`, label: "cal availability-revoke", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
91
|
+
if (!answer.ok)
|
|
92
|
+
return failAgentDoor(door, FAIL_TAG, answer.reason, answer.detail);
|
|
93
|
+
if (door.json)
|
|
94
|
+
return emitAgentDoor(door, answer.body);
|
|
95
|
+
writeLine(door.io.stdout, `Revoked: ${shareLine(answer.body.share)}`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
3
|
+
import { readPipedBody } from "./cal-people.js";
|
|
4
|
+
import { isInteractiveStdin, writeLine } from "./cli-io.js";
|
|
5
|
+
import { parseWeekly } from "./local-args-tower-cal-booking.js";
|
|
6
|
+
import { DEFAULT_DASHBOARD_URL } from "../local-state-config.js";
|
|
7
|
+
const FAIL_TAG = "[cal cli]";
|
|
8
|
+
const REQUEST_TIMEOUT_MS = 60000;
|
|
9
|
+
const PAGES = "/api/cal/booking-pages";
|
|
10
|
+
const pageLine = (p) => `${p.id} ${p.status} ${p.title} ${p.duration_minutes} min ${p.time_zone} next ${p.window_days} days ${p.mode === "single_use" ? "single-use" : "reusable"}`;
|
|
11
|
+
/** Flags into the page body. Names match the door's body; a flag the person did not give is left out. */
|
|
12
|
+
function pageBody(command, piped) {
|
|
13
|
+
const weekly = (command.bookingWeekly ?? []).flatMap((raw) => parseWeekly(raw) ?? []);
|
|
14
|
+
const flags = {
|
|
15
|
+
...(command.title ? { title: command.title } : {}), ...(command.timeZone ? { time_zone: command.timeZone } : {}),
|
|
16
|
+
...(weekly.length ? { weekly_ranges: weekly } : {}), ...(command.duration === undefined ? {} : { duration_minutes: command.duration }),
|
|
17
|
+
...(command.bufferBefore === undefined ? {} : { buffer_before_minutes: command.bufferBefore }), ...(command.bufferAfter === undefined ? {} : { buffer_after_minutes: command.bufferAfter }),
|
|
18
|
+
...(command.minNotice === undefined ? {} : { min_notice_minutes: command.minNotice }), ...(command.windowDays === undefined ? {} : { window_days: command.windowDays }),
|
|
19
|
+
...(command.meet ? { conference: "google_meet" } : {}), ...(command.location ? { location: command.location } : {}),
|
|
20
|
+
...(command.singleUse ? { mode: "single_use" } : {}), ...(command.calendarId ? { calendar_id: command.calendarId } : {}), ...(command.accountName ? { account_name: command.accountName } : {}),
|
|
21
|
+
};
|
|
22
|
+
return { ...piped, ...flags };
|
|
23
|
+
}
|
|
24
|
+
/** `cal booking-page` (BLI-21556): create, list, edit, rotate, close or reopen a public booking page. The same doors the web page calls. */
|
|
25
|
+
export async function runCalBookingPage(command, door) {
|
|
26
|
+
const out = door.io.stdout;
|
|
27
|
+
if (command.bookingMode === "list") {
|
|
28
|
+
const answer = await askAgentDoor(door, { method: "GET", path: PAGES, label: "cal booking-page --list", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
29
|
+
if (!answer.ok)
|
|
30
|
+
return failAgentDoor(door, FAIL_TAG, answer.reason, answer.detail);
|
|
31
|
+
if (door.json)
|
|
32
|
+
return emitAgentDoor(door, answer.body);
|
|
33
|
+
const { pages } = answer.body;
|
|
34
|
+
if (!pages.length)
|
|
35
|
+
writeLine(out, "No booking pages yet.");
|
|
36
|
+
for (const page of pages) {
|
|
37
|
+
writeLine(out, pageLine(page));
|
|
38
|
+
for (const r of page.upcoming ?? [])
|
|
39
|
+
writeLine(out, ` ${r.slot_start} ${r.guest_name} ${r.status}`);
|
|
40
|
+
}
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
let piped = {};
|
|
44
|
+
if (!isInteractiveStdin(door.io) && !command.bookingWeekly?.length && command.bookingMode === "create") {
|
|
45
|
+
const body = await readPipedBody(door);
|
|
46
|
+
if (body === null)
|
|
47
|
+
return failAgentDoor(door, FAIL_TAG, "invalid_body", "Pipe a JSON object like {\"title\":\"Office hours\",\"time_zone\":\"America/Toronto\",\"weekly_ranges\":[{\"weekday\":1,\"start\":\"09:00\",\"end\":\"12:00\"}]}, or use --title and --weekly.");
|
|
48
|
+
piped = body;
|
|
49
|
+
}
|
|
50
|
+
const body = pageBody(command, piped);
|
|
51
|
+
const change = command.bookingMode === "change";
|
|
52
|
+
if (change) {
|
|
53
|
+
if (command.rotate)
|
|
54
|
+
body["rotate_link"] = true;
|
|
55
|
+
if (command.bookingClosed !== undefined)
|
|
56
|
+
body["closed"] = command.bookingClosed;
|
|
57
|
+
delete body["calendar_id"];
|
|
58
|
+
delete body["account_name"];
|
|
59
|
+
const answer = await askAgentDoor(door, { method: "PATCH", path: `${PAGES}/${encodeURIComponent(command.bookingId ?? "")}`, body, label: "cal booking-page --id", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
60
|
+
return printPage(answer, door, "Changed");
|
|
61
|
+
}
|
|
62
|
+
if (!body["title"] || !body["weekly_ranges"])
|
|
63
|
+
return failAgentDoor(door, FAIL_TAG, "invalid_body", "A booking page needs --title \"<name>\" and at least one --weekly <day>/<HH:MM>-<HH:MM>, like mon/09:00-12:00.");
|
|
64
|
+
body["time_zone"] ??= Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
65
|
+
body["request_key"] = command.idempotencyKey ?? (typeof piped["request_key"] === "string" ? piped["request_key"] : randomUUID());
|
|
66
|
+
return printPage(await askAgentDoor(door, { method: "POST", path: PAGES, body, label: "cal booking-page", timeoutMs: REQUEST_TIMEOUT_MS }), door, "Created");
|
|
67
|
+
}
|
|
68
|
+
function printPage(answer, door, verb) {
|
|
69
|
+
if (!answer.ok)
|
|
70
|
+
return failAgentDoor(door, FAIL_TAG, answer.reason, answer.detail);
|
|
71
|
+
if (door.json)
|
|
72
|
+
return emitAgentDoor(door, answer.body);
|
|
73
|
+
const result = answer.body;
|
|
74
|
+
writeLine(door.io.stdout, `${verb}: ${pageLine(result.page)}`);
|
|
75
|
+
if (result.token)
|
|
76
|
+
writeLine(door.io.stdout, `Link (shown once, ${result.token_rotated ? "new token" : "copy it now"}): ${result.url}`);
|
|
77
|
+
else if (result.reason)
|
|
78
|
+
writeLine(door.io.stdout, `No new link: ${result.reason === "link_revoked" ? "that page was turned off" : "that page was already opened, so its token is not shown again"}.`);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
/** A pasted link or a bare token -> the token and the Tower it belongs to. */
|
|
82
|
+
export function readBookingTarget(raw, dashboardUrl) {
|
|
83
|
+
const text = raw.trim();
|
|
84
|
+
if (/^https?:\/\//i.test(text)) {
|
|
85
|
+
const url = new URL(text);
|
|
86
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
87
|
+
const at = parts.findIndex((part) => part === "booking" || part === "booking-reservations");
|
|
88
|
+
return { token: parts[at >= 0 ? at + 1 : parts.length - 1] ?? "", dashboardUrl: url.origin };
|
|
89
|
+
}
|
|
90
|
+
return { token: text, dashboardUrl: dashboardUrl ?? DEFAULT_DASHBOARD_URL };
|
|
91
|
+
}
|
|
92
|
+
const when = (r) => `${r.start} to ${r.end}`;
|
|
93
|
+
/** The guest verbs. They need no paired session: the link or token in the path is the credential. */
|
|
94
|
+
export async function runCalBookingGuest(command, io) {
|
|
95
|
+
const target = readBookingTarget(command.subjectId ?? "", command.dashboardUrl);
|
|
96
|
+
const door = { dashboardUrl: target.dashboardUrl, deviceToken: "", io, json: command.json, homeDir: command.homeDir };
|
|
97
|
+
const token = encodeURIComponent(target.token);
|
|
98
|
+
const out = io.stdout;
|
|
99
|
+
const fail = (a) => failAgentDoor(door, FAIL_TAG, a.reason, a.detail);
|
|
100
|
+
if (command.action === "booking-slots") {
|
|
101
|
+
const answer = await askAgentDoor(door, { method: "GET", path: `/api/cal/booking/${token}/slots`, label: "cal booking-slots", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
102
|
+
if (!answer.ok)
|
|
103
|
+
return fail(answer);
|
|
104
|
+
if (door.json)
|
|
105
|
+
return emitAgentDoor(door, answer.body);
|
|
106
|
+
const page = answer.body;
|
|
107
|
+
writeLine(out, `${page.title}: ${page.duration_minutes} minutes, times in ${page.time_zone}${page.location ? `, ${page.location}` : page.conference === "google_meet" ? ", Google Meet" : ""}`);
|
|
108
|
+
if (!page.slots.length)
|
|
109
|
+
writeLine(out, "No times are open right now.");
|
|
110
|
+
for (const slot of page.slots)
|
|
111
|
+
writeLine(out, when(slot));
|
|
112
|
+
if (page.coverage.own_incomplete)
|
|
113
|
+
writeLine(out, "The host's calendar could not be fully read, so a time may turn out to be taken.");
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
if (command.action === "book") {
|
|
117
|
+
const body = { start: command.bookingStart, guest_name: command.name, guest_email: command.guestEmail, ...(command.guestNote ? { note: command.guestNote } : {}), request_key: command.idempotencyKey ?? randomUUID() };
|
|
118
|
+
const answer = await askAgentDoor(door, { method: "POST", path: `/api/cal/booking/${token}/reservations`, body, label: "cal book", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
119
|
+
if (!answer.ok)
|
|
120
|
+
return fail(answer);
|
|
121
|
+
if (door.json)
|
|
122
|
+
return emitAgentDoor(door, answer.body);
|
|
123
|
+
const result = answer.body;
|
|
124
|
+
writeLine(out, `Booked: ${result.reservation.title}, ${when(result.reservation)} (${result.reservation.time_zone}). An invitation is on its way.`);
|
|
125
|
+
writeLine(out, result.manage_url ? `Keep this link to change or cancel (shown once, stops working when the meeting starts): ${result.manage_url}` : "Your booking link could not be shown again. Ask the host if you need to change it.");
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
if (command.action === "booking-change") {
|
|
129
|
+
const body = { start: command.bookingStart, request_key: command.idempotencyKey ?? randomUUID() };
|
|
130
|
+
const answer = await askAgentDoor(door, { method: "PATCH", path: `/api/cal/booking-reservations/${token}`, body, label: "cal booking-change", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
131
|
+
if (!answer.ok)
|
|
132
|
+
return fail(answer);
|
|
133
|
+
if (door.json)
|
|
134
|
+
return emitAgentDoor(door, answer.body);
|
|
135
|
+
const result = answer.body;
|
|
136
|
+
writeLine(out, `Moved to ${when(result.reservation)} (${result.reservation.time_zone}). The old link no longer works. New link (shown once): ${result.manage_url}`);
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
const answer = await askAgentDoor(door, { method: "DELETE", path: `/api/cal/booking-reservations/${token}`, label: "cal booking-cancel", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
140
|
+
if (!answer.ok)
|
|
141
|
+
return fail(answer);
|
|
142
|
+
if (door.json)
|
|
143
|
+
return emitAgentDoor(door, answer.body);
|
|
144
|
+
const result = answer.body;
|
|
145
|
+
writeLine(out, `Cancelled: ${result.reservation.title}, ${when(result.reservation)}.${result.changed_outside_tower ? " The host's calendar had already dropped the event." : ""}`);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit cal join [--event <id> --occurrence <iso>] [--print]` (BLI-21564): open the meeting link.
|
|
3
|
+
*
|
|
4
|
+
* With no event it picks the meeting running now or starting within 15 minutes from `/api/cal/next` (which already
|
|
5
|
+
* drops cancelled and declined meetings). The link must be an `https:` address with no credentials - the same test
|
|
6
|
+
* as the web Join button (`calendarHttpsLink`) - and a missing or unsafe link is refused by name, never opened.
|
|
7
|
+
* `--print` only prints it. Opening is best effort (`browser-open.ts`); the link is printed either way.
|
|
8
|
+
*/
|
|
9
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
10
|
+
import { openInBrowser } from "./browser-open.js";
|
|
11
|
+
import { writeLine } from "./cli-io.js";
|
|
12
|
+
const TAG = "[cal cli]";
|
|
13
|
+
export const JOIN_LOOKAHEAD_MS = 15 * 60_000;
|
|
14
|
+
/** The same rule as `calendarHttpsLink` on the dashboard: https only, no user or password. */
|
|
15
|
+
export function httpsJoinLink(value) {
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(value ?? "");
|
|
18
|
+
return url.protocol === "https:" && !url.username && !url.password ? url.href : null;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Running now, or starting within 15 minutes; the first one with a link wins, else the first one (to be refused by name). */
|
|
25
|
+
export function pickJoinCandidate(events, now) {
|
|
26
|
+
const near = events.filter(event => !event.all_day && event.status !== "cancelled"
|
|
27
|
+
&& Date.parse(event.starts_at) <= now + JOIN_LOOKAHEAD_MS && Date.parse(event.ends_at) > now);
|
|
28
|
+
return near.find(event => httpsJoinLink(event.meeting_url) !== null) ?? near[0] ?? null;
|
|
29
|
+
}
|
|
30
|
+
export async function runCalJoin(command, door, now = Date.now) {
|
|
31
|
+
const zone = command.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
32
|
+
let meeting;
|
|
33
|
+
if (command.remindEvent) {
|
|
34
|
+
const query = new URLSearchParams({ tz: zone, occurrence_start: command.occurrenceStart ?? "" });
|
|
35
|
+
const answer = await askAgentDoor(door, { method: "GET", path: `/api/cal/events/${encodeURIComponent(command.remindEvent)}?${query}`, label: "cal join", timeoutMs: 30000 });
|
|
36
|
+
if (!answer.ok)
|
|
37
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
38
|
+
meeting = answer.body.event;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const answer = await askAgentDoor(door, { method: "GET", path: `/api/cal/next?${new URLSearchParams({ tz: zone, hours: "2" })}`, label: "cal join", timeoutMs: 30000 });
|
|
42
|
+
if (!answer.ok)
|
|
43
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
44
|
+
meeting = pickJoinCandidate(answer.body.events ?? [], now());
|
|
45
|
+
if (!meeting)
|
|
46
|
+
return failAgentDoor(door, TAG, "nothing_to_join", "No meeting is running or starts in the next 15 minutes.");
|
|
47
|
+
}
|
|
48
|
+
if (!meeting.meeting_url)
|
|
49
|
+
return failAgentDoor(door, TAG, "no_join_link", `"${meeting.title}" has no join link on its calendar entry.`, { event_id: meeting.event_id });
|
|
50
|
+
const link = httpsJoinLink(meeting.meeting_url);
|
|
51
|
+
if (!link)
|
|
52
|
+
return failAgentDoor(door, TAG, "link_not_https", `"${meeting.title}" has a join link that is not a plain https address, so it was not opened.`, { event_id: meeting.event_id });
|
|
53
|
+
const opened = command.printOnly ? null : openInBrowser(link);
|
|
54
|
+
console.error(`${TAG} join`, JSON.stringify({ event_id: meeting.event_id, kind: meeting.meeting_kind, print_only: command.printOnly === true, open: opened ? opened.reason : "not_attempted" }));
|
|
55
|
+
if (door.json) {
|
|
56
|
+
return emitAgentDoor(door, { ok: true, event_id: meeting.event_id, title: meeting.title, starts_at: meeting.starts_at, join_url: link, join_kind: meeting.meeting_kind, opened: opened?.ok === true, open_reason: opened ? opened.reason : "print_only" });
|
|
57
|
+
}
|
|
58
|
+
writeLine(door.io.stdout, link);
|
|
59
|
+
if (opened && !opened.ok)
|
|
60
|
+
writeLine(door.io.stderr, opened.detail);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit cal links <event id> [--occurrence <iso>] [--add doc:<id>|note:<id> [--series]] [--remove <link id>]`
|
|
3
|
+
* (BLI-21588): the same doors the web event panel uses. A link is yours alone and gives nobody any access; a target you
|
|
4
|
+
* can no longer open prints as unavailable with no title.
|
|
5
|
+
*/
|
|
6
|
+
import { runCalAgenda } from "./cal-agenda.js";
|
|
7
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
8
|
+
import { writeLine } from "./cli-io.js";
|
|
9
|
+
const TAG = "[cal cli]";
|
|
10
|
+
const TIMEOUT_MS = 30000;
|
|
11
|
+
/** One line: the link id, the kind, the title (or "unavailable"), where it opens, and a note's recording facts. */
|
|
12
|
+
export function linkLine(row) {
|
|
13
|
+
const id = row.link_id ? `${row.link_id} ` : "";
|
|
14
|
+
const scope = row.scope === "series" ? "series " : "";
|
|
15
|
+
if (row.state === "unavailable")
|
|
16
|
+
return `${id}${scope}${row.kind.toUpperCase().padEnd(4)} unavailable (${row.id}): you can no longer open it`;
|
|
17
|
+
const facts = row.kind === "note" ? ` [${row.status}; ${row.recordings} recording(s), ${row.alternates} alternate, ${row.missed} missed]` : "";
|
|
18
|
+
return `${id}${scope}${row.kind.toUpperCase().padEnd(4)} ${row.title} ${row.href}${facts}`;
|
|
19
|
+
}
|
|
20
|
+
export async function runCalLinks(command, door) {
|
|
21
|
+
const base = `/api/cal/events/${encodeURIComponent(command.subjectId)}/links`;
|
|
22
|
+
if (command.linkRemove) {
|
|
23
|
+
const answer = await askAgentDoor(door, { method: "DELETE", path: `${base}/${encodeURIComponent(command.linkRemove)}`, label: "cal links", timeoutMs: TIMEOUT_MS });
|
|
24
|
+
if (!answer.ok)
|
|
25
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
26
|
+
if (door.json)
|
|
27
|
+
return emitAgentDoor(door, answer.body);
|
|
28
|
+
writeLine(door.io.stdout, `Removed link ${command.linkRemove}. The history keeps it.`);
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
if (command.linkAdd) {
|
|
32
|
+
const body = {
|
|
33
|
+
target_kind: command.linkAdd.kind, target_id: command.linkAdd.id,
|
|
34
|
+
scope: command.linkSeries ? "series" : "occurrence",
|
|
35
|
+
...(command.linkSeries ? {} : { occurrence_start: command.occurrenceStart }),
|
|
36
|
+
};
|
|
37
|
+
const answer = await askAgentDoor(door, { method: "POST", path: base, body, label: "cal links", timeoutMs: TIMEOUT_MS });
|
|
38
|
+
if (!answer.ok)
|
|
39
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
40
|
+
if (door.json)
|
|
41
|
+
return emitAgentDoor(door, answer.body);
|
|
42
|
+
writeLine(door.io.stdout, `Linked: ${linkLine(answer.body.link)}`);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
const query = new URLSearchParams();
|
|
46
|
+
if (command.occurrenceStart)
|
|
47
|
+
query.set("occurrence_start", command.occurrenceStart);
|
|
48
|
+
const answer = await askAgentDoor(door, { method: "GET", path: `${base}?${query}`, label: "cal links", timeoutMs: TIMEOUT_MS });
|
|
49
|
+
if (!answer.ok)
|
|
50
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
51
|
+
if (door.json)
|
|
52
|
+
return emitAgentDoor(door, answer.body);
|
|
53
|
+
const list = answer.body;
|
|
54
|
+
if (list.links.length === 0)
|
|
55
|
+
writeLine(door.io.stdout, "No links on this event yet. Add one with: cockpit cal links <event id> --add doc:<id> --occurrence <iso>");
|
|
56
|
+
for (const row of list.links)
|
|
57
|
+
writeLine(door.io.stdout, linkLine(row));
|
|
58
|
+
for (const suggestion of list.suggestions) {
|
|
59
|
+
writeLine(door.io.stdout, `Suggested (${suggestion.reason}): ${linkLine(suggestion.target)} -> attach with --add note:${suggestion.target.id}`);
|
|
60
|
+
}
|
|
61
|
+
if (list.suggestions_read === false)
|
|
62
|
+
writeLine(door.io.stdout, "Suggestions could not be read just now.");
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
/** `links` and `agenda` share one routing line in `cal.ts`, which is at its size ceiling. */
|
|
66
|
+
export function runCalContext(command, door) {
|
|
67
|
+
return command.action === "agenda" ? runCalAgenda(command, door) : runCalLinks(command, door);
|
|
68
|
+
}
|
|
@@ -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
|
+
}
|