@xynogen/pix-runtime 0.10.2 → 0.12.2
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/LICENSE +21 -0
- package/README.md +206 -11
- package/package.json +8 -1
- package/src/audio.ts +298 -0
- package/src/binaries/catalog.ts +279 -0
- package/src/binaries/ensure.ts +294 -0
- package/src/binaries/index.ts +43 -0
- package/src/binaries/resolve.ts +199 -0
- package/src/binaries/store.ts +149 -0
- package/src/binaries-tab.ts +364 -0
- package/src/exec.ts +365 -0
- package/src/extension.ts +35 -3
- package/src/icon-catalog.ts +1 -0
- package/src/os.ts +250 -0
- package/src/paths.ts +87 -0
- package/src/persistence.ts +122 -28
- package/src/pix-command.ts +110 -10
- package/src/platform.ts +75 -0
- package/src/runtime.ts +126 -80
- package/src/safe-path.ts +101 -0
- package/src/testing.ts +19 -3
- package/src/user-shell.ts +51 -0
- package/src/which.ts +21 -4
package/src/runtime.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* after a lazy load; writes are serialized and atomic.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
10
9
|
import { DiagnosticSink } from "./diagnostics.ts";
|
|
11
10
|
import {
|
|
12
11
|
type ConfigChange,
|
|
@@ -18,8 +17,9 @@ import {
|
|
|
18
17
|
type SubscribeOptions,
|
|
19
18
|
} from "./events.ts";
|
|
20
19
|
import { importOptimizerSidecar, migrate } from "./migrations.ts";
|
|
20
|
+
import { agentDir } from "./paths.ts";
|
|
21
21
|
import {
|
|
22
|
-
|
|
22
|
+
ConfigParseError,
|
|
23
23
|
FileStorage,
|
|
24
24
|
parseRawDocument,
|
|
25
25
|
type StorageAdapter,
|
|
@@ -99,7 +99,7 @@ class RuntimeImpl implements PixRuntime {
|
|
|
99
99
|
private readOnly = false;
|
|
100
100
|
|
|
101
101
|
constructor(adapters: RuntimeAdapters = {}) {
|
|
102
|
-
this.agentDir = adapters.agentDir ??
|
|
102
|
+
this.agentDir = adapters.agentDir ?? agentDir();
|
|
103
103
|
this.storage = adapters.storage ?? new FileStorage(this.agentDir);
|
|
104
104
|
this.registry = adapters.registry ?? new SectionRegistry();
|
|
105
105
|
}
|
|
@@ -139,16 +139,45 @@ class RuntimeImpl implements PixRuntime {
|
|
|
139
139
|
try {
|
|
140
140
|
doc = parseRawDocument(this.storage.readRaw());
|
|
141
141
|
} catch (err) {
|
|
142
|
-
this.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
142
|
+
if (err instanceof ConfigParseError) this.parseFailed(err);
|
|
143
|
+
else
|
|
144
|
+
this.sink.push({
|
|
145
|
+
code: "READ_FAILED",
|
|
146
|
+
severity: "warning",
|
|
147
|
+
message: "config read failed; using defaults",
|
|
148
|
+
cause: err,
|
|
149
|
+
});
|
|
148
150
|
}
|
|
149
151
|
return this.publish(this.resolve(doc));
|
|
150
152
|
}
|
|
151
153
|
|
|
154
|
+
/** A malformed pix.json stays untouched: defaults apply, writes are refused. */
|
|
155
|
+
private parseFailed(err: ConfigParseError, path?: string): void {
|
|
156
|
+
this.sink.push({
|
|
157
|
+
code: "PARSE_ERROR",
|
|
158
|
+
severity: "error",
|
|
159
|
+
path,
|
|
160
|
+
message: `${this.storage.path}: ${err.message}. File left unchanged; fix it to save settings.`,
|
|
161
|
+
cause: err,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Migration + sidecar import for one raw file read. Pure except `ctx`. */
|
|
166
|
+
private planInit(raw: string | undefined, ctx: ParseContext) {
|
|
167
|
+
const doc = parseRawDocument(raw);
|
|
168
|
+
const migrated = migrate(doc, ctx);
|
|
169
|
+
const readOnly = migrated.document.$version !== CONFIG_FORMAT_VERSION;
|
|
170
|
+
let needsWrite = migrated.changed;
|
|
171
|
+
let archive: (() => void) | undefined;
|
|
172
|
+
if (!readOnly) {
|
|
173
|
+
const sidecar = importOptimizerSidecar(migrated.document, this.agentDir, ctx);
|
|
174
|
+
if (sidecar.changed) needsWrite = true;
|
|
175
|
+
archive = sidecar.archive;
|
|
176
|
+
}
|
|
177
|
+
// Do not normalize a future-version file.
|
|
178
|
+
return { working: readOnly ? doc : migrated.document, readOnly, needsWrite, archive };
|
|
179
|
+
}
|
|
180
|
+
|
|
152
181
|
snapshot(): ConfigSnapshot {
|
|
153
182
|
return this.current ?? this.lazyLoad();
|
|
154
183
|
}
|
|
@@ -182,36 +211,38 @@ class RuntimeImpl implements PixRuntime {
|
|
|
182
211
|
const hadLazy = this.current !== null;
|
|
183
212
|
const previous = this.current;
|
|
184
213
|
this.storage.ensureDir();
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
214
|
+
let working: RawDocument = {};
|
|
215
|
+
let needsWrite = false;
|
|
216
|
+
try {
|
|
217
|
+
// Unlocked probe: most sessions need no write, so they never take the lock.
|
|
218
|
+
let plan = this.planInit(this.storage.readRaw(), this.parseContext());
|
|
219
|
+
this.readOnly = plan.readOnly;
|
|
220
|
+
working = plan.working;
|
|
221
|
+
if (plan.needsWrite && !plan.readOnly) {
|
|
222
|
+
try {
|
|
223
|
+
// Re-plan under the lock so a concurrent writer's change survives.
|
|
224
|
+
this.storage.transact((raw) => {
|
|
225
|
+
plan = this.planInit(raw, { diagnostic: () => {} });
|
|
226
|
+
working = plan.working;
|
|
227
|
+
needsWrite = plan.needsWrite && !plan.readOnly;
|
|
228
|
+
return needsWrite ? serializeRawDocument(plan.working) : undefined;
|
|
229
|
+
});
|
|
230
|
+
if (needsWrite) plan.archive?.();
|
|
231
|
+
} catch (err) {
|
|
232
|
+
needsWrite = false;
|
|
233
|
+
if (err instanceof ConfigParseError) throw err;
|
|
234
|
+
this.sink.push({
|
|
235
|
+
code: "WRITE_FAILED",
|
|
236
|
+
severity: "error",
|
|
237
|
+
message: "initial migration write failed",
|
|
238
|
+
cause: err,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
212
241
|
}
|
|
213
|
-
}
|
|
214
|
-
|
|
242
|
+
} catch (err) {
|
|
243
|
+
if (!(err instanceof ConfigParseError)) throw err;
|
|
244
|
+
this.parseFailed(err);
|
|
245
|
+
working = {};
|
|
215
246
|
}
|
|
216
247
|
|
|
217
248
|
const values = this.resolve(working);
|
|
@@ -242,6 +273,10 @@ class RuntimeImpl implements PixRuntime {
|
|
|
242
273
|
}
|
|
243
274
|
return snapshot;
|
|
244
275
|
});
|
|
276
|
+
// A failed init (unreadable agent dir) must not stay cached: the next call retries.
|
|
277
|
+
this.initPromise.catch(() => {
|
|
278
|
+
this.initPromise = null;
|
|
279
|
+
});
|
|
245
280
|
return this.initPromise;
|
|
246
281
|
}
|
|
247
282
|
|
|
@@ -267,10 +302,6 @@ class RuntimeImpl implements PixRuntime {
|
|
|
267
302
|
return stripDefaults(copy, defaults) > 0 ? copy : undefined;
|
|
268
303
|
}
|
|
269
304
|
|
|
270
|
-
private persist(doc: RawDocument): void {
|
|
271
|
-
this.storage.writeAtomic(serializeRawDocument(doc));
|
|
272
|
-
}
|
|
273
|
-
|
|
274
305
|
private changedPaths(prev: ConfigSnapshot, next: ConfigSnapshot): string[] {
|
|
275
306
|
const out: string[] = [];
|
|
276
307
|
for (const section of this.registry.all()) {
|
|
@@ -339,49 +370,56 @@ class RuntimeImpl implements PixRuntime {
|
|
|
339
370
|
}
|
|
340
371
|
|
|
341
372
|
const previous = this.snapshot();
|
|
342
|
-
const base = parseRawDocument(this.storage.readRaw());
|
|
343
373
|
const ctx = this.parseContext();
|
|
374
|
+
let values: Map<string, unknown> | undefined;
|
|
344
375
|
|
|
345
|
-
// Re-resolve all sections from the latest on-disk doc so unknown fields
|
|
346
|
-
// and sibling sections survive.
|
|
347
|
-
const values = new Map<string, unknown>();
|
|
348
|
-
for (const s of this.registry.all()) values.set(s.key, s.parse(base[s.key], ctx));
|
|
349
|
-
|
|
350
|
-
// Guard against a version-skewed second runtime copy whose registry
|
|
351
|
-
// lacks this section (e.g. an old npm copy handling a new handle). The
|
|
352
|
-
// functional updater would otherwise crash the whole process on
|
|
353
|
-
// `undefined`. Fall back to the handle's own parse of the raw doc.
|
|
354
|
-
const currentValue = (
|
|
355
|
-
values.has(section.key)
|
|
356
|
-
? values.get(section.key)
|
|
357
|
-
: section.__section.parse(base[section.key], ctx)
|
|
358
|
-
) as Readonly<T>;
|
|
359
|
-
const mergedValue =
|
|
360
|
-
typeof updater === "function"
|
|
361
|
-
? (updater as (c: Readonly<T>) => T)(currentValue)
|
|
362
|
-
: deepMerge(currentValue as T, updater);
|
|
363
|
-
|
|
364
|
-
// Re-validate through the section's own parse so a bad patch (NaN,
|
|
365
|
-
// Infinity, wrong type) can never enter the live snapshot.
|
|
366
|
-
const nextValue = section.__section.parse(mergedValue, ctx) as T;
|
|
367
|
-
|
|
368
|
-
if (JSON.stringify(currentValue) === JSON.stringify(nextValue)) return undefined;
|
|
369
|
-
values.set(section.key, nextValue);
|
|
370
|
-
|
|
371
|
-
const doc = this.buildRawFromValues(values, base);
|
|
372
376
|
try {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
this.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
377
|
+
// Read, patch, and write under one lock hold: a second Pi process cannot
|
|
378
|
+
// write between our read and our write, so its change is not lost.
|
|
379
|
+
this.storage.transact((raw) => {
|
|
380
|
+
const base = parseRawDocument(raw);
|
|
381
|
+
|
|
382
|
+
// Re-resolve all sections from the latest on-disk doc so unknown fields
|
|
383
|
+
// and sibling sections survive.
|
|
384
|
+
const next = new Map<string, unknown>();
|
|
385
|
+
for (const s of this.registry.all()) next.set(s.key, s.parse(base[s.key], ctx));
|
|
386
|
+
|
|
387
|
+
// Guard against a version-skewed second runtime copy whose registry
|
|
388
|
+
// lacks this section (e.g. an old npm copy handling a new handle). The
|
|
389
|
+
// functional updater would otherwise crash the whole process on
|
|
390
|
+
// `undefined`. Fall back to the handle's own parse of the raw doc.
|
|
391
|
+
const currentValue = (
|
|
392
|
+
next.has(section.key)
|
|
393
|
+
? next.get(section.key)
|
|
394
|
+
: section.__section.parse(base[section.key], ctx)
|
|
395
|
+
) as Readonly<T>;
|
|
396
|
+
const mergedValue =
|
|
397
|
+
typeof updater === "function"
|
|
398
|
+
? (updater as (c: Readonly<T>) => T)(currentValue)
|
|
399
|
+
: deepMerge(currentValue as T, updater);
|
|
400
|
+
|
|
401
|
+
// Re-validate through the section's own parse so a bad patch (NaN,
|
|
402
|
+
// Infinity, wrong type) can never enter the live snapshot.
|
|
403
|
+
const nextValue = section.__section.parse(mergedValue, ctx) as T;
|
|
404
|
+
|
|
405
|
+
if (JSON.stringify(currentValue) === JSON.stringify(nextValue)) return undefined;
|
|
406
|
+
next.set(section.key, nextValue);
|
|
407
|
+
values = next;
|
|
408
|
+
return serializeRawDocument(this.buildRawFromValues(next, base));
|
|
381
409
|
});
|
|
382
|
-
|
|
410
|
+
} catch (err) {
|
|
411
|
+
if (err instanceof ConfigParseError) this.parseFailed(err, section.key);
|
|
412
|
+
else
|
|
413
|
+
this.sink.push({
|
|
414
|
+
code: "WRITE_FAILED",
|
|
415
|
+
severity: "error",
|
|
416
|
+
path: section.key,
|
|
417
|
+
message: "config write failed; snapshot unchanged",
|
|
418
|
+
cause: err,
|
|
419
|
+
});
|
|
383
420
|
return undefined;
|
|
384
421
|
}
|
|
422
|
+
if (!values) return undefined;
|
|
385
423
|
|
|
386
424
|
const snapshot = this.publish(values);
|
|
387
425
|
const change: ConfigChange = {
|
|
@@ -401,7 +439,15 @@ class RuntimeImpl implements PixRuntime {
|
|
|
401
439
|
reload(options: ReloadOptions = {}): Promise<ConfigChange | undefined> {
|
|
402
440
|
return this.queue.run(async () => {
|
|
403
441
|
const previous = this.snapshot();
|
|
404
|
-
|
|
442
|
+
let doc: RawDocument;
|
|
443
|
+
try {
|
|
444
|
+
doc = parseRawDocument(this.storage.readRaw());
|
|
445
|
+
} catch (err) {
|
|
446
|
+
// Keep the last good snapshot. Do not reset live settings to defaults.
|
|
447
|
+
if (!(err instanceof ConfigParseError)) throw err;
|
|
448
|
+
this.parseFailed(err);
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
405
451
|
const values = this.resolve(doc);
|
|
406
452
|
const snapshot = this.publish(values);
|
|
407
453
|
const changed = this.changedPaths(previous, snapshot);
|
package/src/safe-path.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* safe-path.ts — pre-flight check before a tool writes to a model-chosen path.
|
|
3
|
+
*
|
|
4
|
+
* The rules differ per OS: system dirs, path separators, and case. Callers get
|
|
5
|
+
* one call and a reason they can show to the model.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { constants as fsConstants } from "node:fs";
|
|
9
|
+
import { access, lstat } from "node:fs/promises";
|
|
10
|
+
import { posix, win32 } from "node:path";
|
|
11
|
+
import { homeDir } from "./paths.ts";
|
|
12
|
+
import { currentPlatform, type HostPlatform } from "./platform.ts";
|
|
13
|
+
|
|
14
|
+
export type PathValidation = { ok: true; path: string } | { ok: false; reason: string };
|
|
15
|
+
|
|
16
|
+
export interface SafePathOptions {
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
host?: HostPlatform;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Absolute prefixes that never get tool output, for this host. */
|
|
22
|
+
export function sensitivePrefixes(opts: SafePathOptions = {}): string[] {
|
|
23
|
+
const host = opts.host ?? currentPlatform();
|
|
24
|
+
const env = opts.env ?? process.env;
|
|
25
|
+
const p = host.os === "win32" ? win32 : posix;
|
|
26
|
+
const home = homeDir(env, host.os);
|
|
27
|
+
const secrets = [".ssh", ".aws", ".gnupg", p.join(".config", "gh")].map((d) => p.join(home, d));
|
|
28
|
+
if (host.os !== "win32") return ["/etc", "/proc", "/sys", "/boot", ...secrets];
|
|
29
|
+
const sys = [env.SystemRoot ?? "C:\\Windows", env.ProgramFiles, env["ProgramFiles(x86)"]];
|
|
30
|
+
const appData = env.APPDATA ?? p.join(home, "AppData", "Roaming");
|
|
31
|
+
return [...sys, p.join(appData, "GitHub CLI"), ...secrets].filter((d): d is string => !!d);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True when `path` is `prefix` or inside it. Case-insensitive on Windows. */
|
|
35
|
+
function isUnder(path: string, prefix: string, host: HostPlatform): boolean {
|
|
36
|
+
const p = host.os === "win32" ? win32 : posix;
|
|
37
|
+
// win32.relative already compares case-insensitively.
|
|
38
|
+
const rel = p.relative(p.resolve(prefix), p.resolve(path));
|
|
39
|
+
return rel !== ".." && !rel.startsWith(`..${p.sep}`) && !p.isAbsolute(rel);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Rejects null bytes, sensitive system locations, symlinks (target or any
|
|
44
|
+
* existing parent — Windows junctions count), existing directories, and paths
|
|
45
|
+
* with no writable existing ancestor.
|
|
46
|
+
*/
|
|
47
|
+
export async function validateOutputPath(
|
|
48
|
+
absPath: string,
|
|
49
|
+
opts: SafePathOptions = {},
|
|
50
|
+
): Promise<PathValidation> {
|
|
51
|
+
const host = opts.host ?? currentPlatform();
|
|
52
|
+
const p = host.os === "win32" ? win32 : posix;
|
|
53
|
+
if (absPath.includes("\0")) return { ok: false, reason: "path contains a null byte" };
|
|
54
|
+
// `\\?\C:\Windows\x` and `\\.\C:\...` reach the same files as `C:\Windows\x`, but
|
|
55
|
+
// win32.relative() sees another root, so isUnder() would miss them. Normal
|
|
56
|
+
// output paths never need a device or namespace prefix.
|
|
57
|
+
if (host.os === "win32" && /^[\\/]{2}[?.][\\/]|^\\\?\?\\/.test(absPath))
|
|
58
|
+
return { ok: false, reason: `refusing a Windows device or namespace path: ${absPath}` };
|
|
59
|
+
|
|
60
|
+
for (const prefix of sensitivePrefixes({ ...opts, host })) {
|
|
61
|
+
if (isUnder(absPath, prefix, host))
|
|
62
|
+
return { ok: false, reason: `refusing to write under ${prefix}` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// lstat does not follow symlinks. That is the point here.
|
|
66
|
+
try {
|
|
67
|
+
const st = await lstat(absPath);
|
|
68
|
+
if (st.isSymbolicLink()) return { ok: false, reason: `target is a symlink: ${absPath}` };
|
|
69
|
+
if (st.isDirectory())
|
|
70
|
+
return { ok: false, reason: `target is an existing directory: ${absPath}` };
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT")
|
|
73
|
+
return { ok: false, reason: `cannot stat target: ${(err as Error).message}` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Every existing ancestor must not be a symlink (stops a safe-looking dir → /etc
|
|
77
|
+
// redirect). The nearest one must be writable so mkdir -p works.
|
|
78
|
+
const parent = p.dirname(absPath);
|
|
79
|
+
let nearest: string | null = null;
|
|
80
|
+
for (let cursor = parent; cursor !== p.dirname(cursor); cursor = p.dirname(cursor)) {
|
|
81
|
+
let st: Awaited<ReturnType<typeof lstat>>;
|
|
82
|
+
try {
|
|
83
|
+
st = await lstat(cursor);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
86
|
+
return { ok: false, reason: `cannot stat parent: ${(err as Error).message}` };
|
|
87
|
+
}
|
|
88
|
+
if (st.isSymbolicLink()) return { ok: false, reason: `parent is a symlink: ${cursor}` };
|
|
89
|
+
if (st.isDirectory() && nearest === null) nearest = cursor;
|
|
90
|
+
}
|
|
91
|
+
if (nearest === null)
|
|
92
|
+
return { ok: false, reason: `no existing ancestor directory for ${parent}` };
|
|
93
|
+
try {
|
|
94
|
+
// ponytail: W_OK reads only the read-only attribute on Windows, not ACLs.
|
|
95
|
+
// A denied ACL still fails later at mkdir/writeFile with a clear EPERM.
|
|
96
|
+
await access(nearest, fsConstants.W_OK);
|
|
97
|
+
} catch {
|
|
98
|
+
return { ok: false, reason: `no writable ancestor directory: ${nearest}` };
|
|
99
|
+
}
|
|
100
|
+
return { ok: true, path: absPath };
|
|
101
|
+
}
|
package/src/testing.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* never touch the real agent directory or the process singleton.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
-
import { tmpdir } from "node:os";
|
|
6
|
+
import { mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
|
8
7
|
import { join } from "node:path";
|
|
8
|
+
import { tempDir } from "./paths.ts";
|
|
9
9
|
import { FileStorage } from "./persistence.ts";
|
|
10
10
|
import { createRuntime, type PixRuntime } from "./runtime.ts";
|
|
11
11
|
|
|
@@ -16,9 +16,25 @@ export interface IsolatedRuntime {
|
|
|
16
16
|
cleanup(): void;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* True when this process may create symlinks. Windows allows it only in Developer
|
|
21
|
+
* Mode or as admin (else EPERM). Use it to skip symlink tests: `it.skipIf(!canSymlink())`.
|
|
22
|
+
*/
|
|
23
|
+
export function canSymlink(): boolean {
|
|
24
|
+
const dir = mkdtempSync(join(tempDir(), "pix-symlink-probe-"));
|
|
25
|
+
try {
|
|
26
|
+
symlinkSync(dir, join(dir, "link"), "dir");
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
} finally {
|
|
31
|
+
rmSync(dir, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
/** Create a runtime backed by a fresh temp directory. */
|
|
20
36
|
export function createIsolatedRuntime(): IsolatedRuntime {
|
|
21
|
-
const agentDir = mkdtempSync(join(
|
|
37
|
+
const agentDir = mkdtempSync(join(tempDir(), "pix-runtime-"));
|
|
22
38
|
const runtime = createRuntime({ agentDir, storage: new FileStorage(agentDir) });
|
|
23
39
|
return {
|
|
24
40
|
runtime,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* user-shell.ts — pick the shell for user `!` commands, per OS.
|
|
3
|
+
*
|
|
4
|
+
* - Windows: PowerShell (Pi resolves pwsh 7, else powershell.exe 5.1). Pi's
|
|
5
|
+
* default is Git Bash, which is not what a Windows user types into `!`.
|
|
6
|
+
* - POSIX: $SHELL when it is not bash. zsh also needs rc + aliases: `zsh -c`
|
|
7
|
+
* is non-interactive and resolves aliases at parse time, so we source
|
|
8
|
+
* .zshrc and then `eval` the command to get a fresh parse boundary.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { homeDir } from "./paths.ts";
|
|
12
|
+
import { currentPlatform, type HostPlatform } from "./platform.ts";
|
|
13
|
+
|
|
14
|
+
export type UserShell =
|
|
15
|
+
| { kind: "powershell"; wrap(command: string): string }
|
|
16
|
+
| { kind: "posix"; shellPath: string; wrap(command: string): string };
|
|
17
|
+
|
|
18
|
+
export interface UserShellOptions {
|
|
19
|
+
host?: HostPlatform;
|
|
20
|
+
shell?: string;
|
|
21
|
+
home?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Guarded: `$PSStyle` exists only in PowerShell 7.2+. Strips ANSI color from output. */
|
|
25
|
+
// ponytail: duplicated from pix-powershell (Package Independence). One line, keep both in sync.
|
|
26
|
+
export const PLAIN_TEXT_PREAMBLE = "if ($PSStyle) { $PSStyle.OutputRendering = 'PlainText' }\n";
|
|
27
|
+
|
|
28
|
+
/** Single-quote a string for POSIX shell embedding. */
|
|
29
|
+
function quote(s: string): string {
|
|
30
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function userShell(opts: UserShellOptions = {}): UserShell | undefined {
|
|
34
|
+
const host = opts.host ?? currentPlatform();
|
|
35
|
+
// ponytail: no opt-out back to Git Bash on Windows. Add a pix.json knob on request.
|
|
36
|
+
if (host.os === "win32") {
|
|
37
|
+
return { kind: "powershell", wrap: (command) => `${PLAIN_TEXT_PREAMBLE}${command}` };
|
|
38
|
+
}
|
|
39
|
+
const shell = "shell" in opts ? opts.shell : process.env.SHELL;
|
|
40
|
+
if (!shell) return undefined;
|
|
41
|
+
const name = shell.split("/").pop() ?? "";
|
|
42
|
+
if (name === "bash") return undefined;
|
|
43
|
+
if (name !== "zsh") return { kind: "posix", shellPath: shell, wrap: (command) => command };
|
|
44
|
+
// ponytail: only zsh gets rc wrapping. fish/nu run with shellPath alone. Add their rc logic on request.
|
|
45
|
+
const rc = quote(`${opts.home ?? homeDir()}/.zshrc`);
|
|
46
|
+
return {
|
|
47
|
+
kind: "posix",
|
|
48
|
+
shellPath: shell,
|
|
49
|
+
wrap: (command) => `. ${rc} 2>/dev/null; eval ${quote(command)}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
package/src/which.ts
CHANGED
|
@@ -16,15 +16,24 @@
|
|
|
16
16
|
import { accessSync, constants, statSync } from "node:fs";
|
|
17
17
|
import { access, stat } from "node:fs/promises";
|
|
18
18
|
import { delimiter, isAbsolute, join, sep } from "node:path";
|
|
19
|
+
import { currentPlatform } from "./platform.ts";
|
|
19
20
|
|
|
20
|
-
const isWindows =
|
|
21
|
+
const isWindows = currentPlatform().os === "win32";
|
|
21
22
|
|
|
22
23
|
/** Windows executable extensions, from `PATHEXT` with a sane fallback. */
|
|
23
24
|
function pathExtensions(env: NodeJS.ProcessEnv): string[] {
|
|
24
25
|
if (!isWindows) return [""];
|
|
25
26
|
const raw = env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD";
|
|
26
|
-
// Leading "" lets an already-suffixed name (foo.exe) match as-is.
|
|
27
|
-
|
|
27
|
+
// Leading "" lets an already-suffixed name (foo.exe) match as-is. Lowercased:
|
|
28
|
+
// NTFS matches either case, and the returned path should read `rtk.exe`, not
|
|
29
|
+
// `rtk.EXE`, since that is how the files are conventionally named.
|
|
30
|
+
return [
|
|
31
|
+
"",
|
|
32
|
+
...raw
|
|
33
|
+
.split(";")
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.map((ext) => ext.toLowerCase()),
|
|
36
|
+
];
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
/** Directories to scan, from `PATH`. Windows also probes the current directory first. */
|
|
@@ -40,7 +49,15 @@ export interface FindExecutableOptions {
|
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
function candidatesFor(name: string, env: NodeJS.ProcessEnv): string[] {
|
|
43
|
-
|
|
52
|
+
let exts = pathExtensions(env);
|
|
53
|
+
// Windows cannot spawn an extensionless file (npm ships a `npm` sh script
|
|
54
|
+
// beside `npm.cmd`), so only accept the bare name when it already carries a
|
|
55
|
+
// PATHEXT extension.
|
|
56
|
+
if (isWindows) {
|
|
57
|
+
const lower = name.toLowerCase();
|
|
58
|
+
const suffixed = exts.some((ext) => ext && lower.endsWith(ext));
|
|
59
|
+
exts = suffixed ? [""] : exts.filter(Boolean);
|
|
60
|
+
}
|
|
44
61
|
const withExts = (base: string): string[] => exts.map((ext) => base + ext);
|
|
45
62
|
// A name with a separator (or absolute) is a direct path — do not scan PATH.
|
|
46
63
|
if (isAbsolute(name) || name.includes(sep) || (isWindows && name.includes("/"))) {
|