@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.
Files changed (135) hide show
  1. package/dist/adapters/git-state.js +1 -0
  2. package/dist/adapters/raw-evidence-budget-admission.js +63 -0
  3. package/dist/adapters/raw-evidence-collection-state.js +6 -50
  4. package/dist/adapters/raw-evidence-git-diff.js +1 -0
  5. package/dist/agent-rules.js +8 -0
  6. package/dist/autostart-contract.js +12 -0
  7. package/dist/autostart-darwin-plist.js +2 -2
  8. package/dist/autostart-darwin.js +1 -1
  9. package/dist/autostart-remind.js +245 -0
  10. package/dist/autostart-self-heal.js +17 -1
  11. package/dist/autostart-windows-scripts.js +7 -7
  12. package/dist/autostart.js +16 -15
  13. package/dist/commands/approvals-check-where.js +14 -0
  14. package/dist/commands/approvals-render.js +8 -1
  15. package/dist/commands/approvals.js +3 -2
  16. package/dist/commands/autostart-command.js +10 -0
  17. package/dist/commands/autostart-heal.js +36 -1
  18. package/dist/commands/brain.js +1 -1
  19. package/dist/commands/cal-agenda.js +44 -0
  20. package/dist/commands/cal-availability-share.js +97 -0
  21. package/dist/commands/cal-booking.js +147 -0
  22. package/dist/commands/cal-join.js +62 -0
  23. package/dist/commands/cal-links.js +68 -0
  24. package/dist/commands/cal-people.js +136 -0
  25. package/dist/commands/cal-remind.js +176 -0
  26. package/dist/commands/cal-reminders.js +33 -0
  27. package/dist/commands/cal-suggest.js +89 -0
  28. package/dist/commands/cal.js +41 -1
  29. package/dist/commands/cycle-args.js +127 -0
  30. package/dist/commands/cycles.js +117 -0
  31. package/dist/commands/docs-comments-args.js +85 -0
  32. package/dist/commands/docs-comments.js +128 -0
  33. package/dist/commands/docs-files-args.js +49 -0
  34. package/dist/commands/docs-files.js +179 -0
  35. package/dist/commands/docs-formatting.js +45 -0
  36. package/dist/commands/docs-inbox-args.js +93 -0
  37. package/dist/commands/docs-inbox.js +126 -0
  38. package/dist/commands/docs-notion.js +5 -0
  39. package/dist/commands/docs-read.js +2 -1
  40. package/dist/commands/docs-recovery.js +173 -0
  41. package/dist/commands/docs-refs-args.js +104 -0
  42. package/dist/commands/docs-refs.js +153 -0
  43. package/dist/commands/docs-search-args.js +79 -0
  44. package/dist/commands/docs-search.js +64 -0
  45. package/dist/commands/docs-sync-args.js +82 -0
  46. package/dist/commands/docs-sync.js +197 -0
  47. package/dist/commands/docs-tree.js +136 -0
  48. package/dist/commands/docs.js +85 -51
  49. package/dist/commands/doctor-update.js +4 -1
  50. package/dist/commands/inbox-args.js +81 -0
  51. package/dist/commands/inbox.js +88 -0
  52. package/dist/commands/issue-batch.js +61 -0
  53. package/dist/commands/issue-relations.js +151 -0
  54. package/dist/commands/issue-write.js +38 -5
  55. package/dist/commands/issue.js +32 -1
  56. package/dist/commands/local-args-collector-sources.js +2 -1
  57. package/dist/commands/local-args-tower-approvals.js +16 -4
  58. package/dist/commands/local-args-tower-cal-booking.js +81 -0
  59. package/dist/commands/local-args-tower-cal-links.js +42 -0
  60. package/dist/commands/local-args-tower-cal-remind.js +74 -0
  61. package/dist/commands/local-args-tower-cal-share.js +41 -0
  62. package/dist/commands/local-args-tower-cal.js +81 -8
  63. package/dist/commands/local-args-tower-docs-msg.js +98 -9
  64. package/dist/commands/local-args-tower-msg.js +222 -10
  65. package/dist/commands/local-args-tower-search.js +32 -5
  66. package/dist/commands/local-args-tower-work.js +110 -34
  67. package/dist/commands/local-auth.js +1 -1
  68. package/dist/commands/local-help-commands-tower-ops.js +6 -4
  69. package/dist/commands/local-help-commands-tower.js +47 -31
  70. package/dist/commands/local-help-commands.js +3 -1
  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 +3 -3
  84. package/dist/commands/manager-hints.js +4 -2
  85. package/dist/commands/manager-limits.json +3 -1
  86. package/dist/commands/manager-read-reasons.js +1 -0
  87. package/dist/commands/manager-render-home.js +13 -5
  88. package/dist/commands/manager-render.js +3 -0
  89. package/dist/commands/manager.js +3 -1
  90. package/dist/commands/mcp-stdio-probe.js +1 -0
  91. package/dist/commands/msg-activity.js +134 -0
  92. package/dist/commands/msg-channel-args.js +92 -0
  93. package/dist/commands/msg-channel.js +125 -0
  94. package/dist/commands/msg-context-args.js +67 -0
  95. package/dist/commands/msg-context.js +65 -0
  96. package/dist/commands/msg-dm.js +28 -0
  97. package/dist/commands/msg-file.js +185 -0
  98. package/dist/commands/msg-mark-args.js +78 -0
  99. package/dist/commands/msg-mark.js +148 -0
  100. package/dist/commands/msg-notify-args.js +100 -0
  101. package/dist/commands/msg-notify.js +122 -0
  102. package/dist/commands/msg-schedule-args.js +114 -0
  103. package/dist/commands/msg-schedule.js +226 -0
  104. package/dist/commands/msg-search-args.js +71 -0
  105. package/dist/commands/msg-search.js +48 -0
  106. package/dist/commands/msg-write.js +15 -1
  107. package/dist/commands/msg.js +62 -39
  108. package/dist/commands/project-args.js +82 -0
  109. package/dist/commands/project.js +71 -7
  110. package/dist/commands/public-root.js +1 -1
  111. package/dist/commands/search.js +23 -0
  112. package/dist/commands/session-sync-failures.js +17 -4
  113. package/dist/commands/session-sync-health.js +7 -2
  114. package/dist/commands/session-sync-hold.js +42 -1
  115. package/dist/commands/session-sync-outside-roots.js +72 -14
  116. package/dist/commands/session-sync-upload.js +2 -0
  117. package/dist/commands/session-sync.js +1 -0
  118. package/dist/commands/sources-check.js +63 -2
  119. package/dist/commands/sources-discovery.js +1 -1
  120. package/dist/commands/sources-reconciliation.js +87 -0
  121. package/dist/commands/sync-doctor.js +14 -1
  122. package/dist/commands/sync-report.js +1 -1
  123. package/dist/commands/template-args.js +122 -0
  124. package/dist/commands/templates.js +162 -0
  125. package/dist/commands/view-args.js +131 -0
  126. package/dist/commands/views.js +138 -0
  127. package/dist/commands/work-notify-args.js +67 -0
  128. package/dist/commands/work-notify.js +63 -0
  129. package/dist/delivery-hold-notice.js +34 -0
  130. package/dist/evidence-transcripts-client.js +1 -0
  131. package/dist/log-rotation.js +2 -2
  132. package/dist/notifier.js +83 -0
  133. package/dist/process-runner.js +4 -0
  134. package/dist/repo-identity-git.js +1 -0
  135. package/package.json +4 -4
@@ -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
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The saved, pin and forward verbs of `cockpit msg` (BLI-21774): `save`, `saved`, `pin`, `pins`, `forward`. Same doors the
3
+ * browser calls (`/api/msg/messages/<id>/save`, `/api/msg/saved`, `.../pin`, `/api/msg/channels/<id>/pins`,
4
+ * `.../forward`); `--json` prints the door's body unchanged so ids, rows, coverage and reasons match HTTP. A save is private
5
+ * to you; a pin is shared channel state; a forward posts ONE message holding your comment and the source's exact link,
6
+ * never its text. A refusal keeps the door's reason: message_not_found_or_unreadable, pin_limit_reached, pin_deleted_message,
7
+ * channel_archived, forward_source_deleted.
8
+ */
9
+ import { randomUUID } from "node:crypto";
10
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
11
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
12
+ import { runMsgContext } from "./msg-context.js";
13
+ import { runMsgSchedule } from "./msg-schedule.js";
14
+ import { refuseMsgWrite } from "./msg-write.js";
15
+ const TAG = "[msg cli]";
16
+ const DEADLINE_MS = 30_000;
17
+ const COMMENT_MAX_CHARS = 2_000;
18
+ function oneLine(content, deleted) {
19
+ if (deleted)
20
+ return "(This message was deleted)";
21
+ const line = (content ?? "").split("\n")[0] ?? "";
22
+ return line.length > 140 ? `${line.slice(0, 137)}...` : line;
23
+ }
24
+ async function markVerb(command, door, kind) {
25
+ const on = command.remove !== true;
26
+ const answer = await askAgentDoor(door, {
27
+ path: `/api/msg/messages/${encodeURIComponent(command.targetId ?? "")}/${kind}`,
28
+ method: on ? "PUT" : "DELETE",
29
+ label: `msg ${kind}`,
30
+ timeoutMs: DEADLINE_MS,
31
+ });
32
+ if (!answer.ok)
33
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
34
+ const body = answer.body;
35
+ writeLine(door.io.stderr, `${TAG} ${kind} ${JSON.stringify({ message_id: command.targetId, on, changed: body.changed === true })}`);
36
+ if (door.json)
37
+ return emitAgentDoor(door, { ok: true, ...answer.body });
38
+ const past = kind === "save" ? (on ? "Saved" : "Unsaved") : on ? "Pinned" : "Unpinned";
39
+ writeLine(door.io.stdout, body.changed === false ? `${past} already: ${command.targetId} was already ${past.toLowerCase()}.` : `${past} ${command.targetId}.`);
40
+ return 0;
41
+ }
42
+ async function savedVerb(command, door) {
43
+ const query = new URLSearchParams();
44
+ if (command.before)
45
+ query.set("before", command.before);
46
+ if (command.limit)
47
+ query.set("limit", String(command.limit));
48
+ const answer = await askAgentDoor(door, {
49
+ path: `/api/msg/saved${query.size > 0 ? `?${query.toString()}` : ""}`,
50
+ method: "GET",
51
+ label: "msg saved",
52
+ timeoutMs: DEADLINE_MS,
53
+ });
54
+ if (!answer.ok)
55
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
56
+ const body = answer.body;
57
+ const items = body.items ?? [];
58
+ writeLine(door.io.stderr, `${TAG} saved ${JSON.stringify({ returned: items.length, dropped: body.coverage?.dropped ?? 0, has_more: body.hasMore === true })}`);
59
+ if (door.json)
60
+ return emitAgentDoor(door, { ok: true, ...answer.body });
61
+ for (const item of items) {
62
+ writeLine(door.io.stdout, `${item.message_id} ${item.saved_at} ${oneLine(item.message.content, Boolean(item.message.deleted_at))}`);
63
+ }
64
+ if (items.length === 0)
65
+ writeLine(door.io.stdout, "Nothing saved yet.");
66
+ const dropped = body.coverage?.dropped ?? 0;
67
+ if (dropped > 0)
68
+ writeLine(door.io.stdout, `${dropped} saved ${dropped === 1 ? "message is" : "messages are"} not shown: you can no longer read ${dropped === 1 ? "it" : "them"}.`);
69
+ writeLine(door.io.stdout, body.hasMore && body.nextCursor ? `more: --before ${body.nextCursor}` : "complete");
70
+ return 0;
71
+ }
72
+ async function pinsVerb(command, door, resolve) {
73
+ const ref = command.channelRef ?? "";
74
+ const resolved = await resolve(ref);
75
+ if (resolved.status === "list_failed")
76
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
77
+ if (resolved.status === "not_found") {
78
+ return failAgentDoor(door, TAG, "channel_not_found_or_unreadable", `That channel does not exist, or you cannot read it: ${ref}`);
79
+ }
80
+ const answer = await askAgentDoor(door, { path: `/api/msg/channels/${encodeURIComponent(resolved.id)}/pins`, method: "GET", label: "msg pins", timeoutMs: DEADLINE_MS });
81
+ if (!answer.ok)
82
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
83
+ const body = answer.body;
84
+ const pins = body.pins ?? [];
85
+ writeLine(door.io.stderr, `${TAG} pins ${JSON.stringify({ channel_id: resolved.id, returned: pins.length, dropped: body.coverage?.dropped ?? 0 })}`);
86
+ if (door.json)
87
+ return emitAgentDoor(door, { ok: true, ...answer.body });
88
+ for (const pin of pins) {
89
+ const who = pin.pinned_by ? body.displayNameById?.[pin.pinned_by] ?? "someone" : "someone";
90
+ writeLine(door.io.stdout, `${pin.message_id} pinned ${pin.pinned_at} by ${who} ${oneLine(pin.message.content, Boolean(pin.message.deleted_at))}`);
91
+ }
92
+ if (pins.length === 0)
93
+ writeLine(door.io.stdout, "Nothing is pinned in this channel.");
94
+ return 0;
95
+ }
96
+ async function forwardVerb(command, door, resolve) {
97
+ const ref = command.forwardTo ?? "";
98
+ const resolved = await resolve(ref);
99
+ if (resolved.status === "list_failed")
100
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
101
+ if (resolved.status === "not_found") {
102
+ return failAgentDoor(door, TAG, "channel_not_found_or_unreadable", `That channel does not exist, or you cannot read it: ${ref}`);
103
+ }
104
+ let comment = "";
105
+ if (!isInteractiveStdin(door.io)) {
106
+ try {
107
+ comment = (await readPipedText(door.io.stdin, { maxChars: COMMENT_MAX_CHARS, overflowMessage: `A forward comment is limited to ${COMMENT_MAX_CHARS} characters.` })).trim();
108
+ }
109
+ catch (error) {
110
+ return failAgentDoor(door, TAG, "content_too_long", error instanceof Error ? error.message : String(error));
111
+ }
112
+ }
113
+ const requestId = command.requestId ?? randomUUID();
114
+ const answer = await askAgentDoor(door, {
115
+ path: `/api/msg/messages/${encodeURIComponent(command.targetId ?? "")}/forward`,
116
+ method: "POST",
117
+ label: "msg forward",
118
+ timeoutMs: DEADLINE_MS,
119
+ body: {
120
+ channel_id: resolved.id,
121
+ request_id: requestId,
122
+ ...(comment ? { comment } : {}),
123
+ ...(command.threadId ? { thread_parent_id: command.threadId } : {}),
124
+ },
125
+ });
126
+ if (!answer.ok)
127
+ return refuseMsgWrite(door, answer, requestId, "forward");
128
+ const body = answer.body;
129
+ writeLine(door.io.stderr, `${TAG} forwarded ${JSON.stringify({ source_id: command.targetId, destination_channel_id: resolved.id, message_id: body.message?.id ?? null, request_id: requestId })}`);
130
+ if (door.json)
131
+ return emitAgentDoor(door, { ok: true, ...answer.body });
132
+ writeLine(door.io.stdout, `Forwarded ${command.targetId} to ${ref} as ${body.message?.id ?? "a new message"}. It holds your comment and the link only.`);
133
+ return 0;
134
+ }
135
+ export async function runMsgMark(command, door, resolve) {
136
+ switch (command.action) {
137
+ case "save": return markVerb(command, door, "save");
138
+ case "pin": return markVerb(command, door, "pin");
139
+ case "saved": return savedVerb(command, door);
140
+ case "pins": return pinsVerb(command, door, resolve);
141
+ // BLI-21802: the schedule verbs ride the same entry so `msg.ts` stays under the file-size ratchet.
142
+ case "schedule":
143
+ case "remind":
144
+ case "scheduled": return runMsgSchedule(command, door, resolve);
145
+ case "context": return runMsgContext(command, door);
146
+ default: return forwardVerb(command, door, resolve);
147
+ }
148
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Arguments of `cockpit msg notify` (BLI-21605): `notify [--json]`, `notify set --level <all|mentions|none>
3
+ * [--channel <id|name>] [--mute|--unmute] [--clear]`, `notify quiet <HH:MM-HH:MM> [--timezone <zone>] | --off`,
4
+ * `notify receipts [--message <id>] [--before <cursor>] [--limit <n>]`. Split out of `local-args-tower-msg.ts`.
5
+ */
6
+ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl } from "./local-arg-values.js";
7
+ export const MSG_NOTIFY_ONLY_FLAGS = ["--level", "--timezone", "--message", "--mute", "--unmute", "--clear", "--off"];
8
+ const VERBS = new Set(["set", "quiet", "receipts"]);
9
+ const LEVELS = ["all", "mentions", "none"];
10
+ const WINDOW = /^([01]\d|2[0-3]):([0-5]\d)-([01]\d|2[0-3]):([0-5]\d)$/;
11
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ function browserZone() {
13
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
14
+ }
15
+ export function parseMsgNotifyArgs(values, rest) {
16
+ const first = rest[0];
17
+ if (first !== undefined && !VERBS.has(first)) {
18
+ throw new Error(`Unknown msg notify command: ${first}. Try set, quiet or receipts, or no verb to show your settings.`);
19
+ }
20
+ const verb = first ?? "show";
21
+ const positional = rest.slice(1);
22
+ const has = (flag) => values.flags.has(flag) || values.booleans.has(flag);
23
+ const allowed = {
24
+ show: [],
25
+ set: ["--level", "--channel", "--mute", "--unmute", "--clear"],
26
+ quiet: ["--timezone", "--off"],
27
+ receipts: ["--message", "--before", "--limit"],
28
+ };
29
+ const stray = [...MSG_NOTIFY_ONLY_FLAGS, "--channel", "--before", "--limit"].find((flag) => has(flag) && !allowed[verb].includes(flag));
30
+ if (stray)
31
+ throw new Error(`${stray} does not belong to \`cockpit msg notify${verb === "show" ? "" : ` ${verb}`}\`.`);
32
+ const stray2 = ["--thread", "--members", "--description", "--from", "--request-id", "--expected-revision", "--name", "--out", "--file", "--mark-read", "--mentions", "--threads", "--unread", "--remove", "--private"].find(has);
33
+ if (stray2)
34
+ throw new Error(`${stray2} does not belong to \`cockpit msg notify\`.`);
35
+ const level = optionalNonEmpty(values.flags.get("--level"));
36
+ const channelRef = optionalNonEmpty(values.flags.get("--channel"));
37
+ const mute = values.booleans.has("--mute");
38
+ const unmute = values.booleans.has("--unmute");
39
+ const clear = values.booleans.has("--clear");
40
+ const quietOff = values.booleans.has("--off");
41
+ const timezone = optionalNonEmpty(values.flags.get("--timezone"));
42
+ const messageId = optionalNonEmpty(values.flags.get("--message"));
43
+ const before = optionalNonEmpty(values.flags.get("--before"));
44
+ const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
45
+ let quietWindow;
46
+ if (verb === "set") {
47
+ if (positional.length > 0)
48
+ throw new Error(`msg notify set does not take "${positional[0]}".`);
49
+ if (level && !LEVELS.includes(level))
50
+ throw new Error("--level must be all, mentions or none.");
51
+ if (mute && unmute)
52
+ throw new Error("Pick one of --mute or --unmute.");
53
+ if (!channelRef && (mute || unmute || clear))
54
+ throw new Error("--mute, --unmute and --clear need --channel <id|name>.");
55
+ if (clear && (level || mute || unmute))
56
+ throw new Error("--clear removes the channel override; do not combine it with --level, --mute or --unmute.");
57
+ if (!level && !mute && !unmute && !clear)
58
+ throw new Error("msg notify set needs --level <all|mentions|none>, or with --channel also --mute, --unmute or --clear.");
59
+ }
60
+ if (verb === "quiet") {
61
+ quietWindow = optionalNonEmpty(positional[0]);
62
+ if (quietOff && quietWindow)
63
+ throw new Error("Pass a window or --off, not both.");
64
+ if (!quietOff && (!quietWindow || !WINDOW.test(quietWindow)))
65
+ throw new Error("msg notify quiet needs a window like 22:00-07:00 (24-hour, may wrap midnight), or --off.");
66
+ if (positional.length > 1)
67
+ throw new Error("msg notify quiet takes one window.");
68
+ if (quietOff && timezone)
69
+ throw new Error("--timezone belongs with a window, not --off.");
70
+ if (quietWindow && quietWindow.slice(0, 5) === quietWindow.slice(6))
71
+ throw new Error("Quiet hours need a start and an end that differ.");
72
+ }
73
+ if (verb === "receipts") {
74
+ if (positional.length > 0)
75
+ throw new Error(`msg notify receipts does not take "${positional[0]}".`);
76
+ if (messageId && !UUID.test(messageId))
77
+ throw new Error("--message takes a message id (a UUID).");
78
+ if (limit !== undefined && limit > 100)
79
+ throw new Error("--limit takes 1 to 100.");
80
+ }
81
+ return {
82
+ kind: "msg",
83
+ action: "notify",
84
+ notifyVerb: verb,
85
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
86
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
87
+ isPrivate: false,
88
+ json: values.booleans.has("--json"),
89
+ ...(level ? { notifyLevel: level } : {}),
90
+ ...(channelRef ? { channelRef } : {}),
91
+ ...(mute ? { mute } : {}),
92
+ ...(unmute ? { unmute } : {}),
93
+ ...(clear ? { clear } : {}),
94
+ ...(quietOff ? { quietOff } : {}),
95
+ ...(quietWindow ? { quietWindow, timezone: timezone ?? browserZone() } : {}),
96
+ ...(messageId ? { targetId: messageId } : {}),
97
+ ...(before ? { before } : {}),
98
+ ...(limit === undefined ? {} : { limit }),
99
+ };
100
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `cockpit msg notify` (BLI-21605): your message notification settings, quiet hours, channel overrides and delivery
3
+ * receipts. Same doors the browser calls (`/api/msg/notifications/settings`, `/api/msg/channels/<id>/notifications`,
4
+ * `/api/msg/notifications/receipts`); `--json` prints the door's body unchanged so ids, levels and reasons match HTTP.
5
+ * A phone receipt says `push_accepted` (the push service took it), never that a phone showed it.
6
+ */
7
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
8
+ import { writeLine } from "./cli-io.js";
9
+ const TAG = "[msg cli]";
10
+ const DEADLINE_MS = 30_000;
11
+ function settingsLines(settings) {
12
+ const quiet = settings.quiet_hours;
13
+ const lines = [
14
+ `Default level: ${settings.default_level}`,
15
+ quiet ? `Quiet hours: ${quiet.start}-${quiet.end} (${quiet.timezone})` : "Quiet hours: off",
16
+ ];
17
+ if (settings.channels.length === 0)
18
+ lines.push("No channel overrides.");
19
+ for (const channel of settings.channels) {
20
+ lines.push(`Channel ${channel.channel_id}: ${channel.muted ? "muted" : (channel.level ?? "default level")}${channel.muted && channel.level ? ` (level ${channel.level})` : ""}`);
21
+ }
22
+ return lines;
23
+ }
24
+ async function showSettings(door) {
25
+ const answer = await askAgentDoor(door, { path: "/api/msg/notifications/settings", method: "GET", label: "msg notify", timeoutMs: DEADLINE_MS });
26
+ if (!answer.ok)
27
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
28
+ const settings = answer.body.settings;
29
+ writeLine(door.io.stderr, `${TAG} notify settings ${JSON.stringify({ channels: settings.channels.length, quiet_hours: settings.quiet_hours !== null })}`);
30
+ if (door.json)
31
+ return emitAgentDoor(door, { ok: true, ...answer.body });
32
+ for (const line of settingsLines(settings))
33
+ writeLine(door.io.stdout, line);
34
+ return 0;
35
+ }
36
+ async function putSettings(door, label, body, done) {
37
+ const answer = await askAgentDoor(door, { path: "/api/msg/notifications/settings", method: "PUT", label, timeoutMs: DEADLINE_MS, body });
38
+ if (!answer.ok)
39
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
40
+ const result = answer.body;
41
+ writeLine(door.io.stderr, `${TAG} ${label} ${JSON.stringify({ changed: result.changed === true, fields: result.changedFields ?? [] })}`);
42
+ if (door.json)
43
+ return emitAgentDoor(door, { ok: true, ...answer.body });
44
+ writeLine(door.io.stdout, result.changed === false ? `${done} (it was already that way).` : `${done}.`);
45
+ return 0;
46
+ }
47
+ async function setChannel(command, door, resolve) {
48
+ const ref = command.channelRef ?? "";
49
+ const resolved = await resolve(ref);
50
+ if (resolved.status === "list_failed")
51
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
52
+ if (resolved.status === "not_found") {
53
+ return failAgentDoor(door, TAG, "channel_not_found_or_unreadable", `That channel does not exist, or you cannot read it: ${ref}`);
54
+ }
55
+ const path = `/api/msg/channels/${encodeURIComponent(resolved.id)}/notifications`;
56
+ const body = {
57
+ ...(command.notifyLevel ? { level: command.notifyLevel } : {}),
58
+ ...(command.mute ? { muted: true } : {}),
59
+ ...(command.unmute ? { muted: false } : {}),
60
+ };
61
+ const answer = command.clear
62
+ ? await askAgentDoor(door, { path, method: "DELETE", label: "msg notify clear", timeoutMs: DEADLINE_MS })
63
+ : await askAgentDoor(door, { path, method: "PUT", label: "msg notify set", timeoutMs: DEADLINE_MS, body });
64
+ if (!answer.ok)
65
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
66
+ const result = answer.body;
67
+ writeLine(door.io.stderr, `${TAG} notify channel ${JSON.stringify({ channel_id: resolved.id, changed: result.changed === true, cleared: command.clear === true })}`);
68
+ if (door.json)
69
+ return emitAgentDoor(door, { ok: true, channel_id: resolved.id, ...answer.body });
70
+ const now = result.channel ? (result.channel.muted ? "muted" : (result.channel.level ?? "your default level")) : "your default level";
71
+ writeLine(door.io.stdout, `Channel ${resolved.id}: ${now}${result.changed === false ? " (it was already that way)" : ""}.`);
72
+ return 0;
73
+ }
74
+ async function showReceipts(command, door) {
75
+ const query = new URLSearchParams();
76
+ if (command.targetId)
77
+ query.set("message_id", command.targetId);
78
+ if (command.before)
79
+ query.set("before", command.before);
80
+ if (command.limit)
81
+ query.set("limit", String(command.limit));
82
+ const answer = await askAgentDoor(door, {
83
+ path: `/api/msg/notifications/receipts${query.size > 0 ? `?${query.toString()}` : ""}`,
84
+ method: "GET",
85
+ label: "msg notify receipts",
86
+ timeoutMs: DEADLINE_MS,
87
+ });
88
+ if (!answer.ok)
89
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
90
+ const body = answer.body;
91
+ const receipts = body.receipts ?? [];
92
+ writeLine(door.io.stderr, `${TAG} notify receipts ${JSON.stringify({ returned: receipts.length, has_more: body.hasMore === true })}`);
93
+ if (door.json)
94
+ return emitAgentDoor(door, { ok: true, ...body });
95
+ if (receipts.length === 0)
96
+ writeLine(door.io.stdout, "No delivery receipts.");
97
+ for (const receipt of receipts) {
98
+ writeLine(door.io.stdout, `${receipt.created_at} ${receipt.transport} ${receipt.kind} ${receipt.outcome} ${receipt.reason} message ${receipt.message_id}`);
99
+ }
100
+ if (body.nextCursor)
101
+ writeLine(door.io.stdout, `More: --before ${body.nextCursor}`);
102
+ return 0;
103
+ }
104
+ export async function runMsgNotify(command, door, resolve) {
105
+ switch (command.notifyVerb ?? "show") {
106
+ case "show":
107
+ return showSettings(door);
108
+ case "set":
109
+ if (command.channelRef)
110
+ return setChannel(command, door, resolve);
111
+ return putSettings(door, "msg notify set", { default_level: command.notifyLevel }, `Default level is ${command.notifyLevel}`);
112
+ case "quiet": {
113
+ if (command.quietOff)
114
+ return putSettings(door, "msg notify quiet", { quiet_hours: null }, "Quiet hours are off");
115
+ const [start, end] = (command.quietWindow ?? "").split("-");
116
+ const zone = command.timezone ?? "UTC";
117
+ return putSettings(door, "msg notify quiet", { quiet_hours: { start, end, timezone: zone } }, `Quiet hours are ${start}-${end} (${zone})`);
118
+ }
119
+ case "receipts":
120
+ return showReceipts(command, door);
121
+ }
122
+ }