@bli-cockpit/cli 0.2.259 → 0.2.260

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.
@@ -1,5 +1,7 @@
1
1
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
2
2
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+ /** One `@`, no spaces: `"ping @bob"` is text, not an address (BLI-23213). */
4
+ const DM_ADDRESS_PATTERN = /^[^\s@]+@[^\s@]+$/;
3
5
  import { MSG_CHANNEL_BOOLEAN_FLAGS, MSG_CHANNEL_VALUE_FLAGS, isMsgChannelAction, msgChannelNormalizeArgs, parseMsgChannelArgs, rejectMsgChannelFlags } from "./msg-channel-args.js";
4
6
  import { MSG_CONTEXT_VALUE_FLAGS, parseMsgContextArgs, rejectMsgContextFlags } from "./msg-context-args.js";
5
7
  import { MSG_MARK_VALUE_FLAGS, isMsgMarkAction, parseMsgMarkArgs, rejectMsgMarkFlags } from "./msg-mark-args.js";
@@ -123,14 +125,16 @@ export function parseMsgArgs(rawArgs) {
123
125
  throw new Error("--before belongs to msg read or msg thread.");
124
126
  }
125
127
  const { markRead, unreadFrom } = parseMsgReadLineFlags(action, values);
126
- const { channelName, dmEmail } = parseMsgCreateTarget(action, rest);
128
+ const { channelName, dmEmail, dmTextArgument } = parseMsgCreateTarget(action, rest);
127
129
  const { isPrivate, memberEmails, description } = parseMsgCreateFlags(action, values);
128
130
  const { channelRef, threadId, limit } = parseMsgReadTarget(action, rest, values);
129
131
  const writeSafety = parseMsgWriteSafety(action, rest, values);
130
132
  const built = buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit, markRead, unreadFrom, writeSafety);
131
133
  // `msg dm a@x b@y` is a group message: the single-address shape (dmEmail) is unchanged (BLI-21685).
132
134
  const withGroup = action === "dm" && rest.length > 1 ? { ...built, dmEmails: rest } : built;
133
- return fileIds.length > 0 ? { ...withGroup, fileIds } : withGroup;
135
+ // BLI-23213: text typed after `msg dm <email>` is refused by name at run time (no help dump); the text is piped in.
136
+ const withText = dmTextArgument ? { ...withGroup, dmTextArgument } : withGroup;
137
+ return fileIds.length > 0 ? { ...withText, fileIds } : withText;
134
138
  }
135
139
  /**
136
140
  * `cockpit msg search [words] [--from <email|me>]... [--in <channel>] [--since <day>] [--until <day>] [--timezone <zone>]
@@ -333,8 +337,8 @@ function parseMsgWriteSafety(action, rest, values) {
333
337
  const requestId = optionalNonEmpty(values.flags.get("--request-id"));
334
338
  if (requestId && !UUID_PATTERN.test(requestId))
335
339
  throw new Error("--request-id must be a UUID.");
336
- if (requestId && action !== "send" && action !== "edit" && action !== "delete") {
337
- throw new Error("--request-id belongs to `cockpit msg send`, `edit` or `delete`.");
340
+ if (requestId && action !== "send" && action !== "dm" && action !== "edit" && action !== "delete") {
341
+ throw new Error("--request-id belongs to `cockpit msg send`, `dm`, `edit` or `delete`.");
338
342
  }
339
343
  const expectedRevision = optionalPositiveInteger(values.flags.get("--expected-revision"), "--expected-revision");
340
344
  if (expectedRevision !== undefined && action !== "edit" && action !== "delete") {
@@ -379,6 +383,7 @@ function parseMsgAction(positionals) {
379
383
  function parseMsgCreateTarget(action, rest) {
380
384
  let channelName;
381
385
  let dmEmail;
386
+ let dmTextArgument = false;
382
387
  if (action === "create") {
383
388
  channelName = optionalNonEmpty(rest[0]);
384
389
  if (!channelName)
@@ -396,11 +401,9 @@ function parseMsgCreateTarget(action, rest) {
396
401
  dmEmail = optionalNonEmpty(rest[0]);
397
402
  if (!dmEmail)
398
403
  throw new Error("msg dm needs the person's email address, e.g. `cockpit msg dm ada@example.com` (several for a group message).");
399
- const notAnAddress = rest.find((email) => !email.includes("@"));
400
- if (notAnAddress !== undefined)
401
- throw new Error(`msg dm takes email addresses; "${notAnAddress}" is not one.`);
404
+ dmTextArgument = rest.some((email) => !DM_ADDRESS_PATTERN.test(email));
402
405
  }
403
- return { channelName, dmEmail };
406
+ return { channelName, dmEmail, dmTextArgument };
404
407
  }
405
408
  function parseMsgCreateFlags(action, values) {
406
409
  const isPrivate = values.booleans.has("--private");
@@ -107,7 +107,7 @@ export const TOWER_COMMAND_HELP = [
107
107
  "Channels and messages, typed. <channel> is a channel id, or its name with or without a leading #.",
108
108
  "channels — every channel you are a member of (or, as a super_admin, every channel).",
109
109
  "create <name> [--private] [--members a@x.test,b@y.test] [--description \"<text>\"] — makes a channel and prints its id. A leading # is fine; --private means membership decides who may read it, and --members names who joins at birth BY EMAIL. An address Tower does not carry refuses the whole create — no half-built channel.",
110
- "dm <email> — opens (or re-opens) the direct message with one person. Idempotent: the same address always resolves to the same channel, and you never have to name yourself.",
110
+ "dm <email> [--request-id <uuid>] — opens (or re-opens) the direct message with one person. Idempotent: the same address always resolves to the same channel, and you never have to name yourself. Pipe text in and it is also sent there in the same call, through the same door and request-id receipt as send: `echo \"hello\" | cockpit msg dm ada@example.com` (retry a lost answer with the SAME --request-id; it posts once). The text is never accepted on the command line: `msg dm ada@example.com \"hello\"` is refused dm_text_argument, exit 1, and nothing is sent.",
111
111
  "dm <email> [<email>...] — with several addresses, opens (or re-opens) the group message with exactly those people plus you; the same set always resolves to the same channel. channel <channel> [--json] — the channel's details: name, description, kind, archived, audience (a public channel is everyone in the org, not a member count), source workspace label (where it came from; it grants no access), whether you can manage it, and whether you hid it. channel <channel> --rename <name> | --describe <text> | --archive | --unarchive — changes it (one update; --rename and --describe can go together). Only the person who made the channel, its owners and its moderators can; anyone else is refused channel_manage_refused, a name in use channel_name_taken, a direct message dm_not_editable. Archiving keeps every message and link and stops new posts (channel_archived); unarchive restores posting. members <channel> [--json] — who is in a private channel or a direct message; a public channel says everyone in the org can read it. members <channel> --add <email> | --remove <email> — private channels only, by the same people who can manage it; a removed person's next read is refused. A direct message keeps the people it started with (dm_not_editable): start a new group with `msg dm a@x b@y`. hide <channel> / unhide <channel> — drops a PUBLIC channel from your own sidebar, or puts it back; it changes nothing for anyone else and nothing about reading, search, mentions or notifications. A private channel or direct message is refused hide_not_public.",
112
112
  "read <channel> [--limit <n>] [--thread <id>] [--mark-read] — the channel's most recent top-level messages, oldest first; --thread <id> reads one thread's replies instead. The first page also says where the messages come from (for an imported Slack channel: how long ago the archive was checked, or that it is unknown) and where YOUR read line stands. Reading never moves the read line; --mark-read moves it to the newest message shown, and says plainly if that failed.",
113
113
  "unread <channel> --from <message-id> — marks that message and everything after it unread, so the web sidebar badge and the NEW MESSAGES line start there. Opening or reading a channel never clears unseen history.",
@@ -1,13 +1,39 @@
1
- /** `cockpit msg dm <email>`: lookup-or-create a direct message (BLI-3749). Split out of `msg.ts` for the file-size ratchet. */
1
+ /**
2
+ * `cockpit msg dm <email> [<email>...]`: lookup-or-create a direct message (BLI-3749). Split out of `msg.ts` for the file-size ratchet.
3
+ * BLI-23213: text piped in is sent into the DM through the same send door `msg send` uses (request id, receipt,
4
+ * `--request-id` for a retry); text typed on the command line is refused by name, because a message never travels there.
5
+ */
6
+ import { randomUUID } from "node:crypto";
2
7
  import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
3
- import { writeLine } from "./cli-io.js";
8
+ import { isInteractiveStdin, writeLine } from "./cli-io.js";
9
+ import { postMessage, readContent } from "./msg-write.js";
4
10
  const TAG = "[msg cli]";
5
11
  const WRITE_DEADLINE_MS = 30_000;
12
+ export const DM_TEXT_ARGUMENT_DETAIL = "dm takes addresses only; pipe the text in: echo \"<text>\" | cockpit msg dm <email>";
13
+ /**
14
+ * `msg dm a@x "hello"` used to drown its one reason line in the whole msg manual and send nothing, so reports
15
+ * written that way vanished. It is refused here, before pairing or any request, with the reason and the right form.
16
+ */
17
+ export function refuseDmTextArgument(command, io) {
18
+ writeLine(io.stderr, `${TAG} refused ${JSON.stringify({ reason: "dm_text_argument" })}`);
19
+ if (command.json)
20
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: "dm_text_argument", detail: DM_TEXT_ARGUMENT_DETAIL }));
21
+ else
22
+ writeLine(io.stderr, DM_TEXT_ARGUMENT_DETAIL);
23
+ return 1;
24
+ }
6
25
  export async function openDm(command, door) {
7
26
  const emails = command.dmEmails ?? (command.dmEmail ? [command.dmEmail] : []);
8
27
  const email = emails.join(", ");
9
28
  if (emails.length === 0)
10
29
  return failAgentDoor(door, TAG, "invalid_body", "msg dm needs an email address.");
30
+ // Read before opening, so text over the limit is refused before anything is created.
31
+ const piped = isInteractiveStdin(door.io) ? { ok: true, text: "" } : await readContent(door.io);
32
+ if (!piped.ok)
33
+ return failAgentDoor(door, TAG, piped.reason, piped.detail);
34
+ if (command.requestId && piped.text === "") {
35
+ return failAgentDoor(door, TAG, "invalid_body", "--request-id on msg dm goes with piped text, e.g. `echo \"hello\" | cockpit msg dm <email> --request-id <uuid>`. Nothing was opened or sent.");
36
+ }
11
37
  const answer = await askAgentDoor(door, {
12
38
  path: "/api/msg/channels",
13
39
  method: "POST",
@@ -18,11 +44,25 @@ export async function openDm(command, door) {
18
44
  if (!answer.ok)
19
45
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
20
46
  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 });
47
+ writeLine(door.io.stderr, `${TAG} dm resolved ${JSON.stringify({ channel_id: channel?.id ?? null, piped_chars: piped.text.length })}`);
24
48
  // Idempotent by contract (`get_or_create_dm`), so the wording says what is
25
49
  // 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 ?? "?"}`);
50
+ const opened = `${emails.length > 1 ? "Group message" : "Direct message"} with ${email}: ${channel?.id ?? "?"}`;
51
+ if (piped.text === "") {
52
+ if (door.json)
53
+ return emitAgentDoor(door, { ok: true, channel });
54
+ writeLine(door.io.stdout, opened);
55
+ return 0;
56
+ }
57
+ if (!channel?.id)
58
+ return failAgentDoor(door, TAG, "dm_channel_missing", "Tower answered without the direct message's id, so the piped text was NOT sent.");
59
+ const requestId = command.requestId ?? randomUUID();
60
+ const sent = await postMessage(door, channel.id, { text: piped.text, requestId });
61
+ if (!sent.ok)
62
+ return sent.exitCode;
63
+ if (door.json)
64
+ return emitAgentDoor(door, { ok: true, channel, message: sent.message, mutation: sent.mutation });
65
+ writeLine(door.io.stdout, opened);
66
+ writeLine(door.io.stdout, `Sent to ${email} (${sent.message?.id ?? "?"}).`);
27
67
  return 0;
28
68
  }
@@ -60,6 +60,31 @@ export function refuseMsgWrite(door, answer, requestId, verb) {
60
60
  }
61
61
  return failAgentDoor(door, TAG, answer.reason, answer.detail, mutation ? { mutation } : {});
62
62
  }
63
+ /**
64
+ * The ONE send door `msg send` and `msg dm` (with piped text, BLI-23213) share: `POST /api/msg/channels/<id>/messages`
65
+ * with a request id, so a lost answer can be checked with `msg outcome` and a retry with the same id posts once.
66
+ * A refusal has already been printed when this answers `ok: false`; the receipt line is printed on success.
67
+ */
68
+ export async function postMessage(door, channelId, post) {
69
+ const fileIds = post.fileIds ?? [];
70
+ const answer = await askAgentDoor(door, {
71
+ path: `/api/msg/channels/${encodeURIComponent(channelId)}/messages`,
72
+ method: "POST",
73
+ label: "msg send",
74
+ timeoutMs: WRITE_DEADLINE_MS,
75
+ body: {
76
+ ...(post.text === "" ? {} : { content: post.text }),
77
+ ...(fileIds.length > 0 ? { file_ids: fileIds } : {}),
78
+ request_id: post.requestId,
79
+ ...(post.threadId ? { thread_parent_id: post.threadId } : {}),
80
+ },
81
+ });
82
+ if (!answer.ok)
83
+ return { ok: false, exitCode: refuseMsgWrite(door, answer, post.requestId, "send") };
84
+ const { message = null, mutation = null } = answer.body;
85
+ writeLine(door.io.stderr, `${TAG} sent ${JSON.stringify({ channel_id: channelId, message_id: message?.id ?? null, request_id: post.requestId, chars: post.text.length, files: fileIds.length })}`);
86
+ return { ok: true, message, mutation };
87
+ }
63
88
  /** `cockpit msg edit <message-id> [--expected-revision <n>] [--request-id <uuid>]` — the new text is piped in. */
64
89
  export async function editMessage(command, door) {
65
90
  const content = await readContent(door.io);
@@ -23,14 +23,16 @@ import { markReadThrough } from "./msg-read-line.js";
23
23
  import { runMsgActivityVerb } from "./msg-activity.js";
24
24
  import { runMsgDraft } from "./msg-draft.js";
25
25
  import { runMsgSearch } from "./msg-search.js";
26
- import { openDm } from "./msg-dm.js";
26
+ import { openDm, refuseDmTextArgument } from "./msg-dm.js";
27
27
  import { runMsgMark } from "./msg-mark.js";
28
28
  import { runMsgChannel } from "./msg-channel.js";
29
29
  import { runMsgFile } from "./msg-file.js";
30
- import { deleteMessage, editMessage, messageOutcome, messageText, readContent, refuseMsgWrite } from "./msg-write.js";
30
+ import { deleteMessage, editMessage, messageOutcome, messageText, postMessage, readContent } from "./msg-write.js";
31
31
  const TAG = "[msg cli]";
32
32
  const [READ_DEADLINE_MS, WRITE_DEADLINE_MS] = [30_000, 30_000];
33
33
  export async function runMsg(command, io) {
34
+ if (command.action === "dm" && command.dmTextArgument)
35
+ return refuseDmTextArgument(command, io);
34
36
  const door = await openAgentDoor("msg", command, io);
35
37
  switch (command.action) {
36
38
  case "show": return showMessage(command, door);
@@ -327,22 +329,10 @@ async function sendMessage(command, door) {
327
329
  return failAgentDoor(door, TAG, "invalid_body", "There was nothing to send. Nothing was sent.");
328
330
  // BLI-21314: every send carries a request id, so a lost response can be checked and a retry posts once.
329
331
  const requestId = command.requestId ?? randomUUID();
330
- const answer = await askAgentDoor(door, {
331
- path: `/api/msg/channels/${encodeURIComponent(resolved.id)}/messages`,
332
- method: "POST",
333
- label: "msg send",
334
- timeoutMs: WRITE_DEADLINE_MS,
335
- body: {
336
- ...(text === "" ? {} : { content: text }),
337
- ...(fileIds.length > 0 ? { file_ids: fileIds } : {}),
338
- request_id: requestId,
339
- ...(command.threadId ? { thread_parent_id: command.threadId } : {}),
340
- },
341
- });
342
- if (!answer.ok)
343
- return refuseMsgWrite(door, answer, requestId, "send");
344
- const { message = null, mutation = null } = answer.body;
345
- writeLine(door.io.stderr, `${TAG} sent ${JSON.stringify({ channel_id: resolved.id, message_id: message?.id ?? null, request_id: requestId, chars: text.length, files: fileIds.length })}`);
332
+ const sent = await postMessage(door, resolved.id, { text, fileIds, requestId, ...(command.threadId ? { threadId: command.threadId } : {}) });
333
+ if (!sent.ok)
334
+ return sent.exitCode;
335
+ const { message, mutation } = sent;
346
336
  if (door.json)
347
337
  return emitAgentDoor(door, { ok: true, message, mutation });
348
338
  writeLine(door.io.stdout, `Sent to ${ref} (${message?.id ?? "?"}).`);
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.259");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.260");
25
25
  return 0;
26
26
  }
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.259",
3
+ "version": "0.2.260",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {