@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.
Files changed (127) hide show
  1. package/dist/adapters/git-state.js +1 -0
  2. package/dist/adapters/raw-evidence-collection-state.js +22 -11
  3. package/dist/adapters/raw-evidence-git-diff.js +1 -0
  4. package/dist/agent-rules.js +8 -0
  5. package/dist/autostart-contract.js +12 -0
  6. package/dist/autostart-darwin-plist.js +2 -2
  7. package/dist/autostart-darwin.js +1 -1
  8. package/dist/autostart-remind.js +245 -0
  9. package/dist/autostart-self-heal.js +17 -1
  10. package/dist/autostart-windows-scripts.js +7 -7
  11. package/dist/autostart.js +16 -15
  12. package/dist/commands/approvals-check-where.js +14 -0
  13. package/dist/commands/approvals-render.js +8 -1
  14. package/dist/commands/approvals.js +3 -2
  15. package/dist/commands/autostart-command.js +10 -0
  16. package/dist/commands/autostart-heal.js +36 -1
  17. package/dist/commands/brain.js +1 -1
  18. package/dist/commands/cal-agenda.js +44 -0
  19. package/dist/commands/cal-availability-share.js +97 -0
  20. package/dist/commands/cal-booking.js +147 -0
  21. package/dist/commands/cal-join.js +62 -0
  22. package/dist/commands/cal-links.js +68 -0
  23. package/dist/commands/cal-people.js +136 -0
  24. package/dist/commands/cal-remind.js +176 -0
  25. package/dist/commands/cal-reminders.js +33 -0
  26. package/dist/commands/cal-suggest.js +89 -0
  27. package/dist/commands/cal.js +41 -1
  28. package/dist/commands/cycle-args.js +127 -0
  29. package/dist/commands/cycles.js +117 -0
  30. package/dist/commands/docs-comments-args.js +85 -0
  31. package/dist/commands/docs-comments.js +128 -0
  32. package/dist/commands/docs-files-args.js +49 -0
  33. package/dist/commands/docs-files.js +179 -0
  34. package/dist/commands/docs-formatting.js +45 -0
  35. package/dist/commands/docs-inbox-args.js +93 -0
  36. package/dist/commands/docs-inbox.js +126 -0
  37. package/dist/commands/docs-notion.js +5 -0
  38. package/dist/commands/docs-read.js +2 -1
  39. package/dist/commands/docs-recovery.js +173 -0
  40. package/dist/commands/docs-refs-args.js +104 -0
  41. package/dist/commands/docs-refs.js +153 -0
  42. package/dist/commands/docs-search-args.js +79 -0
  43. package/dist/commands/docs-search.js +64 -0
  44. package/dist/commands/docs-sync-args.js +82 -0
  45. package/dist/commands/docs-sync.js +197 -0
  46. package/dist/commands/docs-tree.js +136 -0
  47. package/dist/commands/docs.js +85 -51
  48. package/dist/commands/doctor-update.js +4 -1
  49. package/dist/commands/inbox-args.js +81 -0
  50. package/dist/commands/inbox.js +88 -0
  51. package/dist/commands/issue-batch.js +61 -0
  52. package/dist/commands/issue-relations.js +151 -0
  53. package/dist/commands/issue-write.js +38 -5
  54. package/dist/commands/issue.js +32 -1
  55. package/dist/commands/local-args-tower-approvals.js +16 -4
  56. package/dist/commands/local-args-tower-cal-booking.js +81 -0
  57. package/dist/commands/local-args-tower-cal-links.js +42 -0
  58. package/dist/commands/local-args-tower-cal-remind.js +74 -0
  59. package/dist/commands/local-args-tower-cal-share.js +41 -0
  60. package/dist/commands/local-args-tower-cal.js +81 -8
  61. package/dist/commands/local-args-tower-docs-msg.js +98 -9
  62. package/dist/commands/local-args-tower-msg.js +279 -10
  63. package/dist/commands/local-args-tower-search.js +32 -5
  64. package/dist/commands/local-args-tower-work.js +110 -34
  65. package/dist/commands/local-args-tower.js +1 -0
  66. package/dist/commands/local-args.js +2 -0
  67. package/dist/commands/local-auth.js +1 -1
  68. package/dist/commands/local-help-commands-tower-ops.js +2 -0
  69. package/dist/commands/local-help-commands-tower.js +48 -32
  70. package/dist/commands/local-help-commands.js +1 -0
  71. package/dist/commands/local-help-cycles.js +9 -0
  72. package/dist/commands/local-help-docs-files.js +10 -0
  73. package/dist/commands/local-help-docs-formatting.js +7 -0
  74. package/dist/commands/local-help-docs-inbox.js +10 -0
  75. package/dist/commands/local-help-docs-recovery.js +10 -0
  76. package/dist/commands/local-help-docs-refs.js +11 -0
  77. package/dist/commands/local-help-docs-sync.js +9 -0
  78. package/dist/commands/local-help-inbox.js +9 -0
  79. package/dist/commands/local-help-project.js +21 -0
  80. package/dist/commands/local-help-templates.js +9 -0
  81. package/dist/commands/local-help-views.js +10 -0
  82. package/dist/commands/local-help-work-notify.js +6 -0
  83. package/dist/commands/local-help.js +4 -2
  84. package/dist/commands/local.js +2 -0
  85. package/dist/commands/mcp-stdio-probe.js +1 -0
  86. package/dist/commands/msg-activity.js +134 -0
  87. package/dist/commands/msg-channel-args.js +92 -0
  88. package/dist/commands/msg-channel.js +125 -0
  89. package/dist/commands/msg-context-args.js +67 -0
  90. package/dist/commands/msg-context.js +65 -0
  91. package/dist/commands/msg-dm.js +28 -0
  92. package/dist/commands/msg-draft.js +193 -0
  93. package/dist/commands/msg-file.js +185 -0
  94. package/dist/commands/msg-mark-args.js +78 -0
  95. package/dist/commands/msg-mark.js +148 -0
  96. package/dist/commands/msg-notify-args.js +100 -0
  97. package/dist/commands/msg-notify.js +122 -0
  98. package/dist/commands/msg-schedule-args.js +114 -0
  99. package/dist/commands/msg-schedule.js +226 -0
  100. package/dist/commands/msg-search-args.js +71 -0
  101. package/dist/commands/msg-search.js +48 -0
  102. package/dist/commands/msg-write.js +15 -1
  103. package/dist/commands/msg.js +77 -39
  104. package/dist/commands/project-args.js +82 -0
  105. package/dist/commands/project.js +71 -7
  106. package/dist/commands/public-root.js +1 -1
  107. package/dist/commands/push.js +50 -0
  108. package/dist/commands/search.js +23 -0
  109. package/dist/commands/session-sync-failures.js +17 -4
  110. package/dist/commands/session-sync-health.js +7 -2
  111. package/dist/commands/session-sync-hold.js +42 -1
  112. package/dist/commands/session-sync-upload.js +2 -0
  113. package/dist/commands/session-sync.js +1 -0
  114. package/dist/commands/sync-doctor.js +14 -1
  115. package/dist/commands/sync-report.js +1 -1
  116. package/dist/commands/template-args.js +122 -0
  117. package/dist/commands/templates.js +162 -0
  118. package/dist/commands/view-args.js +131 -0
  119. package/dist/commands/views.js +138 -0
  120. package/dist/commands/work-notify-args.js +67 -0
  121. package/dist/commands/work-notify.js +63 -0
  122. package/dist/delivery-hold-notice.js +34 -0
  123. package/dist/log-rotation.js +2 -2
  124. package/dist/notifier.js +83 -0
  125. package/dist/process-runner.js +4 -0
  126. package/dist/repo-identity-git.js +1 -0
  127. package/package.json +4 -4
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Arguments of `cockpit msg context <message-id> [--add <kind:id>] [--remove <link-id>] [--json]` (BLI-21829).
3
+ * `--add doc:<id>|ticket:<BLI-id or uuid>|event:<id>[@<iso occurrence>]|note:<id>`; `--remove` takes the message id first and the
4
+ * link id after it. Split out of `local-args-tower-msg.ts`.
5
+ */
6
+ import { optionalNonEmpty, optionalUrl } from "./local-arg-values.js";
7
+ export const MSG_CONTEXT_VALUE_FLAGS = ["--add"];
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ const IDENTIFIER = /^[A-Za-z][A-Za-z0-9]{1,12}-\d+$/;
10
+ const KINDS = ["doc", "ticket", "event", "note"];
11
+ /** `--add` used on another msg verb is named, not ignored. */
12
+ export function rejectMsgContextFlags(values) {
13
+ if (values.flags.has("--add"))
14
+ throw new Error("--add belongs to `cockpit msg context`.");
15
+ }
16
+ export function parseContextSpec(spec) {
17
+ const match = /^(doc|ticket|event|note):(.+)$/i.exec(spec.trim());
18
+ if (!match)
19
+ throw new Error("--add takes doc:<id>, ticket:<BLI-id or uuid>, event:<id>[@<iso occurrence>] or note:<id>.");
20
+ const kind = match[1].toLowerCase();
21
+ const [id = "", occurrence] = match[2].split("@", 2);
22
+ const ok = kind === "ticket" ? UUID.test(id) || IDENTIFIER.test(id) : UUID.test(id);
23
+ if (!ok)
24
+ throw new Error(`--add ${kind}: needs ${kind === "ticket" ? "a BLI id or a uuid" : "a uuid"}, not "${id}".`);
25
+ if (occurrence !== undefined) {
26
+ if (kind !== "event")
27
+ throw new Error("An @<iso occurrence> belongs to event: only.");
28
+ const time = Date.parse(occurrence);
29
+ if (Number.isNaN(time))
30
+ throw new Error(`--add event: the occurrence "${occurrence}" is not an ISO time.`);
31
+ return { kind, id, occurrence: new Date(time).toISOString() };
32
+ }
33
+ return { kind, id };
34
+ }
35
+ export function parseMsgContextArgs(values, rest) {
36
+ const stray = ["--channel", "--members", "--description", "--from", "--expected-revision", "--name", "--out", "--file", "--level", "--timezone", "--message", "--to", "--before", "--limit", "--thread", "--request-id"]
37
+ .find((flag) => values.flags.has(flag));
38
+ if (stray || values.booleans.has("--private"))
39
+ throw new Error(`${stray ?? "--private"} does not belong to \`cockpit msg context\`.`);
40
+ const targetId = optionalNonEmpty(rest[0]);
41
+ if (!targetId || !UUID.test(targetId)) {
42
+ throw new Error("msg context needs a message id (a UUID), e.g. `cockpit msg context <message-id> --json`.");
43
+ }
44
+ const add = optionalNonEmpty(values.flags.get("--add"));
45
+ const removing = values.booleans.has("--remove");
46
+ if (add && removing)
47
+ throw new Error("msg context takes --add or --remove, not both.");
48
+ const base = {
49
+ kind: "msg",
50
+ action: "context",
51
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
52
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
53
+ isPrivate: false,
54
+ json: values.booleans.has("--json"),
55
+ targetId,
56
+ };
57
+ if (removing) {
58
+ const linkId = optionalNonEmpty(rest[1]);
59
+ if (!linkId || !UUID.test(linkId) || rest.length > 2) {
60
+ throw new Error("msg context --remove takes the message id and then the link id, e.g. `cockpit msg context <message-id> --remove <link-id>`.");
61
+ }
62
+ return { ...base, contextRemove: linkId };
63
+ }
64
+ if (rest.length > 1)
65
+ throw new Error(`msg context takes one message id, not ${rest.length}.`);
66
+ return { ...base, ...(add ? { contextAdd: parseContextSpec(add) } : {}) };
67
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `cockpit msg context` (BLI-21829): the Tower docs, tickets, calendar events and meeting notes pointed at from one message.
3
+ * Same doors the browser calls (`/api/msg/messages/<id>/context`, `.../context/<link-id>`); `--json` prints the door's body
4
+ * unchanged so link ids, kinds and `state: "unavailable"` match HTTP. Every target is read again for YOU: one you cannot open
5
+ * shows as "Not available to you" with no title. A refusal keeps the door's reason: context_target_unavailable,
6
+ * context_not_author, context_already_linked, context_limit_reached, context_link_not_found.
7
+ */
8
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
9
+ import { writeLine } from "./cli-io.js";
10
+ const TAG = "[msg cli]";
11
+ const DEADLINE_MS = 30_000;
12
+ function line(link) {
13
+ if (link.state === "unavailable")
14
+ return `${link.link_id} ${link.kind} Not available to you`;
15
+ const label = link.kind === "ticket" && link.identifier ? `${link.identifier} ${link.title ?? ""}`.trim() : (link.title ?? link.id);
16
+ return `${link.link_id} ${link.kind} ${label}${link.href ? ` ${link.href}` : ""}`;
17
+ }
18
+ export async function runMsgContext(command, door) {
19
+ const messageId = encodeURIComponent(command.targetId ?? "");
20
+ if (command.contextRemove) {
21
+ const answer = await askAgentDoor(door, {
22
+ path: `/api/msg/messages/${messageId}/context/${encodeURIComponent(command.contextRemove)}`,
23
+ method: "DELETE",
24
+ label: "msg context remove",
25
+ timeoutMs: DEADLINE_MS,
26
+ });
27
+ if (!answer.ok)
28
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
29
+ writeLine(door.io.stderr, `${TAG} context removed ${JSON.stringify({ message_id: command.targetId, link_id: command.contextRemove })}`);
30
+ if (door.json)
31
+ return emitAgentDoor(door, { ok: true, ...answer.body });
32
+ writeLine(door.io.stdout, `Removed context ${command.contextRemove} from ${command.targetId}.`);
33
+ return 0;
34
+ }
35
+ if (command.contextAdd) {
36
+ const add = command.contextAdd;
37
+ const answer = await askAgentDoor(door, {
38
+ path: `/api/msg/messages/${messageId}/context`,
39
+ method: "POST",
40
+ body: { target_kind: add.kind, target_id: add.id, ...(add.occurrence ? { occurrence_start: add.occurrence } : {}) },
41
+ label: "msg context add",
42
+ timeoutMs: DEADLINE_MS,
43
+ });
44
+ if (!answer.ok)
45
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
46
+ const link = answer.body.link;
47
+ writeLine(door.io.stderr, `${TAG} context added ${JSON.stringify({ message_id: command.targetId, kind: add.kind, link_id: link?.link_id ?? null })}`);
48
+ if (door.json)
49
+ return emitAgentDoor(door, { ok: true, ...answer.body });
50
+ writeLine(door.io.stdout, `Added ${add.kind} context to ${command.targetId}${link ? `:\n${line(link)}` : "."}`);
51
+ return 0;
52
+ }
53
+ const answer = await askAgentDoor(door, { path: `/api/msg/messages/${messageId}/context`, method: "GET", label: "msg context", timeoutMs: DEADLINE_MS });
54
+ if (!answer.ok)
55
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
56
+ const links = answer.body.links ?? [];
57
+ writeLine(door.io.stderr, `${TAG} context ${JSON.stringify({ message_id: command.targetId, links: links.length, unavailable: links.filter((link) => link.state === "unavailable").length })}`);
58
+ if (door.json)
59
+ return emitAgentDoor(door, { ok: true, ...answer.body });
60
+ for (const link of links)
61
+ writeLine(door.io.stdout, line(link));
62
+ if (links.length === 0)
63
+ writeLine(door.io.stdout, "No Tower context on this message.");
64
+ return 0;
65
+ }
@@ -0,0 +1,28 @@
1
+ /** `cockpit msg dm <email>`: lookup-or-create a direct message (BLI-3749). Split out of `msg.ts` for the file-size ratchet. */
2
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
3
+ import { writeLine } from "./cli-io.js";
4
+ const TAG = "[msg cli]";
5
+ const WRITE_DEADLINE_MS = 30_000;
6
+ export async function openDm(command, door) {
7
+ const emails = command.dmEmails ?? (command.dmEmail ? [command.dmEmail] : []);
8
+ const email = emails.join(", ");
9
+ if (emails.length === 0)
10
+ return failAgentDoor(door, TAG, "invalid_body", "msg dm needs an email address.");
11
+ const answer = await askAgentDoor(door, {
12
+ path: "/api/msg/channels",
13
+ method: "POST",
14
+ label: "msg dm",
15
+ timeoutMs: WRITE_DEADLINE_MS,
16
+ body: { dm_participant_emails: emails },
17
+ });
18
+ if (!answer.ok)
19
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
20
+ const channel = answer.body.channel ?? null;
21
+ writeLine(door.io.stderr, `${TAG} dm resolved ${JSON.stringify({ channel_id: channel?.id ?? null })}`);
22
+ if (door.json)
23
+ return emitAgentDoor(door, { ok: true, channel });
24
+ // Idempotent by contract (`get_or_create_dm`), so the wording says what is
25
+ // true either way rather than claiming a creation that may not have happened.
26
+ writeLine(door.io.stdout, `${emails.length > 1 ? "Group message" : "Direct message"} with ${email}: ${channel?.id ?? "?"}`);
27
+ return 0;
28
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * `cockpit msg draft` (BLI-21414): the text you have not sent yet, per channel and thread, the same
3
+ * drafts the browser autosaves. `list`, `show`, `save` (body piped in), `discard`, `restore` and
4
+ * `send` (posts the stored draft with a fresh request id, then prints the receipt). Same doors the
5
+ * browser calls: `/api/msg/drafts`, `/api/msg/drafts/restore` and the channel post door.
6
+ *
7
+ * The channel arrives already resolved to an id by `msg.ts`. Draft text is never logged; stderr lines
8
+ * carry ids, counts and reason labels.
9
+ */
10
+ import { randomUUID } from "node:crypto";
11
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
12
+ import { writeLine } from "./cli-io.js";
13
+ import { readContent, refuseMsgWrite } from "./msg-write.js";
14
+ const TAG = "[msg draft cli]";
15
+ const DEADLINE_MS = 30_000;
16
+ function placeQuery(channelId, command) {
17
+ const query = new URLSearchParams({ channel_id: channelId });
18
+ if (command.threadId)
19
+ query.set("thread_parent_id", command.threadId);
20
+ return query.toString();
21
+ }
22
+ /** A refusal keeps the door's reason. A conflict names the stored revision; the stored text goes to --json only, never to a log line. */
23
+ function refuseDraft(door, answer) {
24
+ const stored = answer.body?.draft ?? null;
25
+ writeLine(door.io.stderr, `${TAG} refused ${JSON.stringify({ reason: answer.reason, stored_revision: stored?.revision ?? null })}`);
26
+ if (door.json) {
27
+ writeLine(door.io.stdout, JSON.stringify({ ok: false, error: answer.reason, detail: answer.detail, draft: stored }));
28
+ }
29
+ else {
30
+ writeLine(door.io.stderr, answer.detail);
31
+ if (answer.reason === "draft_revision_conflict" && stored) {
32
+ writeLine(door.io.stderr, `The stored draft is at revision ${stored.revision}. Read it with \`cockpit msg draft show\`, then save again with --expected-revision ${stored.revision}.`);
33
+ }
34
+ }
35
+ return 1;
36
+ }
37
+ export async function runMsgDraft(command, door, channelId) {
38
+ switch (command.draftVerb ?? "list") {
39
+ case "list":
40
+ return listDrafts(door);
41
+ case "show":
42
+ return showDraft(command, door, channelId ?? "");
43
+ case "save":
44
+ return saveDraft(command, door, channelId ?? "");
45
+ case "discard":
46
+ return discardDraft(command, door, channelId ?? "");
47
+ case "restore":
48
+ return restoreDraft(command, door, channelId ?? "");
49
+ case "send":
50
+ return sendDraft(command, door, channelId ?? "");
51
+ }
52
+ }
53
+ async function listDrafts(door) {
54
+ const answer = await askAgentDoor(door, { path: "/api/msg/drafts", method: "GET", label: "msg draft list", timeoutMs: DEADLINE_MS });
55
+ if (!answer.ok)
56
+ return refuseDraft(door, answer);
57
+ const body = answer.body;
58
+ const drafts = body.drafts ?? [];
59
+ writeLine(door.io.stderr, `${TAG} listed ${JSON.stringify({ returned: drafts.length, coverage: body.coverage ?? "unknown" })}`);
60
+ if (door.json)
61
+ return emitAgentDoor(door, { ok: true, drafts, coverage: body.coverage, limit: body.limit, returned: body.returned ?? drafts.length });
62
+ if (drafts.length === 0) {
63
+ writeLine(door.io.stdout, "No unsent drafts.");
64
+ return 0;
65
+ }
66
+ for (const draft of drafts) {
67
+ writeLine(door.io.stdout, `${draft.channel_id}${draft.thread_parent_id ? ` thread ${draft.thread_parent_id}` : ""} revision ${draft.revision} ${draft.content.length} chars ${draft.updated_at}`);
68
+ }
69
+ if (body.coverage === "capped")
70
+ writeLine(door.io.stdout, `Showing the newest ${body.limit ?? drafts.length}; there are more.`);
71
+ return 0;
72
+ }
73
+ async function showDraft(command, door, channelId) {
74
+ const answer = await askAgentDoor(door, { path: `/api/msg/drafts?${placeQuery(channelId, command)}`, method: "GET", label: "msg draft show", timeoutMs: DEADLINE_MS });
75
+ if (!answer.ok)
76
+ return refuseDraft(door, answer);
77
+ const draft = answer.body.draft ?? null;
78
+ writeLine(door.io.stderr, `${TAG} shown ${JSON.stringify({ channel_id: channelId, found: draft !== null, state: draft?.state ?? "none", revision: draft?.revision ?? null })}`);
79
+ if (door.json)
80
+ return emitAgentDoor(door, { ok: true, draft });
81
+ if (!draft) {
82
+ writeLine(door.io.stdout, "No draft here.");
83
+ return 0;
84
+ }
85
+ writeLine(door.io.stderr, `revision ${draft.revision}, ${draft.state}`);
86
+ writeLine(door.io.stdout, draft.content);
87
+ return 0;
88
+ }
89
+ async function saveDraft(command, door, channelId) {
90
+ const content = await readContent(door.io);
91
+ if (!content.ok)
92
+ return failAgentDoor(door, TAG, content.reason, content.detail);
93
+ if (content.text === "") {
94
+ return failAgentDoor(door, TAG, "invalid_body", "There was nothing to save. Use `cockpit msg draft discard` to drop a draft. Nothing was changed.");
95
+ }
96
+ const answer = await askAgentDoor(door, {
97
+ path: "/api/msg/drafts",
98
+ method: "PUT",
99
+ label: "msg draft save",
100
+ timeoutMs: DEADLINE_MS,
101
+ body: {
102
+ channel_id: channelId,
103
+ thread_parent_id: command.threadId ?? null,
104
+ content: content.text,
105
+ ...(command.expectedRevision === undefined ? {} : { expected_revision: command.expectedRevision }),
106
+ },
107
+ });
108
+ if (!answer.ok)
109
+ return refuseDraft(door, answer);
110
+ const result = answer.body;
111
+ writeLine(door.io.stderr, `${TAG} saved ${JSON.stringify({ channel_id: channelId, outcome: result.outcome ?? null, revision: result.draft?.revision ?? null, chars: content.text.length })}`);
112
+ if (door.json)
113
+ return emitAgentDoor(door, { ok: true, ...result });
114
+ writeLine(door.io.stdout, `Saved draft, revision ${result.draft?.revision ?? "?"}.`);
115
+ return 0;
116
+ }
117
+ async function discardDraft(command, door, channelId) {
118
+ const query = placeQuery(channelId, command) + (command.expectedRevision === undefined ? "" : `&expected_revision=${command.expectedRevision}`);
119
+ const answer = await askAgentDoor(door, { path: `/api/msg/drafts?${query}`, method: "DELETE", label: "msg draft discard", timeoutMs: DEADLINE_MS });
120
+ if (!answer.ok)
121
+ return refuseDraft(door, answer);
122
+ const result = answer.body;
123
+ writeLine(door.io.stderr, `${TAG} discarded ${JSON.stringify({ channel_id: channelId, changed: result.changed ?? null, revision: result.draft?.revision ?? null })}`);
124
+ if (door.json)
125
+ return emitAgentDoor(door, { ok: true, ...result });
126
+ writeLine(door.io.stdout, `Discarded the draft (revision ${result.draft?.revision ?? "?"}). Its text is kept; \`cockpit msg draft restore\` brings it back.`);
127
+ return 0;
128
+ }
129
+ async function restoreDraft(command, door, channelId) {
130
+ const answer = await askAgentDoor(door, {
131
+ path: "/api/msg/drafts/restore",
132
+ method: "POST",
133
+ label: "msg draft restore",
134
+ timeoutMs: DEADLINE_MS,
135
+ body: {
136
+ channel_id: channelId,
137
+ thread_parent_id: command.threadId ?? null,
138
+ ...(command.expectedRevision === undefined ? {} : { expected_revision: command.expectedRevision }),
139
+ },
140
+ });
141
+ if (!answer.ok)
142
+ return refuseDraft(door, answer);
143
+ const result = answer.body;
144
+ writeLine(door.io.stderr, `${TAG} restored ${JSON.stringify({ channel_id: channelId, changed: result.changed ?? null, revision: result.draft?.revision ?? null })}`);
145
+ if (door.json)
146
+ return emitAgentDoor(door, { ok: true, ...result });
147
+ writeLine(door.io.stdout, `Restored the draft, revision ${result.draft?.revision ?? "?"}.`);
148
+ return 0;
149
+ }
150
+ /** Posts the stored draft with a fresh request id and the draft's revision, then prints the receipt. */
151
+ async function sendDraft(command, door, channelId) {
152
+ const read = await askAgentDoor(door, { path: `/api/msg/drafts?${placeQuery(channelId, command)}`, method: "GET", label: "msg draft send read", timeoutMs: DEADLINE_MS });
153
+ if (!read.ok)
154
+ return refuseDraft(door, read);
155
+ const draft = read.body.draft ?? null;
156
+ if (!draft || draft.state !== "active") {
157
+ return failAgentDoor(door, TAG, "draft_not_found", "There is no unsent draft here to send.", { state: draft?.state ?? "none" });
158
+ }
159
+ if (command.expectedRevision !== undefined && command.expectedRevision !== draft.revision) {
160
+ return refuseDraft(door, {
161
+ reason: "draft_revision_conflict",
162
+ detail: "This draft changed since you read it. Nothing was sent.",
163
+ body: { draft },
164
+ });
165
+ }
166
+ const requestId = command.requestId ?? randomUUID();
167
+ const answer = await askAgentDoor(door, {
168
+ path: `/api/msg/channels/${encodeURIComponent(channelId)}/messages`,
169
+ method: "POST",
170
+ label: "msg draft send",
171
+ timeoutMs: DEADLINE_MS,
172
+ body: {
173
+ content: draft.content,
174
+ request_id: requestId,
175
+ draft_revision: draft.revision,
176
+ ...(command.threadId ? { thread_parent_id: command.threadId } : {}),
177
+ },
178
+ });
179
+ if (!answer.ok)
180
+ return refuseMsgWrite(door, answer, requestId, "send");
181
+ const { message = null, mutation = null, draft: settled = null } = answer.body;
182
+ writeLine(door.io.stderr, `${TAG} sent ${JSON.stringify({
183
+ channel_id: channelId, message_id: message?.id ?? null, request_id: requestId, draft_revision: draft.revision,
184
+ draft_marked: settled?.marked ?? null, draft_reason: settled?.reason ?? null,
185
+ })}`);
186
+ if (door.json)
187
+ return emitAgentDoor(door, { ok: true, message, mutation, draft: settled });
188
+ writeLine(door.io.stdout, `Sent the draft as ${message?.id ?? "?"} (request ${requestId}, ${String(mutation?.["outcome"] ?? "committed")}).`);
189
+ if (settled && settled.marked === false) {
190
+ writeLine(door.io.stdout, `The draft was not marked sent (${settled.reason ?? "unknown"}); it is still saved.`);
191
+ }
192
+ return 0;
193
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `cockpit msg file` (BLI-21521): first-party message files over the same doors the browser calls.
3
+ *
4
+ * upload <channel> <path|-> reserve (POST /api/msg/files), PUT the bytes to the signed URL, finalize
5
+ * show <id> the descriptor
6
+ * download <id> [--out] a 60-second signed link, fetched to disk (never overwrites)
7
+ * cancel <id> ends an unattached file
8
+ * list <channel> your own unsent uploads in that channel
9
+ *
10
+ * The device token goes to Tower's own routes only; the PUT and the download go to the storage host with
11
+ * the signed URL and nothing else. File names are shown to the person but never written to a log line.
12
+ */
13
+ import { randomUUID } from "node:crypto";
14
+ import { readFile, stat, writeFile } from "node:fs/promises";
15
+ import { basename, extname, resolve } from "node:path";
16
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
17
+ import { isInteractiveStdin, writeLine } from "./cli-io.js";
18
+ const TAG = "[msg file cli]";
19
+ const DEADLINE_MS = 30_000;
20
+ /** A 25 MB transfer on a slow link; the signed URL itself lives two hours. */
21
+ const TRANSFER_DEADLINE_MS = 10 * 60_000;
22
+ const MAX_BYTES = 26_214_400;
23
+ const MEDIA_TYPES = {
24
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp",
25
+ ".pdf": "application/pdf", ".txt": "text/plain", ".md": "text/markdown", ".json": "application/json",
26
+ ".csv": "text/csv", ".zip": "application/zip", ".svg": "image/svg+xml",
27
+ };
28
+ const describe = (file) => `${file.id} ${file.state} ${file.byte_size} bytes ${file.name}`;
29
+ async function readStdinBytes(door) {
30
+ const chunks = [];
31
+ let total = 0;
32
+ for await (const chunk of door.io.stdin) {
33
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
34
+ total += buffer.length;
35
+ if (total > MAX_BYTES)
36
+ return "too_large";
37
+ chunks.push(buffer);
38
+ }
39
+ return Buffer.concat(chunks);
40
+ }
41
+ /** `channelId` is already resolved by `msg.ts` for `upload` and `list`. */
42
+ export async function runMsgFile(command, door, channelId) {
43
+ switch (command.fileVerb) {
44
+ case "upload": return uploadFile(command, door, channelId ?? "");
45
+ case "show": return showFile(command, door);
46
+ case "download": return downloadFile(command, door);
47
+ case "cancel": return cancelFile(command, door);
48
+ case "list": return listFiles(door, channelId ?? "");
49
+ default: return failAgentDoor(door, TAG, "invalid_body", "Try upload, show, download, cancel or list.");
50
+ }
51
+ }
52
+ async function uploadFile(command, door, channelId) {
53
+ const source = command.filePath ?? "";
54
+ let bytes;
55
+ let name = command.fileName ?? basename(source);
56
+ if (source === "-") {
57
+ if (isInteractiveStdin(door.io))
58
+ return failAgentDoor(door, TAG, "nothing_piped", "Pipe the file in, e.g. `cat report.pdf | cockpit msg file upload general - --name report.pdf`.");
59
+ const read = await readStdinBytes(door);
60
+ if (read === "too_large")
61
+ return failAgentDoor(door, TAG, "file_too_large", `A file can be at most ${MAX_BYTES.toLocaleString("en-US")} bytes (25 MB).`);
62
+ bytes = read;
63
+ }
64
+ else {
65
+ const path = resolve(source);
66
+ try {
67
+ const info = await stat(path);
68
+ if (!info.isFile())
69
+ return failAgentDoor(door, TAG, "invalid_body", "That path is not a file.");
70
+ if (info.size > MAX_BYTES)
71
+ return failAgentDoor(door, TAG, "file_too_large", `A file can be at most ${MAX_BYTES.toLocaleString("en-US")} bytes (25 MB).`);
72
+ bytes = await readFile(path);
73
+ }
74
+ catch {
75
+ return failAgentDoor(door, TAG, "invalid_body", "Tower could not read that file. Check the path.");
76
+ }
77
+ }
78
+ if (name === "")
79
+ name = "file";
80
+ const mediaType = MEDIA_TYPES[extname(name).toLowerCase()] ?? "application/octet-stream";
81
+ const reserved = await askAgentDoor(door, {
82
+ path: "/api/msg/files", method: "POST", label: "msg file reserve", timeoutMs: DEADLINE_MS,
83
+ body: { channel_id: channelId, name, media_type: mediaType, byte_size: bytes.length },
84
+ });
85
+ if (!reserved.ok)
86
+ return failAgentDoor(door, TAG, reserved.reason, reserved.detail);
87
+ const { file, upload } = reserved.body;
88
+ let put;
89
+ try {
90
+ put = await door.io.fetch(upload.url, {
91
+ method: upload.method ?? "PUT", headers: upload.headers ?? {}, body: new Uint8Array(bytes),
92
+ signal: AbortSignal.timeout(TRANSFER_DEADLINE_MS),
93
+ });
94
+ }
95
+ catch (error) {
96
+ writeLine(door.io.stderr, `${TAG} put failed ${JSON.stringify({ file_id: file.id, reason: "storage_unreachable", bytes: bytes.length, error_name: error instanceof Error ? error.name : "error" })}`);
97
+ return failAgentDoor(door, TAG, "storage_unreachable", `Could not reach storage. Nothing was sent. Cancel with \`cockpit msg file cancel ${file.id}\` or upload again.`, { file_id: file.id });
98
+ }
99
+ if (!put.ok) {
100
+ writeLine(door.io.stderr, `${TAG} put refused ${JSON.stringify({ file_id: file.id, reason: `storage_http_${put.status}`, bytes: bytes.length })}`);
101
+ return failAgentDoor(door, TAG, "storage_unreachable", `Storage refused the upload (HTTP ${put.status}). Cancel with \`cockpit msg file cancel ${file.id}\` or upload again.`, { file_id: file.id });
102
+ }
103
+ const done = await askAgentDoor(door, { path: `/api/msg/files/${file.id}/finalize`, method: "POST", label: "msg file finalize", timeoutMs: DEADLINE_MS, body: {} });
104
+ if (!done.ok) {
105
+ writeLine(door.io.stderr, `${TAG} finish failed, safe to repeat: POST /api/msg/files/${file.id}/finalize`);
106
+ return failAgentDoor(door, TAG, done.reason, done.detail, { file_id: file.id });
107
+ }
108
+ const ready = done.body.file;
109
+ writeLine(door.io.stderr, `${TAG} uploaded ${JSON.stringify({ file_id: ready.id, bytes: bytes.length, state: ready.state })}`);
110
+ if (door.json)
111
+ return emitAgentDoor(door, { ok: true, file: ready });
112
+ writeLine(door.io.stdout, `Uploaded ${describe(ready)}`);
113
+ writeLine(door.io.stdout, `Send it with: cockpit msg send ${command.channelRef ?? "<channel>"} --file ${ready.id}`);
114
+ return 0;
115
+ }
116
+ async function showFile(command, door) {
117
+ const answer = await askAgentDoor(door, { path: `/api/msg/files/${encodeURIComponent(command.targetId ?? "")}`, method: "GET", label: "msg file show", timeoutMs: DEADLINE_MS });
118
+ if (!answer.ok)
119
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
120
+ const file = answer.body.file;
121
+ if (door.json)
122
+ return emitAgentDoor(door, { ok: true, file });
123
+ writeLine(door.io.stdout, describe(file));
124
+ return 0;
125
+ }
126
+ async function cancelFile(command, door) {
127
+ const answer = await askAgentDoor(door, { path: `/api/msg/files/${encodeURIComponent(command.targetId ?? "")}`, method: "DELETE", label: "msg file cancel", timeoutMs: DEADLINE_MS });
128
+ if (!answer.ok)
129
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
130
+ const file = answer.body.file;
131
+ writeLine(door.io.stderr, `${TAG} cancelled ${JSON.stringify({ file_id: file.id, state: file.state })}`);
132
+ if (door.json)
133
+ return emitAgentDoor(door, { ok: true, file });
134
+ writeLine(door.io.stdout, `Cancelled ${describe(file)}`);
135
+ return 0;
136
+ }
137
+ async function listFiles(door, channelId) {
138
+ const answer = await askAgentDoor(door, { path: `/api/msg/files?channel_id=${encodeURIComponent(channelId)}`, method: "GET", label: "msg file list", timeoutMs: DEADLINE_MS });
139
+ if (!answer.ok)
140
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
141
+ const body = answer.body;
142
+ if (door.json)
143
+ return emitAgentDoor(door, { ok: true, ...body });
144
+ if (body.files.length === 0)
145
+ writeLine(door.io.stdout, "No unsent files in that channel.");
146
+ for (const file of body.files)
147
+ writeLine(door.io.stdout, describe(file));
148
+ writeLine(door.io.stdout, `${body.returned} unsent file(s), coverage ${body.coverage}.`);
149
+ return 0;
150
+ }
151
+ async function downloadFile(command, door) {
152
+ const id = encodeURIComponent(command.targetId ?? "");
153
+ const described = await askAgentDoor(door, { path: `/api/msg/files/${id}`, method: "GET", label: "msg file show", timeoutMs: DEADLINE_MS });
154
+ if (!described.ok)
155
+ return failAgentDoor(door, TAG, described.reason, described.detail);
156
+ const file = described.body.file;
157
+ const link = await askAgentDoor(door, { path: `/api/msg/files/${id}/download?format=json`, method: "GET", label: "msg file link", timeoutMs: DEADLINE_MS });
158
+ if (!link.ok)
159
+ return failAgentDoor(door, TAG, link.reason, link.detail);
160
+ const { url } = link.body;
161
+ let bytes;
162
+ try {
163
+ const response = await door.io.fetch(url, { signal: AbortSignal.timeout(TRANSFER_DEADLINE_MS) });
164
+ if (!response.ok) {
165
+ writeLine(door.io.stderr, `${TAG} download refused ${JSON.stringify({ file_id: file.id, reason: `storage_http_${response.status}` })}`);
166
+ return failAgentDoor(door, TAG, "storage_unreachable", `Storage refused the download (HTTP ${response.status}).`);
167
+ }
168
+ bytes = Buffer.from(await response.arrayBuffer());
169
+ }
170
+ catch {
171
+ return failAgentDoor(door, TAG, "storage_unreachable", "Could not reach storage. Try again.");
172
+ }
173
+ const target = resolve(command.outPath ?? basename(file.name) ?? randomUUID());
174
+ try {
175
+ await writeFile(target, bytes, { flag: "wx" });
176
+ }
177
+ catch {
178
+ return failAgentDoor(door, TAG, "out_exists", "Tower will not overwrite or could not write that path. Pick another with --out.");
179
+ }
180
+ writeLine(door.io.stderr, `${TAG} downloaded ${JSON.stringify({ file_id: file.id, bytes: bytes.length })}`);
181
+ if (door.json)
182
+ return emitAgentDoor(door, { ok: true, file, path: target, bytes: bytes.length });
183
+ writeLine(door.io.stdout, `Saved ${bytes.length} bytes to ${target}`);
184
+ return 0;
185
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Arguments of `cockpit msg save|saved|pin|pins|forward` (BLI-21774): `save <message-id> [--remove]`, `saved [--before <cursor>]
3
+ * [--limit <n>]`, `pin <message-id> [--remove]`, `pins <channel>`, `forward <message-id> --to <channel> [--thread <id>]
4
+ * [--request-id <uuid>]` (the comment is piped in). Split out of `local-args-tower-msg.ts`.
5
+ */
6
+ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl } from "./local-arg-values.js";
7
+ export const MSG_MARK_ACTIONS = ["save", "saved", "pin", "pins", "forward"];
8
+ export const MSG_MARK_VALUE_FLAGS = ["--to"];
9
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
+ export function isMsgMarkAction(action) {
11
+ return MSG_MARK_ACTIONS.includes(action);
12
+ }
13
+ /** `--to` used on another msg verb is named, not ignored. */
14
+ export function rejectMsgMarkFlags(values) {
15
+ if (values.flags.has("--to"))
16
+ throw new Error("--to belongs to `cockpit msg forward`.");
17
+ }
18
+ export function parseMsgMarkArgs(action, values, rest) {
19
+ const stray = ["--channel", "--members", "--description", "--from", "--expected-revision", "--name", "--out", "--file", "--level", "--timezone", "--message"]
20
+ .find((flag) => values.flags.has(flag));
21
+ if (stray || values.booleans.has("--private"))
22
+ throw new Error(`${stray ?? "--private"} does not belong to \`cockpit msg ${action}\`.`);
23
+ const base = {
24
+ kind: "msg",
25
+ action,
26
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
27
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
28
+ isPrivate: false,
29
+ json: values.booleans.has("--json"),
30
+ };
31
+ const remove = values.booleans.has("--remove");
32
+ const before = optionalNonEmpty(values.flags.get("--before"));
33
+ const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
34
+ const thread = optionalNonEmpty(values.flags.get("--thread"));
35
+ const requestId = optionalNonEmpty(values.flags.get("--request-id"));
36
+ const to = optionalNonEmpty(values.flags.get("--to"));
37
+ const mentioned = ["--mentions", "--threads", "--unread", "--mark-read"].find((flag) => values.booleans.has(flag));
38
+ if (mentioned)
39
+ throw new Error(`${mentioned} does not belong to \`cockpit msg ${action}\`.`);
40
+ if (remove && action !== "save" && action !== "pin")
41
+ throw new Error("--remove belongs to `cockpit msg save` and `cockpit msg pin`.");
42
+ if ((before || limit !== undefined) && action !== "saved")
43
+ throw new Error("--before and --limit belong to `cockpit msg saved`.");
44
+ if ((thread || requestId || to) && action !== "forward")
45
+ throw new Error("--to, --thread and --request-id belong to `cockpit msg forward`.");
46
+ if (action === "saved") {
47
+ if (rest.length > 0)
48
+ throw new Error(`msg saved does not take "${rest[0]}".`);
49
+ return { ...base, ...(before ? { before } : {}), ...(limit === undefined ? {} : { limit }) };
50
+ }
51
+ if (action === "pins") {
52
+ const channelRef = optionalNonEmpty(rest[0]);
53
+ if (!channelRef)
54
+ throw new Error("msg pins needs a channel id or name, e.g. `cockpit msg pins general`.");
55
+ if (rest.length > 1)
56
+ throw new Error(`msg pins takes one channel, not ${rest.length}.`);
57
+ return { ...base, channelRef };
58
+ }
59
+ const targetId = optionalNonEmpty(rest[0]);
60
+ if (!targetId || !UUID.test(targetId)) {
61
+ throw new Error(`msg ${action} needs a message id (a UUID), e.g. \`cockpit msg ${action} <message-id>\`.`);
62
+ }
63
+ if (rest.length > 1)
64
+ throw new Error(`msg ${action} takes one message id, not ${rest.length}.`);
65
+ if (action === "forward") {
66
+ if (!to)
67
+ throw new Error("msg forward needs --to <channel>, e.g. `echo \"worth a look\" | cockpit msg forward <message-id> --to general`.");
68
+ if (thread && !UUID.test(thread))
69
+ throw new Error("--thread takes a message id (a UUID).");
70
+ if (requestId && !UUID.test(requestId))
71
+ throw new Error("--request-id takes a UUID.");
72
+ return {
73
+ ...base, targetId, forwardTo: to,
74
+ ...(thread ? { threadId: thread } : {}), ...(requestId ? { requestId } : {}),
75
+ };
76
+ }
77
+ return { ...base, targetId, ...(remove ? { remove } : {}) };
78
+ }