@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,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a person checks an approval whose last try may have gone out, per act kind (BLI-21954).
|
|
3
|
+
*
|
|
4
|
+
* The dashboard's `apps/dashboard/src/lib/approvals/delivery-check-words.ts` is the source of these
|
|
5
|
+
* words (its `whereShort`). The CLI ships as its own npm package and cannot import the dashboard,
|
|
6
|
+
* so it keeps this one copy, with no imports, and a dashboard test (`delivery-check-words.test.ts`)
|
|
7
|
+
* fails if the two drift: a kind added there must be added here.
|
|
8
|
+
*/
|
|
9
|
+
export const CHECK_WHERE = { "mail.send": "Sent", "msg.send": "the channel" };
|
|
10
|
+
/** For a kind with no row: names no place, so it never tells a person to look in the wrong one. */
|
|
11
|
+
export const CHECK_WHERE_FALLBACK = "where it was going";
|
|
12
|
+
export function checkWhere(actKind) {
|
|
13
|
+
return (actKind && CHECK_WHERE[actKind]) || CHECK_WHERE_FALLBACK;
|
|
14
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* exception: the verbs take ids, so each card and rule ends with a dim `id <uuid>` line to copy.
|
|
7
7
|
* `--json` never comes through here.
|
|
8
8
|
*/
|
|
9
|
+
import { checkWhere } from "./approvals-check-where.js";
|
|
9
10
|
import { dim } from "./cli-io.js";
|
|
10
11
|
const STATUS_WORDS = {
|
|
11
12
|
pending: "waiting for you",
|
|
@@ -15,9 +16,15 @@ const STATUS_WORDS = {
|
|
|
15
16
|
rejected: "rejected",
|
|
16
17
|
};
|
|
17
18
|
const HOW_WORDS = { tap: "approved by you", edit: "approved by you with an edit", rule: "sent by a standing rule", reject: "rejected by you" };
|
|
19
|
+
/** Where the person looks differs per kind: Sent for an email, the channel for a Tower message. */
|
|
20
|
+
function checkWhat(actKind) {
|
|
21
|
+
return `check ${checkWhere(actKind)}, then approve with --checked-sent sent or --checked-sent not-sent`;
|
|
22
|
+
}
|
|
18
23
|
export function statusWords(card) {
|
|
19
24
|
if (card.status === "executing" && card.stuck)
|
|
20
|
-
return
|
|
25
|
+
return `stuck sending, it may or may not have gone out: ${checkWhat(card.act_kind)}`;
|
|
26
|
+
if (card.status === "failed" && card.delivery_unknown)
|
|
27
|
+
return `delivery unknown, it may have gone out: ${checkWhat(card.act_kind)}`;
|
|
21
28
|
return STATUS_WORDS[card.status] ?? card.status;
|
|
22
29
|
}
|
|
23
30
|
function sourceLine(card) {
|
|
@@ -109,7 +109,8 @@ async function approveCard(command, door) {
|
|
|
109
109
|
return failAgentDoor(door, TAG, piped.reason, piped.detail);
|
|
110
110
|
edit = piped.value;
|
|
111
111
|
}
|
|
112
|
-
|
|
112
|
+
const body = { ...(edit === undefined ? {} : { edit }), ...(command.deliveryChecked ? { delivery_checked: command.deliveryChecked } : {}) };
|
|
113
|
+
return sendApproval(command.id ?? "", body, door);
|
|
113
114
|
}
|
|
114
115
|
async function editCard(command, door, color) {
|
|
115
116
|
const piped = await readJsonObject(door, "edit");
|
|
@@ -137,7 +138,7 @@ async function sendApproval(id, requestBody, door) {
|
|
|
137
138
|
if (!answer.ok)
|
|
138
139
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
139
140
|
const body = answer.body;
|
|
140
|
-
writeLine(door.io.stderr, `${TAG} approve answered ${JSON.stringify({ card_id: body.card.id, status: body.card.status, with_edit: "edit" in requestBody, bookkeeping_failed: Boolean(body.bookkeeping_failed) })}`);
|
|
141
|
+
writeLine(door.io.stderr, `${TAG} approve answered ${JSON.stringify({ card_id: body.card.id, status: body.card.status, with_edit: "edit" in requestBody, delivery_checked: requestBody["delivery_checked"] ?? null, bookkeeping_failed: Boolean(body.bookkeeping_failed) })}`);
|
|
141
142
|
if (body.bookkeeping_failed)
|
|
142
143
|
writeLine(door.io.stderr, `${TAG} the act ran but Tower could not finish its own bookkeeping (rule counts); the receipt is still true`);
|
|
143
144
|
if (door.json)
|
|
@@ -52,8 +52,18 @@ export async function runAutostart(command, io) {
|
|
|
52
52
|
return autostartExitCode(command.action, result);
|
|
53
53
|
}
|
|
54
54
|
writeAutostartResult(io, result);
|
|
55
|
+
writeRemindLine(io, result);
|
|
55
56
|
return autostartExitCode(command.action, result);
|
|
56
57
|
}
|
|
58
|
+
/** BLI-21564: the reminder job's own line beside the sync agent's, with its message when it needs attention. */
|
|
59
|
+
function writeRemindLine(io, result) {
|
|
60
|
+
const remind = result.remind;
|
|
61
|
+
if (!remind || remind.status === "unsupported")
|
|
62
|
+
return;
|
|
63
|
+
writeLine(io.stdout, `Reminder job (${remind.task_name ?? remind.label}, every ${remind.interval_seconds}s): ${remind.status === "installed" ? (remind.loaded ? "installed and loaded" : "installed, not loaded") : remind.status.replace("_", " ")}`);
|
|
64
|
+
if (remind.message)
|
|
65
|
+
writeLine(io.stderr, remind.message);
|
|
66
|
+
}
|
|
57
67
|
function autostartExitCode(action, result) {
|
|
58
68
|
if (result.status === "unsupported")
|
|
59
69
|
return 1;
|
|
@@ -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
|
+
}
|