@bli-cockpit/cli 0.2.258 → 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.
@@ -350,6 +350,7 @@ async function create(command, door) {
350
350
  ...(command.allDay ? { all_day: true } : {}),
351
351
  ...(command.attendees.length > 0 ? { attendees: command.attendees } : {}),
352
352
  ...(command.meet ? { meet: true } : {}),
353
+ ...(command.recurrence?.length ? { recurrence: command.recurrence } : {}),
353
354
  ...(command.recheck ? { recheck_availability: true } : {}),
354
355
  ...detailFields(command),
355
356
  ...(command.idempotencyKey ? { idempotency_key: command.idempotencyKey } : {}),
@@ -78,7 +78,7 @@ const CAL_ALLOWED_FLAGS = [
78
78
  "--scope", "--occurrence", "--response", "--comment", "--reminder", "--visibility", "--transparency", "--key",
79
79
  "--view", "--days", "--week-start", "--hide-weekends", "--density", "--zone",
80
80
  "--with", "--pin", "--remove",
81
- "--duration", "--weekends", "--recheck", ...SHARE_BOOLEANS, ...SHARE_VALUE_FLAGS, ...BOOKING_BOOLEANS, ...BOOKING_VALUE_FLAGS, ...REMIND_BOOLEANS, ...REMIND_VALUE_FLAGS, ...LINKS_BOOLEANS, ...LINKS_VALUE_FLAGS,
81
+ "--duration", "--weekends", "--recheck", "--rrule", ...SHARE_BOOLEANS, ...SHARE_VALUE_FLAGS, ...BOOKING_BOOLEANS, ...BOOKING_VALUE_FLAGS, ...REMIND_BOOLEANS, ...REMIND_VALUE_FLAGS, ...LINKS_BOOLEANS, ...LINKS_VALUE_FLAGS,
82
82
  ];
83
83
  const CAL_VALUE_FLAGS = [
84
84
  "--home",
@@ -104,7 +104,7 @@ const CAL_VALUE_FLAGS = [
104
104
  "--reason",
105
105
  "--scope", "--occurrence", "--response", "--comment", "--reminder", "--visibility", "--transparency", "--key",
106
106
  "--view", "--days", "--week-start", "--hide-weekends", "--density", "--zone",
107
- "--with", "--pin", "--remove", "--duration", ...SHARE_VALUE_FLAGS, ...BOOKING_VALUE_FLAGS, ...REMIND_VALUE_FLAGS, ...LINKS_VALUE_FLAGS,
107
+ "--with", "--pin", "--remove", "--duration", "--rrule", ...SHARE_VALUE_FLAGS, ...BOOKING_VALUE_FLAGS, ...REMIND_VALUE_FLAGS, ...LINKS_VALUE_FLAGS,
108
108
  ];
109
109
  export function parseCalArgs(args) {
110
110
  const values = parseNamedArgs(args, { allowedFlags: CAL_ALLOWED_FLAGS, valueFlags: CAL_VALUE_FLAGS });
@@ -406,8 +406,14 @@ function readPeopleFlags(action, values) {
406
406
  }
407
407
  if (values.booleans.has("--recheck") && action !== "create")
408
408
  throw new Error(`cal ${action} does not take --recheck; it belongs to cal create.`);
409
- if (action === "create" && values.booleans.has("--recheck"))
410
- return { recheck: true };
409
+ if (values.flags.has("--rrule") && action !== "create")
410
+ throw new Error(`cal ${action} does not take --rrule; it belongs to cal create.`);
411
+ if (action === "create") {
412
+ const recurrence = (values.flagValues.get("--rrule") ?? []).map((line) => line.trim()).filter(Boolean);
413
+ if (recurrence.some((line) => !/^(RRULE|EXRULE|RDATE|EXDATE)[:;]/i.test(line)))
414
+ throw new Error("--rrule takes a Google recurrence line such as \"RRULE:FREQ=WEEKLY;COUNT=4\".");
415
+ return { ...(values.booleans.has("--recheck") ? { recheck: true } : {}), ...(recurrence.length ? { recurrence } : {}) };
416
+ }
411
417
  if (action === "suggest")
412
418
  return readSuggestFlags(values);
413
419
  if (action === "availability") {
@@ -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");
@@ -54,6 +54,7 @@ const NOTES_ACTIONS = new Set([
54
54
  "transcription-stats",
55
55
  "bot-upcoming",
56
56
  "record",
57
+ "app",
57
58
  ]);
58
59
  /** Actions whose first positional is the note it acts on. */
59
60
  const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
@@ -143,6 +144,7 @@ export function parseNotesArgs(args) {
143
144
  if (action === "connect")
144
145
  rejectBadConnectRequest(values, provider, workspace, rest);
145
146
  const record = action === "record" ? readRecordRequest(rest) : undefined;
147
+ const app = action === "app" ? readAppRequest(rest) : undefined;
146
148
  const paths = readNotesPaths(action, rest);
147
149
  if (action === "rename" && !optionalNonEmpty(values.flags.get("--name")))
148
150
  throw new Error("notes rename needs --name <name>.");
@@ -166,6 +168,7 @@ export function parseNotesArgs(args) {
166
168
  ...(action === "connect" && values.booleans.has("--key-stdin") ? { keyStdin: true } : {}),
167
169
  ...(paths ? { paths } : {}),
168
170
  ...(record ? record : {}),
171
+ ...(app ? app : {}),
169
172
  filePath: optionalNonEmpty(values.flags.get("--file")),
170
173
  name: optionalNonEmpty(values.flags.get("--name")),
171
174
  exclude: optionalNonEmpty(values.flags.get("--exclude")),
@@ -190,7 +193,7 @@ function readNotesAction(first) {
190
193
  // almost always wants — same reasoning as bare `cockpit` running convergence.
191
194
  const action = (first === undefined ? "list" : first);
192
195
  if (!NOTES_ACTIONS.has(action)) {
193
- throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, summarize, move, place, accounts, connect, detach, sync, sync-status, recordings, recording, webhook, granola-status, or transcription-stats (and record start|stop|status|cancel).`);
196
+ throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, summarize, move, place, accounts, connect, detach, sync, sync-status, recordings, recording, webhook, granola-status, or transcription-stats (and record start|stop|status|cancel, app install|uninstall|status).`);
194
197
  }
195
198
  return action;
196
199
  }
@@ -207,6 +210,16 @@ function readRecordRequest(rest) {
207
210
  throw new Error(`notes record ${verb} does not take "${rest[1]}".`);
208
211
  return { recordAction: verb };
209
212
  }
213
+ const APP_VERBS = ["install", "uninstall", "status"];
214
+ /** BLI-23207: `app <install|uninstall|status>`; bare `app` is the status. */
215
+ function readAppRequest(rest) {
216
+ const verb = (rest[0] ?? "status");
217
+ if (!APP_VERBS.includes(verb))
218
+ throw new Error("notes app needs one of: install, uninstall, status.");
219
+ if (rest.length > 1)
220
+ throw new Error(`notes app ${verb} does not take "${rest[1]}".`);
221
+ return { appAction: verb };
222
+ }
210
223
  function readRepairTruncated(action, values) {
211
224
  const repairTruncated = values.booleans.has("--repair-truncated");
212
225
  if (repairTruncated && action !== "sync") {
@@ -314,6 +327,7 @@ function readNotesPaths(action, rest) {
314
327
  }
315
328
  const takesNoPositional = action !== "paste"
316
329
  && action !== "record"
330
+ && action !== "app"
317
331
  && action !== "connect"
318
332
  && action !== "sync"
319
333
  && action !== "sync-status"
@@ -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.",
@@ -182,7 +182,7 @@ export const TOWER_COMMAND_HELP = [
182
182
  [
183
183
  "cal",
184
184
  [
185
- 'Usage: cockpit cal [event <id>|export <id>|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|view-settings|view-settings-change|people <label>|availability --with <label|email> --from <d> --to <d>|overlays [--pin <label|email>|--remove <email>]|suggest --with <label|email> --from <d> --to <d>|availability-share --range <start>/<end> [--link]|availability-revoke <id>|booking-page [--list|--id <id>]|booking-slots <link>|book <link> --start <iso> --name <n> --email <e>|booking-change <manage link> --start <iso>|booking-cancel <manage link>|next|find "<words>"|calendars|add-ical|create [--recheck]|duplicate <event id>|batch|undo [<change id>]|rsvp <event id> --response <accepted|declined|tentative>|change <event id>|cancel <event id> --yes|share <id>|sync <id>|sync-status [<id>]|detach <id>|delete <event id> --yes|remind|join [--event <id> --occurrence <iso>] [--print]|reminders [--on|--off] [--lead <min>] [--sound on|off]|snooze <event id> --occurrence <iso> --minutes <5|10|15>|dismiss <event id> --occurrence <iso>|links <event id> [--occurrence <iso>] [--add doc:<id>|note:<id> [--series]] [--remove <link id>]|agenda [--from <d> --to <d>]] [flags]',
185
+ 'Usage: cockpit cal [event <id>|export <id>|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|view-settings|view-settings-change|people <label>|availability --with <label|email> --from <d> --to <d>|overlays [--pin <label|email>|--remove <email>]|suggest --with <label|email> --from <d> --to <d>|availability-share --range <start>/<end> [--link]|availability-revoke <id>|booking-page [--list|--id <id>]|booking-slots <link>|book <link> --start <iso> --name <n> --email <e>|booking-change <manage link> --start <iso>|booking-cancel <manage link>|next|find "<words>"|calendars|add-ical|create [--recheck] [--rrule <line>]|duplicate <event id>|batch|undo [<change id>]|rsvp <event id> --response <accepted|declined|tentative>|change <event id>|cancel <event id> --yes|share <id>|sync <id>|sync-status [<id>]|detach <id>|delete <event id> --yes|remind|join [--event <id> --occurrence <iso>] [--print]|reminders [--on|--off] [--lead <min>] [--sound on|off]|snooze <event id> --occurrence <iso> --minutes <5|10|15>|dismiss <event id> --occurrence <iso>|links <event id> [--occurrence <iso>] [--add doc:<id>|note:<id> [--series]] [--remove <link id>]|agenda [--from <d> --to <d>]] [flags]',
186
186
  "",
187
187
  "Every calendar you attached, in one place — plus any calendar a colleague marked shared.",
188
188
  "today [--offset 1] [--tz <zone>] — what is on today, in YOUR zone (this machine's, unless --tz says otherwise). --offset 1 is tomorrow, -1 yesterday.",
@@ -192,7 +192,7 @@ export const TOWER_COMMAND_HELP = [
192
192
  "conflicts [--from <iso> --to <iso>] [--tz <IANA>] — own-account double-bookings with duplicate invitation merging, checked calendars and age. Visible calendars only by default; --all includes hidden calendars. All-day and free events never conflict.",
193
193
  "event <id> [--occurrence-start <iso>] [--tz <zone>] [--json] — exact occurrence details, human recurrence, named guests/RSVP, organizer, source zone, HTTPS Join and edit capability. The selector must match a real occurrence; cancelled/missing instances are refused.",
194
194
  "export <id> [--occurrence-start <iso>] [--json] — iCalendar text to stdout; redirect to event.ics. Recurring exports include the complete series and exceptions, keeping source zone/recurrence. No calendar credentials.",
195
- "calendar-change <id> --color <hex|provider> — persistent owner colour override, or reset to provider colour. Also accepts stdin JSON {color_override, selected}. History is retained. view-settings [--json] reads your saved view settings (default view, N days, week start, weekends, density, up to 3 extra time zones) plus the calendar actions and keyboard shortcuts; never saved means the defaults, said so. view-settings-change [--view day|week|days|month] [--days 2-9] [--week-start monday|sunday] [--hide-weekends on|off] [--density normal|compact] [--zone <IANA>]... changes only what you name; --zone repeats (up to 3), --zone none clears; stdin JSON like {\"day_count\":4} works too. people <label> [--json] — exact team search: a display name or alias (any case) or an email; an address nobody on the team owns comes back as typed. No fuzzy, prefix or nickname match; an ambiguous label lists every match. availability --with <label|email> [--with ...] --from <d> --to <d> [--tz <IANA>] [--account <id>] [--json] — when people are BUSY, start and end only, asked of Google with your own grant (up to 100 people, 31 days). A person Google will not answer for is unknown, never free. Stdin JSON {with|emails, from, to, tz, account_id} works too. overlays — the people pinned on your calendar (at most 10). overlays --pin <label|email> pins one, overlays --remove <email> drops one; history is kept. The web calendar shows pinned people's busy time. suggest --with <label|email> [--with ...] --from <d> --to <d> [--duration 30] [--hours 09:00-17:00] [--weekends] [--tz <IANA>] [--account <id>] [--limit <n>] [--json] — ranked common free slots for several people (up to 10 shown, 50 max; 15 to 480 minutes): everyone free first, then outside someone's local hours, then slots with an unknown person (never shown as free); Tower defaults (30 minutes, 09:00 to 17:00 your zone, no weekends) are named when used; stdin JSON works. create --recheck re-reads everyone's busy time just before writing and refuses with the reason (slot_taken, slot_unconfirmed) instead of double-booking. availability-share --range <start>/<end> [--range ...] [--duration 30] [--tz <IANA>] [--text|--link] [--expires <iso>] [--location <where>] [--meet] [--single-use] [--allow-conflicts] [--label <s>] [--json] offers your free time inside the ranges (a time without an offset is read in --tz; your own busy time is skipped unless --allow-conflicts): --text (default) prints words to paste, stores nothing, names the zone and the time it was made; --link stores a revocable link (14 days by default, 90 at most) whose times are rechecked on every open and prints the URL ONCE; a repeat of the same --key gives no second link. --list shows your links; --id <id> with --expires or --label changes one; availability-revoke <id> turns one off. Stdin JSON works. If your own calendars could not be fully read it says so. booking-page --title <t> --weekly mon/09:00-12:00 [--weekly ...] [--duration 30] [--buffer-before 0] [--buffer-after 0] [--notice 240] [--window 14] [--meet] [--location <s>] [--single-use] [--tz <IANA>] makes a public booking page on your own Google calendar (30 minutes, no buffers, 4 hours notice, 14 days by default; up to 21 weekly ranges) and prints its link ONCE; --list shows pages and upcoming bookings; --id <id> with --rotate, --close|--open or any field above changes one; stdin JSON works. A guest (no Tower login needed) runs booking-slots <link>, book <link> --start <slot start> --name <n> --email <e> [--note <s>] (3 upcoming bookings per email per page), booking-change <manage link> --start <iso> (the old manage link stops working; the new one shows once) and booking-cancel <manage link>; the manage link expires when the meeting starts. Laptop reminders (slice 11): reminders shows or sets your switch (--on|--off, --lead 0 to 60 minutes, default 10, --sound on|off); they are OFF until you turn them on. Once on, a scheduled `cockpit cal remind` tick (every minute, installed with the sync agent) shows one TOWER banner per meeting per lead time across all your laptops, never one for a meeting you declined or that was cancelled, and records a banner more than 5 minutes late as missed. join opens the link of the meeting running now or starting within 15 minutes (or --event <id> --occurrence <iso>); --print only prints it; a missing or non-https link is refused by name. snooze <event id> --occurrence <iso> --minutes 5|10|15 and dismiss <event id> --occurrence <iso> change one nudge. next leaves out meetings you declined (the door takes ?declined=1 to keep them). Event links (slice 12): links <event id> [--occurrence <iso>] lists YOUR docs and meeting notes linked to that occurrence plus its series, each read again for you (a doc or note you can no longer open prints as unavailable, no title), a note showing its status and recording, alternate and missed counts, and suggests notes the bot recorded for exactly this event (never by title); --add doc:<id>|note:<id> --occurrence <iso> (or --series for every occurrence) attaches one you can read now (20 per event; a repeat is already_linked); --remove <link id> removes it and the history keeps it. A link is private to you and gives attendees no access. agenda [--from <d> --to <d>] [--tz <IANA>] [--json] lists events plus tickets due in the window as DUE lines (all-day markers, never busy time, never sent to Google), with how many rows were read.",
195
+ "calendar-change <id> --color <hex|provider> — persistent owner colour override, or reset to provider colour. Also accepts stdin JSON {color_override, selected}. History is retained. view-settings [--json] reads your saved view settings (default view, N days, week start, weekends, density, up to 3 extra time zones) plus the calendar actions and keyboard shortcuts (d or 1 day, w or 0 week, m month, 2 to 9 that many days, t today, j and k or the left and right arrows move, c a new event draft, / search, ` the sidebar, \\ the side panel, ? the list, Esc closes one layer); never saved means the defaults, said so. view-settings-change [--view day|week|days|month] [--days 2-9] [--week-start monday|sunday] [--hide-weekends on|off] [--density normal|compact] [--zone <IANA>]... changes only what you name; --zone repeats (up to 3), --zone none clears; stdin JSON like {\"day_count\":4} works too. people <label> [--json] — exact team search: a display name or alias (any case) or an email; an address nobody on the team owns comes back as typed. No fuzzy, prefix or nickname match; an ambiguous label lists every match. availability --with <label|email> [--with ...] --from <d> --to <d> [--tz <IANA>] [--account <id>] [--json] — when people are BUSY, start and end only, asked of Google with your own grant (up to 100 people, 31 days). A person Google will not answer for is unknown, never free. Stdin JSON {with|emails, from, to, tz, account_id} works too. overlays — the people pinned on your calendar (at most 10). overlays --pin <label|email> pins one, overlays --remove <email> drops one; history is kept. The web calendar shows pinned people's busy time. suggest --with <label|email> [--with ...] --from <d> --to <d> [--duration 30] [--hours 09:00-17:00] [--weekends] [--tz <IANA>] [--account <id>] [--limit <n>] [--json] — ranked common free slots for several people (up to 10 shown, 50 max; 15 to 480 minutes): everyone free first, then outside someone's local hours, then slots with an unknown person (never shown as free); Tower defaults (30 minutes, 09:00 to 17:00 your zone, no weekends) are named when used; stdin JSON works. create --recheck re-reads everyone's busy time just before writing and refuses with the reason (slot_taken, slot_unconfirmed) instead of double-booking. create --rrule \"RRULE:FREQ=WEEKLY;COUNT=4\" makes the event repeat (a Google recurrence line; repeatable for EXDATE or RDATE lines). availability-share --range <start>/<end> [--range ...] [--duration 30] [--tz <IANA>] [--text|--link] [--expires <iso>] [--location <where>] [--meet] [--single-use] [--allow-conflicts] [--label <s>] [--json] offers your free time inside the ranges (a time without an offset is read in --tz; your own busy time is skipped unless --allow-conflicts): --text (default) prints words to paste, stores nothing, names the zone and the time it was made; --link stores a revocable link (14 days by default, 90 at most) whose times are rechecked on every open and prints the URL ONCE; a repeat of the same --key gives no second link. --list shows your links; --id <id> with --expires or --label changes one; availability-revoke <id> turns one off. Stdin JSON works. If your own calendars could not be fully read it says so. booking-page --title <t> --weekly mon/09:00-12:00 [--weekly ...] [--duration 30] [--buffer-before 0] [--buffer-after 0] [--notice 240] [--window 14] [--meet] [--location <s>] [--single-use] [--tz <IANA>] makes a public booking page on your own Google calendar (30 minutes, no buffers, 4 hours notice, 14 days by default; up to 21 weekly ranges) and prints its link ONCE; --list shows pages and upcoming bookings; --id <id> with --rotate, --close|--open or any field above changes one; stdin JSON works. A guest (no Tower login needed) runs booking-slots <link>, book <link> --start <slot start> --name <n> --email <e> [--note <s>] (3 upcoming bookings per email per page), booking-change <manage link> --start <iso> (the old manage link stops working; the new one shows once) and booking-cancel <manage link>; the manage link expires when the meeting starts. Laptop reminders (slice 11): reminders shows or sets your switch (--on|--off, --lead 0 to 60 minutes, default 10, --sound on|off); they are OFF until you turn them on. Once on, a scheduled `cockpit cal remind` tick (every minute, installed with the sync agent) shows one TOWER banner per meeting per lead time across all your laptops, never one for a meeting you declined or that was cancelled, and records a banner more than 5 minutes late as missed. join opens the link of the meeting running now or starting within 15 minutes (or --event <id> --occurrence <iso>); --print only prints it; a missing or non-https link is refused by name. snooze <event id> --occurrence <iso> --minutes 5|10|15 and dismiss <event id> --occurrence <iso> change one nudge. next leaves out meetings you declined (the door takes ?declined=1 to keep them). Event links (slice 12): links <event id> [--occurrence <iso>] lists YOUR docs and meeting notes linked to that occurrence plus its series, each read again for you (a doc or note you can no longer open prints as unavailable, no title), a note showing its status and recording, alternate and missed counts, and suggests notes the bot recorded for exactly this event (never by title); --add doc:<id>|note:<id> --occurrence <iso> (or --series for every occurrence) attaches one you can read now (20 per event; a repeat is already_linked); --remove <link id> removes it and the history keeps it. A link is private to you and gives attendees no access. agenda [--from <d> --to <d>] [--tz <IANA>] [--json] lists events plus tickets due in the window as DUE lines (all-day markers, never busy time, never sent to Google), with how many rows were read.",
196
196
  "calendars — every calendar, its provider (google_oauth or ical_url), whether it is shared, and when it last synced. The ids other verbs take are on the last line.",
197
197
  "add-ical [--name \"<what to call it>\"] — attaches a personal calendar by its SECRET iCal ADDRESS, read from stdin. There is no flag for it:",
198
198
  ' macOS: printf "%s" "https://calendar.google.com/calendar/ical/…/basic.ics" | cockpit cal add-ical --name "Personal"',
@@ -3,10 +3,11 @@ export const NOTES_HELP = [
3
3
  [
4
4
  "notes",
5
5
  [
6
- "Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|summarize <id>|move <id>|accounts|connect|detach <id>|sync <id>|sync-status [<id>]|recordings <note-id>|recording <id>|record start|stop|status|cancel] [flags]",
6
+ "Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|summarize <id>|move <id>|accounts|connect|detach <id>|sync <id>|sync-status [<id>]|recordings <note-id>|recording <id>|record start|stop|status|cancel|app install|uninstall|status] [flags]",
7
7
  "Audio uploads: MP3, M4A, WAV, WebM and OGG, up to 1 GB. Over 4 MB (a whole meeting) the file goes straight to storage and is transcribed in the background; --wait then polls for up to 4 hours and prints progress, otherwise for at most 330 seconds.",
8
8
  "",
9
9
  "record start|stop [<session-id>]|status|cancel — record a meeting in a room from this Mac's microphone (Apple Silicon macOS; Windows says it is not supported yet). Nothing leaves the machine while recording and there is no auto-stop. `stop` trims the silence away locally, uploads only the speech, and Tower writes a note with Speaker 1, Speaker 2... lines (add --wait to watch the transcription). `cancel` stops and KEEPS the file; `status` lists what is recording or waiting; `stop <session-id>` finishes a cancelled or crashed session. The original recording stays on this machine.",
10
+ "app install|uninstall|status [--json] — the BLI Notetaker menu-bar app (Apple Silicon macOS; Windows and Linux say they are not supported yet). `install` asks Tower for the newest release, downloads it, refuses it if its sha256 differs, puts it in ~/Applications/BLI Notetaker.app (replacing an older one), clears the download quarantine and starts it at login with a LaunchAgent. The menu runs the `record` verbs above: Record, Stop, Open last note, Finish last recording. Log: ~/Library/Logs/BLI Notetaker.log. `uninstall` removes the app and the LaunchAgent and keeps your recordings; `status` says what is installed, whether it runs, and whether a newer release exists.",
10
11
  "folders [--tree] [--json]: list folders and visible note counts.",
11
12
  "mkdir <path> [--json]: create a folder and any missing ancestors.",
12
13
  "rename <path> --name <name> [--json]: rename a folder and its descendant paths.",
@@ -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 ?? "?"}).`);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * BLI-23207: where the BLI Notetaker menu-bar app lives on a Mac, and the
3
+ * LaunchAgent that starts it at login. Sibling of `notes-app.ts`, which installs it.
4
+ *
5
+ * The LaunchAgent runs the bundle's executable directly (RunAtLoad, KeepAlive
6
+ * false: Quit means quit) and routes the app's stderr, where its
7
+ * `[notetaker-app]` log lines go, to `~/Library/Logs/BLI Notetaker.log`. Two
8
+ * environment values ride along: the paired Tower address, so "Open last
9
+ * note" opens the right dashboard without the app ever reading the CLI's
10
+ * session file (it holds the device token), and the person's login shell, which
11
+ * the app asks once for `node` and `cockpit` (launchd's PATH has neither).
12
+ */
13
+ import path from "node:path";
14
+ import { getCollectorRuntimePaths } from "../local-state-paths.js";
15
+ import { xmlEscape } from "../autostart-xml.js";
16
+ export const NOTETAKER_APP_LABEL = "ca.buildlaunchiterate.notetaker";
17
+ export const NOTETAKER_APP_NAME = "BLI Notetaker.app";
18
+ export const NOTETAKER_APP_EXECUTABLE = "bli-notetaker";
19
+ export function notetakerAppPaths(homeDir) {
20
+ const applications = path.join(homeDir, "Applications");
21
+ const app = path.join(applications, NOTETAKER_APP_NAME);
22
+ return {
23
+ app,
24
+ executable: path.join(app, "Contents", "MacOS", NOTETAKER_APP_EXECUTABLE),
25
+ applications,
26
+ plist: path.join(homeDir, "Library", "LaunchAgents", `${NOTETAKER_APP_LABEL}.plist`),
27
+ log: path.join(homeDir, "Library", "Logs", "BLI Notetaker.log"),
28
+ receipt: path.join(getCollectorRuntimePaths(homeDir).state_dir, "notetaker-app.json"),
29
+ };
30
+ }
31
+ export function renderNotetakerAppPlist(input) {
32
+ const env = [];
33
+ if (input.towerUrl)
34
+ env.push(["BLI_TOWER_URL", input.towerUrl]);
35
+ if (input.shell && input.shell.startsWith("/"))
36
+ env.push(["SHELL", input.shell]);
37
+ const s = (value) => `<string>${xmlEscape(value)}</string>`;
38
+ return [
39
+ '<?xml version="1.0" encoding="UTF-8"?>',
40
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
41
+ '<plist version="1.0">',
42
+ "<dict>",
43
+ ` <key>Label</key>${s(NOTETAKER_APP_LABEL)}`,
44
+ ` <key>ProgramArguments</key><array>${s(input.paths.executable)}</array>`,
45
+ " <key>RunAtLoad</key><true/>",
46
+ " <key>KeepAlive</key><false/>",
47
+ ` <key>ProcessType</key>${s("Interactive")}`,
48
+ ` <key>LimitLoadToSessionType</key>${s("Aqua")}`,
49
+ ` <key>StandardOutPath</key>${s(input.paths.log)}`,
50
+ ` <key>StandardErrorPath</key>${s(input.paths.log)}`,
51
+ ...(env.length
52
+ ? [" <key>EnvironmentVariables</key>", " <dict>", ...env.map(([k, v]) => ` <key>${xmlEscape(k)}</key>${s(v)}`), " </dict>"]
53
+ : []),
54
+ "</dict>",
55
+ "</plist>",
56
+ "",
57
+ ].join("\n");
58
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * `cockpit notes app install | uninstall | status` (BLI-23207): put the BLI
3
+ * Notetaker menu-bar app on this Mac, take it off, or say what is there.
4
+ *
5
+ * install asks Tower for the newest `notetaker-app-v*` release
6
+ * (`GET /api/notes/app/release`; the repository is private, so
7
+ * Tower reads it with its own GitHub token and hands back the
8
+ * sha256 and a short-lived signed download link), downloads the
9
+ * zip, REFUSES it if the hash differs, unpacks it to
10
+ * `~/Applications/BLI Notetaker.app` (replacing an older one),
11
+ * clears the download quarantine, writes the LaunchAgent and
12
+ * starts it.
13
+ * uninstall stops it and removes the app, the LaunchAgent and the receipt.
14
+ * Recordings and the log stay.
15
+ * status what is installed, whether it is running, and whether a newer
16
+ * release exists.
17
+ *
18
+ * Apple Silicon macOS only; Windows and Linux are refused by name. The app is
19
+ * a shell over `cockpit notes record`; it captures nothing itself. Its layout
20
+ * and LaunchAgent are `notes-app-plist.ts`.
21
+ */
22
+ import { createHash, randomBytes } from "node:crypto";
23
+ import { createWriteStream } from "node:fs";
24
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
25
+ import path from "node:path";
26
+ import { Readable, Transform } from "node:stream";
27
+ import { pipeline } from "node:stream/promises";
28
+ import { launchdUserDomain, reregisterDarwinAgent } from "../autostart-darwin.js";
29
+ import { writeLine } from "./cli-io.js";
30
+ import { ask } from "./notes-door.js";
31
+ import { NOTETAKER_APP_EXECUTABLE, NOTETAKER_APP_LABEL, NOTETAKER_APP_NAME, notetakerAppPaths, renderNotetakerAppPlist, } from "./notes-app-plist.js";
32
+ const TAG = "[notes app]";
33
+ const RELEASE_DEADLINE_MS = 30_000;
34
+ const DOWNLOAD_DEADLINE_MS = 10 * 60_000;
35
+ function say(io, command, human, json) {
36
+ writeLine(io.stdout, command.json ? JSON.stringify({ ok: true, ...json }) : human);
37
+ return 0;
38
+ }
39
+ function refuse(io, command, reason, sentence, fields = {}) {
40
+ writeLine(io.stderr, `${TAG} refused ${JSON.stringify({ reason, ...fields })}`);
41
+ if (command.json)
42
+ writeLine(io.stdout, JSON.stringify({ ok: false, reason, detail: sentence }));
43
+ else
44
+ writeLine(io.stderr, sentence);
45
+ return 1;
46
+ }
47
+ const exists = async (file) => stat(file).then(() => true, () => false);
48
+ async function readReceipt(file) {
49
+ try {
50
+ const parsed = JSON.parse(await readFile(file, "utf8"));
51
+ return typeof parsed.tag === "string" && typeof parsed.version === "string" ? parsed : null;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ export async function runNotesApp(command, io, deps) {
58
+ const verb = command.appAction ?? "status";
59
+ if (deps.platform !== "darwin") {
60
+ const where = deps.platform === "win32" ? "Windows" : deps.platform === "linux" ? "Linux" : deps.platform;
61
+ return refuse(io, command, "platform_not_supported", `The BLI Notetaker app is not available on ${where} yet. It runs in the menu bar on Apple Silicon Macs; Windows is a follow-up. \`cockpit notes record\` is the terminal way to record.`, { platform: deps.platform });
62
+ }
63
+ const paths = notetakerAppPaths(deps.homeDir);
64
+ if (verb === "uninstall")
65
+ return await uninstall(command, io, deps, paths);
66
+ if (verb === "install")
67
+ return await install(command, io, deps, paths);
68
+ return await status(command, io, deps, paths);
69
+ }
70
+ /** Ask the paired Tower for the newest release; a refusal comes back as reason + sentence. */
71
+ async function newestRelease(deps) {
72
+ const door = await deps.door();
73
+ if (!("deviceToken" in door))
74
+ return { ok: false, reason: door.reason, detail: door.detail };
75
+ const answer = await ask(door, { path: "/api/notes/app/release", method: "GET", label: "notes app release", timeoutMs: RELEASE_DEADLINE_MS });
76
+ if (!answer.ok)
77
+ return answer;
78
+ const body = answer.body;
79
+ const sha = typeof body.sha256 === "string" ? body.sha256.toLowerCase() : "";
80
+ if (!body.tag || !body.version || !/^[0-9a-f]{64}$/.test(sha) || !body.zip?.url?.startsWith("https://")) {
81
+ return { ok: false, reason: "release_answer_unreadable", detail: "Tower answered without a release this command can install." };
82
+ }
83
+ return { ok: true, release: { tag: body.tag, version: body.version, sha256: sha, zip: body.zip }, door };
84
+ }
85
+ /** Stream `url` to `dest`, hashing as it goes. No credential is sent: the link is GitHub's own signed one. */
86
+ export async function downloadToFile(fetchImpl, url, dest) {
87
+ let response;
88
+ try {
89
+ response = await fetchImpl(url, { signal: AbortSignal.timeout(DOWNLOAD_DEADLINE_MS) });
90
+ }
91
+ catch {
92
+ return { ok: false, reason: "download_unreachable" };
93
+ }
94
+ if (!response.ok || !response.body)
95
+ return { ok: false, reason: `download_http_${response.status}` };
96
+ const hash = createHash("sha256");
97
+ let bytes = 0;
98
+ const meter = new Transform({
99
+ transform(chunk, _encoding, done) {
100
+ hash.update(chunk);
101
+ bytes += chunk.length;
102
+ done(null, chunk);
103
+ },
104
+ });
105
+ try {
106
+ await pipeline(Readable.fromWeb(response.body), meter, createWriteStream(dest, { mode: 0o600 }));
107
+ }
108
+ catch {
109
+ return { ok: false, reason: "download_interrupted" };
110
+ }
111
+ return { ok: true, bytes, sha256: hash.digest("hex") };
112
+ }
113
+ async function appleSilicon(deps) {
114
+ const answer = await deps.exec("sysctl", ["-n", "hw.optional.arm64"]).catch(() => ({ code: 1, stdout: "", stderr: "" }));
115
+ return answer.code === 0 && answer.stdout.trim() === "1";
116
+ }
117
+ async function install(command, io, deps, paths) {
118
+ if (!(await appleSilicon(deps))) {
119
+ return refuse(io, command, "apple_silicon_only", "The BLI Notetaker app is built for Apple Silicon Macs (M1 and later); this Mac is not one. `cockpit notes record` works here from a terminal.");
120
+ }
121
+ const found = await newestRelease(deps);
122
+ if (!found.ok)
123
+ return refuse(io, command, found.reason, found.detail);
124
+ const { release, door } = found;
125
+ const previous = await readReceipt(paths.receipt);
126
+ writeLine(io.stderr, `${TAG} release ${JSON.stringify({ tag: release.tag, zip_bytes: release.zip.bytes, previous: previous?.tag ?? null })}`);
127
+ if (!command.json)
128
+ writeLine(io.stderr, `Downloading BLI Notetaker ${release.version} (${(release.zip.bytes / 1_000_000).toFixed(1)} MB)...`);
129
+ await mkdir(paths.applications, { recursive: true });
130
+ const staging = path.join(paths.applications, `.bli-notetaker-install-${randomBytes(4).toString("hex")}`);
131
+ await mkdir(staging, { recursive: true, mode: 0o700 });
132
+ try {
133
+ const zip = path.join(staging, "app.zip");
134
+ const downloaded = await downloadToFile(io.fetch, release.zip.url, zip);
135
+ if (!downloaded.ok) {
136
+ return refuse(io, command, downloaded.reason, `The download did not finish (${downloaded.reason}). Nothing was installed; try again.`);
137
+ }
138
+ if (downloaded.sha256 !== release.sha256) {
139
+ return refuse(io, command, "sha256_mismatch", "The downloaded app does not match the checksum its release publishes, so it was not installed. Nothing on this Mac changed.", { tag: release.tag, bytes: downloaded.bytes, expected_prefix: release.sha256.slice(0, 12), got_prefix: downloaded.sha256.slice(0, 12) });
140
+ }
141
+ writeLine(io.stderr, `${TAG} downloaded ${JSON.stringify({ tag: release.tag, bytes: downloaded.bytes, sha256_prefix: downloaded.sha256.slice(0, 12), reason: "sha256_match" })}`);
142
+ const unpacked = path.join(staging, "unpacked");
143
+ const ditto = await deps.exec("ditto", ["-x", "-k", zip, unpacked]).catch(() => ({ code: 1, stdout: "", stderr: "" }));
144
+ const stagedApp = path.join(unpacked, NOTETAKER_APP_NAME);
145
+ if (ditto.code !== 0 || !(await exists(path.join(stagedApp, "Contents", "MacOS", NOTETAKER_APP_EXECUTABLE)))) {
146
+ return refuse(io, command, "bundle_unreadable", "The downloaded zip did not hold a BLI Notetaker app. Nothing was installed.", { ditto_exit: ditto.code });
147
+ }
148
+ // Stop the running copy first. A recording in progress keeps going: the recorder is its own process, and the new app adopts it.
149
+ const stopped = await deps.exec("launchctl", ["bootout", `${launchdUserDomain(deps.uid)}/${NOTETAKER_APP_LABEL}`]).catch(() => ({ code: 1, stdout: "", stderr: "" }));
150
+ const replaced = await exists(paths.app);
151
+ if (replaced)
152
+ await rm(paths.app, { recursive: true, force: true });
153
+ await rename(stagedApp, paths.app);
154
+ const xattr = await deps.exec("xattr", ["-dr", "com.apple.quarantine", paths.app]).catch(() => ({ code: 1, stdout: "", stderr: "" }));
155
+ writeLine(io.stderr, `${TAG} unpacked ${JSON.stringify({ replaced, previous: previous?.tag ?? null, bootout_exit: stopped.code, xattr_exit: xattr.code })}`);
156
+ await mkdir(path.dirname(paths.plist), { recursive: true });
157
+ await mkdir(path.dirname(paths.log), { recursive: true });
158
+ await writeFile(paths.plist, renderNotetakerAppPlist({ paths, towerUrl: door.dashboardUrl, shell: deps.shell }), "utf8");
159
+ const receipt = { tag: release.tag, version: release.version, sha256: release.sha256, installed_at: deps.now().toISOString() };
160
+ await mkdir(path.dirname(paths.receipt), { recursive: true });
161
+ await writeFile(paths.receipt, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
162
+ const started = await reregisterDarwinAgent({ exec: deps.exec, plistPath: paths.plist, uid: deps.uid, label: NOTETAKER_APP_LABEL });
163
+ if (!started.ok) {
164
+ return refuse(io, command, "launchagent_bootstrap_failed", `BLI Notetaker ${release.version} is installed at ${paths.app}, but launchd would not start it (${started.detail ?? started.reason}). Open it from ~/Applications, or run \`cockpit notes app install\` again.`, { tag: release.tag });
165
+ }
166
+ writeLine(io.stderr, `${TAG} installed ${JSON.stringify({ tag: release.tag, replaced, reason: started.reason })}`);
167
+ return say(io, command, [
168
+ `Installed BLI Notetaker ${release.version}${replaced ? ` (replaced ${previous?.version ?? "an older copy"})` : ""} and started it.`,
169
+ "Look for the ring in the menu bar: Record, Stop, Open last note. It starts again at login.",
170
+ `App: ${paths.app}`,
171
+ `Log: ${paths.log}`,
172
+ "The app is ad-hoc signed, not notarized; the installer cleared the download quarantine so macOS opens it. The first Record asks for the microphone.",
173
+ ].join("\n"), { installed: true, tag: release.tag, version: release.version, replaced, previous: previous?.tag ?? null, app: paths.app, launch_agent: paths.plist, log: paths.log });
174
+ }
175
+ finally {
176
+ await rm(staging, { recursive: true, force: true }).catch(() => undefined);
177
+ }
178
+ }
179
+ async function uninstall(command, io, deps, paths) {
180
+ const stopped = await deps.exec("launchctl", ["bootout", `${launchdUserDomain(deps.uid)}/${NOTETAKER_APP_LABEL}`]).catch(() => ({ code: 1, stdout: "", stderr: "" }));
181
+ const had = { app: await exists(paths.app), launch_agent: await exists(paths.plist), receipt: await exists(paths.receipt) };
182
+ await rm(paths.plist, { force: true });
183
+ await rm(paths.app, { recursive: true, force: true });
184
+ await rm(paths.receipt, { force: true });
185
+ writeLine(io.stderr, `${TAG} uninstalled ${JSON.stringify({ ...had, bootout_exit: stopped.code })}`);
186
+ if (!had.app && !had.launch_agent) {
187
+ return say(io, command, "BLI Notetaker was not installed. Nothing to remove.", { removed: false });
188
+ }
189
+ return say(io, command, [
190
+ "Removed BLI Notetaker: the app, its LaunchAgent and its receipt.",
191
+ `Your recordings stay on this Mac (\`cockpit notes record status\` lists any not yet uploaded), and so does the log: ${paths.log}`,
192
+ ].join("\n"), { removed: true, ...had });
193
+ }
194
+ async function status(command, io, deps, paths) {
195
+ const receipt = await readReceipt(paths.receipt);
196
+ const installed = await exists(paths.executable);
197
+ const plist = await exists(paths.plist);
198
+ const printed = await deps.exec("launchctl", ["print", `${launchdUserDomain(deps.uid)}/${NOTETAKER_APP_LABEL}`]).catch(() => ({ code: 1, stdout: "", stderr: "" }));
199
+ const loaded = printed.code === 0;
200
+ const running = loaded && /\bstate = running\b/.test(printed.stdout);
201
+ const pid = Number(/\bpid = (\d+)/.exec(printed.stdout)?.[1] ?? NaN);
202
+ // The newest release is best effort: a status line without it is still an answer.
203
+ const newest = await newestRelease(deps);
204
+ const newestTag = newest.ok ? newest.release.tag : null;
205
+ const update = Boolean(installed && newestTag && receipt?.tag !== newestTag);
206
+ writeLine(io.stderr, `${TAG} status ${JSON.stringify({ installed, tag: receipt?.tag ?? null, plist, loaded, running, newest: newestTag, newest_reason: newest.ok ? null : newest.reason })}`);
207
+ const json = {
208
+ installed, tag: receipt?.tag ?? null, version: receipt?.version ?? null, installed_at: receipt?.installed_at ?? null,
209
+ app: paths.app, launch_agent: { path: paths.plist, present: plist, loaded, running, pid: Number.isFinite(pid) ? pid : null },
210
+ log: paths.log, newest: newest.ok ? { tag: newest.release.tag, version: newest.release.version } : { reason: newest.reason },
211
+ update_available: update,
212
+ };
213
+ const lines = installed
214
+ ? [
215
+ `BLI Notetaker ${receipt?.version ?? "(version unknown)"} is installed: ${paths.app}`,
216
+ running ? `Running (pid ${pid}).` : loaded ? "Registered with launchd but not running; it starts at login, or run `cockpit notes app install` to start it now." : plist ? "Its LaunchAgent is on disk but not loaded; run `cockpit notes app install` to start it." : "No LaunchAgent; run `cockpit notes app install` to start it at login.",
217
+ newest.ok ? (update ? `A newer release exists: ${newest.release.version}. Run \`cockpit notes app install\` to update.` : "It is the newest release.") : `Could not check for a newer release (${newest.reason}).`,
218
+ `Log: ${paths.log}`,
219
+ ]
220
+ : [
221
+ "BLI Notetaker is not installed. Run `cockpit notes app install`.",
222
+ newest.ok ? `Newest release: ${newest.release.version}.` : `Could not check the newest release (${newest.reason}).`,
223
+ ];
224
+ return say(io, command, lines.join("\n"), json);
225
+ }
@@ -36,11 +36,14 @@
36
36
  * - `notes-granola-status.ts` — ticket 10718: the Granola CRON's own health
37
37
  * (it runs on a shared server-side key, not a `notes_accounts` row), read
38
38
  * off its `granola_sync_runs` ledger.
39
+ * - `notes-app.ts` — BLI-23207: `app install|uninstall|status`, the BLI
40
+ * Notetaker menu-bar app on this Mac (its LaunchAgent: `notes-app-plist.ts`).
39
41
  * - `notes-transcription-stats.ts` — BLI-18003: what meeting transcription
40
42
  * cost and did per provider for a month, and the spend against the cap.
41
43
  *
42
44
  * Every public name is still importable from this file.
43
45
  */
46
+ import os from "node:os";
44
47
  import { loadPairedSession } from "../tower-client.js";
45
48
  import { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
46
49
  import { uploadNotes, pasteNote, shareNote, summarizeNote, moveNote, placeNote } from "./notes-writes.js";
@@ -51,6 +54,8 @@ import { showGranolaStatus } from "./notes-granola-status.js";
51
54
  import { showTranscriptionStats } from "./notes-transcription-stats.js";
52
55
  import { listRecordings, showRecording } from "./notes-recordings.js";
53
56
  import { defaultRecordDeps, runNotesRecord } from "./notes-record.js";
57
+ import { runNotesApp } from "./notes-app.js";
58
+ import { defaultExec, errorMessage } from "./cli-io.js";
54
59
  export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
55
60
  export { uploadNotes, pasteNote, shareNote, summarizeNote, moveNote, placeNote } from "./notes-writes.js";
56
61
  export { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, syncStatusNotetakers, } from "./notes-accounts.js";
@@ -100,6 +105,26 @@ export async function runNotes(command, io) {
100
105
  });
101
106
  }));
102
107
  }
108
+ // BLI-23207: the menu-bar app is installed on this Mac; only install and status ask Tower (for the newest release).
109
+ if (command.action === "app") {
110
+ return runNotesApp(command, io, {
111
+ platform: process.platform,
112
+ homeDir: command.homeDir ?? os.homedir(),
113
+ uid: process.getuid?.() ?? 0,
114
+ exec: io.exec ?? defaultExec(),
115
+ shell: io.env.SHELL,
116
+ now: () => new Date(),
117
+ door: async () => {
118
+ try {
119
+ const session = await loadPairedSession("notes app", command.homeDir);
120
+ return { dashboardUrl: command.dashboardUrl ?? session.dashboard_url, deviceToken: session.device_token, io, json: command.json };
121
+ }
122
+ catch (error) {
123
+ return { reason: "not_paired", detail: errorMessage(error) };
124
+ }
125
+ },
126
+ });
127
+ }
103
128
  const session = await loadPairedSession("notes", command.homeDir);
104
129
  const door = {
105
130
  dashboardUrl: command.dashboardUrl ?? session.dashboard_url,
@@ -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.258");
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.258",
3
+ "version": "0.2.260",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,9 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.76",
31
- "@bli-cockpit/mcp": "0.1.158",
32
- "@bli-cockpit/telemetry-core": "0.1.83"
30
+ "@bli-cockpit/memory-mcp": "0.1.77",
31
+ "@bli-cockpit/mcp": "0.1.159",
32
+ "@bli-cockpit/telemetry-core": "0.1.84"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",