@bli-cockpit/cli 0.2.39 → 0.2.40

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.
@@ -20,7 +20,39 @@ export function writeExecOutput(io, result, options) {
20
20
  writeRaw(io.stderr, result.stderr);
21
21
  }
22
22
  export function isInteractiveStdin(io) {
23
- return Boolean(io.stdin.isTTY);
23
+ const isTTY = io.stdin.isTTY;
24
+ // Git Bash (mintty/MSYS) gives Node a pipe for an interactive terminal, so
25
+ // `isTTY` is undefined and the piped branch would wait forever on an EOF
26
+ // that never comes (BLI-3480). MSYSTEM is set only inside MSYS shells, so
27
+ // launchd / Task Scheduler / spawned runs keep the non-interactive branch.
28
+ if (isTTY === undefined && process.env["MSYSTEM"])
29
+ return true;
30
+ return Boolean(isTTY);
31
+ }
32
+ /**
33
+ * Drops one leading U+FEFF. PowerShell 5.1 redirection and Notepad both write
34
+ * byte-order marks; stored verbatim, a BOM makes the first line of an env blob
35
+ * unreachable and pollutes pasted notes (BLI-3480). One strip, never more —
36
+ * a BOM anywhere else is content.
37
+ */
38
+ export function stripLeadingBom(text) {
39
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
40
+ }
41
+ /**
42
+ * Reads piped stdin whole as UTF-8, BOM-stripped. The one implementation
43
+ * behind every stdin-fed command (jarvis, correct, notes paste, settings env
44
+ * set) — each caller keeps its own size cap via `maxChars`.
45
+ */
46
+ export async function readPipedText(stream, options) {
47
+ stream.setEncoding("utf8");
48
+ let text = "";
49
+ for await (const chunk of stream) {
50
+ text += chunk;
51
+ if (options?.maxChars !== undefined && text.length > options.maxChars) {
52
+ throw new Error(options.overflowMessage ?? `Piped input is limited to ${options.maxChars} characters.`);
53
+ }
54
+ }
55
+ return stripLeadingBom(text);
24
56
  }
25
57
  /**
26
58
  * Reads one line from stdin after writing a prompt. Shared by the onboard email
@@ -23,7 +23,7 @@
23
23
  * is printed on stdout with an exit code of 0 — the correction WAS filed, with
24
24
  * its outcome recorded. Only a failure to file at all is a non-zero exit.
25
25
  */
26
- import { isInteractiveStdin, writeLine } from "./cli-io.js";
26
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
27
27
  import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
28
28
  const DIM = "\x1b[2m";
29
29
  const RESET = "\x1b[0m";
@@ -134,21 +134,10 @@ async function resolveText(command, io) {
134
134
  return command.text;
135
135
  if (isInteractiveStdin(io))
136
136
  return null;
137
- const piped = await readAll(io.stdin);
137
+ const piped = await readPipedText(io.stdin, { maxChars: MAX_CORRECTION_LENGTH, overflowMessage: `A correction is limited to ${MAX_CORRECTION_LENGTH} characters.` });
138
138
  const trimmed = piped.trim();
139
139
  return trimmed.length > 0 ? trimmed : null;
140
140
  }
141
- async function readAll(stream) {
142
- stream.setEncoding("utf8");
143
- let text = "";
144
- for await (const chunk of stream) {
145
- text += chunk;
146
- if (text.length > MAX_CORRECTION_LENGTH) {
147
- throw new Error(`A correction is limited to ${MAX_CORRECTION_LENGTH} characters.`);
148
- }
149
- }
150
- return text;
151
- }
152
141
  function writeFailure(command, io, reason, detail) {
153
142
  if (command.json) {
154
143
  writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
@@ -5,7 +5,7 @@
5
5
  * existing paired device session, and every turn is executed by the dashboard
6
6
  * through the same JARVIS runtime used by web chat and Slack.
7
7
  */
8
- import { isInteractiveStdin, readLine, writeLine } from "./cli-io.js";
8
+ import { isInteractiveStdin, readLine, readPipedText, writeLine } from "./cli-io.js";
9
9
  import { attachedFileRefusalSentence, readAttachedImage, } from "./jarvis-attachment.js";
10
10
  import { loadPairedSession, towerFailureDetail, towerJsonRequest, towerRequest, } from "../tower-client.js";
11
11
  import { readTowerTurn, streamFailureDetail, } from "../tower-stream.js";
@@ -136,7 +136,7 @@ async function resolveOneShotPrompt(command, io) {
136
136
  return validatePrompt(command.prompt);
137
137
  if (isInteractiveStdin(io))
138
138
  return null;
139
- const piped = await readAll(io.stdin);
139
+ const piped = await readPipedText(io.stdin, { maxChars: 4000, overflowMessage: "JARVIS questions are limited to 4000 characters." });
140
140
  return validatePrompt(piped);
141
141
  }
142
142
  async function sendOneTurn(context, prompt, io) {
@@ -368,15 +368,4 @@ function validatePrompt(raw) {
368
368
  throw new Error("JARVIS questions are limited to 4000 characters.");
369
369
  }
370
370
  return prompt;
371
- }
372
- async function readAll(stream) {
373
- stream.setEncoding("utf8");
374
- let text = "";
375
- for await (const chunk of stream) {
376
- text += chunk;
377
- if (text.length > 4000) {
378
- throw new Error("JARVIS questions are limited to 4000 characters.");
379
- }
380
- }
381
- return text;
382
371
  }
@@ -348,7 +348,7 @@ function localSubcommandHelp(command) {
348
348
  "shelf [--limit <n>] — the notes YOU have put in, and what became of each.",
349
349
  "shelves — the shelves in use, with how many notes are on each.",
350
350
  "upload <paths...> [--exclude \"<sentence>\"] — put one or more local files in. Explicit paths only; your shell does any globbing, and a file that does not exist is named rather than skipped.",
351
- "paste [--file <path>] [--name <n>] [--exclude \"<sentence>\"] — put text in with no file. Defaults to stdin: `pbpaste | cockpit notes paste` on macOS, `Get-Clipboard | cockpit notes paste` on Windows.",
351
+ "paste [--file <path>] [--name <n>] [--exclude \"<sentence>\"] — put text in. --file <path> is safest (any editor encoding is decoded). Stdin works too: `pbpaste | cockpit notes paste` on macOS; on Windows use PowerShell 7 (`Get-Clipboard | cockpit notes paste`) — PowerShell 5.1 turns non-ASCII into `?` on pipes.",
352
352
  "--exclude carries your own sentence about what to leave out, exactly as the browser's box does.",
353
353
  "share <id> [--yes] — let everyone signed in read the statements that are safe to share. Asks first in a terminal; --yes is required without one, and --json implies --yes.",
354
354
  "unshare <id> — take it back. Never asks: it only ever narrows who can read.",
@@ -99,4 +99,31 @@ export async function readNoteFile(filePath) {
99
99
  return { ok: false, refusal: "file_unreadable", detail: errorMessage(error) };
100
100
  }
101
101
  return { ok: true, bytes, fileName, extension: extension || "none" };
102
+ }
103
+ /**
104
+ * Decodes note/env bytes to text the way an editor would, not the way UTF-8
105
+ * hopes (BLI-3480). PowerShell 5.1 redirection writes UTF-16LE with a BOM and
106
+ * Notepad's "Unicode" save does the same; decoded blindly as UTF-8 that text
107
+ * becomes NUL-interleaved mojibake that passes an emptiness check and stores
108
+ * silently. The BOM decides the codec; a BOM-less file that still decodes to
109
+ * NULs is refused by name rather than stored as garbage.
110
+ */
111
+ export function decodeTextBytes(bytes) {
112
+ let text;
113
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
114
+ text = bytes.subarray(2).toString("utf16le");
115
+ }
116
+ else if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
117
+ text = new TextDecoder("utf-16be").decode(bytes.subarray(2));
118
+ }
119
+ else if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
120
+ text = bytes.subarray(3).toString("utf8");
121
+ }
122
+ else {
123
+ text = bytes.toString("utf8");
124
+ }
125
+ if (text.includes("\u0000")) {
126
+ return { ok: false, reason: "undecodable_text_encoding" };
127
+ }
128
+ return { ok: true, text };
102
129
  }
@@ -23,8 +23,8 @@
23
23
  * run has nobody to ask and asking it to guess is worse than the flag it
24
24
  * already typed the command for.
25
25
  */
26
- import { isInteractiveStdin, readLine, writeLine, yesByDefault } from "./cli-io.js";
27
- import { NOTE_SLOW_UPLOAD_BYTES, noteFileRefusalSentence, readNoteFile, } from "./notes-file.js";
26
+ import { isInteractiveStdin, readLine, readPipedText, writeLine, yesByDefault } from "./cli-io.js";
27
+ import { NOTE_SLOW_UPLOAD_BYTES, decodeTextBytes, noteFileRefusalSentence, readNoteFile, } from "./notes-file.js";
28
28
  import { loadPairedSession, towerFailureDetail, towerRequest, } from "../tower-client.js";
29
29
  import { readResponseJson } from "../upload-http.js";
30
30
  /**
@@ -261,15 +261,24 @@ async function pasteNote(command, door) {
261
261
  writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: read.refusal, detail: read.detail })}`);
262
262
  return fail(door, read.refusal, sentence);
263
263
  }
264
- text = read.bytes.toString("utf8");
264
+ const decoded = decodeTextBytes(read.bytes);
265
+ if (!decoded.ok) {
266
+ writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: decoded.reason })}`);
267
+ return fail(door, decoded.reason, "That file is not text this command can decode (it may be a binary or an unusual encoding). Nothing was sent.");
268
+ }
269
+ text = decoded.text;
265
270
  }
266
271
  else {
267
272
  if (isInteractiveStdin(door.io)) {
268
- return fail(door, "nothing_piped", "Pipe the note in (`pbpaste | cockpit notes paste` on macOS, "
269
- + "`Get-Clipboard | cockpit notes paste` on Windows) or pass --file <path>.");
273
+ return fail(door, "nothing_piped", "Pass --file <path> (safest on Windows), or pipe the note in "
274
+ + "(`pbpaste | cockpit notes paste` on macOS; on Windows use PowerShell 7 "
275
+ + "Windows PowerShell 5.1 turns non-ASCII into `?` on pipes).");
270
276
  }
271
277
  try {
272
- text = await readAll(door.io.stdin);
278
+ text = await readPipedText(door.io.stdin, {
279
+ maxChars: PASTE_MAX_CHARS,
280
+ overflowMessage: `A pasted note is limited to ${PASTE_MAX_CHARS} characters. Save it to a file and use --file instead.`,
281
+ });
273
282
  }
274
283
  catch (error) {
275
284
  return fail(door, "paste_too_long", errorText(error));
@@ -474,17 +483,6 @@ function sayScope(door, body) {
474
483
  writeLine(door.io.stderr, `${TAG} narrowed ${JSON.stringify({ scope: body.scope ?? null, reason: body.degradedBecause })}`);
475
484
  writeLine(door.io.stderr, body.degradedNote ?? "This answer is narrower than the browser's.");
476
485
  }
477
- async function readAll(stream) {
478
- stream.setEncoding("utf8");
479
- let text = "";
480
- for await (const chunk of stream) {
481
- text += chunk;
482
- if (text.length > PASTE_MAX_CHARS) {
483
- throw new Error(`A pasted note is limited to ${PASTE_MAX_CHARS} characters. Save it to a file and use --file instead.`);
484
- }
485
- }
486
- return text;
487
- }
488
486
  function errorText(error) {
489
487
  return error instanceof Error ? error.message : String(error);
490
488
  }
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.39");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.40");
19
19
  return 0;
20
20
  }
21
21
 
@@ -239,8 +239,9 @@ async function setEnvBlob(command, tower, io) {
239
239
  const failure = {
240
240
  reason: "content_not_piped",
241
241
  detail: "settings env set reads the file from stdin. Pipe it in: " +
242
- "`cat <path> | cockpit settings env set --project <project> --file <name> --content-stdin` " +
243
- "(PowerShell: `Get-Content <path> -Raw | cockpit settings env set …`).",
242
+ "`cat <path> | cockpit settings env set --project <project> --file <name> --content-stdin`. " +
243
+ "On Windows use PowerShell 7 (`pwsh`): `Get-Content <path> -Raw | cockpit settings env set …`" +
244
+ "Windows PowerShell 5.1 turns non-ASCII into `?` on pipes.",
244
245
  };
245
246
  return writeCommandFailure(io, command.json, failure);
246
247
  }
@@ -17,7 +17,7 @@
17
17
  * - **stdout belongs to `--json`.** Every operational line goes to stderr, which
18
18
  * launchd captures; a `--json` run puts exactly one object on stdout.
19
19
  */
20
- import { writeLine } from "./cli-io.js";
20
+ import { readPipedText, writeLine } from "./cli-io.js";
21
21
  import { loadPairedSession, towerFailureDetail, towerJsonRequest, } from "../tower-client.js";
22
22
  /** Client-side ceiling. These routes are small reads and writes, not turns. */
23
23
  const REQUEST_DEADLINE_MS = 30_000;
@@ -101,12 +101,11 @@ export function parseModelKey(key) {
101
101
  }
102
102
  return { provider: key.slice(0, separator), model: key.slice(separator + 1) };
103
103
  }
104
- /** Reads piped stdin whole. Used only for env content, which is never echoed. */
104
+ /**
105
+ * Reads piped stdin whole, BOM-stripped (BLI-3480 — PowerShell redirection
106
+ * writes one, and stored verbatim it makes the first env line unreachable).
107
+ * Used only for env content, which is never echoed.
108
+ */
105
109
  export async function readAllStdin(stream) {
106
- stream.setEncoding("utf8");
107
- let text = "";
108
- for await (const chunk of stream) {
109
- text += chunk;
110
- }
111
- return text;
110
+ return readPipedText(stream);
112
111
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.39",
3
+ "version": "0.2.40",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {