@aliou/pi-processes 0.4.2 → 0.4.4

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.
@@ -71,6 +71,28 @@ export function registerProcessesSettings(
71
71
  },
72
72
  ],
73
73
  },
74
+ {
75
+ label: "Execution",
76
+ items: [
77
+ {
78
+ id: "execution.shellPath",
79
+ label: "Shell path",
80
+ description:
81
+ "Absolute shell path override used to execute commands",
82
+ currentValue:
83
+ tabConfig?.execution?.shellPath ??
84
+ resolved.execution.shellPath ??
85
+ "auto",
86
+ values: [
87
+ "auto",
88
+ "/run/current-system/sw/bin/bash",
89
+ "/bin/bash",
90
+ "/usr/bin/bash",
91
+ "/usr/local/bin/bash",
92
+ ],
93
+ },
94
+ ],
95
+ },
74
96
  {
75
97
  label: "Widget",
76
98
  items: [
@@ -97,6 +119,13 @@ export function registerProcessesSettings(
97
119
  updated.widget.showStatusWidget = newValue === "on";
98
120
  return updated;
99
121
  }
122
+ if (id === "execution.shellPath") {
123
+ if (!updated.execution) updated.execution = {};
124
+ updated.execution.shellPath =
125
+ newValue === "auto" ? undefined : newValue;
126
+ return updated;
127
+ }
128
+
100
129
  // Numeric fields.
101
130
  const num = Number.parseInt(newValue, 10);
102
131
  if (Number.isNaN(num)) return null;
package/config.ts CHANGED
@@ -20,6 +20,10 @@ export interface ProcessesConfig {
20
20
  /** Hard cap on output lines returned to the agent. */
21
21
  maxOutputLines?: number;
22
22
  };
23
+ execution?: {
24
+ /** Absolute shell path override. Leave unset to auto-resolve. */
25
+ shellPath?: string;
26
+ };
23
27
  widget?: {
24
28
  /** Show the status widget below the editor. */
25
29
  showStatusWidget?: boolean;
@@ -35,6 +39,9 @@ export interface ResolvedProcessesConfig {
35
39
  defaultTailLines: number;
36
40
  maxOutputLines: number;
37
41
  };
42
+ execution: {
43
+ shellPath?: string;
44
+ };
38
45
  widget: {
39
46
  showStatusWidget: boolean;
40
47
  };
@@ -49,6 +56,7 @@ const DEFAULT_CONFIG: ResolvedProcessesConfig = {
49
56
  defaultTailLines: 100,
50
57
  maxOutputLines: 200,
51
58
  },
59
+ execution: {},
52
60
  widget: {
53
61
  showStatusWidget: true,
54
62
  },
package/hooks/widget.ts CHANGED
@@ -126,8 +126,9 @@ export function setupProcessWidget(pi: ExtensionAPI, manager: ProcessManager) {
126
126
  }
127
127
 
128
128
  pi.on("session_start", async (_event, ctx) => {
129
+ // Startup defer: capture context only. First render happens on process
130
+ // manager events or explicit settings updates.
129
131
  latestContext = ctx;
130
- updateWidget();
131
132
  });
132
133
 
133
134
  pi.on("session_switch", async (_event, ctx) => {
package/index.ts CHANGED
@@ -16,7 +16,9 @@ export default async function (pi: ExtensionAPI) {
16
16
  }
17
17
 
18
18
  await configLoader.load();
19
- const manager = new ProcessManager();
19
+ const manager = new ProcessManager({
20
+ getConfiguredShellPath: () => configLoader.getConfig().execution.shellPath,
21
+ });
20
22
 
21
23
  const { update: updateWidget } = setupProcessesHooks(pi, manager);
22
24
  setupProcessesCommands(pi, manager);
package/manager.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ChildProcess, spawn } from "node:child_process";
1
+ import type { ChildProcess } from "node:child_process";
2
2
  import { EventEmitter } from "node:events";
3
3
  import {
4
4
  appendFileSync,
@@ -19,6 +19,7 @@ import {
19
19
  type StartOptions,
20
20
  } from "./constants";
21
21
  import { isProcessGroupAlive, killProcessGroup } from "./utils";
22
+ import { spawnCommand } from "./utils/command-executor";
22
23
 
23
24
  interface ManagedProcess extends ProcessInfo {
24
25
  process: ChildProcess;
@@ -26,16 +27,23 @@ interface ManagedProcess extends ProcessInfo {
26
27
  combinedFile: string;
27
28
  }
28
29
 
30
+ interface ProcessManagerOptions {
31
+ getConfiguredShellPath?: () => string | undefined;
32
+ }
33
+
29
34
  export class ProcessManager {
30
35
  private processes: Map<string, ManagedProcess> = new Map();
31
36
  private counter = 0;
32
37
  private logDir: string;
33
38
  private events = new EventEmitter();
34
39
  private watcher: ReturnType<typeof setInterval> | null = null;
40
+ private getConfiguredShellPath: () => string | undefined;
35
41
 
36
- constructor() {
42
+ constructor(options?: ProcessManagerOptions) {
37
43
  this.logDir = join(tmpdir(), `pi-processes-${Date.now()}`);
38
44
  mkdirSync(this.logDir, { recursive: true });
45
+ this.getConfiguredShellPath =
46
+ options?.getConfiguredShellPath ?? (() => undefined);
39
47
  }
40
48
 
41
49
  onEvent(listener: (event: ManagerEvent) => void): () => void {
@@ -129,12 +137,7 @@ export class ProcessManager {
129
137
  appendFileSync(stderrFile, "");
130
138
  appendFileSync(combinedFile, "");
131
139
 
132
- const child = spawn("/bin/bash", ["-lc", command], {
133
- cwd,
134
- env: process.env,
135
- stdio: ["ignore", "pipe", "pipe"],
136
- detached: true,
137
- });
140
+ const child = spawnCommand(command, cwd, this.getConfiguredShellPath());
138
141
 
139
142
  child.unref();
140
143
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-processes",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "keywords": [
@@ -34,7 +34,7 @@
34
34
  ],
35
35
  "dependencies": {
36
36
  "@sinclair/typebox": "^0.34.41",
37
- "@aliou/pi-utils-settings": "0.3.0",
37
+ "@aliou/pi-utils-settings": "0.4.0",
38
38
  "@aliou/pi-utils-ui": "^0.1.0"
39
39
  },
40
40
  "peerDependencies": {
@@ -0,0 +1,47 @@
1
+ import { existsSync } from "node:fs";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { resolveShellExecutable } from "./command-executor";
4
+
5
+ vi.mock("node:fs", async (importOriginal) => {
6
+ const actual = await importOriginal<typeof import("node:fs")>();
7
+ return { ...actual, existsSync: vi.fn() };
8
+ });
9
+
10
+ const existsSyncMock = vi.mocked(existsSync);
11
+
12
+ describe("resolveShellExecutable", () => {
13
+ it("prefers shell configured in settings when it is an existing absolute path", () => {
14
+ existsSyncMock.mockImplementation(
15
+ (path) => path === "/nix/store/abc-bash-5.3/bin/bash",
16
+ );
17
+
18
+ const resolved = resolveShellExecutable({
19
+ configuredShell: "/nix/store/abc-bash-5.3/bin/bash",
20
+ knownPaths: ["/bin/bash", "/usr/bin/bash"],
21
+ });
22
+
23
+ expect(resolved).toBe("/nix/store/abc-bash-5.3/bin/bash");
24
+ });
25
+
26
+ it("falls back to first existing known shell path", () => {
27
+ existsSyncMock.mockImplementation((path) => path === "/usr/bin/bash");
28
+
29
+ const resolved = resolveShellExecutable({
30
+ configuredShell: undefined,
31
+ knownPaths: ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"],
32
+ });
33
+
34
+ expect(resolved).toBe("/usr/bin/bash");
35
+ });
36
+
37
+ it("throws when no configured/known shell path exists", () => {
38
+ existsSyncMock.mockReturnValue(false);
39
+
40
+ expect(() =>
41
+ resolveShellExecutable({
42
+ configuredShell: undefined,
43
+ knownPaths: ["/bin/bash", "/usr/bin/bash"],
44
+ }),
45
+ ).toThrow(/shell/i);
46
+ });
47
+ });
@@ -0,0 +1,56 @@
1
+ import { type ChildProcess, spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { isAbsolute } from "node:path";
4
+
5
+ interface ResolveShellExecutableOptions {
6
+ configuredShell?: string;
7
+ knownPaths: string[];
8
+ }
9
+
10
+ const DEFAULT_KNOWN_SHELL_PATHS = [
11
+ "/run/current-system/sw/bin/bash",
12
+ "/bin/bash",
13
+ "/usr/bin/bash",
14
+ "/usr/local/bin/bash",
15
+ ];
16
+
17
+ function isExistingAbsolutePath(shell: string | undefined): shell is string {
18
+ return typeof shell === "string" && isAbsolute(shell) && existsSync(shell);
19
+ }
20
+
21
+ export function resolveShellExecutable({
22
+ configuredShell,
23
+ knownPaths,
24
+ }: ResolveShellExecutableOptions): string {
25
+ if (isExistingAbsolutePath(configuredShell)) {
26
+ return configuredShell;
27
+ }
28
+
29
+ for (const path of knownPaths) {
30
+ if (isExistingAbsolutePath(path)) {
31
+ return path;
32
+ }
33
+ }
34
+
35
+ throw new Error(
36
+ "Unable to resolve shell executable. Checked configured shell and known shell paths.",
37
+ );
38
+ }
39
+
40
+ export function spawnCommand(
41
+ command: string,
42
+ cwd: string,
43
+ configuredShell?: string,
44
+ ): ChildProcess {
45
+ const shellExecutable = resolveShellExecutable({
46
+ configuredShell,
47
+ knownPaths: DEFAULT_KNOWN_SHELL_PATHS,
48
+ });
49
+
50
+ return spawn(shellExecutable, ["-lc", command], {
51
+ cwd,
52
+ env: process.env,
53
+ stdio: ["ignore", "pipe", "pipe"],
54
+ detached: true,
55
+ });
56
+ }