@bli-cockpit/cli 0.2.259 → 0.2.261

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,7 @@
1
1
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
2
2
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+ /** One `@`, no spaces: `"ping @bob"` is text, not an address (BLI-23213). */
4
+ const DM_ADDRESS_PATTERN = /^[^\s@]+@[^\s@]+$/;
3
5
  import { MSG_CHANNEL_BOOLEAN_FLAGS, MSG_CHANNEL_VALUE_FLAGS, isMsgChannelAction, msgChannelNormalizeArgs, parseMsgChannelArgs, rejectMsgChannelFlags } from "./msg-channel-args.js";
4
6
  import { MSG_CONTEXT_VALUE_FLAGS, parseMsgContextArgs, rejectMsgContextFlags } from "./msg-context-args.js";
5
7
  import { MSG_MARK_VALUE_FLAGS, isMsgMarkAction, parseMsgMarkArgs, rejectMsgMarkFlags } from "./msg-mark-args.js";
@@ -123,14 +125,16 @@ export function parseMsgArgs(rawArgs) {
123
125
  throw new Error("--before belongs to msg read or msg thread.");
124
126
  }
125
127
  const { markRead, unreadFrom } = parseMsgReadLineFlags(action, values);
126
- const { channelName, dmEmail } = parseMsgCreateTarget(action, rest);
128
+ const { channelName, dmEmail, dmTextArgument } = parseMsgCreateTarget(action, rest);
127
129
  const { isPrivate, memberEmails, description } = parseMsgCreateFlags(action, values);
128
130
  const { channelRef, threadId, limit } = parseMsgReadTarget(action, rest, values);
129
131
  const writeSafety = parseMsgWriteSafety(action, rest, values);
130
132
  const built = buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit, markRead, unreadFrom, writeSafety);
131
133
  // `msg dm a@x b@y` is a group message: the single-address shape (dmEmail) is unchanged (BLI-21685).
132
134
  const withGroup = action === "dm" && rest.length > 1 ? { ...built, dmEmails: rest } : built;
133
- return fileIds.length > 0 ? { ...withGroup, fileIds } : withGroup;
135
+ // BLI-23213: text typed after `msg dm <email>` is refused by name at run time (no help dump); the text is piped in.
136
+ const withText = dmTextArgument ? { ...withGroup, dmTextArgument } : withGroup;
137
+ return fileIds.length > 0 ? { ...withText, fileIds } : withText;
134
138
  }
135
139
  /**
136
140
  * `cockpit msg search [words] [--from <email|me>]... [--in <channel>] [--since <day>] [--until <day>] [--timezone <zone>]
@@ -333,8 +337,8 @@ function parseMsgWriteSafety(action, rest, values) {
333
337
  const requestId = optionalNonEmpty(values.flags.get("--request-id"));
334
338
  if (requestId && !UUID_PATTERN.test(requestId))
335
339
  throw new Error("--request-id must be a UUID.");
336
- if (requestId && action !== "send" && action !== "edit" && action !== "delete") {
337
- throw new Error("--request-id belongs to `cockpit msg send`, `edit` or `delete`.");
340
+ if (requestId && action !== "send" && action !== "dm" && action !== "edit" && action !== "delete") {
341
+ throw new Error("--request-id belongs to `cockpit msg send`, `dm`, `edit` or `delete`.");
338
342
  }
339
343
  const expectedRevision = optionalPositiveInteger(values.flags.get("--expected-revision"), "--expected-revision");
340
344
  if (expectedRevision !== undefined && action !== "edit" && action !== "delete") {
@@ -379,6 +383,7 @@ function parseMsgAction(positionals) {
379
383
  function parseMsgCreateTarget(action, rest) {
380
384
  let channelName;
381
385
  let dmEmail;
386
+ let dmTextArgument = false;
382
387
  if (action === "create") {
383
388
  channelName = optionalNonEmpty(rest[0]);
384
389
  if (!channelName)
@@ -396,11 +401,9 @@ function parseMsgCreateTarget(action, rest) {
396
401
  dmEmail = optionalNonEmpty(rest[0]);
397
402
  if (!dmEmail)
398
403
  throw new Error("msg dm needs the person's email address, e.g. `cockpit msg dm ada@example.com` (several for a group message).");
399
- const notAnAddress = rest.find((email) => !email.includes("@"));
400
- if (notAnAddress !== undefined)
401
- throw new Error(`msg dm takes email addresses; "${notAnAddress}" is not one.`);
404
+ dmTextArgument = rest.some((email) => !DM_ADDRESS_PATTERN.test(email));
402
405
  }
403
- return { channelName, dmEmail };
406
+ return { channelName, dmEmail, dmTextArgument };
404
407
  }
405
408
  function parseMsgCreateFlags(action, values) {
406
409
  const isPrivate = values.booleans.has("--private");
@@ -107,7 +107,7 @@ export const TOWER_COMMAND_HELP = [
107
107
  "Channels and messages, typed. <channel> is a channel id, or its name with or without a leading #.",
108
108
  "channels — every channel you are a member of (or, as a super_admin, every channel).",
109
109
  "create <name> [--private] [--members a@x.test,b@y.test] [--description \"<text>\"] — makes a channel and prints its id. A leading # is fine; --private means membership decides who may read it, and --members names who joins at birth BY EMAIL. An address Tower does not carry refuses the whole create — no half-built channel.",
110
- "dm <email> — opens (or re-opens) the direct message with one person. Idempotent: the same address always resolves to the same channel, and you never have to name yourself.",
110
+ "dm <email> [--request-id <uuid>] — opens (or re-opens) the direct message with one person. Idempotent: the same address always resolves to the same channel, and you never have to name yourself. Pipe text in and it is also sent there in the same call, through the same door and request-id receipt as send: `echo \"hello\" | cockpit msg dm ada@example.com` (retry a lost answer with the SAME --request-id; it posts once). The text is never accepted on the command line: `msg dm ada@example.com \"hello\"` is refused dm_text_argument, exit 1, and nothing is sent.",
111
111
  "dm <email> [<email>...] — with several addresses, opens (or re-opens) the group message with exactly those people plus you; the same set always resolves to the same channel. channel <channel> [--json] — the channel's details: name, description, kind, archived, audience (a public channel is everyone in the org, not a member count), source workspace label (where it came from; it grants no access), whether you can manage it, and whether you hid it. channel <channel> --rename <name> | --describe <text> | --archive | --unarchive — changes it (one update; --rename and --describe can go together). Only the person who made the channel, its owners and its moderators can; anyone else is refused channel_manage_refused, a name in use channel_name_taken, a direct message dm_not_editable. Archiving keeps every message and link and stops new posts (channel_archived); unarchive restores posting. members <channel> [--json] — who is in a private channel or a direct message; a public channel says everyone in the org can read it. members <channel> --add <email> | --remove <email> — private channels only, by the same people who can manage it; a removed person's next read is refused. A direct message keeps the people it started with (dm_not_editable): start a new group with `msg dm a@x b@y`. hide <channel> / unhide <channel> — drops a PUBLIC channel from your own sidebar, or puts it back; it changes nothing for anyone else and nothing about reading, search, mentions or notifications. A private channel or direct message is refused hide_not_public.",
112
112
  "read <channel> [--limit <n>] [--thread <id>] [--mark-read] — the channel's most recent top-level messages, oldest first; --thread <id> reads one thread's replies instead. The first page also says where the messages come from (for an imported Slack channel: how long ago the archive was checked, or that it is unknown) and where YOUR read line stands. Reading never moves the read line; --mark-read moves it to the newest message shown, and says plainly if that failed.",
113
113
  "unread <channel> --from <message-id> — marks that message and everything after it unread, so the web sidebar badge and the NEW MESSAGES line start there. Opening or reading a channel never clears unseen history.",
@@ -6,8 +6,8 @@ export const NOTES_HELP = [
6
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
- "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.",
9
+ "record start|stop [<session-id>]|status|cancel — record a meeting in a room from this machine's microphone (Apple Silicon macOS and Windows; Linux says it is not supported). On Windows the microphone needs Settings > Privacy & security > Microphone on, including Let desktop apps access your microphone. 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 app (Apple Silicon macOS menu bar, or 64-bit Windows 10/11 taskbar notification area; Linux and Windows on Arm say they are not supported yet). `install` asks Tower for the newest release for this machine, downloads it, refuses it if its sha256 differs, and starts it at login: on the Mac it goes in ~/Applications/BLI Notetaker.app (replacing an older one, download quarantine cleared) with a LaunchAgent; on Windows it goes in %LOCALAPPDATA%\\Programs\\BLI Notetaker\\bli-notetaker.exe with a Startup entry (HKCU Run value, no admin; Windows does not block it because the CLI downloaded it). The menu runs the `record` verbs above: Record, Stop, Open last note, Finish last recording. Log: ~/Library/Logs/BLI Notetaker.log on the Mac, %LOCALAPPDATA%\\BLI Notetaker\\notetaker.log on Windows. `uninstall` removes the app and its startup entry and keeps your recordings and the log; `status` says what is installed, whether it runs, whether it starts at login, and whether a newer release exists.",
11
11
  "folders [--tree] [--json]: list folders and visible note counts.",
12
12
  "mkdir <path> [--json]: create a folder and any missing ancestors.",
13
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,84 @@
1
+ /**
2
+ * What the macOS and Windows halves of `cockpit notes app` share (BLI-23208): the dependency bag, the Tower release
3
+ * question (platform-aware), the checked download, and the one refusal / answer shape. Sibling of `notes-app.ts`
4
+ * (router and the Mac half) and `notes-app-windows.ts`.
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import { createWriteStream } from "node:fs";
8
+ import { readFile, stat } from "node:fs/promises";
9
+ import { Readable, Transform } from "node:stream";
10
+ import { pipeline } from "node:stream/promises";
11
+ import { writeLine } from "./cli-io.js";
12
+ import { ask } from "./notes-door.js";
13
+ export const TAG = "[notes app]";
14
+ const RELEASE_DEADLINE_MS = 30_000;
15
+ const DOWNLOAD_DEADLINE_MS = 10 * 60_000;
16
+ export function say(io, command, human, json) {
17
+ writeLine(io.stdout, command.json ? JSON.stringify({ ok: true, ...json }) : human);
18
+ return 0;
19
+ }
20
+ export function refuse(io, command, reason, sentence, fields = {}) {
21
+ writeLine(io.stderr, `${TAG} refused ${JSON.stringify({ reason, ...fields })}`);
22
+ if (command.json)
23
+ writeLine(io.stdout, JSON.stringify({ ok: false, reason, detail: sentence }));
24
+ else
25
+ writeLine(io.stderr, sentence);
26
+ return 1;
27
+ }
28
+ export const exists = async (file) => stat(file).then(() => true, () => false);
29
+ export async function readReceipt(file) {
30
+ try {
31
+ const parsed = JSON.parse((await readFile(file, "utf8")).replace(/^/, ""));
32
+ return typeof parsed.tag === "string" && typeof parsed.version === "string" ? parsed : null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /** Ask the paired Tower for the newest release for `platform`; a refusal comes back as reason + sentence. */
39
+ export async function newestRelease(deps, platform) {
40
+ const door = await deps.door();
41
+ if (!("deviceToken" in door))
42
+ return { ok: false, reason: door.reason, detail: door.detail };
43
+ const answer = await ask(door, { path: `/api/notes/app/release?platform=${platform}`, method: "GET", label: "notes app release", timeoutMs: RELEASE_DEADLINE_MS });
44
+ if (!answer.ok)
45
+ return answer;
46
+ const body = answer.body;
47
+ const sha = typeof body.sha256 === "string" ? body.sha256.toLowerCase() : "";
48
+ if (!body.tag || !body.version || !/^[0-9a-f]{64}$/.test(sha) || !body.zip?.url?.startsWith("https://")) {
49
+ return { ok: false, reason: "release_answer_unreadable", detail: "Tower answered without a release this command can install." };
50
+ }
51
+ // A door older than BLI-23208 ignores `?platform=` and answers the Mac zip: never install that on another machine.
52
+ if (platform !== "darwin-arm64" && body.platform !== platform) {
53
+ return { ok: false, reason: "release_platform_mismatch", detail: `Tower answered for ${body.platform ?? "an unnamed platform"}, not ${platform}. Its release door needs the Windows update before this machine can install the app.` };
54
+ }
55
+ return { ok: true, release: { tag: body.tag, version: body.version, sha256: sha, zip: body.zip }, door };
56
+ }
57
+ /** Stream `url` to `dest`, hashing as it goes. No credential is sent: the link is GitHub's own signed one. */
58
+ export async function downloadToFile(fetchImpl, url, dest) {
59
+ let response;
60
+ try {
61
+ response = await fetchImpl(url, { signal: AbortSignal.timeout(DOWNLOAD_DEADLINE_MS) });
62
+ }
63
+ catch {
64
+ return { ok: false, reason: "download_unreachable" };
65
+ }
66
+ if (!response.ok || !response.body)
67
+ return { ok: false, reason: `download_http_${response.status}` };
68
+ const hash = createHash("sha256");
69
+ let bytes = 0;
70
+ const meter = new Transform({
71
+ transform(chunk, _encoding, done) {
72
+ hash.update(chunk);
73
+ bytes += chunk.length;
74
+ done(null, chunk);
75
+ },
76
+ });
77
+ try {
78
+ await pipeline(Readable.fromWeb(response.body), meter, createWriteStream(dest, { mode: 0o600 }));
79
+ }
80
+ catch {
81
+ return { ok: false, reason: "download_interrupted" };
82
+ }
83
+ return { ok: true, bytes, sha256: hash.digest("hex") };
84
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * `cockpit notes app install | uninstall | status` on Windows (BLI-23208): the same contract as the Mac half in
3
+ * `notes-app.ts`, with the Windows parts swapped in.
4
+ *
5
+ * install asks Tower for the `win32-x64` release, downloads the zip with Node (so it carries no Mark-of-the-Web
6
+ * and SmartScreen has nothing to block), REFUSES it if the sha256 differs, unpacks it with the tar.exe
7
+ * Windows 10 1803+ ships, stops a running copy, replaces
8
+ * `%LOCALAPPDATA%\Programs\BLI Notetaker\bli-notetaker.exe`, writes `install-receipt.json`, registers the
9
+ * HKCU Run value (no admin, no Task Scheduler) and starts the app now, windowless and detached.
10
+ * uninstall stops it, deletes the Run value and the install directory. Recordings and the log stay.
11
+ * status installed, startup registered (and pointing here), running (pid), newest release, update available.
12
+ *
13
+ * Every external program is addressed by its System32 path (a Git-for-Windows `tar` earlier on PATH must not win).
14
+ * Output of `reg` and `tasklist` is read defensively: NULs, a BOM and localised "no match" prose are all tolerated.
15
+ */
16
+ import { randomBytes } from "node:crypto";
17
+ import { mkdir, rename, rm, writeFile } from "node:fs/promises";
18
+ import { execFile } from "node:child_process";
19
+ import path from "node:path";
20
+ import { writeLine } from "./cli-io.js";
21
+ import { parseWmiLaunchOutput, startProcessScript, windowsPowerShellPath } from "./notes-record-capture-win32.js";
22
+ import { TAG, downloadToFile, exists, newestRelease, readReceipt, refuse, say } from "./notes-app-shared.js";
23
+ export const NOTETAKER_WIN_EXE = "bli-notetaker.exe";
24
+ export const NOTETAKER_WIN_RUN_VALUE = "BLI Notetaker";
25
+ export const NOTETAKER_WIN_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
26
+ const WIN = "win32-x64";
27
+ const REPLACE_ATTEMPTS = 7;
28
+ const REPLACE_WAIT_MS = 500;
29
+ const BUSY = new Set(["EPERM", "EBUSY", "EACCES"]);
30
+ const NONE = { code: 1, stdout: "", stderr: "" };
31
+ /** `%LOCALAPPDATA%` (or, failing that, the Local sibling of `%APPDATA%`, or `<home>\AppData\Local`). */
32
+ export function notetakerWindowsPaths(env, homeDir, p = path.win32) {
33
+ const local = env.LOCALAPPDATA || (env.APPDATA ? p.join(p.dirname(env.APPDATA), "Local") : p.join(homeDir, "AppData", "Local"));
34
+ const programs = p.join(local, "Programs");
35
+ const dir = p.join(programs, "BLI Notetaker");
36
+ return { programs, dir, exe: p.join(dir, NOTETAKER_WIN_EXE), receipt: p.join(dir, "install-receipt.json"), log: p.join(local, "BLI Notetaker", "notetaker.log") };
37
+ }
38
+ export function windowsSystemTool(env, name, p = path.win32) {
39
+ return p.join(env.SystemRoot ?? env.WINDIR ?? "C:\\Windows", "System32", name);
40
+ }
41
+ /** The Run value: the quoted exe, then `--tower-url` when the address is plain enough to sit on a command line. */
42
+ export function runValueCommand(exe, towerUrl) {
43
+ const plain = towerUrl !== undefined && /^https?:\/\/[^\s"]+$/.test(towerUrl);
44
+ return `"${exe}"${plain ? ` --tower-url ${towerUrl}` : ""}`;
45
+ }
46
+ /** `reg add` arguments. Node quotes the `/d` value for CreateProcess (`\"` for each embedded quote), which reg.exe reads back. */
47
+ export const regAddArgs = (value) => ["add", NOTETAKER_WIN_RUN_KEY, "/v", NOTETAKER_WIN_RUN_VALUE, "/t", "REG_SZ", "/d", value, "/f"];
48
+ const clean = (text) => text.replace(/\u0000/g, "").replace(/^\uFEFF/, "");
49
+ /** `reg query ... /v "BLI Notetaker"` → the value's data, or null when there is none. */
50
+ export function parseRunValue(stdout) {
51
+ const match = /^\s+BLI Notetaker\s+REG_(?:SZ|EXPAND_SZ)\s+(.*?)\s*$/im.exec(clean(stdout));
52
+ return match?.[1] ?? null;
53
+ }
54
+ /** First token of a Run value: the quoted exe path, or the text up to the first space. */
55
+ export function runValueExe(value) {
56
+ return value.startsWith('"') ? value.slice(1).split('"')[0] ?? "" : value.split(" ")[0] ?? "";
57
+ }
58
+ /** `tasklist /FO CSV /NH` → the pid of the first `bli-notetaker.exe` row. Prose in any language means none. */
59
+ export function parseTasklistPid(stdout) {
60
+ const match = /^"bli-notetaker\.exe","(\d+)"/im.exec(clean(stdout));
61
+ return match ? Number(match[1]) : null;
62
+ }
63
+ /**
64
+ * Start the app through Start-Process (ShellExecute), never as a Node child: libuv creates children with handle
65
+ * inheritance on, so a Node-spawned app held this CLI's stdout pipe for as long as it ran and anything reading
66
+ * `install` through a pipe (a script, an agent) waited until the app quit. Seen on a Windows 11 box.
67
+ */
68
+ async function defaultLaunch(exe, args) {
69
+ const powershell = windowsPowerShellPath();
70
+ const script = startProcessScript(exe, args);
71
+ return await new Promise((resolve) => {
72
+ execFile(powershell, ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { windowsHide: true, timeout: 20_000, encoding: "buffer" }, (error, stdout) => {
73
+ if (error && !stdout?.length)
74
+ return resolve({ ok: false, detail: error.code?.toString() ?? "start_process_failed" });
75
+ const answer = parseWmiLaunchOutput(stdout);
76
+ resolve(answer.ok ? { ok: true } : { ok: false, detail: `start_process_${answer.code}` });
77
+ });
78
+ });
79
+ }
80
+ const timer = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
81
+ /** Run `action` until it stops failing with a "still in use" code, for about three seconds. */
82
+ export async function whileBusy(deps, action) {
83
+ for (let attempt = 1;; attempt += 1) {
84
+ try {
85
+ await action();
86
+ return { ok: true, attempts: attempt };
87
+ }
88
+ catch (error) {
89
+ const code = error.code ?? "unknown";
90
+ if (!BUSY.has(code) || attempt >= REPLACE_ATTEMPTS)
91
+ return { ok: false, code, attempts: attempt };
92
+ await (deps.sleep ?? timer)(REPLACE_WAIT_MS);
93
+ }
94
+ }
95
+ }
96
+ export async function runNotesAppWindows(command, io, deps) {
97
+ const env = deps.env ?? process.env;
98
+ const p = deps.pathApi ?? path.win32;
99
+ const paths = notetakerWindowsPaths(env, deps.homeDir, p);
100
+ const tool = (name) => windowsSystemTool(env, name, p);
101
+ const run = async (name, args) => await deps.exec(tool(name), args).catch(() => NONE);
102
+ const verb = command.appAction ?? "status";
103
+ if (verb === "install")
104
+ return await install(command, io, deps, paths, run, tool);
105
+ if (verb === "uninstall")
106
+ return await uninstall(command, io, deps, paths, run);
107
+ return await status(command, io, deps, paths, run);
108
+ }
109
+ const stopApp = (run) => run("taskkill.exe", ["/F", "/T", "/IM", NOTETAKER_WIN_EXE]);
110
+ async function install(command, io, deps, paths, run, tool) {
111
+ const p = deps.pathApi ?? path.win32;
112
+ const found = await newestRelease(deps, WIN);
113
+ if (!found.ok)
114
+ return refuse(io, command, found.reason, found.detail, { platform: WIN });
115
+ const { release, door } = found;
116
+ const previous = await readReceipt(paths.receipt);
117
+ writeLine(io.stderr, `${TAG} release ${JSON.stringify({ platform: WIN, tag: release.tag, zip_bytes: release.zip.bytes, previous: previous?.tag ?? null })}`);
118
+ if (!command.json)
119
+ writeLine(io.stderr, `Downloading BLI Notetaker ${release.version} (${(release.zip.bytes / 1_000_000).toFixed(1)} MB)...`);
120
+ await mkdir(paths.programs, { recursive: true });
121
+ const staging = p.join(paths.programs, `.bli-notetaker-install-${randomBytes(4).toString("hex")}`);
122
+ await mkdir(staging, { recursive: true });
123
+ try {
124
+ const zip = p.join(staging, "app.zip");
125
+ const downloaded = await downloadToFile(io.fetch, release.zip.url, zip);
126
+ if (!downloaded.ok) {
127
+ return refuse(io, command, downloaded.reason, `The download did not finish (${downloaded.reason}). Nothing was installed; try again.`);
128
+ }
129
+ if (downloaded.sha256 !== release.sha256) {
130
+ 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 PC changed.", { tag: release.tag, bytes: downloaded.bytes, expected_prefix: release.sha256.slice(0, 12), got_prefix: downloaded.sha256.slice(0, 12) });
131
+ }
132
+ writeLine(io.stderr, `${TAG} downloaded ${JSON.stringify({ tag: release.tag, bytes: downloaded.bytes, sha256_prefix: downloaded.sha256.slice(0, 12), reason: "sha256_match" })}`);
133
+ const unpacked = p.join(staging, "unpacked");
134
+ await mkdir(unpacked, { recursive: true });
135
+ const untar = await deps.exec(tool("tar.exe"), ["-xf", zip, "-C", unpacked]).catch(() => NONE);
136
+ const stagedExe = p.join(unpacked, NOTETAKER_WIN_EXE);
137
+ if (untar.code !== 0 || !(await exists(stagedExe))) {
138
+ return refuse(io, command, "bundle_unreadable", "The downloaded zip did not hold bli-notetaker.exe (or this PC's tar.exe could not unpack it). Nothing was installed.", { tar_exit: untar.code });
139
+ }
140
+ // Stop the running copy first. A recording in progress keeps going: the recorder is its own process, not the tray's child.
141
+ const stopped = await stopApp(run);
142
+ await mkdir(paths.dir, { recursive: true });
143
+ const replaced = await exists(paths.exe);
144
+ const moved = await whileBusy(deps, () => rename(stagedExe, paths.exe));
145
+ if (!moved.ok) {
146
+ return refuse(io, command, "exe_in_use", "BLI Notetaker is still running and Windows will not let it be replaced. Quit it from the notification area icon (click ^ if it is hidden), then run `cockpit notes app install` again.", { tag: release.tag, error_code: moved.code, attempts: moved.attempts, taskkill_exit: stopped.code });
147
+ }
148
+ writeLine(io.stderr, `${TAG} unpacked ${JSON.stringify({ replaced, previous: previous?.tag ?? null, taskkill_exit: stopped.code, replace_attempts: moved.attempts })}`);
149
+ const receipt = { tag: release.tag, version: release.version, sha256: release.sha256, installed_at: deps.now().toISOString() };
150
+ await writeFile(paths.receipt, `${JSON.stringify(receipt)}\n`, "utf8");
151
+ const registered = await run("reg.exe", regAddArgs(runValueCommand(paths.exe, door.dashboardUrl)));
152
+ writeLine(io.stderr, `${TAG} startup ${JSON.stringify({ registered: registered.code === 0, reg_exit: registered.code })}`);
153
+ const started = await (deps.launch ?? defaultLaunch)(paths.exe, door.dashboardUrl ? ["--tower-url", door.dashboardUrl] : []);
154
+ writeLine(io.stderr, `${TAG} installed ${JSON.stringify({ tag: release.tag, replaced, startup_registered: registered.code === 0, started: started.ok, launch_detail: started.detail ?? null })}`);
155
+ if (registered.code !== 0 || !started.ok) {
156
+ return refuse(io, command, registered.code !== 0 ? "startup_registration_failed" : "launch_failed", `BLI Notetaker ${release.version} is installed at ${paths.exe}, but ${registered.code !== 0 ? "Windows would not register it to start when you sign in" : `it could not be started (${started.detail ?? "unknown"})`}. Run \`cockpit notes app install\` again, or open it from that folder.`, { tag: release.tag, reg_exit: registered.code });
157
+ }
158
+ return say(io, command, [
159
+ `Installed BLI Notetaker ${release.version}${replaced ? ` (replaced ${previous?.version ?? "an older copy"})` : ""} and started it.`,
160
+ "Look for the BLI Notetaker icon in the taskbar notification area (click ^ if it is hidden): Record, Stop, Open last note. It starts again when you sign in.",
161
+ `App: ${paths.exe}`,
162
+ `Log: ${paths.log}`,
163
+ "The app is not code-signed. Windows SmartScreen does not block it because this command downloaded it itself, so it carries no \"downloaded from the internet\" mark. The first Record uses the microphone allowed in Settings > Privacy & security > Microphone.",
164
+ ].join("\n"), { installed: true, platform: WIN, tag: release.tag, version: release.version, replaced, previous: previous?.tag ?? null, app: paths.exe, startup: { registered: true, key: `${NOTETAKER_WIN_RUN_KEY}\\${NOTETAKER_WIN_RUN_VALUE}` }, log: paths.log });
165
+ }
166
+ finally {
167
+ await rm(staging, { recursive: true, force: true }).catch(() => undefined);
168
+ }
169
+ }
170
+ async function uninstall(command, io, deps, paths, run) {
171
+ // taskkill exits 128 when nothing was running, which is fine.
172
+ const stopped = await stopApp(run);
173
+ const had = { app: await exists(paths.exe), receipt: await exists(paths.receipt), startup: parseRunValue((await run("reg.exe", ["query", NOTETAKER_WIN_RUN_KEY, "/v", NOTETAKER_WIN_RUN_VALUE])).stdout) !== null };
174
+ const deleted = await run("reg.exe", ["delete", NOTETAKER_WIN_RUN_KEY, "/v", NOTETAKER_WIN_RUN_VALUE, "/f"]);
175
+ const dirGone = await whileBusy(deps, () => rm(paths.dir, { recursive: true, force: true }));
176
+ writeLine(io.stderr, `${TAG} uninstalled ${JSON.stringify({ ...had, taskkill_exit: stopped.code, reg_delete_exit: deleted.code, dir_removed: dirGone.ok, error_code: dirGone.ok ? null : dirGone.code })}`);
177
+ if (!dirGone.ok) {
178
+ return refuse(io, command, "exe_in_use", "BLI Notetaker is still running and Windows will not let its folder be removed. Quit it from the notification area icon, then run `cockpit notes app uninstall` again.", { error_code: dirGone.code, attempts: dirGone.attempts });
179
+ }
180
+ if (!had.app && !had.startup)
181
+ return say(io, command, "BLI Notetaker was not installed. Nothing to remove.", { removed: false });
182
+ return say(io, command, [
183
+ "Removed BLI Notetaker: the app, its startup entry and its receipt.",
184
+ `Your recordings stay on this PC (\`cockpit notes record status\` lists any not yet uploaded), and so does the log: ${paths.log}`,
185
+ ].join("\n"), { removed: true, ...had });
186
+ }
187
+ async function status(command, io, deps, paths, run) {
188
+ const receipt = await readReceipt(paths.receipt);
189
+ const installed = await exists(paths.exe);
190
+ const value = parseRunValue((await run("reg.exe", ["query", NOTETAKER_WIN_RUN_KEY, "/v", NOTETAKER_WIN_RUN_VALUE])).stdout);
191
+ const p = deps.pathApi ?? path.win32;
192
+ const pointsHere = value !== null && p.normalize(runValueExe(value)).toLowerCase() === p.normalize(paths.exe).toLowerCase();
193
+ const pid = parseTasklistPid((await run("tasklist.exe", ["/FI", `IMAGENAME eq ${NOTETAKER_WIN_EXE}`, "/FO", "CSV", "/NH"])).stdout);
194
+ const running = pid !== null;
195
+ // The newest release is best effort: a status line without it is still an answer.
196
+ const newest = await newestRelease(deps, WIN);
197
+ const newestTag = newest.ok ? newest.release.tag : null;
198
+ const update = Boolean(installed && newestTag && receipt?.tag !== newestTag);
199
+ writeLine(io.stderr, `${TAG} status ${JSON.stringify({ platform: WIN, installed, tag: receipt?.tag ?? null, startup_registered: value !== null, points_here: pointsHere, running, newest: newestTag, newest_reason: newest.ok ? null : newest.reason })}`);
200
+ const json = {
201
+ platform: WIN, installed, tag: receipt?.tag ?? null, version: receipt?.version ?? null, installed_at: receipt?.installed_at ?? null,
202
+ app: paths.exe, startup: { registered: value !== null, points_here: pointsHere, key: `${NOTETAKER_WIN_RUN_KEY}\\${NOTETAKER_WIN_RUN_VALUE}` },
203
+ process: { running, pid }, log: paths.log,
204
+ newest: newest.ok ? { tag: newest.release.tag, version: newest.release.version } : { reason: newest.reason },
205
+ update_available: update,
206
+ };
207
+ const lines = installed
208
+ ? [
209
+ `BLI Notetaker ${receipt?.version ?? "(version unknown)"} is installed: ${paths.exe}`,
210
+ running ? `Running (pid ${pid}). Look for its icon in the taskbar notification area (click ^ if it is hidden).` : "Not running; run `cockpit notes app install` to start it now, or it starts when you sign in.",
211
+ value === null ? "Not set to start when you sign in; run `cockpit notes app install` to register it." : pointsHere ? "Starts again when you sign in." : "Its sign-in entry points at a different file; run `cockpit notes app install` to repair it.",
212
+ 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}).`,
213
+ `Log: ${paths.log}`,
214
+ ]
215
+ : [
216
+ "BLI Notetaker is not installed. Run `cockpit notes app install`.",
217
+ newest.ok ? `Newest release: ${newest.release.version}.` : `Could not check the newest release (${newest.reason}).`,
218
+ ];
219
+ return say(io, command, lines.join("\n"), json);
220
+ }