@gogomi/pi-windows-shell 0.1.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/shell.ts ADDED
@@ -0,0 +1,304 @@
1
+ /**
2
+ * PowerShell discovery and foreground execution
3
+ */
4
+
5
+ import { spawn } from "node:child_process";
6
+ import { openSync, closeSync, mkdirSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import type { PowerShellResult, PowerShellDiscovery } from "./types.js";
9
+ import { truncateOutput } from "./output.js";
10
+
11
+ let cachedDiscovery: PowerShellDiscovery | null = null;
12
+
13
+ /**
14
+ * Discover available PowerShell installation.
15
+ * Prefer pwsh (PowerShell 7+), fallback to powershell.exe (Windows PowerShell).
16
+ */
17
+ export async function findPowerShell(): Promise<PowerShellDiscovery> {
18
+ if (cachedDiscovery) {
19
+ return cachedDiscovery;
20
+ }
21
+
22
+ // Try pwsh first (PowerShell 7+)
23
+ try {
24
+ const result = await spawnPowerShell("pwsh", "Get-Command pwsh -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source", 5000);
25
+ if (result.exitCode === 0 && result.stdout.trim()) {
26
+ cachedDiscovery = { exe: "pwsh", kind: "pwsh" };
27
+ return cachedDiscovery;
28
+ }
29
+ } catch {
30
+ // pwsh not available
31
+ }
32
+
33
+ // Fallback to Windows PowerShell
34
+ try {
35
+ const result = await spawnPowerShell("powershell.exe", "Get-Command powershell.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source", 5000);
36
+ if (result.exitCode === 0 && result.stdout.trim()) {
37
+ cachedDiscovery = { exe: "powershell.exe", kind: "powershell.exe" };
38
+ return cachedDiscovery;
39
+ }
40
+ } catch {
41
+ // powershell.exe not available
42
+ }
43
+
44
+ // Last resort: assume powershell.exe is available
45
+ cachedDiscovery = { exe: "powershell.exe", kind: "powershell.exe" };
46
+ return cachedDiscovery;
47
+ }
48
+
49
+ /**
50
+ * Clear the PowerShell discovery cache.
51
+ */
52
+ export function clearDiscoveryCache(): void {
53
+ cachedDiscovery = null;
54
+ }
55
+
56
+ /**
57
+ * Spawn a PowerShell command and capture output.
58
+ */
59
+ function spawnPowerShell(
60
+ shellExe: string,
61
+ command: string,
62
+ timeoutMs: number
63
+ ): Promise<PowerShellResult> {
64
+ return new Promise((resolve) => {
65
+ const timeout = setTimeout(() => {
66
+ proc.kill();
67
+ resolve({
68
+ shell: shellExe.includes("pwsh") ? "pwsh" : "powershell.exe",
69
+ cwd: "",
70
+ command,
71
+ exitCode: -1,
72
+ stdout: "",
73
+ stderr: "Command timed out",
74
+ timedOut: true,
75
+ truncated: false,
76
+ });
77
+ }, timeoutMs);
78
+
79
+ const proc = spawn(shellExe, [
80
+ "-NoProfile",
81
+ "-NonInteractive",
82
+ "-ExecutionPolicy", "Bypass",
83
+ "-Command",
84
+ command,
85
+ ], {
86
+ windowsHide: true,
87
+ stdio: ["ignore", "pipe", "pipe"],
88
+ });
89
+
90
+ let stdout = "";
91
+ let stderr = "";
92
+
93
+ proc.stdout?.on("data", (data) => {
94
+ stdout += data.toString();
95
+ });
96
+
97
+ proc.stderr?.on("data", (data) => {
98
+ stderr += data.toString();
99
+ });
100
+
101
+ proc.on("close", (code) => {
102
+ clearTimeout(timeout);
103
+ resolve({
104
+ shell: shellExe.includes("pwsh") ? "pwsh" : "powershell.exe",
105
+ cwd: "",
106
+ command,
107
+ exitCode: code ?? -1,
108
+ stdout,
109
+ stderr,
110
+ timedOut: false,
111
+ truncated: false,
112
+ });
113
+ });
114
+
115
+ proc.on("error", (error) => {
116
+ clearTimeout(timeout);
117
+ resolve({
118
+ shell: shellExe.includes("pwsh") ? "pwsh" : "powershell.exe",
119
+ cwd: "",
120
+ command,
121
+ exitCode: -1,
122
+ stdout: "",
123
+ stderr: error.message,
124
+ timedOut: false,
125
+ truncated: false,
126
+ });
127
+ });
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Execute a PowerShell command in foreground with full options.
133
+ */
134
+ export async function executePowerShell(options: {
135
+ command: string;
136
+ cwd?: string;
137
+ timeoutMs?: number;
138
+ maxOutputBytes?: number;
139
+ maxLines?: number;
140
+ }): Promise<PowerShellResult> {
141
+ const discovery = await findPowerShell();
142
+ const cwd = options.cwd || process.cwd();
143
+ const timeoutMs = options.timeoutMs ?? 120000;
144
+
145
+ return new Promise((resolve) => {
146
+ const timeout = setTimeout(() => {
147
+ proc.kill();
148
+ resolve({
149
+ shell: discovery.kind,
150
+ cwd,
151
+ command: options.command,
152
+ exitCode: -1,
153
+ stdout: "",
154
+ stderr: "Command timed out",
155
+ timedOut: true,
156
+ truncated: false,
157
+ });
158
+ }, timeoutMs);
159
+
160
+ const proc = spawn(discovery.exe, [
161
+ "-NoProfile",
162
+ "-NonInteractive",
163
+ "-ExecutionPolicy", "Bypass",
164
+ "-Command",
165
+ options.command,
166
+ ], {
167
+ cwd,
168
+ windowsHide: true,
169
+ stdio: ["ignore", "pipe", "pipe"],
170
+ });
171
+
172
+ let stdout = "";
173
+ let stderr = "";
174
+
175
+ proc.stdout?.on("data", (data) => {
176
+ stdout += data.toString();
177
+ });
178
+
179
+ proc.stderr?.on("data", (data) => {
180
+ stderr += data.toString();
181
+ });
182
+
183
+ proc.on("close", (code) => {
184
+ clearTimeout(timeout);
185
+
186
+ // Truncate output if needed
187
+ const maxBytes = options.maxOutputBytes ?? 50000;
188
+ const maxLines = options.maxLines ?? 2000;
189
+
190
+ const truncatedStdout = truncateOutput(stdout, { maxBytes, maxLines });
191
+ const truncatedStderr = truncateOutput(stderr, { maxBytes: 10000, maxLines: 200 });
192
+
193
+ resolve({
194
+ shell: discovery.kind,
195
+ cwd,
196
+ command: options.command,
197
+ exitCode: code ?? -1,
198
+ stdout: truncatedStdout.text,
199
+ stderr: truncatedStderr.text,
200
+ timedOut: false,
201
+ truncated: truncatedStdout.truncated,
202
+ });
203
+ });
204
+
205
+ proc.on("error", (error) => {
206
+ clearTimeout(timeout);
207
+ resolve({
208
+ shell: discovery.kind,
209
+ cwd,
210
+ command: options.command,
211
+ exitCode: -1,
212
+ stdout: "",
213
+ stderr: error.message,
214
+ timedOut: false,
215
+ truncated: false,
216
+ });
217
+ });
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Start a detached PowerShell process and write output to a file.
223
+ * Uses fs.openSync for a numeric file descriptor passed directly to spawn stdio —
224
+ * the OS handles the I/O natively, avoiding Node.js stream/pipeline quirks on Windows.
225
+ */
226
+ export async function startDetachedProcess(options: {
227
+ command: string;
228
+ cwd?: string;
229
+ outputFile: string;
230
+ append?: boolean;
231
+ }): Promise<{ pid: number; shell: "pwsh" | "powershell.exe" }> {
232
+ const discovery = await findPowerShell();
233
+ const cwd = options.cwd || process.cwd();
234
+ const flags = options.append !== false ? "a" : "w";
235
+
236
+ // Ensure parent directory exists for the output file
237
+ mkdirSync(dirname(options.outputFile), { recursive: true });
238
+
239
+ return new Promise((resolve, reject) => {
240
+ let fdOut: number;
241
+ let fdErr: number;
242
+ try {
243
+ fdOut = openSync(options.outputFile, flags);
244
+ fdErr = openSync(options.outputFile, flags);
245
+ } catch (error: unknown) {
246
+ const msg = error instanceof Error ? error.message : String(error);
247
+ reject(new Error(`Failed to open output file: ${msg}`));
248
+ return;
249
+ }
250
+
251
+ const proc = spawn(discovery.exe, [
252
+ "-NoProfile",
253
+ "-NonInteractive",
254
+ "-ExecutionPolicy", "Bypass",
255
+ "-Command",
256
+ options.command,
257
+ ], {
258
+ cwd,
259
+ windowsHide: true,
260
+ stdio: ["ignore", fdOut, fdErr],
261
+ });
262
+
263
+ proc.on("error", (error: Error) => {
264
+ try { closeSync(fdOut); } catch {}
265
+ try { closeSync(fdErr); } catch {}
266
+ reject(new Error(`Failed to spawn ${discovery.exe}: ${error.message}`));
267
+ });
268
+
269
+ proc.on("close", () => {
270
+ try { closeSync(fdOut); } catch {}
271
+ try { closeSync(fdErr); } catch {}
272
+ });
273
+
274
+ if (!proc.pid) {
275
+ try { closeSync(fdOut); } catch {}
276
+ try { closeSync(fdErr); } catch {}
277
+ reject(new Error(`Failed to spawn ${discovery.exe}: no PID returned`));
278
+ return;
279
+ }
280
+
281
+ proc.unref();
282
+ resolve({ pid: proc.pid, shell: discovery.kind });
283
+ });
284
+ }
285
+
286
+ /**
287
+ * Get version info about the discovered PowerShell.
288
+ */
289
+ export async function getPowerShellVersion(): Promise<string | null> {
290
+ try {
291
+ const result = await spawnPowerShell(
292
+ (await findPowerShell()).exe,
293
+ "$PSVersionTable.PSVersion.ToString()",
294
+ 10000
295
+ );
296
+
297
+ if (result.exitCode === 0) {
298
+ return result.stdout.trim();
299
+ }
300
+ return null;
301
+ } catch {
302
+ return null;
303
+ }
304
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "outDir": "./dist",
11
+ "rootDir": "."
12
+ },
13
+ "include": ["*.ts"],
14
+ "exclude": ["node_modules", "dist"]
15
+ }
package/types.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Shared types for pi-windows-shell extension
3
+ */
4
+
5
+ export interface ManagedProcess {
6
+ id: string;
7
+ name: string;
8
+ pid: number;
9
+ command: string;
10
+ cwd: string;
11
+ shell: "pwsh" | "powershell.exe";
12
+ outputFile: string;
13
+ startedAt: string;
14
+ status: "running" | "exited" | "unknown";
15
+ lastCheckedAt?: string;
16
+ exitCode?: number | null;
17
+ }
18
+
19
+ export interface ProcessRegistry {
20
+ version: string;
21
+ processes: ManagedProcess[];
22
+ }
23
+
24
+ export interface PowerShellResult {
25
+ shell: "pwsh" | "powershell.exe";
26
+ cwd: string;
27
+ command: string;
28
+ exitCode: number;
29
+ stdout: string;
30
+ stderr: string;
31
+ timedOut: boolean;
32
+ truncated: boolean;
33
+ }
34
+
35
+ export interface PowerShellDiscovery {
36
+ exe: string;
37
+ kind: "pwsh" | "powershell.exe";
38
+ }
39
+
40
+ export interface TailResult {
41
+ lines: string[];
42
+ truncated: boolean;
43
+ totalLines: number;
44
+ linesRead: number;
45
+ fileExists: boolean;
46
+ }
47
+
48
+ export interface CleanupResult {
49
+ removedEntries: number;
50
+ deletedLogs: number;
51
+ keptRunning: number;
52
+ }