@bli-cockpit/cli 0.2.62 → 0.2.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/local-args-tower-mail.js +151 -0
- package/dist/commands/local-args-tower.js +3 -1
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help-commands.js +23 -0
- package/dist/commands/local-help.js +2 -0
- package/dist/commands/local.js +3 -0
- package/dist/commands/mail.js +337 -0
- package/dist/commands/public-root.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit mail` argument parsing (BLI-3708) — the mail surface's half of
|
|
3
|
+
* `local-args-tower.ts`, a sibling of `local-args-tower-work.ts` for the same
|
|
4
|
+
* reason that file is one: mail is its own noun with its own vocabulary (a
|
|
5
|
+
* mailbox, a thread, an attachment, a recipient), and folding it into another
|
|
6
|
+
* family's parser would stretch that family's doc comment past the truth.
|
|
7
|
+
*
|
|
8
|
+
* TWO SECRETS NEVER TRAVEL ON ARGV, and both are refused here rather than at
|
|
9
|
+
* the door:
|
|
10
|
+
*
|
|
11
|
+
* - **The app password.** `cockpit mail add-imap --address <a>` reads it from
|
|
12
|
+
* STDIN, always. There is no `--password` flag to forget about: argv is
|
|
13
|
+
* world-readable on a shared machine (`ps`), lands in shell history, and is
|
|
14
|
+
* captured by this very product's own session harvester. That is the
|
|
15
|
+
* sourcing-secrets verdict applied to the one credential this product
|
|
16
|
+
* stores.
|
|
17
|
+
* - **The message body.** `cockpit mail send` takes it from stdin or
|
|
18
|
+
* `--file`, the same discipline `cockpit issue comment` and `cockpit docs
|
|
19
|
+
* update` keep.
|
|
20
|
+
*/
|
|
21
|
+
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
|
|
22
|
+
const MAIL_ACTIONS = new Set([
|
|
23
|
+
"accounts",
|
|
24
|
+
"add-imap",
|
|
25
|
+
"detach",
|
|
26
|
+
"inbox",
|
|
27
|
+
"read",
|
|
28
|
+
"search",
|
|
29
|
+
"send",
|
|
30
|
+
"attachment",
|
|
31
|
+
"sync",
|
|
32
|
+
]);
|
|
33
|
+
/** Every verb that names ONE thing first: a thread, an attachment, a mailbox. */
|
|
34
|
+
const MAIL_ACTIONS_NEEDING_A_SUBJECT = new Set(["read", "attachment", "detach", "sync"]);
|
|
35
|
+
export function parseMailArgs(args) {
|
|
36
|
+
const values = parseNamedArgs(args, {
|
|
37
|
+
allowedFlags: [
|
|
38
|
+
"--home",
|
|
39
|
+
"--dashboard-url",
|
|
40
|
+
"--account",
|
|
41
|
+
"--address",
|
|
42
|
+
"--name",
|
|
43
|
+
"--limit",
|
|
44
|
+
"--before",
|
|
45
|
+
"--unread",
|
|
46
|
+
"--label",
|
|
47
|
+
"--to",
|
|
48
|
+
"--cc",
|
|
49
|
+
"--subject",
|
|
50
|
+
"--reply-to",
|
|
51
|
+
"--file",
|
|
52
|
+
"--body-stdin",
|
|
53
|
+
"--out",
|
|
54
|
+
"--json",
|
|
55
|
+
],
|
|
56
|
+
valueFlags: [
|
|
57
|
+
"--home",
|
|
58
|
+
"--dashboard-url",
|
|
59
|
+
"--account",
|
|
60
|
+
"--address",
|
|
61
|
+
"--name",
|
|
62
|
+
"--limit",
|
|
63
|
+
"--before",
|
|
64
|
+
"--label",
|
|
65
|
+
"--to",
|
|
66
|
+
"--cc",
|
|
67
|
+
"--subject",
|
|
68
|
+
"--reply-to",
|
|
69
|
+
"--file",
|
|
70
|
+
"--out",
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
const first = values.positionals[0];
|
|
74
|
+
const action = (first === undefined ? "inbox" : first);
|
|
75
|
+
if (!MAIL_ACTIONS.has(action)) {
|
|
76
|
+
throw new Error(`Unknown mail command: ${first}. Try accounts, add-imap, detach, inbox, read, search, send, attachment, or sync.`);
|
|
77
|
+
}
|
|
78
|
+
const rest = values.positionals.slice(first === undefined ? 0 : 1);
|
|
79
|
+
let subject;
|
|
80
|
+
let query;
|
|
81
|
+
if (MAIL_ACTIONS_NEEDING_A_SUBJECT.has(action)) {
|
|
82
|
+
subject = optionalNonEmpty(rest[0]);
|
|
83
|
+
if (!subject) {
|
|
84
|
+
throw new Error(action === "read"
|
|
85
|
+
? "mail read needs a thread id — take one from `cockpit mail inbox --json`."
|
|
86
|
+
: `mail ${action} needs an id.`);
|
|
87
|
+
}
|
|
88
|
+
if (rest.length > 1)
|
|
89
|
+
throw new Error(`mail ${action} takes one id, not ${rest.length}.`);
|
|
90
|
+
}
|
|
91
|
+
else if (action === "search") {
|
|
92
|
+
query = optionalNonEmpty(rest.join(" "));
|
|
93
|
+
if (!query)
|
|
94
|
+
throw new Error('mail search needs words: cockpit mail search "invoice from stripe".');
|
|
95
|
+
}
|
|
96
|
+
else if (rest.length > 0) {
|
|
97
|
+
throw new Error(`mail ${action} does not take "${rest[0]}".`);
|
|
98
|
+
}
|
|
99
|
+
const address = optionalNonEmpty(values.flags.get("--address"));
|
|
100
|
+
if (action === "add-imap" && !address) {
|
|
101
|
+
throw new Error("mail add-imap needs --address, and reads the app password from stdin.");
|
|
102
|
+
}
|
|
103
|
+
const to = splitAddresses(values.flags.get("--to"));
|
|
104
|
+
const cc = splitAddresses(values.flags.get("--cc"));
|
|
105
|
+
if (action === "send" && to.length === 0) {
|
|
106
|
+
throw new Error('mail send needs --to "someone@example.com" (comma-separated for several).');
|
|
107
|
+
}
|
|
108
|
+
if (action === "send" && !optionalNonEmpty(values.flags.get("--account"))) {
|
|
109
|
+
throw new Error("mail send needs --account <id>: with several mailboxes attached, which address this goes out from is yours to say. `cockpit mail accounts` lists them.");
|
|
110
|
+
}
|
|
111
|
+
const outPath = optionalNonEmpty(values.flags.get("--out"));
|
|
112
|
+
if (action === "attachment" && !outPath) {
|
|
113
|
+
throw new Error("mail attachment needs --out <path>: the file is written to disk, never to stdout.");
|
|
114
|
+
}
|
|
115
|
+
const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
|
|
116
|
+
if (limit !== undefined && action !== "inbox" && action !== "search") {
|
|
117
|
+
throw new Error("--limit belongs to `cockpit mail inbox` or `cockpit mail search`.");
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
kind: "mail",
|
|
121
|
+
action,
|
|
122
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
123
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
124
|
+
...(subject ? { subjectId: subject } : {}),
|
|
125
|
+
...(query ? { query } : {}),
|
|
126
|
+
accountId: optionalNonEmpty(values.flags.get("--account")),
|
|
127
|
+
address,
|
|
128
|
+
displayName: optionalNonEmpty(values.flags.get("--name")),
|
|
129
|
+
...(limit === undefined ? {} : { limit }),
|
|
130
|
+
before: optionalNonEmpty(values.flags.get("--before")),
|
|
131
|
+
unreadOnly: values.booleans.has("--unread"),
|
|
132
|
+
label: optionalNonEmpty(values.flags.get("--label")),
|
|
133
|
+
to,
|
|
134
|
+
cc,
|
|
135
|
+
subjectLine: optionalNonEmpty(values.flags.get("--subject")),
|
|
136
|
+
replyTo: optionalNonEmpty(values.flags.get("--reply-to")),
|
|
137
|
+
filePath: optionalNonEmpty(values.flags.get("--file")),
|
|
138
|
+
bodyStdin: values.booleans.has("--body-stdin"),
|
|
139
|
+
outPath,
|
|
140
|
+
json: values.booleans.has("--json"),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** `a@x.com, b@y.com` → two recipients. Exact, never fuzzy; empties dropped. */
|
|
144
|
+
function splitAddresses(raw) {
|
|
145
|
+
if (!raw)
|
|
146
|
+
return [];
|
|
147
|
+
return raw
|
|
148
|
+
.split(",")
|
|
149
|
+
.map((value) => value.trim())
|
|
150
|
+
.filter((value) => value !== "");
|
|
151
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* channels/messages (BLI-3706)
|
|
21
21
|
* local-args-tower-work.ts issue, project — the issue tracker
|
|
22
22
|
* (BLI-3716)
|
|
23
|
+
* local-args-tower-mail.ts mail — the mailboxes (BLI-3708)
|
|
23
24
|
* local-args-tower-search.ts search — one bar over all five
|
|
24
25
|
* corpora (BLI-3728)
|
|
25
26
|
*
|
|
@@ -30,4 +31,5 @@ export { parseBriefArgs, WORKBOOK_MIN_WIDTH, parseWorkbookArgs, parseNotesArgs,
|
|
|
30
31
|
export { SCOUT_MIN_PREFIX_LENGTH, parseScoutArgs, parseOpsArgs, SLACK_WORKSPACE_KEYS, parseSlackArgs, parseSettingsArgs, parseTeamArgs, parseModelArgs, } from "./local-args-tower-admin.js";
|
|
31
32
|
export { parseDocsArgs, parseMsgArgs, } from "./local-args-tower-docs-msg.js";
|
|
32
33
|
export { ISSUE_STATES, parseIssueArgs, parseProjectArgs, } from "./local-args-tower-work.js";
|
|
33
|
-
export { SEARCH_KINDS, parseSearchArgs } from "./local-args-tower-search.js";
|
|
34
|
+
export { SEARCH_KINDS, parseSearchArgs } from "./local-args-tower-search.js";
|
|
35
|
+
export { parseMailArgs } from "./local-args-tower-mail.js";
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* verbatim, no logic change.
|
|
17
17
|
*/
|
|
18
18
|
import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
|
|
19
|
-
import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
|
|
19
|
+
import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
|
|
20
20
|
// `normalizeUrl` has always been part of this module's surface — `local.ts` and
|
|
21
21
|
// `local-auth.ts` import it from here — so it stays exported from this address
|
|
22
22
|
// even though it now lives next door. The same goes for the four names the
|
|
@@ -103,6 +103,8 @@ export function parseLocalArgs(argv) {
|
|
|
103
103
|
return parseMsgArgs(argv.slice(1));
|
|
104
104
|
case "issue":
|
|
105
105
|
return parseIssueArgs(argv.slice(1));
|
|
106
|
+
case "mail":
|
|
107
|
+
return parseMailArgs(argv.slice(1));
|
|
106
108
|
case "project":
|
|
107
109
|
return parseProjectArgs(argv.slice(1));
|
|
108
110
|
case "search":
|
|
@@ -596,6 +596,29 @@ export function localSubcommandHelp(command) {
|
|
|
596
596
|
"The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
|
|
597
597
|
],
|
|
598
598
|
],
|
|
599
|
+
[
|
|
600
|
+
"mail",
|
|
601
|
+
[
|
|
602
|
+
"Usage: cockpit mail [accounts|add-imap|inbox|read <thread>|search \"<words>\"|send|attachment <id>|sync <account>|detach <account>] [flags]",
|
|
603
|
+
"",
|
|
604
|
+
"Every mailbox you attached, in one place. One person, several addresses — nothing here has a \"current\" mailbox.",
|
|
605
|
+
"accounts — every mailbox, its provider (gmail_oauth or imap), its health and when it last synced. The ids other verbs take are on the last line.",
|
|
606
|
+
"add-imap --address <you@gmail.com> [--name \"<display name>\"] — attaches a personal Google account over IMAP + SMTP. The APP PASSWORD is read from stdin and there is no flag for it:",
|
|
607
|
+
" macOS: printf \"%s\" \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
|
|
608
|
+
" PowerShell: \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
|
|
609
|
+
" Make one at myaccount.google.com/apppasswords (2-Step Verification has to be on). A work @buildlaunchiterate.ca address connects in the browser instead.",
|
|
610
|
+
"inbox [--account <id>] [--unread] [--label <l>] [--limit <n>] [--before <iso>] — everything across every mailbox, newest first. * is unread, @ has an attachment.",
|
|
611
|
+
"read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox --json`.",
|
|
612
|
+
"search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box.",
|
|
613
|
+
"send --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] — the BODY comes from --file or stdin. --account is required: which address this goes out from is yours to say.",
|
|
614
|
+
"attachment <id> --out <path> — downloads one attachment to a file. Attachments are pointers until you ask; nothing is stored in Tower.",
|
|
615
|
+
"sync <account> — reads that mailbox now instead of waiting for the cron. Prints what landed, or the reason it did not.",
|
|
616
|
+
"detach <account> — removes the mailbox, its stored mail and its credential.",
|
|
617
|
+
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
618
|
+
"A refusal keeps Tower's own reason label — needs_rls_client, account_not_found_or_unreadable, account_not_active, google_oauth_not_configured, credential_unavailable, and so on.",
|
|
619
|
+
"The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
|
|
620
|
+
],
|
|
621
|
+
],
|
|
599
622
|
[
|
|
600
623
|
"project",
|
|
601
624
|
[
|
|
@@ -45,6 +45,7 @@ export const rootCommandNames = new Set([
|
|
|
45
45
|
"docs",
|
|
46
46
|
"msg",
|
|
47
47
|
"issue",
|
|
48
|
+
"mail",
|
|
48
49
|
"project",
|
|
49
50
|
"search",
|
|
50
51
|
"release",
|
|
@@ -89,6 +90,7 @@ export function localCommandHelp(command) {
|
|
|
89
90
|
" cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>] [--parent <id>|root|--clear-parent] [--query <text>] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
|
|
90
91
|
" cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--dashboard-url <url>] [--json]",
|
|
91
92
|
" cockpit issue [list|show <BLI-id>|create --title <t>|update <BLI-id>|move <BLI-id> <state>|comment <BLI-id>|history <BLI-id>] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] [--priority 0-4] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
|
|
93
|
+
" cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--dashboard-url <url>] [--json]",
|
|
92
94
|
" cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
|
|
93
95
|
" cockpit search \"<words>\" [--kind doc,msg,issue,note,memory] [--limit <n>] [--dashboard-url <url>] [--json]",
|
|
94
96
|
" cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
|
package/dist/commands/local.js
CHANGED
|
@@ -35,6 +35,7 @@ import { runClean } from "./clean.js";
|
|
|
35
35
|
import { runDocs } from "./docs.js";
|
|
36
36
|
import { runMsg } from "./msg.js";
|
|
37
37
|
import { runIssue } from "./issue.js";
|
|
38
|
+
import { runMail } from "./mail.js";
|
|
38
39
|
import { runProject } from "./project.js";
|
|
39
40
|
import { runSearch } from "./search.js";
|
|
40
41
|
import { parseLocalArgs } from "./local-args.js";
|
|
@@ -142,6 +143,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
142
143
|
return await runMsg(command, io);
|
|
143
144
|
case "issue":
|
|
144
145
|
return await runIssue(command, io);
|
|
146
|
+
case "mail":
|
|
147
|
+
return await runMail(command, io);
|
|
145
148
|
case "project":
|
|
146
149
|
return await runProject(command, io);
|
|
147
150
|
case "search":
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit mail` — the mailboxes a person attached, from a terminal
|
|
3
|
+
* (BLI-3708).
|
|
4
|
+
*
|
|
5
|
+
* Nine verbs over `/api/mail/**`, the same doors the browser will use when the
|
|
6
|
+
* UI lands. Every one of those routes already accepts the collector device
|
|
7
|
+
* token (`resolveCaller({ allowDeviceToken: true })`), so this command is a
|
|
8
|
+
* terminal in front of an existing door and adds no authority of its own.
|
|
9
|
+
*
|
|
10
|
+
* The CLI is the FIRST surface for this wave, deliberately (AGENTS.md, "CLI is
|
|
11
|
+
* king"): a mailbox is easier to reason about as `--json` than as a rendered
|
|
12
|
+
* list, and every bug fixed here is fixed for the browser and MCP halves that
|
|
13
|
+
* share the same doors.
|
|
14
|
+
*
|
|
15
|
+
* TWO SECRETS NEVER TOUCH ARGV. `add-imap` reads the Google app password from
|
|
16
|
+
* STDIN and there is no flag that would take it; `send` reads the body from
|
|
17
|
+
* stdin or `--file`. `local-args-tower-mail.ts` says why in full.
|
|
18
|
+
*
|
|
19
|
+
* ONE MORE RULE, for the attachment verb: bytes go to a FILE, never to stdout.
|
|
20
|
+
* `--out` is required. A terminal that prints a PDF is a terminal somebody has
|
|
21
|
+
* to close, and `--json` output on stdout is a contract other tools parse.
|
|
22
|
+
*/
|
|
23
|
+
import { writeFile } from "node:fs/promises";
|
|
24
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
25
|
+
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
26
|
+
import { readNoteFile } from "./notes-file.js";
|
|
27
|
+
import { towerRequest } from "../tower-client.js";
|
|
28
|
+
const TAG = "[mail cli]";
|
|
29
|
+
const READ_DEADLINE_MS = 30_000;
|
|
30
|
+
const WRITE_DEADLINE_MS = 60_000;
|
|
31
|
+
/** A sync pass reads a bounded page, but a cold mailbox can still take a while. */
|
|
32
|
+
const SYNC_DEADLINE_MS = 120_000;
|
|
33
|
+
const BODY_MAX_CHARS = 200_000;
|
|
34
|
+
const PASSWORD_MAX_CHARS = 200;
|
|
35
|
+
export async function runMail(command, io) {
|
|
36
|
+
const door = await openAgentDoor("mail", command, io);
|
|
37
|
+
switch (command.action) {
|
|
38
|
+
case "accounts":
|
|
39
|
+
return listAccounts(door);
|
|
40
|
+
case "add-imap":
|
|
41
|
+
return addImap(command, door);
|
|
42
|
+
case "detach":
|
|
43
|
+
return detach(command, door);
|
|
44
|
+
case "inbox":
|
|
45
|
+
return inbox(command, door);
|
|
46
|
+
case "read":
|
|
47
|
+
return readThread(command, door);
|
|
48
|
+
case "search":
|
|
49
|
+
return search(command, door);
|
|
50
|
+
case "send":
|
|
51
|
+
return send(command, door);
|
|
52
|
+
case "attachment":
|
|
53
|
+
return attachment(command, door);
|
|
54
|
+
case "sync":
|
|
55
|
+
return sync(command, door);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function listAccounts(door) {
|
|
59
|
+
const answer = await askAgentDoor(door, {
|
|
60
|
+
path: "/api/mail/accounts",
|
|
61
|
+
method: "GET",
|
|
62
|
+
label: "mail accounts",
|
|
63
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
64
|
+
});
|
|
65
|
+
if (!answer.ok)
|
|
66
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
67
|
+
const accounts = answer.body.accounts ?? [];
|
|
68
|
+
if (door.json)
|
|
69
|
+
return emitAgentDoor(door, { ok: true, accounts });
|
|
70
|
+
if (accounts.length === 0) {
|
|
71
|
+
writeLine(door.io.stdout, "No mailboxes attached yet.");
|
|
72
|
+
writeLine(door.io.stdout, "");
|
|
73
|
+
writeLine(door.io.stdout, "A personal address: cockpit mail add-imap --address you@gmail.com (app password on stdin)");
|
|
74
|
+
writeLine(door.io.stdout, "A work address: open Tower in a browser and connect Google.");
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
for (const account of accounts) {
|
|
78
|
+
const health = account.status === "active" ? account.backfill_state : `${account.status}: ${account.status_reason ?? "no reason recorded"}`;
|
|
79
|
+
writeLine(door.io.stdout, `${account.address.padEnd(34)} ${account.provider.padEnd(12)} ${health.padEnd(18)} ${account.last_sync_at ?? "never synced"}`);
|
|
80
|
+
}
|
|
81
|
+
writeLine(door.io.stdout, "");
|
|
82
|
+
writeLine(door.io.stdout, `${accounts.length} mailbox(es). ids: ${accounts.map((account) => account.id).join(", ")}`);
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
async function addImap(command, door) {
|
|
86
|
+
// The password comes from stdin and nowhere else. An interactive terminal
|
|
87
|
+
// with nothing piped in is told how, rather than left waiting on a prompt
|
|
88
|
+
// that would echo the secret into the scrollback.
|
|
89
|
+
if (isInteractiveStdin(door.io)) {
|
|
90
|
+
return failAgentDoor(door, TAG, "password_required_on_stdin", 'The app password is read from stdin, never from a flag. Try: printf "%s" "abcd efgh ijkl mnop" | cockpit mail add-imap --address you@gmail.com');
|
|
91
|
+
}
|
|
92
|
+
let appPassword;
|
|
93
|
+
try {
|
|
94
|
+
appPassword = (await readPipedText(door.io.stdin, {
|
|
95
|
+
maxChars: PASSWORD_MAX_CHARS,
|
|
96
|
+
overflowMessage: "That does not look like a Google app password (they are 16 characters).",
|
|
97
|
+
})).trim();
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
return failAgentDoor(door, TAG, "password_unreadable", error instanceof Error ? error.message : String(error));
|
|
101
|
+
}
|
|
102
|
+
if (appPassword === "") {
|
|
103
|
+
return failAgentDoor(door, TAG, "password_required_on_stdin", "Nothing arrived on stdin, so nothing was attached.");
|
|
104
|
+
}
|
|
105
|
+
const answer = await askAgentDoor(door, {
|
|
106
|
+
path: "/api/mail/accounts",
|
|
107
|
+
method: "POST",
|
|
108
|
+
label: "mail add-imap",
|
|
109
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
110
|
+
body: {
|
|
111
|
+
address: command.address,
|
|
112
|
+
display_name: command.displayName ?? null,
|
|
113
|
+
app_password: appPassword,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
if (!answer.ok)
|
|
117
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
118
|
+
const account = answer.body.account;
|
|
119
|
+
if (door.json)
|
|
120
|
+
return emitAgentDoor(door, { ok: true, account });
|
|
121
|
+
writeLine(door.io.stdout, `Attached ${account?.address ?? command.address} (${account?.id ?? "no id returned"}).`);
|
|
122
|
+
writeLine(door.io.stdout, "Nothing is read until a sync runs: `cockpit mail sync <id>`, or wait for the cron.");
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
async function detach(command, door) {
|
|
126
|
+
const answer = await askAgentDoor(door, {
|
|
127
|
+
path: `/api/mail/accounts?account_id=${encodeURIComponent(command.subjectId ?? "")}`,
|
|
128
|
+
method: "DELETE",
|
|
129
|
+
label: "mail detach",
|
|
130
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
131
|
+
});
|
|
132
|
+
if (!answer.ok)
|
|
133
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
134
|
+
if (door.json)
|
|
135
|
+
return emitAgentDoor(door, { ok: true, detached: command.subjectId });
|
|
136
|
+
writeLine(door.io.stdout, `Detached ${command.subjectId}. Its stored mail and its credential went with it.`);
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
async function inbox(command, door) {
|
|
140
|
+
const query = new URLSearchParams();
|
|
141
|
+
if (command.accountId)
|
|
142
|
+
query.set("account_id", command.accountId);
|
|
143
|
+
if (command.limit !== undefined)
|
|
144
|
+
query.set("limit", String(command.limit));
|
|
145
|
+
if (command.before)
|
|
146
|
+
query.set("before", command.before);
|
|
147
|
+
if (command.unreadOnly)
|
|
148
|
+
query.set("unread", "1");
|
|
149
|
+
if (command.label)
|
|
150
|
+
query.set("label", command.label);
|
|
151
|
+
const suffix = query.toString();
|
|
152
|
+
const answer = await askAgentDoor(door, {
|
|
153
|
+
path: `/api/mail/inbox${suffix ? `?${suffix}` : ""}`,
|
|
154
|
+
method: "GET",
|
|
155
|
+
label: "mail inbox",
|
|
156
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
157
|
+
});
|
|
158
|
+
if (!answer.ok)
|
|
159
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
160
|
+
const body = answer.body;
|
|
161
|
+
if (door.json)
|
|
162
|
+
return emitAgentDoor(door, { ok: true, ...body });
|
|
163
|
+
const messages = body.messages ?? [];
|
|
164
|
+
if (messages.length === 0) {
|
|
165
|
+
writeLine(door.io.stdout, (body.accounts ?? []).length === 0 ? "No mailboxes attached yet." : "No mail matches that.");
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
for (const message of messages) {
|
|
169
|
+
writeLine(door.io.stdout, messageLine(message));
|
|
170
|
+
}
|
|
171
|
+
writeLine(door.io.stdout, "");
|
|
172
|
+
writeLine(door.io.stdout, `${messages.length} message(s) across ${(body.accounts ?? []).length} mailbox(es).`);
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
function messageLine(message) {
|
|
176
|
+
const flag = message.is_unread ? "*" : " ";
|
|
177
|
+
const paperclip = message.has_attachments ? "@" : " ";
|
|
178
|
+
const from = message.from_address?.address ?? "(no sender)";
|
|
179
|
+
const day = message.internal_date.slice(0, 16).replace("T", " ");
|
|
180
|
+
return `${flag}${paperclip} ${day} ${from.padEnd(30).slice(0, 30)} ${(message.subject ?? "(no subject)").slice(0, 60)}`;
|
|
181
|
+
}
|
|
182
|
+
async function readThread(command, door) {
|
|
183
|
+
const answer = await askAgentDoor(door, {
|
|
184
|
+
path: `/api/mail/threads/${encodeURIComponent(command.subjectId ?? "")}`,
|
|
185
|
+
method: "GET",
|
|
186
|
+
label: "mail read",
|
|
187
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
188
|
+
});
|
|
189
|
+
if (!answer.ok)
|
|
190
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
191
|
+
const body = answer.body;
|
|
192
|
+
if (door.json)
|
|
193
|
+
return emitAgentDoor(door, { ok: true, ...body });
|
|
194
|
+
writeLine(door.io.stdout, body.thread?.subject ?? "(no subject)");
|
|
195
|
+
writeLine(door.io.stdout, `${body.thread?.account_address ?? "?"} — ${body.messages?.length ?? 0} message(s)`);
|
|
196
|
+
for (const message of body.messages ?? []) {
|
|
197
|
+
writeLine(door.io.stdout, "");
|
|
198
|
+
writeLine(door.io.stdout, `--- ${message.internal_date} ${message.from_address?.address ?? "(no sender)"} [${message.id}]`);
|
|
199
|
+
writeLine(door.io.stdout, (message.body_text ?? message.snippet ?? "(no text body — this message is HTML only)").trim());
|
|
200
|
+
}
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
async function search(command, door) {
|
|
204
|
+
const query = new URLSearchParams({ q: command.query ?? "" });
|
|
205
|
+
if (command.accountId)
|
|
206
|
+
query.set("account_id", command.accountId);
|
|
207
|
+
if (command.limit !== undefined)
|
|
208
|
+
query.set("limit", String(command.limit));
|
|
209
|
+
const answer = await askAgentDoor(door, {
|
|
210
|
+
path: `/api/mail/search?${query.toString()}`,
|
|
211
|
+
method: "GET",
|
|
212
|
+
label: "mail search",
|
|
213
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
214
|
+
});
|
|
215
|
+
if (!answer.ok)
|
|
216
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
217
|
+
const matches = answer.body.matches ?? [];
|
|
218
|
+
if (door.json)
|
|
219
|
+
return emitAgentDoor(door, { ok: true, query: command.query, matches });
|
|
220
|
+
if (matches.length === 0) {
|
|
221
|
+
writeLine(door.io.stdout, `Nothing in your mail matched "${command.query}".`);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
for (const message of matches)
|
|
225
|
+
writeLine(door.io.stdout, messageLine(message));
|
|
226
|
+
writeLine(door.io.stdout, "");
|
|
227
|
+
writeLine(door.io.stdout, `${matches.length} match(es).`);
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
async function send(command, door) {
|
|
231
|
+
const body = await readBody(command, door);
|
|
232
|
+
if (!body.ok)
|
|
233
|
+
return failAgentDoor(door, TAG, body.reason, body.detail);
|
|
234
|
+
if (body.text.trim() === "") {
|
|
235
|
+
return failAgentDoor(door, TAG, "body_required", 'A message needs a body: `echo "on my way" | cockpit mail send --account <id> --to someone@example.com --subject "Re: lunch"`.');
|
|
236
|
+
}
|
|
237
|
+
const answer = await askAgentDoor(door, {
|
|
238
|
+
path: "/api/mail/send",
|
|
239
|
+
method: "POST",
|
|
240
|
+
label: "mail send",
|
|
241
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
242
|
+
body: {
|
|
243
|
+
account_id: command.accountId,
|
|
244
|
+
to: command.to.map((address) => ({ address })),
|
|
245
|
+
...(command.cc.length > 0 ? { cc: command.cc.map((address) => ({ address })) } : {}),
|
|
246
|
+
subject: command.subjectLine ?? "",
|
|
247
|
+
text: body.text,
|
|
248
|
+
...(command.replyTo ? { in_reply_to_message_id: command.replyTo } : {}),
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
if (!answer.ok)
|
|
252
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
253
|
+
const sent = answer.body.sent;
|
|
254
|
+
if (door.json)
|
|
255
|
+
return emitAgentDoor(door, { ok: true, sent });
|
|
256
|
+
writeLine(door.io.stdout, `Sent from ${sent?.address ?? "that mailbox"} to ${command.to.join(", ")}${sent?.threaded ? " (as a reply)" : ""}.`);
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
async function attachment(command, door) {
|
|
260
|
+
const outPath = command.outPath;
|
|
261
|
+
// The bytes do not go through `askAgentDoor` — it parses JSON, and this
|
|
262
|
+
// response is a file. Same door, same token, raw response.
|
|
263
|
+
const result = await towerRequest({
|
|
264
|
+
dashboardUrl: door.dashboardUrl,
|
|
265
|
+
path: `/api/mail/attachments/${encodeURIComponent(command.subjectId ?? "")}`,
|
|
266
|
+
deviceToken: door.deviceToken,
|
|
267
|
+
fetch: door.io.fetch,
|
|
268
|
+
method: "GET",
|
|
269
|
+
label: "mail attachment",
|
|
270
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
271
|
+
log: (line) => writeLine(door.io.stderr, line),
|
|
272
|
+
});
|
|
273
|
+
if (!result.ok) {
|
|
274
|
+
return failAgentDoor(door, TAG, result.reason, result.detail);
|
|
275
|
+
}
|
|
276
|
+
if (!result.response.ok) {
|
|
277
|
+
return failAgentDoor(door, TAG, `http_${result.response.status}`, `Tower answered ${result.response.status} for that attachment. Nothing was written to ${outPath}.`);
|
|
278
|
+
}
|
|
279
|
+
const bytes = Buffer.from(await result.response.arrayBuffer());
|
|
280
|
+
try {
|
|
281
|
+
await writeFile(outPath, bytes);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
return failAgentDoor(door, TAG, "write_failed", `Downloaded ${bytes.length} bytes and could not write them to ${outPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
285
|
+
}
|
|
286
|
+
if (door.json)
|
|
287
|
+
return emitAgentDoor(door, { ok: true, path: outPath, bytes: bytes.length });
|
|
288
|
+
writeLine(door.io.stdout, `Wrote ${bytes.length} bytes to ${outPath}.`);
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
async function sync(command, door) {
|
|
292
|
+
const answer = await askAgentDoor(door, {
|
|
293
|
+
path: `/api/mail/accounts/${encodeURIComponent(command.subjectId ?? "")}/sync`,
|
|
294
|
+
method: "POST",
|
|
295
|
+
label: "mail sync",
|
|
296
|
+
timeoutMs: SYNC_DEADLINE_MS,
|
|
297
|
+
});
|
|
298
|
+
if (!answer.ok)
|
|
299
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
300
|
+
const run = answer.body.run;
|
|
301
|
+
if (door.json)
|
|
302
|
+
return emitAgentDoor(door, { ok: true, run });
|
|
303
|
+
if (!run) {
|
|
304
|
+
writeLine(door.io.stdout, "Tower answered without a run. Nothing to report.");
|
|
305
|
+
return 0;
|
|
306
|
+
}
|
|
307
|
+
if (run.reason === "ok") {
|
|
308
|
+
writeLine(door.io.stdout, `Synced: ${run.messagesAdded} new, ${run.messagesUpdated} updated, ${run.messagesDeleted} gone.`);
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
// A failure names itself; the exit code says it failed.
|
|
312
|
+
writeLine(door.io.stderr, `${TAG} sync ${JSON.stringify({ reason: run.reason, detail: run.detail })}`);
|
|
313
|
+
writeLine(door.io.stdout, `That mailbox did not sync: ${run.reason}${run.detail ? ` (${run.detail})` : ""}.`);
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
/** Body on stdin, never argv — `--file` is the safest way on Windows. */
|
|
317
|
+
async function readBody(command, door) {
|
|
318
|
+
if (command.filePath) {
|
|
319
|
+
const read = await readNoteFile(command.filePath);
|
|
320
|
+
if (!read.ok) {
|
|
321
|
+
return { ok: false, reason: read.refusal, detail: `Could not read ${command.filePath}: ${read.detail}` };
|
|
322
|
+
}
|
|
323
|
+
return { ok: true, text: read.bytes.toString("utf8") };
|
|
324
|
+
}
|
|
325
|
+
if (isInteractiveStdin(door.io))
|
|
326
|
+
return { ok: true, text: "" };
|
|
327
|
+
try {
|
|
328
|
+
const text = await readPipedText(door.io.stdin, {
|
|
329
|
+
maxChars: BODY_MAX_CHARS,
|
|
330
|
+
overflowMessage: `A message body is limited to ${BODY_MAX_CHARS} characters here.`,
|
|
331
|
+
});
|
|
332
|
+
return { ok: true, text };
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
return { ok: false, reason: "body_too_long", detail: error instanceof Error ? error.message : String(error) };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.63");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.63",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@bli-cockpit/memory-mcp": "0.1.8",
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.6",
|
|
32
32
|
"@bli-cockpit/telemetry-core": "0.1.29"
|
|
33
33
|
}
|
|
34
34
|
}
|