@bli-cockpit/cli 0.2.257 → 0.2.259

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 } : {}),
@@ -375,6 +375,18 @@ function parseTeamDeviceArgs(rest, flags, common) {
375
375
  }
376
376
  return { kind: "team", action: "device-scope", deviceRef, deviceScope, ...common };
377
377
  }
378
+ if (deviceVerb === "mark-service" || deviceVerb === "unmark-service") {
379
+ const reasonLabel = optionalNonEmpty(flags.get("--reason"));
380
+ if (!deviceRef || extra.length > 0)
381
+ throw new Error(`team device ${deviceVerb} takes one <id|name>${deviceVerb === "mark-service" ? " and --reason <label>" : ""}.`);
382
+ if (deviceVerb === "mark-service") {
383
+ if (!reasonLabel || !/^[a-z0-9][a-z0-9_-]{1,39}$/.test(reasonLabel)) {
384
+ throw new Error("team device mark-service needs --reason <label> (2-40 chars: a-z, 0-9, _ or -), e.g. --reason boat-agent.");
385
+ }
386
+ return { kind: "team", action: "device-mark-service", deviceRef, reasonLabel, ...common };
387
+ }
388
+ return { kind: "team", action: "device-unmark-service", deviceRef, ...common };
389
+ }
378
390
  if (extra.length > 0)
379
391
  throw new Error("team device does not accept that many arguments.");
380
392
  if (!deviceVerb || deviceVerb === "list") {
@@ -406,7 +418,7 @@ function parseTeamDeviceArgs(rest, flags, common) {
406
418
  ...common,
407
419
  };
408
420
  }
409
- throw new Error("team device takes one verb: list, doctor <id|name>, scope <id|name> notetaker_bot|none, or revoke <id|name>.");
421
+ throw new Error("team device takes one verb: list, doctor <id|name>, scope <id|name> notetaker_bot|none, mark-service <id|name> --reason <label>, unmark-service <id|name>, or revoke <id|name>.");
410
422
  }
411
423
  /** `cockpit model [show|set <key>]` — the alias surface for the personal chat model. */
412
424
  export function parseModelArgs(args) {
@@ -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") {
@@ -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"
@@ -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"',
@@ -211,6 +211,11 @@ const COMMAND_HELP = new Map([
211
211
  " cockpit team device scope <id|name> notetaker_bot|none [--json]",
212
212
  " Super admin only. Gives the meeting bot's device the scope Tower's claim door requires,",
213
213
  " or takes it away. Without it the bot is refused every meeting (bot_scope_required).",
214
+ " cockpit team device mark-service <id|name> --reason <label> [--json]",
215
+ " cockpit team device unmark-service <id|name> [--json]",
216
+ " Super admin only. Marks a device as automation (cloud agents, alert senders) so the fleet board",
217
+ " lists it under service identities, judged only on when it last called Tower, instead of a red laptop.",
218
+ " --reason is a short label such as boat-agent. Only this door can set it; a device cannot.",
214
219
  " cockpit team device revoke <id|name> --reason <label> [--note \"<text>\"] [--yes]",
215
220
  " Admin only. Names the device, asks first unless --yes, then revokes it —",
216
221
  " this cannot be undone. --reason must be one of: stale_test_pairing,",
@@ -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.",
@@ -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.257");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.259");
25
25
  return 0;
26
26
  }
27
27
 
@@ -41,6 +41,8 @@ export async function runTeam(command, io) {
41
41
  return deviceDoctor(command, tower, io);
42
42
  if (command.action === "device-scope")
43
43
  return deviceScope(command, tower, io);
44
+ if (command.action === "device-mark-service" || command.action === "device-unmark-service")
45
+ return deviceMarkService(command, tower, io);
44
46
  return deviceRevoke(command, tower, io);
45
47
  }
46
48
  async function showMembers(command, tower, io) {
@@ -246,6 +248,37 @@ async function deviceScope(command, tower, io) {
246
248
  : command.deviceScope === "none" ? `Took the service scope away from ${name}.` : `${name} now holds the notetaker_bot scope. The bot's next claim will go through.`);
247
249
  return 0;
248
250
  }
251
+ /**
252
+ * `cockpit team device mark-service <id|name> --reason <label>` and `unmark-service <id|name>` (ticket 23031): the ONE
253
+ * door that tells the fleet board a device is automation, not a laptop. Super admin on the server.
254
+ */
255
+ async function deviceMarkService(command, tower, io) {
256
+ const mark = command.action === "device-mark-service";
257
+ const listed = await callTower(tower, { path: "/api/team/devices", label: "team device mark-service lookup" });
258
+ if (!listed.ok)
259
+ return writeCommandFailure(io, command.json, listed);
260
+ const device = findDevice(devicesOf(asRecord(listed.body)), command.deviceRef ?? "");
261
+ if (!device)
262
+ return writeCommandFailure(io, command.json, { reason: "device_not_found", detail: "No unique device matches. Run cockpit team device list to see ids and names." });
263
+ const result = await callTower(tower, {
264
+ path: `/api/ambient/devices/${encodeURIComponent(device.device_id)}/service-mark`,
265
+ method: "POST", label: "team device mark-service", body: mark ? { mark: true, reason: command.reasonLabel } : { mark: false },
266
+ });
267
+ writeLine(io.stderr, `[team cli] device mark-service ${JSON.stringify({ device_id: device.device_id, mark, reason: mark ? command.reasonLabel : null, outcome: result.ok ? "answered" : result.reason })}`);
268
+ if (!result.ok)
269
+ return writeCommandFailure(io, command.json, result);
270
+ const body = asRecord(result.body);
271
+ if (command.json) {
272
+ writeLine(io.stdout, JSON.stringify({ ok: true, ...body }));
273
+ return 0;
274
+ }
275
+ const name = String(device.device_name ?? device.device_id);
276
+ writeLine(io.stdout, body.changed === false
277
+ ? `${name} was already ${mark ? "marked as automation" : "not marked"}. Nothing changed.`
278
+ : mark ? `${name} is now marked as automation (${command.reasonLabel}). The fleet board lists it under service identities.`
279
+ : `Took the automation mark off ${name}. The fleet board grades it as a laptop again.`);
280
+ return 0;
281
+ }
249
282
  /** Exact directory labels locate a roster id; attribution remains the server resolver's job. */
250
283
  async function githubLogin(command, tower, io) {
251
284
  let personId = command.personRef ?? "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.257",
3
+ "version": "0.2.259",
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.157",
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",