@bastani/atomic 0.6.0-0 → 0.6.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/README.md +1 -0
- package/dist/lib/spawn.d.ts +102 -0
- package/dist/lib/spawn.d.ts.map +1 -0
- package/dist/sdk/providers/claude.d.ts.map +1 -1
- package/dist/sdk/runtime/attached-footer.d.ts +14 -0
- package/dist/sdk/runtime/attached-footer.d.ts.map +1 -1
- package/dist/sdk/runtime/tmux.d.ts +22 -11
- package/dist/sdk/runtime/tmux.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/commands/cli/chat/index.test.ts +60 -0
- package/src/commands/cli/chat/index.ts +11 -33
- package/src/commands/cli/footer.tsx +170 -54
- package/src/lib/spawn.test.ts +109 -0
- package/src/lib/spawn.ts +371 -33
- package/src/sdk/providers/claude.ts +17 -0
- package/src/sdk/runtime/attached-footer.ts +96 -7
- package/src/sdk/runtime/tmux.ts +102 -52
- package/src/services/system/auto-sync.ts +14 -8
package/src/lib/spawn.ts
CHANGED
|
@@ -5,12 +5,21 @@
|
|
|
5
5
|
* eliminating duplication across postinstall-playwright, postinstall-liteparse, etc.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import {
|
|
9
|
+
copyFileSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
mkdtempSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
} from "node:fs";
|
|
8
15
|
import { join } from "node:path";
|
|
9
|
-
import { homedir } from "node:os";
|
|
16
|
+
import { homedir, tmpdir } from "node:os";
|
|
10
17
|
|
|
11
18
|
export interface SpawnResult {
|
|
12
19
|
success: boolean;
|
|
13
20
|
details: string;
|
|
21
|
+
stdout?: string;
|
|
22
|
+
stderr?: string;
|
|
14
23
|
}
|
|
15
24
|
|
|
16
25
|
export interface RunCommandOptions {
|
|
@@ -49,14 +58,19 @@ export async function runCommand(cmd: string[], options?: RunCommandOptions): Pr
|
|
|
49
58
|
new Response(proc.stdout).text(),
|
|
50
59
|
proc.exited,
|
|
51
60
|
]);
|
|
61
|
+
const trimmedStdout = stdout.trim();
|
|
62
|
+
const trimmedStderr = stderr.trim();
|
|
52
63
|
return {
|
|
53
64
|
success: exitCode === 0,
|
|
54
|
-
details:
|
|
65
|
+
details: trimmedStderr.length > 0 ? trimmedStderr : trimmedStdout,
|
|
66
|
+
stdout: trimmedStdout,
|
|
67
|
+
stderr: trimmedStderr,
|
|
55
68
|
};
|
|
56
69
|
} catch (error) {
|
|
57
70
|
return {
|
|
58
71
|
success: false,
|
|
59
72
|
details: error instanceof Error ? error.message : String(error),
|
|
73
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
60
74
|
};
|
|
61
75
|
}
|
|
62
76
|
}
|
|
@@ -68,11 +82,290 @@ export function prependPath(directory: string): void {
|
|
|
68
82
|
const pathDelimiter = process.platform === "win32" ? ";" : ":";
|
|
69
83
|
const currentPath = process.env.PATH ?? "";
|
|
70
84
|
const entries = currentPath.split(pathDelimiter);
|
|
71
|
-
|
|
85
|
+
const alreadyPresent = process.platform === "win32"
|
|
86
|
+
? entries.some((entry) => entry.toLowerCase() === directory.toLowerCase())
|
|
87
|
+
: entries.includes(directory);
|
|
88
|
+
if (!alreadyPresent) {
|
|
72
89
|
process.env.PATH = directory + pathDelimiter + currentPath;
|
|
73
90
|
}
|
|
74
91
|
}
|
|
75
92
|
|
|
93
|
+
function windowsAtomicBinDir(): string {
|
|
94
|
+
return join(getHomeDir(), ".atomic", "bin");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function resolveCommandFromCurrentPath(cmd: string): string | null {
|
|
98
|
+
return Bun.which(cmd, { PATH: process.env.PATH ?? "" });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type MuxBinaryName = "tmux" | "psmux" | "pmux";
|
|
102
|
+
|
|
103
|
+
export function requiredMuxBinaryCandidatesForPlatform(
|
|
104
|
+
platform: NodeJS.Platform = process.platform,
|
|
105
|
+
): MuxBinaryName[] {
|
|
106
|
+
return platform === "win32" ? ["psmux", "pmux"] : ["tmux"];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function isMuxBinaryRequiredForPlatform(
|
|
110
|
+
binary: MuxBinaryName,
|
|
111
|
+
platform: NodeJS.Platform = process.platform,
|
|
112
|
+
): boolean {
|
|
113
|
+
return requiredMuxBinaryCandidatesForPlatform(platform).includes(binary);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function hasRequiredMuxBinary(): boolean {
|
|
117
|
+
return requiredMuxBinaryCandidatesForPlatform().some(
|
|
118
|
+
(candidate) => resolveCommandFromCurrentPath(candidate),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function prependPathIfDirectory(directory: string | undefined): void {
|
|
123
|
+
if (!directory || !existsSync(directory)) return;
|
|
124
|
+
prependPath(directory);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function prependWindowsMuxInstallPaths(): void {
|
|
128
|
+
if (process.platform !== "win32") return;
|
|
129
|
+
|
|
130
|
+
const home = getHomeDir();
|
|
131
|
+
prependPathIfDirectory(
|
|
132
|
+
process.env.SCOOP ? join(process.env.SCOOP, "shims") : undefined,
|
|
133
|
+
);
|
|
134
|
+
prependPathIfDirectory(home ? join(home, "scoop", "shims") : undefined);
|
|
135
|
+
prependPathIfDirectory(
|
|
136
|
+
process.env.LOCALAPPDATA
|
|
137
|
+
? join(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links")
|
|
138
|
+
: undefined,
|
|
139
|
+
);
|
|
140
|
+
prependPathIfDirectory(
|
|
141
|
+
process.env.LOCALAPPDATA
|
|
142
|
+
? join(process.env.LOCALAPPDATA, "Microsoft", "WindowsApps")
|
|
143
|
+
: undefined,
|
|
144
|
+
);
|
|
145
|
+
prependPathIfDirectory(
|
|
146
|
+
process.env.ChocolateyInstall
|
|
147
|
+
? join(process.env.ChocolateyInstall, "bin")
|
|
148
|
+
: undefined,
|
|
149
|
+
);
|
|
150
|
+
prependPathIfDirectory("C:\\ProgramData\\chocolatey\\bin");
|
|
151
|
+
prependPathIfDirectory(home ? join(home, ".cargo", "bin") : undefined);
|
|
152
|
+
prependPathIfDirectory(windowsAtomicBinDir());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function prependBunInstallPaths(): void {
|
|
156
|
+
const home = getHomeDir();
|
|
157
|
+
prependPathIfDirectory(process.env.BUN_INSTALL_BIN);
|
|
158
|
+
prependPathIfDirectory(
|
|
159
|
+
process.env.BUN_INSTALL ? join(process.env.BUN_INSTALL, "bin") : undefined,
|
|
160
|
+
);
|
|
161
|
+
prependPathIfDirectory(home ? join(home, ".bun", "bin") : undefined);
|
|
162
|
+
|
|
163
|
+
if (process.platform !== "win32") return;
|
|
164
|
+
|
|
165
|
+
prependPathIfDirectory(
|
|
166
|
+
process.env.SCOOP ? join(process.env.SCOOP, "shims") : undefined,
|
|
167
|
+
);
|
|
168
|
+
prependPathIfDirectory(home ? join(home, "scoop", "shims") : undefined);
|
|
169
|
+
prependPathIfDirectory(
|
|
170
|
+
process.env.LOCALAPPDATA
|
|
171
|
+
? join(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links")
|
|
172
|
+
: undefined,
|
|
173
|
+
);
|
|
174
|
+
prependPathIfDirectory(
|
|
175
|
+
process.env.LOCALAPPDATA
|
|
176
|
+
? join(process.env.LOCALAPPDATA, "Microsoft", "WindowsApps")
|
|
177
|
+
: undefined,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function mergePath(pathValue: string): void {
|
|
182
|
+
const delimiter = process.platform === "win32" ? ";" : ":";
|
|
183
|
+
for (const entry of pathValue.split(delimiter)) {
|
|
184
|
+
const trimmed = entry.trim();
|
|
185
|
+
if (trimmed) prependPath(trimmed);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function refreshWindowsPathFromRegistry(): Promise<void> {
|
|
190
|
+
if (process.platform !== "win32") return;
|
|
191
|
+
|
|
192
|
+
const shell = resolveCommandFromCurrentPath("powershell") ??
|
|
193
|
+
resolveCommandFromCurrentPath("pwsh");
|
|
194
|
+
if (!shell) return;
|
|
195
|
+
|
|
196
|
+
const readRegistryPath =
|
|
197
|
+
"$paths = @([Environment]::GetEnvironmentVariable('Path','Process'), " +
|
|
198
|
+
"[Environment]::GetEnvironmentVariable('Path','User'), " +
|
|
199
|
+
"[Environment]::GetEnvironmentVariable('Path','Machine')) | " +
|
|
200
|
+
"Where-Object { $_ }; $paths -join ';'";
|
|
201
|
+
|
|
202
|
+
const result = await runCommand([
|
|
203
|
+
shell,
|
|
204
|
+
"-NoProfile",
|
|
205
|
+
"-Command",
|
|
206
|
+
readRegistryPath,
|
|
207
|
+
]);
|
|
208
|
+
if (result.success && result.stdout) {
|
|
209
|
+
mergePath(result.stdout);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function refreshWindowsMuxPath(): Promise<void> {
|
|
214
|
+
prependWindowsMuxInstallPaths();
|
|
215
|
+
await refreshWindowsPathFromRegistry();
|
|
216
|
+
prependWindowsMuxInstallPaths();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function refreshWindowsBunPath(): Promise<void> {
|
|
220
|
+
prependBunInstallPaths();
|
|
221
|
+
await refreshWindowsPathFromRegistry();
|
|
222
|
+
prependBunInstallPaths();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
interface GitHubReleaseAsset {
|
|
226
|
+
name: string;
|
|
227
|
+
browser_download_url: string;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface GitHubRelease {
|
|
231
|
+
assets: GitHubReleaseAsset[];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function psmuxReleaseAssetSuffix(
|
|
235
|
+
arch: NodeJS.Architecture = process.arch,
|
|
236
|
+
): string | null {
|
|
237
|
+
switch (arch) {
|
|
238
|
+
case "x64":
|
|
239
|
+
return "windows-x64.zip";
|
|
240
|
+
case "ia32":
|
|
241
|
+
return "windows-x86.zip";
|
|
242
|
+
case "arm64":
|
|
243
|
+
return "windows-arm64.zip";
|
|
244
|
+
default:
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function powershellLiteral(value: string): string {
|
|
250
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function persistWindowsUserPath(directory: string): Promise<SpawnResult> {
|
|
254
|
+
const shell = resolveCommandFromCurrentPath("powershell") ??
|
|
255
|
+
resolveCommandFromCurrentPath("pwsh");
|
|
256
|
+
if (!shell) return { success: true, details: "" };
|
|
257
|
+
|
|
258
|
+
const script =
|
|
259
|
+
`$dir = ${powershellLiteral(directory)}; ` +
|
|
260
|
+
"$current = [Environment]::GetEnvironmentVariable('Path','User'); " +
|
|
261
|
+
"$entries = if ([string]::IsNullOrWhiteSpace($current)) { @() } else { $current -split ';' }; " +
|
|
262
|
+
"$expandedDir = [Environment]::ExpandEnvironmentVariables($dir) -replace '[\\\\/]+$',''; " +
|
|
263
|
+
"$hasDir = $false; " +
|
|
264
|
+
"foreach ($entry in $entries) { " +
|
|
265
|
+
" $expandedEntry = [Environment]::ExpandEnvironmentVariables($entry).Trim().Trim('\"') -replace '[\\\\/]+$',''; " +
|
|
266
|
+
" if ($expandedEntry -ieq $expandedDir) { $hasDir = $true; break } " +
|
|
267
|
+
"} " +
|
|
268
|
+
"if (-not $hasDir) { " +
|
|
269
|
+
" $next = (@($entries) + @($dir) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ';'; " +
|
|
270
|
+
" [Environment]::SetEnvironmentVariable('Path', $next, 'User'); " +
|
|
271
|
+
"}";
|
|
272
|
+
|
|
273
|
+
return runCommand([shell, "-NoProfile", "-Command", script]);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function installPsmuxFromGitHubRelease(): Promise<SpawnResult> {
|
|
277
|
+
try {
|
|
278
|
+
const suffix = psmuxReleaseAssetSuffix();
|
|
279
|
+
if (!suffix) {
|
|
280
|
+
return {
|
|
281
|
+
success: false,
|
|
282
|
+
details: `No psmux release asset is available for ${process.arch}.`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const response = await fetch(
|
|
287
|
+
"https://api.github.com/repos/psmux/psmux/releases/latest",
|
|
288
|
+
{ headers: { "Accept": "application/vnd.github+json" } },
|
|
289
|
+
);
|
|
290
|
+
if (!response.ok) {
|
|
291
|
+
return {
|
|
292
|
+
success: false,
|
|
293
|
+
details: `Could not fetch latest psmux release: ${response.status} ${response.statusText}`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const release = await response.json() as GitHubRelease;
|
|
298
|
+
const asset = release.assets.find((item) => item.name.endsWith(suffix));
|
|
299
|
+
if (!asset) {
|
|
300
|
+
return {
|
|
301
|
+
success: false,
|
|
302
|
+
details: `Latest psmux release does not include a ${suffix} asset.`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const archiveResponse = await fetch(asset.browser_download_url);
|
|
307
|
+
if (!archiveResponse.ok) {
|
|
308
|
+
return {
|
|
309
|
+
success: false,
|
|
310
|
+
details: `Could not download ${asset.name}: ${archiveResponse.status} ${archiveResponse.statusText}`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const tempDir = mkdtempSync(join(tmpdir(), "atomic-psmux-"));
|
|
315
|
+
const zipPath = join(tempDir, asset.name);
|
|
316
|
+
const extractDir = join(tempDir, "extract");
|
|
317
|
+
const installDir = windowsAtomicBinDir();
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
await Bun.write(zipPath, await archiveResponse.arrayBuffer());
|
|
321
|
+
mkdirSync(extractDir, { recursive: true });
|
|
322
|
+
mkdirSync(installDir, { recursive: true });
|
|
323
|
+
|
|
324
|
+
const shell = resolveCommandFromCurrentPath("powershell") ??
|
|
325
|
+
resolveCommandFromCurrentPath("pwsh");
|
|
326
|
+
if (!shell) {
|
|
327
|
+
return {
|
|
328
|
+
success: false,
|
|
329
|
+
details: "PowerShell is required to expand the psmux release archive.",
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const expand = await runCommand([
|
|
334
|
+
shell,
|
|
335
|
+
"-NoProfile",
|
|
336
|
+
"-Command",
|
|
337
|
+
`Expand-Archive -LiteralPath ${powershellLiteral(zipPath)} -DestinationPath ${powershellLiteral(extractDir)} -Force`,
|
|
338
|
+
]);
|
|
339
|
+
if (!expand.success) return expand;
|
|
340
|
+
|
|
341
|
+
for (const binary of ["psmux.exe", "pmux.exe", "tmux.exe"]) {
|
|
342
|
+
const source = join(extractDir, binary);
|
|
343
|
+
if (existsSync(source)) {
|
|
344
|
+
copyFileSync(source, join(installDir, binary));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
prependPath(installDir);
|
|
349
|
+
const persistResult = await persistWindowsUserPath(installDir);
|
|
350
|
+
if (!persistResult.success) return persistResult;
|
|
351
|
+
|
|
352
|
+
return hasRequiredMuxBinary()
|
|
353
|
+
? { success: true, details: "" }
|
|
354
|
+
: {
|
|
355
|
+
success: false,
|
|
356
|
+
details: `Downloaded psmux but no psmux binary was found in ${installDir}.`,
|
|
357
|
+
};
|
|
358
|
+
} finally {
|
|
359
|
+
rmSync(tempDir, { force: true, recursive: true });
|
|
360
|
+
}
|
|
361
|
+
} catch (error) {
|
|
362
|
+
return {
|
|
363
|
+
success: false,
|
|
364
|
+
details: error instanceof Error ? error.message : String(error),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
76
369
|
/**
|
|
77
370
|
* Get the user's home directory.
|
|
78
371
|
* Uses Node.js os.homedir() which handles cross-platform resolution
|
|
@@ -138,62 +431,85 @@ export async function ensureTmuxInstalled(options: EnsureOptions = {}): Promise<
|
|
|
138
431
|
const quiet = options.quiet ?? false;
|
|
139
432
|
const inherit = !quiet;
|
|
140
433
|
|
|
141
|
-
// Check for
|
|
142
|
-
if (
|
|
434
|
+
// Check for the platform-native multiplexer binary.
|
|
435
|
+
if (hasRequiredMuxBinary()) return;
|
|
143
436
|
|
|
144
437
|
let capturedDetails = "";
|
|
145
438
|
const record = (result: SpawnResult) => {
|
|
146
|
-
if (
|
|
439
|
+
if (!result.success && result.details) {
|
|
147
440
|
capturedDetails = result.details;
|
|
148
441
|
}
|
|
149
442
|
};
|
|
150
443
|
|
|
151
444
|
if (process.platform === "win32") {
|
|
152
445
|
// Windows: install psmux
|
|
153
|
-
const winget =
|
|
446
|
+
const winget = resolveCommandFromCurrentPath("winget");
|
|
154
447
|
if (winget) {
|
|
155
|
-
const result = await runCommand([
|
|
448
|
+
const result = await runCommand([
|
|
449
|
+
winget,
|
|
450
|
+
"install",
|
|
451
|
+
"--id",
|
|
452
|
+
"marlocarlo.psmux",
|
|
453
|
+
"--exact",
|
|
454
|
+
"--accept-source-agreements",
|
|
455
|
+
"--accept-package-agreements",
|
|
456
|
+
], { inherit });
|
|
156
457
|
record(result);
|
|
157
|
-
if (result.success
|
|
458
|
+
if (result.success) {
|
|
459
|
+
await refreshWindowsMuxPath();
|
|
460
|
+
if (hasRequiredMuxBinary()) return;
|
|
461
|
+
}
|
|
158
462
|
}
|
|
159
463
|
|
|
160
|
-
const scoop =
|
|
464
|
+
const scoop = resolveCommandFromCurrentPath("scoop");
|
|
161
465
|
if (scoop) {
|
|
162
466
|
await runCommand([scoop, "bucket", "add", "psmux", "https://github.com/psmux/scoop-psmux"], { inherit });
|
|
163
467
|
const result = await runCommand([scoop, "install", "psmux"], { inherit });
|
|
164
468
|
record(result);
|
|
165
|
-
if (result.success
|
|
469
|
+
if (result.success) {
|
|
470
|
+
await refreshWindowsMuxPath();
|
|
471
|
+
if (hasRequiredMuxBinary()) return;
|
|
472
|
+
}
|
|
166
473
|
}
|
|
167
474
|
|
|
168
|
-
const choco =
|
|
475
|
+
const choco = resolveCommandFromCurrentPath("choco");
|
|
169
476
|
if (choco) {
|
|
170
477
|
const result = await runCommand([choco, "install", "psmux", "-y", "--no-progress"], { inherit });
|
|
171
478
|
record(result);
|
|
172
|
-
if (result.success
|
|
479
|
+
if (result.success) {
|
|
480
|
+
await refreshWindowsMuxPath();
|
|
481
|
+
if (hasRequiredMuxBinary()) return;
|
|
482
|
+
}
|
|
173
483
|
}
|
|
174
484
|
|
|
175
|
-
const cargo =
|
|
485
|
+
const cargo = resolveCommandFromCurrentPath("cargo");
|
|
176
486
|
if (cargo) {
|
|
177
487
|
const result = await runCommand([cargo, "install", "psmux"], { inherit });
|
|
178
488
|
record(result);
|
|
179
489
|
if (result.success) {
|
|
180
490
|
const home = getHomeDir();
|
|
181
491
|
if (home) prependPath(join(home, ".cargo", "bin"));
|
|
182
|
-
|
|
492
|
+
await refreshWindowsMuxPath();
|
|
493
|
+
if (hasRequiredMuxBinary()) return;
|
|
183
494
|
}
|
|
184
495
|
}
|
|
496
|
+
|
|
497
|
+
const directResult = await installPsmuxFromGitHubRelease();
|
|
498
|
+
record(directResult);
|
|
499
|
+
if (directResult.success) return;
|
|
500
|
+
|
|
185
501
|
throw new Error(
|
|
186
|
-
capturedDetails || "Could not install psmux
|
|
502
|
+
capturedDetails || "Could not install psmux automatically.",
|
|
187
503
|
);
|
|
188
504
|
}
|
|
189
505
|
|
|
190
506
|
// Unix / macOS
|
|
191
507
|
if (process.platform === "darwin") {
|
|
192
|
-
const brew =
|
|
508
|
+
const brew = resolveCommandFromCurrentPath("brew");
|
|
193
509
|
if (brew) {
|
|
194
510
|
const result = await runCommand([brew, "install", "tmux"], { inherit });
|
|
195
511
|
record(result);
|
|
196
|
-
if (result.success &&
|
|
512
|
+
if (result.success && resolveCommandFromCurrentPath("tmux")) return;
|
|
197
513
|
}
|
|
198
514
|
}
|
|
199
515
|
|
|
@@ -214,7 +530,7 @@ export async function ensureTmuxInstalled(options: EnsureOptions = {}): Promise<
|
|
|
214
530
|
|
|
215
531
|
for (const script of managers) {
|
|
216
532
|
record(await runCommand([shell, "-lc", script], { inherit }));
|
|
217
|
-
if (
|
|
533
|
+
if (resolveCommandFromCurrentPath("tmux")) return;
|
|
218
534
|
}
|
|
219
535
|
|
|
220
536
|
throw new Error(
|
|
@@ -227,51 +543,73 @@ export async function ensureTmuxInstalled(options: EnsureOptions = {}): Promise<
|
|
|
227
543
|
* No-op when already present.
|
|
228
544
|
*/
|
|
229
545
|
export async function ensureBunInstalled(): Promise<void> {
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
const home = getHomeDir();
|
|
546
|
+
if (resolveCommandFromCurrentPath("bun")) return;
|
|
233
547
|
|
|
234
548
|
if (process.platform === "win32") {
|
|
235
549
|
// Windows
|
|
236
|
-
const winget =
|
|
550
|
+
const winget = resolveCommandFromCurrentPath("winget");
|
|
237
551
|
if (winget) {
|
|
238
552
|
const result = await runCommand([winget, "install", "Oven-sh.Bun", "--accept-source-agreements", "--accept-package-agreements"], { inherit: true });
|
|
239
553
|
if (result.success) {
|
|
240
|
-
|
|
241
|
-
if (
|
|
554
|
+
await refreshWindowsBunPath();
|
|
555
|
+
if (resolveCommandFromCurrentPath("bun")) return;
|
|
242
556
|
}
|
|
243
557
|
}
|
|
244
558
|
|
|
245
|
-
const scoop =
|
|
559
|
+
const scoop = resolveCommandFromCurrentPath("scoop");
|
|
246
560
|
if (scoop) {
|
|
247
561
|
const result = await runCommand([scoop, "install", "bun"], { inherit: true });
|
|
248
|
-
if (result.success
|
|
562
|
+
if (result.success) {
|
|
563
|
+
await refreshWindowsBunPath();
|
|
564
|
+
if (resolveCommandFromCurrentPath("bun")) return;
|
|
565
|
+
}
|
|
249
566
|
}
|
|
250
567
|
|
|
251
|
-
|
|
568
|
+
const shell = resolveCommandFromCurrentPath("powershell") ??
|
|
569
|
+
resolveCommandFromCurrentPath("pwsh");
|
|
570
|
+
if (shell) {
|
|
571
|
+
const result = await runCommand([
|
|
572
|
+
shell,
|
|
573
|
+
"-NoProfile",
|
|
574
|
+
"-Command",
|
|
575
|
+
"irm bun.sh/install.ps1 | iex",
|
|
576
|
+
], { inherit: true });
|
|
577
|
+
if (result.success) {
|
|
578
|
+
await refreshWindowsBunPath();
|
|
579
|
+
if (resolveCommandFromCurrentPath("bun")) return;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
throw new Error("Could not install bun automatically.");
|
|
252
584
|
}
|
|
253
585
|
|
|
254
586
|
// Unix / macOS
|
|
255
|
-
const shell =
|
|
587
|
+
const shell = resolveCommandFromCurrentPath("bash") ??
|
|
588
|
+
resolveCommandFromCurrentPath("sh");
|
|
256
589
|
if (shell) {
|
|
257
590
|
const result = await runCommand(
|
|
258
591
|
[shell, "-lc", "curl -fsSL https://bun.sh/install | bash"],
|
|
259
592
|
{ inherit: true },
|
|
260
593
|
);
|
|
261
594
|
if (result.success) {
|
|
262
|
-
|
|
263
|
-
if (
|
|
595
|
+
prependBunInstallPaths();
|
|
596
|
+
if (resolveCommandFromCurrentPath("bun")) return;
|
|
264
597
|
}
|
|
265
598
|
}
|
|
266
599
|
|
|
267
600
|
// macOS Homebrew fallback
|
|
268
601
|
if (process.platform === "darwin") {
|
|
269
|
-
const brew =
|
|
602
|
+
const brew = resolveCommandFromCurrentPath("brew");
|
|
270
603
|
if (brew) {
|
|
271
604
|
const result = await runCommand([brew, "install", "oven-sh/bun/bun"], { inherit: true });
|
|
272
|
-
if (result.success
|
|
605
|
+
if (result.success) {
|
|
606
|
+
prependBunInstallPaths();
|
|
607
|
+
if (resolveCommandFromCurrentPath("bun")) return;
|
|
608
|
+
}
|
|
273
609
|
}
|
|
274
610
|
}
|
|
611
|
+
|
|
612
|
+
throw new Error("Could not install bun automatically.");
|
|
275
613
|
}
|
|
276
614
|
|
|
277
615
|
/**
|
|
@@ -112,6 +112,16 @@ function buildWorkflowHookCommand(subcommand: string, extraArgs: readonly string
|
|
|
112
112
|
}
|
|
113
113
|
const runtime = process.execPath;
|
|
114
114
|
const cliPath = join(import.meta.dir, "..", "..", "cli.ts");
|
|
115
|
+
if (process.platform === "win32") {
|
|
116
|
+
const script = [
|
|
117
|
+
quotePwshLiteral(runtime),
|
|
118
|
+
quotePwshLiteral(cliPath),
|
|
119
|
+
quotePwshLiteral(subcommand),
|
|
120
|
+
...extraArgs.map(quotePwshLiteral),
|
|
121
|
+
].join(" ");
|
|
122
|
+
const encoded = Buffer.from(`& ${script}`, "utf16le").toString("base64");
|
|
123
|
+
return `pwsh -NoProfile -EncodedCommand ${encoded}`;
|
|
124
|
+
}
|
|
115
125
|
return [
|
|
116
126
|
`"${escBash(runtime)}"`,
|
|
117
127
|
`"${escBash(cliPath)}"`,
|
|
@@ -120,6 +130,13 @@ function buildWorkflowHookCommand(subcommand: string, extraArgs: readonly string
|
|
|
120
130
|
].join(" ");
|
|
121
131
|
}
|
|
122
132
|
|
|
133
|
+
function quotePwshLiteral(s: string): string {
|
|
134
|
+
return `'${s
|
|
135
|
+
.replace(/\x00/g, "")
|
|
136
|
+
.replace(/[\n\r]+/g, " ")
|
|
137
|
+
.replace(/'/g, "''")}'`;
|
|
138
|
+
}
|
|
139
|
+
|
|
123
140
|
/**
|
|
124
141
|
* Effectively-unbounded timeout (in seconds) for the Stop hook command.
|
|
125
142
|
*
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* so it can't be used here.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { posix, win32 } from "node:path";
|
|
17
17
|
import type { AgentType } from "../types.ts";
|
|
18
|
-
import { tmuxRun } from "./tmux.ts";
|
|
18
|
+
import { getMuxBinary, tmuxRun } from "./tmux.ts";
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Rows reserved for the footer pane. Matches the single-row height of
|
|
@@ -31,6 +31,82 @@ function escBash(s: string): string {
|
|
|
31
31
|
.replace(/[\\"$`!]/g, "\\$&");
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Escape a string as a PowerShell single-quoted literal. */
|
|
35
|
+
function quotePwshLiteral(s: string): string {
|
|
36
|
+
return `'${s
|
|
37
|
+
.replace(/\x00/g, "")
|
|
38
|
+
.replace(/[\n\r]+/g, " ")
|
|
39
|
+
.replace(/'/g, "''")}'`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function encodePwshCommand(script: string): string {
|
|
43
|
+
return Buffer.from(script, "utf16le").toString("base64");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveAttachedFooterCliPath(
|
|
47
|
+
runtimeDir = import.meta.dir,
|
|
48
|
+
platform: NodeJS.Platform = process.platform,
|
|
49
|
+
): string {
|
|
50
|
+
return platform === "win32"
|
|
51
|
+
? win32.join(runtimeDir, "..", "..", "cli.ts")
|
|
52
|
+
: posix.join(runtimeDir, "..", "..", "cli.ts");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildAttachedFooterCommand({
|
|
56
|
+
runtime,
|
|
57
|
+
cliPath,
|
|
58
|
+
windowName,
|
|
59
|
+
agentType,
|
|
60
|
+
platform = process.platform,
|
|
61
|
+
}: {
|
|
62
|
+
runtime: string;
|
|
63
|
+
cliPath: string;
|
|
64
|
+
windowName: string;
|
|
65
|
+
agentType?: AgentType;
|
|
66
|
+
platform?: NodeJS.Platform;
|
|
67
|
+
}): string {
|
|
68
|
+
if (platform === "win32") {
|
|
69
|
+
const script = [
|
|
70
|
+
quotePwshLiteral(runtime),
|
|
71
|
+
quotePwshLiteral(cliPath),
|
|
72
|
+
quotePwshLiteral("_footer"),
|
|
73
|
+
quotePwshLiteral("--name"),
|
|
74
|
+
quotePwshLiteral(windowName),
|
|
75
|
+
...(agentType
|
|
76
|
+
? [quotePwshLiteral("--agent"), quotePwshLiteral(agentType)]
|
|
77
|
+
: []),
|
|
78
|
+
].join(" ");
|
|
79
|
+
return `pwsh -NoProfile -EncodedCommand ${encodePwshCommand(`& ${script}`)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const agentFlag = agentType ? ` --agent "${escBash(agentType)}"` : "";
|
|
83
|
+
return (
|
|
84
|
+
`"${escBash(runtime)}" "${escBash(cliPath)}" _footer ` +
|
|
85
|
+
`--name "${escBash(windowName)}"${agentFlag}`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function buildAttachedFooterCloseHooks(
|
|
90
|
+
agentPaneId: string,
|
|
91
|
+
footerPaneId: string,
|
|
92
|
+
options: { guardAgentPane?: boolean } = {},
|
|
93
|
+
): Array<{ event: string; command: string }> {
|
|
94
|
+
const killFooter = `kill-pane -t ${footerPaneId}`;
|
|
95
|
+
const paneExitedCommand = options.guardAgentPane === false
|
|
96
|
+
? killFooter
|
|
97
|
+
: `if -F '#{==:#{hook_pane},${agentPaneId}}' '${killFooter}'`;
|
|
98
|
+
|
|
99
|
+
return [
|
|
100
|
+
{ event: "pane-exited", command: paneExitedCommand },
|
|
101
|
+
{ event: "after-kill-pane", command: killFooter },
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function muxSupportsHookPaneFormat(): boolean {
|
|
106
|
+
const binary = getMuxBinary();
|
|
107
|
+
return binary !== "psmux" && binary !== "pmux";
|
|
108
|
+
}
|
|
109
|
+
|
|
34
110
|
export function spawnAttachedFooter(
|
|
35
111
|
windowName: string,
|
|
36
112
|
paneId: string,
|
|
@@ -38,11 +114,13 @@ export function spawnAttachedFooter(
|
|
|
38
114
|
): void {
|
|
39
115
|
const runtime = process.execPath;
|
|
40
116
|
if (!runtime) return;
|
|
41
|
-
const cliPath =
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
117
|
+
const cliPath = resolveAttachedFooterCliPath();
|
|
118
|
+
const cmd = buildAttachedFooterCommand({
|
|
119
|
+
runtime,
|
|
120
|
+
cliPath,
|
|
121
|
+
windowName,
|
|
122
|
+
agentType,
|
|
123
|
+
});
|
|
46
124
|
const split = tmuxRun([
|
|
47
125
|
"split-window",
|
|
48
126
|
"-t", paneId,
|
|
@@ -53,6 +131,17 @@ export function spawnAttachedFooter(
|
|
|
53
131
|
if (!split.ok) return;
|
|
54
132
|
const footerPaneId = split.stdout.trim();
|
|
55
133
|
if (!footerPaneId) return;
|
|
134
|
+
tmuxRun(["select-pane", "-t", paneId]);
|
|
135
|
+
for (const hook of buildAttachedFooterCloseHooks(paneId, footerPaneId, {
|
|
136
|
+
guardAgentPane: muxSupportsHookPaneFormat(),
|
|
137
|
+
})) {
|
|
138
|
+
tmuxRun([
|
|
139
|
+
"set-hook",
|
|
140
|
+
"-w", "-t", footerPaneId,
|
|
141
|
+
hook.event,
|
|
142
|
+
hook.command,
|
|
143
|
+
]);
|
|
144
|
+
}
|
|
56
145
|
// Pin the footer to FOOTER_PANE_LINES on every resize so the agent pane
|
|
57
146
|
// absorbs all new space. Tmux's default proportional redistribution
|
|
58
147
|
// would otherwise grow the footer on larger windows. Window-scoped
|