pi-image-tools 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/powershell.ts CHANGED
@@ -31,6 +31,8 @@ export interface RunPowerShellCommandOptions {
31
31
  timeout: number;
32
32
  }
33
33
 
34
+ const ALLOWED_BUFFERED_COMMANDS = new Set(["img2sixel", "powershell", "powershell.exe"]);
35
+
34
36
  function encodePowerShell(script: string): string {
35
37
  return Buffer.from(script, "utf16le").toString("base64");
36
38
  }
@@ -41,9 +43,20 @@ export function runBufferedCommand(
41
43
  options: RunBufferedCommandOptions,
42
44
  ): Promise<BufferedCommandResult> {
43
45
  return new Promise((resolve) => {
46
+ const normalizedCommand = command.trim().toLowerCase();
47
+ if (!ALLOWED_BUFFERED_COMMANDS.has(normalizedCommand)) {
48
+ resolve({
49
+ status: null,
50
+ stdout: Buffer.alloc(0),
51
+ stderr: Buffer.from(`Command is not allowlisted for pi-image-tools buffered execution: ${command}`),
52
+ error: new Error(`Command is not allowlisted for pi-image-tools buffered execution: ${command}`),
53
+ });
54
+ return;
55
+ }
56
+
44
57
  let child: ReturnType<typeof spawn>;
45
58
  try {
46
- child = spawn(command, [...args], {
59
+ child = spawn(command, [...args], { // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- command is checked against ALLOWED_BUFFERED_COMMANDS immediately above; args are fixed by Sixel/Image preview code and shell is disabled.
47
60
  windowsHide: options.windowsHide,
48
61
  });
49
62
  } catch (error) {
@@ -124,27 +137,68 @@ export function runBufferedCommand(
124
137
  });
125
138
  }
126
139
 
127
- export function runPowerShellCommand(
140
+ const POWERSHELL_NON_WINDOWS_REASON = "PowerShell is only available through pi-image-tools on Windows.";
141
+
142
+ function nonWindowsPowerShellResult(): PowerShellCommandResult {
143
+ return {
144
+ ok: false,
145
+ stdout: "",
146
+ stderr: "",
147
+ missingCommand: false,
148
+ reason: POWERSHELL_NON_WINDOWS_REASON,
149
+ };
150
+ }
151
+
152
+ function buildPowerShellCommandArgs(
128
153
  script: string,
129
154
  options: RunPowerShellCommandOptions,
130
- ): PowerShellCommandResult {
131
- if (process.platform !== "win32") {
132
- return {
133
- ok: false,
134
- stdout: "",
135
- stderr: "",
136
- missingCommand: false,
137
- reason: "PowerShell is only available through pi-image-tools on Windows.",
138
- };
139
- }
140
-
141
- const commandArgs = [
155
+ ): string[] {
156
+ return [
142
157
  "-NoProfile",
143
158
  "-NonInteractive",
144
159
  ...(options.sta ? ["-STA"] : []),
145
160
  ...(options.encoded ? ["-EncodedCommand", encodePowerShell(script)] : ["-Command", script]),
146
161
  ...(options.args ?? []),
147
162
  ];
163
+ }
164
+
165
+ function buildPowerShellResultFromError(
166
+ error: Error,
167
+ stdout: string,
168
+ stderr: string,
169
+ ): PowerShellCommandResult {
170
+ return {
171
+ ok: false,
172
+ stdout,
173
+ stderr,
174
+ missingCommand: isErrnoException(error) && error.code === "ENOENT",
175
+ reason: getErrorMessage(error),
176
+ };
177
+ }
178
+
179
+ function buildPowerShellResultFromStatus(
180
+ status: number | null,
181
+ stdout: string,
182
+ stderr: string,
183
+ ): PowerShellCommandResult {
184
+ return {
185
+ ok: status === 0,
186
+ stdout,
187
+ stderr,
188
+ missingCommand: false,
189
+ reason: status === 0 ? undefined : `PowerShell exited with code ${status}`,
190
+ };
191
+ }
192
+
193
+ export function runPowerShellCommand(
194
+ script: string,
195
+ options: RunPowerShellCommandOptions,
196
+ ): PowerShellCommandResult {
197
+ if (process.platform !== "win32") {
198
+ return nonWindowsPowerShellResult();
199
+ }
200
+
201
+ const commandArgs = buildPowerShellCommandArgs(script, options);
148
202
 
149
203
  const result = spawnSync("powershell.exe", commandArgs, {
150
204
  encoding: "utf8",
@@ -154,22 +208,18 @@ export function runPowerShellCommand(
154
208
  });
155
209
 
156
210
  if (result.error) {
157
- return {
158
- ok: false,
159
- stdout: result.stdout ?? "",
160
- stderr: result.stderr ?? "",
161
- missingCommand: isErrnoException(result.error) && result.error.code === "ENOENT",
162
- reason: getErrorMessage(result.error),
163
- };
211
+ return buildPowerShellResultFromError(
212
+ result.error,
213
+ result.stdout ?? "",
214
+ result.stderr ?? "",
215
+ );
164
216
  }
165
217
 
166
- return {
167
- ok: result.status === 0,
168
- stdout: result.stdout ?? "",
169
- stderr: result.stderr ?? "",
170
- missingCommand: false,
171
- reason: result.status === 0 ? undefined : `PowerShell exited with code ${result.status}`,
172
- };
218
+ return buildPowerShellResultFromStatus(
219
+ result.status,
220
+ result.stdout ?? "",
221
+ result.stderr ?? "",
222
+ );
173
223
  }
174
224
 
175
225
  export async function runPowerShellCommandAsync(
@@ -177,22 +227,10 @@ export async function runPowerShellCommandAsync(
177
227
  options: RunPowerShellCommandOptions,
178
228
  ): Promise<PowerShellCommandResult> {
179
229
  if (process.platform !== "win32") {
180
- return {
181
- ok: false,
182
- stdout: "",
183
- stderr: "",
184
- missingCommand: false,
185
- reason: "PowerShell is only available through pi-image-tools on Windows.",
186
- };
230
+ return nonWindowsPowerShellResult();
187
231
  }
188
232
 
189
- const commandArgs = [
190
- "-NoProfile",
191
- "-NonInteractive",
192
- ...(options.sta ? ["-STA"] : []),
193
- ...(options.encoded ? ["-EncodedCommand", encodePowerShell(script)] : ["-Command", script]),
194
- ...(options.args ?? []),
195
- ];
233
+ const commandArgs = buildPowerShellCommandArgs(script, options);
196
234
 
197
235
  const result = await runBufferedCommand("powershell.exe", commandArgs, {
198
236
  timeout: options.timeout,
@@ -203,20 +241,8 @@ export async function runPowerShellCommandAsync(
203
241
  const stderr = result.stderr.toString("utf8");
204
242
 
205
243
  if (result.error) {
206
- return {
207
- ok: false,
208
- stdout,
209
- stderr,
210
- missingCommand: isErrnoException(result.error) && result.error.code === "ENOENT",
211
- reason: getErrorMessage(result.error),
212
- };
244
+ return buildPowerShellResultFromError(result.error, stdout, stderr);
213
245
  }
214
246
 
215
- return {
216
- ok: result.status === 0,
217
- stdout,
218
- stderr,
219
- missingCommand: false,
220
- reason: result.status === 0 ? undefined : `PowerShell exited with code ${result.status}`,
221
- };
247
+ return buildPowerShellResultFromStatus(result.status, stdout, stderr);
222
248
  }
@@ -0,0 +1,28 @@
1
+ import type { DebugLogger } from "./debug-logger.js";
2
+ import { getErrorMessage } from "./errors.js";
3
+
4
+ /**
5
+ * Best-effort debug logging for Pi preview code.
6
+ *
7
+ * `DebugLogger.log` already swallows its own failures, and `getErrorMessage`
8
+ * never throws, so callers need no try/catch guard. Consolidating the call
9
+ * here keeps both preview renderers consistent and avoids duplicated guards.
10
+ */
11
+ export function logPreviewEvent(
12
+ logger: DebugLogger | undefined,
13
+ event: string,
14
+ fields: Record<string, unknown> = {},
15
+ ): void {
16
+ logger?.log(event, fields);
17
+ }
18
+
19
+ /**
20
+ * Best-effort debug logging for a caught error inside a Pi event handler.
21
+ */
22
+ export function logPreviewHandlerError(
23
+ logger: DebugLogger | undefined,
24
+ event: string,
25
+ error: unknown,
26
+ ): void {
27
+ logPreviewEvent(logger, event, { error: getErrorMessage(error) });
28
+ }
@@ -6,6 +6,14 @@ export const LIST_TYPES_TIMEOUT_MS = 1000;
6
6
  export const READ_TIMEOUT_MS = 5000;
7
7
  export const MAX_BUFFER_BYTES = 50 * 1024 * 1024;
8
8
 
9
+ const ALLOWED_CLIPBOARD_COMMANDS = new Set([
10
+ "osascript",
11
+ "pngpaste",
12
+ "reattach-to-user-namespace",
13
+ "wl-paste",
14
+ "xclip",
15
+ ]);
16
+
9
17
  export interface CommandResult {
10
18
  ok: boolean;
11
19
  stdout: Buffer;
@@ -40,7 +48,17 @@ function toBuffer(value: unknown): Buffer {
40
48
  }
41
49
 
42
50
  export const defaultCommandRunner: CommandRunner = (command, args, options) => {
43
- const result = spawnSync(command, [...args], {
51
+ if (!ALLOWED_CLIPBOARD_COMMANDS.has(command.trim().toLowerCase())) {
52
+ return {
53
+ ok: false,
54
+ stdout: Buffer.alloc(0),
55
+ stderr: Buffer.from(`Command is not allowlisted for clipboard image providers: ${command}`),
56
+ missingCommand: false,
57
+ status: null,
58
+ };
59
+ }
60
+
61
+ const result = spawnSync(command, [...args], { // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- command is checked against ALLOWED_CLIPBOARD_COMMANDS immediately above; provider args are fixed arrays and shell is disabled.
44
62
  env: options.environment,
45
63
  maxBuffer: options.maxBuffer ?? MAX_BUFFER_BYTES,
46
64
  stdio: ["ignore", "pipe", "pipe"],
@@ -1,101 +1,62 @@
1
- import { buildNamespaceWrappedCommand, defaultCommandExists, type CommandExists } from "../shell-environment.js";
2
- import {
3
- defaultCommandRunner,
4
- MAX_BUFFER_BYTES,
5
- READ_TIMEOUT_MS,
6
- type CommandRunner,
7
- } from "./command-runner.js";
8
- import type { ClipboardImageProvider, ClipboardProviderContext, ClipboardReadResult } from "./types.js";
9
-
10
- const PNGF_SCRIPT = `try
11
- set imageData to the clipboard as «class PNGf»
12
- return imageData
13
- on error
14
- return ""
15
- end try`;
16
-
17
- export interface OsascriptPngfProviderOptions {
18
- priority?: number;
19
- commandRunner?: CommandRunner;
20
- commandExists?: CommandExists;
21
- }
22
-
23
- function parseAppleScriptPngfData(stdout: Buffer): Uint8Array | null {
24
- const text = stdout.toString("utf8").trim();
25
- if (text.length === 0) {
26
- return null;
27
- }
28
-
29
- const match = text.match(/«data\s+PNGf([0-9a-fA-F\s]+)»/i);
30
- if (!match) {
31
- return null;
32
- }
33
-
34
- const hex = match[1]?.replace(/\s+/g, "") ?? "";
35
- if (hex.length === 0 || hex.length % 2 !== 0) {
36
- return null;
37
- }
38
-
39
- const bytes = Buffer.from(hex, "hex");
40
- return bytes.length > 0 ? new Uint8Array(bytes) : null;
41
- }
42
-
43
- export class OsascriptPngfProvider implements ClipboardImageProvider {
44
- readonly capabilities;
45
- private readonly commandRunner: CommandRunner;
46
- private readonly commandExists: CommandExists;
47
-
48
- constructor(options: OsascriptPngfProviderOptions = {}) {
49
- this.capabilities = {
50
- id: "mac-osascript-pngf",
51
- name: "osascript PNGf",
52
- platforms: ["darwin"],
53
- priority: options.priority ?? 30,
54
- };
55
- this.commandRunner = options.commandRunner ?? defaultCommandRunner;
56
- this.commandExists = options.commandExists ?? defaultCommandExists;
57
- }
58
-
59
- isAvailable(context: ClipboardProviderContext): boolean {
60
- try {
61
- return this.commandExists("osascript", context);
62
- } catch {
63
- return false;
64
- }
65
- }
66
-
67
- read(context: ClipboardProviderContext): ClipboardReadResult {
68
- const wrapped = buildNamespaceWrappedCommand(
69
- "osascript",
70
- ["-e", PNGF_SCRIPT],
71
- context,
72
- this.commandExists,
73
- );
74
- const result = this.commandRunner(wrapped.command, wrapped.args, {
75
- environment: context.environment,
76
- maxBuffer: MAX_BUFFER_BYTES,
77
- timeout: READ_TIMEOUT_MS,
78
- });
79
-
80
- if (result.missingCommand) {
81
- return { available: false, image: null };
82
- }
83
-
84
- if (!result.ok || result.stdout.length === 0) {
85
- return { available: true, image: null };
86
- }
87
-
88
- const bytes = parseAppleScriptPngfData(result.stdout);
89
- if (!bytes) {
90
- return { available: true, image: null };
91
- }
92
-
93
- return {
94
- available: true,
95
- image: {
96
- bytes,
97
- mimeType: "image/png",
98
- },
99
- };
100
- }
101
- }
1
+ import { NamespacedCommandProvider, providerEmptyImage, providerImageResult, type ClipboardReadResult, type CommandExists, type CommandResult, type CommandRunner } from "./provider-helpers.js";
2
+
3
+ const PNGF_SCRIPT = `try
4
+ set imageData to the clipboard as «class PNGf»
5
+ return imageData
6
+ on error
7
+ return ""
8
+ end try`;
9
+
10
+ export interface OsascriptPngfProviderOptions {
11
+ priority?: number;
12
+ commandRunner?: CommandRunner;
13
+ commandExists?: CommandExists;
14
+ }
15
+
16
+ function parseAppleScriptPngfData(stdout: Buffer): Uint8Array | null {
17
+ const text = stdout.toString("utf8").trim();
18
+ if (text.length === 0) {
19
+ return null;
20
+ }
21
+
22
+ const match = text.match(/«data\s+PNGf([0-9a-fA-F\s]+)»/i);
23
+ if (!match) {
24
+ return null;
25
+ }
26
+
27
+ const hex = match[1]?.replace(/\s+/g, "") ?? "";
28
+ if (hex.length === 0 || hex.length % 2 !== 0) {
29
+ return null;
30
+ }
31
+
32
+ const bytes = Buffer.from(hex, "hex");
33
+ return bytes.length > 0 ? new Uint8Array(bytes) : null;
34
+ }
35
+
36
+ export class OsascriptPngfProvider extends NamespacedCommandProvider {
37
+ constructor(options: OsascriptPngfProviderOptions = {}) {
38
+ super(
39
+ {
40
+ id: "mac-osascript-pngf",
41
+ name: "osascript PNGf",
42
+ platforms: ["darwin"],
43
+ priority: options.priority ?? 30,
44
+ },
45
+ "osascript",
46
+ options,
47
+ );
48
+ }
49
+
50
+ protected buildArgs(): readonly string[] {
51
+ return ["-e", PNGF_SCRIPT];
52
+ }
53
+
54
+ protected readFromResult(result: CommandResult): ClipboardReadResult {
55
+ const bytes = parseAppleScriptPngfData(result.stdout);
56
+ if (!bytes) {
57
+ return providerEmptyImage();
58
+ }
59
+
60
+ return providerImageResult(bytes, "image/png");
61
+ }
62
+ }
@@ -1,77 +1,38 @@
1
- import { buildNamespaceWrappedCommand, defaultCommandExists, type CommandExists } from "../shell-environment.js";
2
- import {
3
- defaultCommandRunner,
4
- MAX_BUFFER_BYTES,
5
- READ_TIMEOUT_MS,
6
- type CommandRunner,
7
- } from "./command-runner.js";
8
- import type { ClipboardImageProvider, ClipboardProviderContext, ClipboardReadResult } from "./types.js";
9
-
10
- const PUBLIC_PNG_SCRIPT = `ObjC.import('AppKit');
11
- const pasteboard = $.NSPasteboard.generalPasteboard;
12
- const data = pasteboard.dataForType('public.png');
13
- if (!data) {
14
- $.exit(2);
15
- }
16
- $.NSFileHandle.fileHandleWithStandardOutput.writeData(data);`;
17
-
18
- export interface OsascriptPublicPngProviderOptions {
19
- priority?: number;
20
- commandRunner?: CommandRunner;
21
- commandExists?: CommandExists;
22
- }
23
-
24
- export class OsascriptPublicPngProvider implements ClipboardImageProvider {
25
- readonly capabilities;
26
- private readonly commandRunner: CommandRunner;
27
- private readonly commandExists: CommandExists;
28
-
29
- constructor(options: OsascriptPublicPngProviderOptions = {}) {
30
- this.capabilities = {
31
- id: "mac-osascript-public-png",
32
- name: "osascript public.png",
33
- platforms: ["darwin"],
34
- priority: options.priority ?? 20,
35
- };
36
- this.commandRunner = options.commandRunner ?? defaultCommandRunner;
37
- this.commandExists = options.commandExists ?? defaultCommandExists;
38
- }
39
-
40
- isAvailable(context: ClipboardProviderContext): boolean {
41
- try {
42
- return this.commandExists("osascript", context);
43
- } catch {
44
- return false;
45
- }
46
- }
47
-
48
- read(context: ClipboardProviderContext): ClipboardReadResult {
49
- const wrapped = buildNamespaceWrappedCommand(
50
- "osascript",
51
- ["-l", "JavaScript", "-e", PUBLIC_PNG_SCRIPT],
52
- context,
53
- this.commandExists,
54
- );
55
- const result = this.commandRunner(wrapped.command, wrapped.args, {
56
- environment: context.environment,
57
- maxBuffer: MAX_BUFFER_BYTES,
58
- timeout: READ_TIMEOUT_MS,
59
- });
60
-
61
- if (result.missingCommand) {
62
- return { available: false, image: null };
63
- }
64
-
65
- if (!result.ok || result.stdout.length === 0) {
66
- return { available: true, image: null };
67
- }
68
-
69
- return {
70
- available: true,
71
- image: {
72
- bytes: new Uint8Array(result.stdout),
73
- mimeType: "image/png",
74
- },
75
- };
76
- }
77
- }
1
+ import { NamespacedCommandProvider, providerImageResult, type ClipboardReadResult, type CommandExists, type CommandResult, type CommandRunner } from "./provider-helpers.js";
2
+
3
+ const PUBLIC_PNG_SCRIPT = `ObjC.import('AppKit');
4
+ const pasteboard = $.NSPasteboard.generalPasteboard;
5
+ const data = pasteboard.dataForType('public.png');
6
+ if (!data) {
7
+ $.exit(2);
8
+ }
9
+ $.NSFileHandle.fileHandleWithStandardOutput.writeData(data);`;
10
+
11
+ export interface OsascriptPublicPngProviderOptions {
12
+ priority?: number;
13
+ commandRunner?: CommandRunner;
14
+ commandExists?: CommandExists;
15
+ }
16
+
17
+ export class OsascriptPublicPngProvider extends NamespacedCommandProvider {
18
+ constructor(options: OsascriptPublicPngProviderOptions = {}) {
19
+ super(
20
+ {
21
+ id: "mac-osascript-public-png",
22
+ name: "osascript public.png",
23
+ platforms: ["darwin"],
24
+ priority: options.priority ?? 20,
25
+ },
26
+ "osascript",
27
+ options,
28
+ );
29
+ }
30
+
31
+ protected buildArgs(): readonly string[] {
32
+ return ["-l", "JavaScript", "-e", PUBLIC_PNG_SCRIPT];
33
+ }
34
+
35
+ protected readFromResult(result: CommandResult): ClipboardReadResult {
36
+ return providerImageResult(new Uint8Array(result.stdout), "image/png");
37
+ }
38
+ }
@@ -1,69 +1,30 @@
1
- import { buildNamespaceWrappedCommand, defaultCommandExists, type CommandExists } from "../shell-environment.js";
2
- import {
3
- defaultCommandRunner,
4
- MAX_BUFFER_BYTES,
5
- READ_TIMEOUT_MS,
6
- type CommandRunner,
7
- } from "./command-runner.js";
8
- import type { ClipboardImageProvider, ClipboardProviderContext, ClipboardReadResult } from "./types.js";
9
-
10
- export interface PngpasteProviderOptions {
11
- priority?: number;
12
- commandRunner?: CommandRunner;
13
- commandExists?: CommandExists;
14
- }
15
-
16
- export class PngpasteProvider implements ClipboardImageProvider {
17
- readonly capabilities;
18
- private readonly commandRunner: CommandRunner;
19
- private readonly commandExists: CommandExists;
20
-
21
- constructor(options: PngpasteProviderOptions = {}) {
22
- this.capabilities = {
23
- id: "mac-pngpaste",
24
- name: "pngpaste",
25
- platforms: ["darwin"],
26
- priority: options.priority ?? 10,
27
- };
28
- this.commandRunner = options.commandRunner ?? defaultCommandRunner;
29
- this.commandExists = options.commandExists ?? defaultCommandExists;
30
- }
31
-
32
- isAvailable(context: ClipboardProviderContext): boolean {
33
- try {
34
- return this.commandExists("pngpaste", context);
35
- } catch {
36
- return false;
37
- }
38
- }
39
-
40
- read(context: ClipboardProviderContext): ClipboardReadResult {
41
- const wrapped = buildNamespaceWrappedCommand(
42
- "pngpaste",
43
- ["-"],
44
- context,
45
- this.commandExists,
46
- );
47
- const result = this.commandRunner(wrapped.command, wrapped.args, {
48
- environment: context.environment,
49
- maxBuffer: MAX_BUFFER_BYTES,
50
- timeout: READ_TIMEOUT_MS,
51
- });
52
-
53
- if (result.missingCommand) {
54
- return { available: false, image: null };
55
- }
56
-
57
- if (!result.ok || result.stdout.length === 0) {
58
- return { available: true, image: null };
59
- }
60
-
61
- return {
62
- available: true,
63
- image: {
64
- bytes: new Uint8Array(result.stdout),
65
- mimeType: "image/png",
66
- },
67
- };
68
- }
69
- }
1
+ import { NamespacedCommandProvider, providerImageResult, type ClipboardReadResult, type CommandExists, type CommandResult, type CommandRunner } from "./provider-helpers.js";
2
+
3
+ export interface PngpasteProviderOptions {
4
+ priority?: number;
5
+ commandRunner?: CommandRunner;
6
+ commandExists?: CommandExists;
7
+ }
8
+
9
+ export class PngpasteProvider extends NamespacedCommandProvider {
10
+ constructor(options: PngpasteProviderOptions = {}) {
11
+ super(
12
+ {
13
+ id: "mac-pngpaste",
14
+ name: "pngpaste",
15
+ platforms: ["darwin"],
16
+ priority: options.priority ?? 10,
17
+ },
18
+ "pngpaste",
19
+ options,
20
+ );
21
+ }
22
+
23
+ protected buildArgs(): readonly string[] {
24
+ return ["-"];
25
+ }
26
+
27
+ protected readFromResult(result: CommandResult): ClipboardReadResult {
28
+ return providerImageResult(new Uint8Array(result.stdout), "image/png");
29
+ }
30
+ }