@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
|
-
|
|
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
|
-
|
|
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.",
|
package/dist/commands/msg-dm.js
CHANGED
|
@@ -1,13 +1,39 @@
|
|
|
1
|
-
/**
|
|
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
|
-
|
|
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);
|
package/dist/commands/msg.js
CHANGED
|
@@ -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,
|
|
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
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.260");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|