chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
397
third_party/pi/packages/env/src/connection.ts
vendored
Normal file
397
third_party/pi/packages/env/src/connection.ts
vendored
Normal file
@@ -0,0 +1,397 @@
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
/** Frame types (docs/protocol.md). */
|
||||
const REQUEST = 1;
|
||||
const RESULT = 2;
|
||||
const ERROR = 3;
|
||||
const EVENT = 4;
|
||||
const CANCEL = 5;
|
||||
const PING = 6;
|
||||
|
||||
const MAX_FRAME = 16 * 1024 * 1024;
|
||||
const PING_INTERVAL_MS = 5000;
|
||||
/** The daemon pings every five seconds; this much silence means the connection is gone. */
|
||||
const SILENCE_LIMIT_MS = 30_000;
|
||||
/** How long starting the daemon and its `hello` may take. */
|
||||
const START_TIMEOUT_MS = 60_000;
|
||||
|
||||
export type Json = Record<string, unknown>;
|
||||
|
||||
/** A failure reported by the daemon, with its Node-style code. */
|
||||
export class RemoteError extends Error {
|
||||
readonly code: string;
|
||||
readonly path?: string;
|
||||
readonly fields: Json;
|
||||
|
||||
constructor(fields: Json) {
|
||||
super(typeof fields.message === "string" ? fields.message : "Remote operation failed");
|
||||
this.name = "RemoteError";
|
||||
this.code = typeof fields.code === "string" ? fields.code : "unknown";
|
||||
if (typeof fields.path === "string") this.path = fields.path;
|
||||
this.fields = fields;
|
||||
}
|
||||
}
|
||||
|
||||
/** What `hello` reports about the remote machine. */
|
||||
export interface RemoteInfo {
|
||||
protocol: number;
|
||||
version: string;
|
||||
os: string;
|
||||
arch: string;
|
||||
home: string;
|
||||
tmpdir: string;
|
||||
/** Path separator: `/`, or `\\` on Windows. */
|
||||
separator: string;
|
||||
/** The daemon's working directory, where Node's `path.resolve` would fall back to `process.cwd()`. */
|
||||
cwd: string;
|
||||
/** Windows: per-drive working directories (`C:` → `C:\\work`) of the `=C:` variables. */
|
||||
driveCwds: Record<string, string>;
|
||||
pid: number;
|
||||
/** Operations added to protocol 1 that this daemon supports, such as `listFiles`; absent from older daemons. */
|
||||
features?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Node's names for the daemon's system and architecture, which `hello` reports in Rust's spelling: `windows` is
|
||||
* `win32`, `macos` is `darwin`, `x86_64` is `x64`, `aarch64` is `arm64`, so they compare with `process.platform` and
|
||||
* `process.arch`. Other names are kept.
|
||||
*/
|
||||
export function nodePlatform(info: Pick<RemoteInfo, "os" | "arch">): { platform: string; arch: string } {
|
||||
const platforms: Record<string, string> = { windows: "win32", macos: "darwin" };
|
||||
const archs: Record<string, string> = { x86_64: "x64", aarch64: "arm64", x86: "ia32" };
|
||||
return { platform: platforms[info.os] ?? info.os, arch: archs[info.arch] ?? info.arch };
|
||||
}
|
||||
|
||||
export interface ConnectionOptions {
|
||||
/**
|
||||
* Command that starts the daemon, before its `serve --token <hex>` arguments, e.g. `["ssh", "-T", "--", "host",
|
||||
* "~/.pi/mobile/tools/pi-env"]`. A function computes it at each start (detecting and deploying first, for example);
|
||||
* its failure fails that start, and the next request tries again.
|
||||
*/
|
||||
command: readonly string[] | (() => Promise<readonly string[]>);
|
||||
/** Receives the daemon's and the transport's diagnostic output. */
|
||||
onLog?: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface Reply {
|
||||
json: Json;
|
||||
payload: Uint8Array;
|
||||
/** The daemon session that answered; handles from it are only valid while it lives. */
|
||||
session: number;
|
||||
}
|
||||
|
||||
type Pending = {
|
||||
resolve: (value: Reply) => void;
|
||||
reject: (error: Error) => void;
|
||||
onEvent?: (json: Json, payload: Uint8Array) => void;
|
||||
};
|
||||
|
||||
export interface RequestOptions {
|
||||
payload?: Uint8Array;
|
||||
/** Aborting sends `cancel`; the request still settles with the daemon's result or error. */
|
||||
signal?: AbortSignal;
|
||||
/** Progress events of the request, such as `exec` output. */
|
||||
onEvent?: (json: Json, payload: Uint8Array) => void;
|
||||
/** The request's id and session, for `kill`. */
|
||||
onStart?: (id: number, session: number) => void;
|
||||
/**
|
||||
* Only run in this daemon session, for requests on handles it opened. After the connection was lost and started
|
||||
* again, such requests fail instead of reaching a daemon that never opened the handle.
|
||||
*/
|
||||
session?: number;
|
||||
}
|
||||
|
||||
/** Lone surrogates become U+FFFD, as Node encodes strings for system calls. */
|
||||
function wellFormed(_key: string, value: unknown): unknown {
|
||||
return typeof value === "string" ? value.toWellFormed() : value;
|
||||
}
|
||||
|
||||
function frame(type: number, id: number, json: Json, payload: Uint8Array = new Uint8Array(0)): Buffer {
|
||||
const body = Buffer.from(JSON.stringify(json, wellFormed), "utf8");
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(9 + body.length + payload.length, 0);
|
||||
header.writeUInt8(type, 4);
|
||||
header.writeUInt32BE(id, 5);
|
||||
header.writeUInt32BE(body.length, 9);
|
||||
return Buffer.concat([header, body, payload]);
|
||||
}
|
||||
|
||||
/** The connection failed or ended; a request may succeed once it is started again. */
|
||||
function lost(message: string): RemoteError {
|
||||
return new RemoteError({ code: "unknown", message, lost: true });
|
||||
}
|
||||
|
||||
/** Whether an error means the connection was lost, not that the daemon refused the request. */
|
||||
export function isConnectionLost(error: unknown): boolean {
|
||||
return error instanceof RemoteError && error.fields.lost === true;
|
||||
}
|
||||
|
||||
function closed(): RemoteError {
|
||||
return new RemoteError({ code: "unknown", message: "Connection closed" });
|
||||
}
|
||||
|
||||
/** One running daemon. */
|
||||
class Session {
|
||||
readonly id: number;
|
||||
readonly child: ChildProcessWithoutNullStreams;
|
||||
readonly token: string;
|
||||
readonly pending = new Map<number, Pending>();
|
||||
/** Received bytes not yet parsed, without concatenating on every chunk. */
|
||||
chunks: Buffer[] = [];
|
||||
buffered = 0;
|
||||
synced = false;
|
||||
lastSeen = Date.now();
|
||||
timer: ReturnType<typeof setInterval> | undefined;
|
||||
live = true;
|
||||
|
||||
constructor(id: number, child: ChildProcessWithoutNullStreams, token: string) {
|
||||
this.id = id;
|
||||
this.child = child;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/** The first `length` buffered bytes as one buffer, without consuming them. */
|
||||
peek(length: number): Buffer {
|
||||
if (this.chunks[0]!.length < length) {
|
||||
this.chunks = [Buffer.concat(this.chunks)];
|
||||
}
|
||||
return this.chunks[0]!.subarray(0, length);
|
||||
}
|
||||
|
||||
consume(length: number): void {
|
||||
this.buffered -= length;
|
||||
while (length > 0) {
|
||||
const first = this.chunks[0]!;
|
||||
if (first.length <= length) {
|
||||
this.chunks.shift();
|
||||
length -= first.length;
|
||||
} else {
|
||||
this.chunks[0] = first.subarray(length);
|
||||
length = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One connection to a pi-env daemon: started lazily on the first request and started again after it is lost. Requests
|
||||
* in flight when it is lost fail with code `unknown`; for mutations their outcome is then unknown.
|
||||
*/
|
||||
export class Connection {
|
||||
readonly #options: ConnectionOptions;
|
||||
#session: Session | undefined;
|
||||
#ready: Promise<{ info: RemoteInfo; session: Session }> | undefined;
|
||||
#sessions = 0;
|
||||
#nextId = 1;
|
||||
#closed = false;
|
||||
|
||||
constructor(options: ConnectionOptions) {
|
||||
this.#options = options;
|
||||
}
|
||||
|
||||
/** Connect if needed and return what the daemon reported about its machine. */
|
||||
async info(): Promise<RemoteInfo> {
|
||||
return (await this.#connect()).info;
|
||||
}
|
||||
|
||||
/** Connect if needed and return the live session's id. */
|
||||
async session(): Promise<number> {
|
||||
return (await this.#connect()).session.id;
|
||||
}
|
||||
|
||||
async request(op: string, json: Json, options: RequestOptions = {}): Promise<Reply> {
|
||||
if (options.session !== undefined) {
|
||||
const current = this.#session;
|
||||
if (current === undefined || current.id !== options.session || !current.live) {
|
||||
throw lost("pi-env connection lost");
|
||||
}
|
||||
return this.#send(current, op, json, options);
|
||||
}
|
||||
const { session } = await this.#connect();
|
||||
return this.#send(session, op, json, options);
|
||||
}
|
||||
|
||||
/** Stop the daemon; it kills everything it started. */
|
||||
close(): void {
|
||||
this.#closed = true;
|
||||
if (this.#session) this.#teardown(this.#session, closed());
|
||||
}
|
||||
|
||||
/** Kill one `exec` without aborting it. */
|
||||
kill(id: number, session: number): void {
|
||||
const current = this.#session;
|
||||
if (current?.id === session && current.live) this.#write(current, frame(CANCEL, id, { mode: "kill" }));
|
||||
}
|
||||
|
||||
#connect(): Promise<{ info: RemoteInfo; session: Session }> {
|
||||
if (this.#closed) return Promise.reject(closed());
|
||||
if (this.#ready === undefined) {
|
||||
const ready = this.#start();
|
||||
this.#ready = ready;
|
||||
// A failed start is not remembered: the next request tries again.
|
||||
ready.catch(() => {
|
||||
if (this.#ready === ready) this.#ready = undefined;
|
||||
});
|
||||
}
|
||||
return this.#ready;
|
||||
}
|
||||
|
||||
#send(session: Session, op: string, json: Json, options: RequestOptions): Promise<Reply> {
|
||||
if (!session.live) return Promise.reject(lost("pi-env connection lost"));
|
||||
const id = this.#nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => this.#write(session, frame(CANCEL, id, {}));
|
||||
const done = () => options.signal?.removeEventListener("abort", onAbort);
|
||||
session.pending.set(id, {
|
||||
resolve: (value) => {
|
||||
done();
|
||||
resolve(value);
|
||||
},
|
||||
reject: (error) => {
|
||||
done();
|
||||
reject(error);
|
||||
},
|
||||
...(options.onEvent ? { onEvent: options.onEvent } : {}),
|
||||
});
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
options.onStart?.(id, session.id);
|
||||
this.#write(session, frame(REQUEST, id, { ...json, op }, options.payload));
|
||||
if (options.signal?.aborted) onAbort();
|
||||
});
|
||||
}
|
||||
|
||||
#write(session: Session, data: Buffer): void {
|
||||
if (session.live) session.child.stdin.write(data);
|
||||
}
|
||||
|
||||
async #start(): Promise<{ info: RemoteInfo; session: Session }> {
|
||||
let command: readonly string[];
|
||||
try {
|
||||
const option = this.#options.command;
|
||||
command = typeof option === "function" ? await option() : option;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new RemoteError({ code: "spawn_error", message, lost: true });
|
||||
}
|
||||
if (this.#closed) throw closed();
|
||||
const [program, ...args] = command;
|
||||
if (program === undefined) throw new RemoteError({ code: "spawn_error", message: "No daemon command" });
|
||||
const token = randomBytes(16).toString("hex");
|
||||
const child = spawn(program, [...args, "serve", "--token", token], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
const session = new Session(++this.#sessions, child, token);
|
||||
this.#session = session;
|
||||
let startTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const failed = new Promise<never>((_resolve, reject) => {
|
||||
startTimer = setTimeout(
|
||||
() => reject(lost(`pi-env did not answer within ${START_TIMEOUT_MS / 1000} s`)),
|
||||
START_TIMEOUT_MS,
|
||||
);
|
||||
child.once("error", (error) =>
|
||||
reject(new RemoteError({ code: "spawn_error", message: error.message, lost: true })),
|
||||
);
|
||||
child.once("exit", (code) => reject(lost(`pi-env exited with code ${code} before it was ready`)));
|
||||
});
|
||||
failed.catch(() => {});
|
||||
child.stdout.on("data", (chunk: Buffer) => this.#onData(session, chunk));
|
||||
child.stderr.on("data", (chunk: Buffer) => this.#options.onLog?.(chunk.toString("utf8")));
|
||||
child.on("error", () => this.#teardown(session, lost("pi-env connection lost")));
|
||||
child.on("exit", () => this.#teardown(session, lost("pi-env connection lost")));
|
||||
child.stdin.on("error", () => {});
|
||||
session.timer = setInterval(() => {
|
||||
this.#write(session, frame(PING, 0, {}));
|
||||
if (Date.now() - session.lastSeen > SILENCE_LIMIT_MS) {
|
||||
this.#teardown(session, lost("pi-env connection timed out"));
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
session.timer.unref();
|
||||
try {
|
||||
const { json } = await Promise.race([this.#send(session, "hello", { protocol: 1 }, {}), failed]);
|
||||
if (json.protocol !== 1) throw lost(`Unsupported protocol ${json.protocol}`);
|
||||
return { info: json as unknown as RemoteInfo, session };
|
||||
} catch (error) {
|
||||
this.#teardown(session, error instanceof Error ? error : lost(String(error)));
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(startTimer);
|
||||
}
|
||||
}
|
||||
|
||||
#teardown(session: Session, error: Error): void {
|
||||
if (!session.live) return;
|
||||
session.live = false;
|
||||
clearInterval(session.timer);
|
||||
if (this.#session === session) {
|
||||
this.#session = undefined;
|
||||
this.#ready = undefined;
|
||||
}
|
||||
session.child.stdin.end();
|
||||
session.child.kill();
|
||||
const pending = [...session.pending.values()];
|
||||
session.pending.clear();
|
||||
for (const request of pending) request.reject(error);
|
||||
}
|
||||
|
||||
#onData(session: Session, chunk: Buffer): void {
|
||||
if (!session.live) return;
|
||||
session.lastSeen = Date.now();
|
||||
session.chunks.push(chunk);
|
||||
session.buffered += chunk.length;
|
||||
if (!session.synced) {
|
||||
// Shell startup files may print before the daemon runs; skip everything before its sync line.
|
||||
const marker = Buffer.from(`PI-ENV ${session.token}\n`, "utf8");
|
||||
const all = session.peek(session.buffered);
|
||||
const index = all.indexOf(marker);
|
||||
if (index === -1) {
|
||||
if (session.buffered > 1024 * 1024) {
|
||||
session.consume(session.buffered - marker.length);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const noise = all.subarray(0, index).toString("utf8").trim();
|
||||
if (noise !== "") this.#options.onLog?.(noise);
|
||||
session.consume(index + marker.length);
|
||||
session.synced = true;
|
||||
}
|
||||
while (session.live && session.buffered >= 4) {
|
||||
const length = session.peek(4).readUInt32BE(0);
|
||||
if (length < 9 || length > MAX_FRAME) {
|
||||
this.#teardown(session, lost("Corrupt frame from pi-env"));
|
||||
return;
|
||||
}
|
||||
if (session.buffered < 4 + length) return;
|
||||
const body = session.peek(4 + length).subarray(4);
|
||||
const type = body.readUInt8(0);
|
||||
const id = body.readUInt32BE(1);
|
||||
const jsonLength = body.readUInt32BE(5);
|
||||
let json: Json;
|
||||
try {
|
||||
if (9 + jsonLength > length) throw new Error("JSON length out of range");
|
||||
const parsed: unknown = JSON.parse(body.subarray(9, 9 + jsonLength).toString("utf8"));
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
||||
throw new Error("not an object");
|
||||
json = parsed as Json;
|
||||
} catch {
|
||||
this.#teardown(session, lost("Corrupt frame from pi-env"));
|
||||
return;
|
||||
}
|
||||
// A plain Uint8Array copy, like `NodeExecutionEnv`'s reader results, not a view of the receive buffer.
|
||||
const payload = new Uint8Array(body.subarray(9 + jsonLength));
|
||||
session.consume(4 + length);
|
||||
this.#dispatch(session, type, id, json, payload);
|
||||
}
|
||||
}
|
||||
|
||||
#dispatch(session: Session, type: number, id: number, json: Json, payload: Uint8Array): void {
|
||||
const pending = session.pending.get(id);
|
||||
if (type === EVENT) {
|
||||
pending?.onEvent?.(json, payload);
|
||||
return;
|
||||
}
|
||||
if (type !== RESULT && type !== ERROR) return;
|
||||
if (pending === undefined) return;
|
||||
session.pending.delete(id);
|
||||
if (type === RESULT) pending.resolve({ json, payload, session: session.id });
|
||||
else pending.reject(new RemoteError(json));
|
||||
}
|
||||
}
|
||||
34
third_party/pi/packages/env/src/errors.ts
vendored
Normal file
34
third_party/pi/packages/env/src/errors.ts
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
import { err, FileError, type Result } from "@earendil-works/pi-durable/env";
|
||||
import { RemoteError } from "./connection.ts";
|
||||
|
||||
export function abortResult<T>(signal: AbortSignal | undefined, path?: string): Result<T, FileError> | undefined {
|
||||
return signal?.aborted ? err(new FileError("aborted", "aborted", path)) : undefined;
|
||||
}
|
||||
|
||||
/** `NodeExecutionEnv`'s mapping of Node error codes to `FileError` codes. */
|
||||
export function toFileError(error: unknown, fallbackPath?: string): FileError {
|
||||
if (error instanceof FileError) return error;
|
||||
if (!(error instanceof RemoteError)) {
|
||||
const cause = error instanceof Error ? error : new Error(String(error));
|
||||
return new FileError("unknown", cause.message, fallbackPath, cause);
|
||||
}
|
||||
const path = error.path ?? fallbackPath;
|
||||
switch (error.code) {
|
||||
case "aborted":
|
||||
return new FileError("aborted", error.message, path, error);
|
||||
case "ENOENT":
|
||||
return new FileError("not_found", error.message, path, error);
|
||||
case "EACCES":
|
||||
case "EPERM":
|
||||
return new FileError("permission_denied", error.message, path, error);
|
||||
case "ENOTDIR":
|
||||
return new FileError("not_directory", error.message, path, error);
|
||||
case "EISDIR":
|
||||
return new FileError("is_directory", error.message, path, error);
|
||||
case "EINVAL":
|
||||
case "SYMLINK":
|
||||
case "NOT_REGULAR":
|
||||
return new FileError("invalid", error.message, path, error);
|
||||
}
|
||||
return new FileError("unknown", error.message, path, error);
|
||||
}
|
||||
32
third_party/pi/packages/env/src/index.ts
vendored
Normal file
32
third_party/pi/packages/env/src/index.ts
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
export {
|
||||
Connection,
|
||||
type ConnectionOptions,
|
||||
isConnectionLost,
|
||||
nodePlatform,
|
||||
RemoteError,
|
||||
type RemoteInfo,
|
||||
type Reply,
|
||||
type RequestOptions,
|
||||
} from "./connection.ts";
|
||||
export { LocalExecutionEnv, type LocalExecutionEnvOptions } from "./list-files.ts";
|
||||
export { RemoteExecutionEnv, type RemoteExecutionEnvOptions } from "./remote-env.ts";
|
||||
export {
|
||||
acceptHostKey,
|
||||
connectSsh,
|
||||
deployDaemon,
|
||||
detectPlatform,
|
||||
forgetHostKey,
|
||||
HostKeyChangedError,
|
||||
HostKeyUnknownError,
|
||||
localDaemon,
|
||||
packagedDaemon,
|
||||
type RemotePlatform,
|
||||
type SshConnectOptions,
|
||||
SshError,
|
||||
type SshTarget,
|
||||
scanHostKey,
|
||||
sshArguments,
|
||||
sshConnection,
|
||||
} from "./ssh.ts";
|
||||
export { openTerminal, RemoteTerminal, type TerminalForeground, type TerminalOptions } from "./terminal.ts";
|
||||
export { RemoteWatcher, type RemoteWatchOptions } from "./watch.ts";
|
||||
102
third_party/pi/packages/env/src/list-files.ts
vendored
Normal file
102
third_party/pi/packages/env/src/list-files.ts
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import {
|
||||
type ExecutionEnv,
|
||||
err,
|
||||
FileError,
|
||||
type FileKind,
|
||||
type FileListEntry,
|
||||
type FileListing,
|
||||
type ListFilesOptions,
|
||||
ok,
|
||||
type Result,
|
||||
} from "@earendil-works/pi-durable/env";
|
||||
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
|
||||
import type { Connection } from "./connection.ts";
|
||||
import { abortResult, toFileError } from "./errors.ts";
|
||||
|
||||
/** Kind bytes of `listFiles` records: `f`, `d`, `l`. */
|
||||
const KINDS: Record<number, FileKind> = { 102: "file", 100: "directory", 108: "symlink" };
|
||||
|
||||
/**
|
||||
* `listFiles` of an absolute path through the daemon: fails with `not_supported` if the daemon does not list files
|
||||
* (a release before `listFiles`), so callers fall back to their own walk.
|
||||
*/
|
||||
export async function listFilesOver(
|
||||
connection: Connection,
|
||||
path: string,
|
||||
options: ListFilesOptions | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<FileListing, FileError>> {
|
||||
for (const key of ["maxEntries", "maxBytes", "timeoutMs"] as const) {
|
||||
const value = options?.[key];
|
||||
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
|
||||
return err(new FileError("invalid", `${key} must be a non-negative safe integer`, path));
|
||||
}
|
||||
}
|
||||
const aborted = abortResult<FileListing>(context.abortSignal, path);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
if (!(await connection.info()).features?.includes("listFiles")) {
|
||||
return err(new FileError("not_supported", "The pi-env daemon does not list files; update it", path));
|
||||
}
|
||||
const { json, payload } = await connection.request(
|
||||
"listFiles",
|
||||
// Fields left undefined are not sent; the daemon applies the defaults.
|
||||
{
|
||||
path,
|
||||
hidden: options?.hidden,
|
||||
gitignore: options?.gitignore,
|
||||
exclude: options?.exclude,
|
||||
maxEntries: options?.maxEntries,
|
||||
maxBytes: options?.maxBytes,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
},
|
||||
context.abortSignal ? { signal: context.abortSignal } : {},
|
||||
);
|
||||
const afterAbort = abortResult<FileListing>(context.abortSignal, path);
|
||||
if (afterAbort) return afterAbort;
|
||||
// Records of a kind byte, the UTF-8 path, and a NUL.
|
||||
const entries: FileListEntry[] = [];
|
||||
const bytes = Buffer.from(payload.buffer, payload.byteOffset, payload.length);
|
||||
for (let start = 0; start < bytes.length; ) {
|
||||
const end = bytes.indexOf(0, start);
|
||||
if (end === -1) break;
|
||||
const kind = KINDS[bytes[start]!];
|
||||
if (kind !== undefined) entries.push({ path: bytes.toString("utf8", start + 1, end), kind });
|
||||
start = end + 1;
|
||||
}
|
||||
return ok({ entries, truncated: json.truncated === true });
|
||||
} catch (error) {
|
||||
return err(toFileError(error, path));
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalExecutionEnvOptions = ConstructorParameters<typeof NodeExecutionEnv>[0] & {
|
||||
/** A connection to this machine's daemon, `new Connection({ command: [localDaemon()] })`, for `listFiles`. */
|
||||
connection: Connection;
|
||||
};
|
||||
|
||||
/**
|
||||
* `NodeExecutionEnv` with `listFiles` through a pi-env daemon on the same machine, so local and remote environments
|
||||
* list files with the same code. Everything else is `NodeExecutionEnv`'s. The connection belongs to the caller, who
|
||||
* closes it; several environments may share it.
|
||||
*/
|
||||
export class LocalExecutionEnv extends NodeExecutionEnv implements ExecutionEnv {
|
||||
readonly #connection: Connection;
|
||||
|
||||
constructor(options: LocalExecutionEnvOptions) {
|
||||
const { connection, ...node } = options;
|
||||
super(node);
|
||||
this.#connection = connection;
|
||||
}
|
||||
|
||||
async listFiles(
|
||||
path: string,
|
||||
options: ListFilesOptions | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<FileListing, FileError>> {
|
||||
const resolved = await this.absolutePath(path, context);
|
||||
if (!resolved.ok) return resolved;
|
||||
return listFilesOver(this.#connection, resolved.value, options, context);
|
||||
}
|
||||
}
|
||||
957
third_party/pi/packages/env/src/remote-env.ts
vendored
Normal file
957
third_party/pi/packages/env/src/remote-env.ts
vendored
Normal file
@@ -0,0 +1,957 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { type PlatformPath, posix, win32 } from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import {
|
||||
type BinaryReader,
|
||||
type DirReader,
|
||||
type ExecutionEnv,
|
||||
ExecutionError,
|
||||
err,
|
||||
FileError,
|
||||
type FileInfo,
|
||||
type FileKind,
|
||||
type FileListing,
|
||||
type FileWatcher,
|
||||
type LineScan,
|
||||
type ListFilesOptions,
|
||||
ok,
|
||||
type Result,
|
||||
type ShellExecOptions,
|
||||
type ShellExecResult,
|
||||
type ShellOutputInfo,
|
||||
type ShellOutputSkip,
|
||||
StreamDecoder,
|
||||
type TextLine,
|
||||
type TextLineReader,
|
||||
type WatchChange,
|
||||
type WatchTarget,
|
||||
} from "@earendil-works/pi-durable/env";
|
||||
import { type Connection, type Json, nodePlatform, RemoteError, type RemoteInfo } from "./connection.ts";
|
||||
import { abortResult, toFileError } from "./errors.ts";
|
||||
import { listFilesOver } from "./list-files.ts";
|
||||
import { RemoteWatcher, type RemoteWatchOptions } from "./watch.ts";
|
||||
|
||||
const MAX_TIMEOUT_MS = 2_147_483_647;
|
||||
const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
|
||||
/** Bytes per `pread` request of a binary reader; several are in flight at once. */
|
||||
const READ_CHUNK = 256 * 1024;
|
||||
const READ_DEPTH = 8;
|
||||
/** Node's `writeFile` writes at most this much per call and checks for an abort before each. */
|
||||
const WRITE_CHUNK = 512 * 1024;
|
||||
const WRITE_DEPTH = 8;
|
||||
/** Node's `readFile`: chunk of known-size reads, chunk of unknown-size reads, and the largest file it reads. */
|
||||
const READ_FILE_CHUNK = 512 * 1024;
|
||||
const READ_FILE_UNKNOWN_CHUNK = 64 * 1024;
|
||||
const READ_FILE_MAX = 2 ** 31 - 1;
|
||||
/** `NodeTextLineReader`'s read size. */
|
||||
const LINE_CHUNK = 64 * 1024;
|
||||
|
||||
export interface RemoteExecutionEnvOptions {
|
||||
connection: Connection;
|
||||
/** The file namespace: equal ids see the same files (`FileSystem.id`), e.g. `pi-env:<env name>`. */
|
||||
id: string;
|
||||
cwd: string;
|
||||
shellPath?: string;
|
||||
/** Added to the remote environment of every command that inherits it, like `NodeExecutionEnv`'s `shellEnv`. */
|
||||
shellEnv?: Record<string, string>;
|
||||
/** How the daemon watches, like `NodeExecutionEnv`'s `watch` option. */
|
||||
watch?: RemoteWatchOptions;
|
||||
}
|
||||
|
||||
/** An info record from the daemon. */
|
||||
type RemoteFileInfo = { name: string; kind: FileKind | "other"; size: number; mtimeSec: number; mtimeNsec: number };
|
||||
type RemoteDirEntry = { name: string; raw?: number[]; info?: RemoteFileInfo; error?: Json };
|
||||
|
||||
/** Node's path rules of the remote system. */
|
||||
export async function remotePath(connection: Connection): Promise<PlatformPath> {
|
||||
return nodePlatform(await connection.info()).platform === "win32" ? win32 : posix;
|
||||
}
|
||||
|
||||
function toInfo(path: string, remote: RemoteFileInfo, paths: PlatformPath): Result<FileInfo, FileError> {
|
||||
if (remote.kind === "other") return err(new FileError("invalid", "Unsupported file type", path));
|
||||
return ok({
|
||||
name: paths.basename(path),
|
||||
path,
|
||||
kind: remote.kind,
|
||||
size: remote.size,
|
||||
mtimeMs: remote.mtimeSec * 1000 + remote.mtimeNsec / 1e6,
|
||||
});
|
||||
}
|
||||
|
||||
function concat(chunks: readonly Uint8Array[], total: number): Uint8Array {
|
||||
if (chunks.length === 1) return chunks[0]!;
|
||||
const bytes = new Uint8Array(total);
|
||||
let position = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, position);
|
||||
position += chunk.length;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `length` bytes from `offset` as consecutive reads of at most `chunk` bytes, several in flight. Ends early at
|
||||
* the end of the file. A short read continues from where it ended, as sequential reads would. `aborted` is checked
|
||||
* after each read; `undefined` means aborted.
|
||||
*/
|
||||
async function readRange(
|
||||
read: (offset: number, length: number) => Promise<Uint8Array>,
|
||||
offset: number,
|
||||
length: number,
|
||||
chunk: number,
|
||||
aborted: () => boolean,
|
||||
): Promise<{ chunks: Uint8Array[]; total: number } | undefined> {
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
let next = offset;
|
||||
const inFlight: { length: number; bytes: Promise<Uint8Array> }[] = [];
|
||||
while (total < length) {
|
||||
while (inFlight.length < READ_DEPTH && next < offset + length) {
|
||||
const size = Math.min(offset + length - next, chunk);
|
||||
const bytes = read(next, size);
|
||||
bytes.catch(() => {});
|
||||
inFlight.push({ length: size, bytes });
|
||||
next += size;
|
||||
}
|
||||
const head = inFlight.shift()!;
|
||||
const bytes = await head.bytes;
|
||||
if (aborted()) return undefined;
|
||||
if (bytes.length === 0) break;
|
||||
chunks.push(bytes);
|
||||
total += bytes.length;
|
||||
if (bytes.length < head.length) {
|
||||
// Reads already in flight assumed a full read; continue right after this one.
|
||||
inFlight.length = 0;
|
||||
next = offset + total;
|
||||
}
|
||||
}
|
||||
return { chunks, total };
|
||||
}
|
||||
|
||||
/** A daemon handle: valid only in the session that opened it. */
|
||||
class Handle {
|
||||
readonly env: RemoteExecutionEnv;
|
||||
readonly id: number;
|
||||
readonly session: number;
|
||||
readonly path: string;
|
||||
|
||||
constructor(env: RemoteExecutionEnv, id: number, session: number, path: string) {
|
||||
this.env = env;
|
||||
this.id = id;
|
||||
this.session = session;
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
request(op: string, json: Json = {}, options: { signal?: AbortSignal; payload?: Uint8Array } = {}) {
|
||||
return this.env.connection.request(op, { ...json, handle: this.id }, { ...options, session: this.session });
|
||||
}
|
||||
|
||||
async pread(offset: number | undefined, length: number): Promise<Uint8Array> {
|
||||
return (await this.request("pread", { ...(offset === undefined ? {} : { offset }), length })).payload;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.request("close").catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteBinaryReader implements BinaryReader {
|
||||
readonly #handle: Handle;
|
||||
#closed = false;
|
||||
|
||||
constructor(handle: Handle) {
|
||||
this.#handle = handle;
|
||||
}
|
||||
|
||||
get #path(): string {
|
||||
return this.#handle.path;
|
||||
}
|
||||
|
||||
#closedResult<T>(): Result<T, FileError> | undefined {
|
||||
return this.#closed ? err(new FileError("invalid", "Binary reader is closed", this.#path)) : undefined;
|
||||
}
|
||||
|
||||
async info(context: Context): Promise<Result<FileInfo, FileError>> {
|
||||
const early = abortResult<FileInfo>(context.abortSignal, this.#path) ?? this.#closedResult<FileInfo>();
|
||||
if (early) return early;
|
||||
try {
|
||||
const { json } = await this.#handle.request("fstat");
|
||||
return toInfo(this.#path, json as unknown as RemoteFileInfo, await remotePath(this.#handle.env.connection));
|
||||
} catch (error) {
|
||||
return err(toFileError(error, this.#path));
|
||||
}
|
||||
}
|
||||
|
||||
async read(offset: number, length: number, context: Context): Promise<Result<Uint8Array, FileError>> {
|
||||
const early = abortResult<Uint8Array>(context.abortSignal, this.#path) ?? this.#closedResult<Uint8Array>();
|
||||
if (early) return early;
|
||||
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(length) || length < 0) {
|
||||
return err(new FileError("invalid", "Offset and length must be non-negative safe integers", this.#path));
|
||||
}
|
||||
try {
|
||||
const read = await readRange(
|
||||
(at, size) => this.#handle.pread(at, size),
|
||||
offset,
|
||||
length,
|
||||
READ_CHUNK,
|
||||
() => context.abortSignal?.aborted === true,
|
||||
);
|
||||
if (read === undefined) return err(new FileError("aborted", "aborted", this.#path));
|
||||
return ok(concat(read.chunks, read.total));
|
||||
} catch (error) {
|
||||
return err(toFileError(error, this.#path));
|
||||
}
|
||||
}
|
||||
|
||||
async scanLines(
|
||||
options: { startLine: number; endLine?: number },
|
||||
context: Context,
|
||||
): Promise<Result<LineScan, FileError>> {
|
||||
const early = abortResult<LineScan>(context.abortSignal, this.#path) ?? this.#closedResult<LineScan>();
|
||||
if (early) return early;
|
||||
const { startLine, endLine } = options;
|
||||
if (
|
||||
!Number.isSafeInteger(startLine) ||
|
||||
startLine < 0 ||
|
||||
(endLine !== undefined && (!Number.isSafeInteger(endLine) || endLine <= startLine))
|
||||
) {
|
||||
return err(new FileError("invalid", "Invalid line range", this.#path));
|
||||
}
|
||||
try {
|
||||
const { json } = await this.#handle.request(
|
||||
"scanLines",
|
||||
{ startLine, ...(endLine === undefined ? {} : { endLine }) },
|
||||
context.abortSignal ? { signal: context.abortSignal } : {},
|
||||
);
|
||||
return ok(json as unknown as LineScan);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, this.#path));
|
||||
}
|
||||
}
|
||||
|
||||
async close(_context: Context): Promise<void> {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
await this.#handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteDirReader implements DirReader {
|
||||
readonly #handle: Handle;
|
||||
#done = false;
|
||||
#closed = false;
|
||||
|
||||
constructor(handle: Handle) {
|
||||
this.#handle = handle;
|
||||
}
|
||||
|
||||
async next(
|
||||
maxEntries: number,
|
||||
context: Context,
|
||||
): Promise<Result<{ entries: FileInfo[]; done: boolean }, FileError>> {
|
||||
const path = this.#handle.path;
|
||||
const aborted = abortResult<{ entries: FileInfo[]; done: boolean }>(context.abortSignal, path);
|
||||
if (aborted) return aborted;
|
||||
if (this.#closed) return err(new FileError("invalid", "Directory reader is closed", path));
|
||||
if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) {
|
||||
return err(new FileError("invalid", "maxEntries must be a positive safe integer", path));
|
||||
}
|
||||
const entries: FileInfo[] = [];
|
||||
try {
|
||||
const paths = await remotePath(this.#handle.env.connection);
|
||||
// Like `NodeDirReader`, count only entries that are part of the listing.
|
||||
while (!this.#done && entries.length < maxEntries) {
|
||||
const { json } = await this.#handle.request("readdir", { max: maxEntries - entries.length });
|
||||
const loopAbort = abortResult<{ entries: FileInfo[]; done: boolean }>(context.abortSignal, path);
|
||||
if (loopAbort) return loopAbort;
|
||||
for (const entry of json.entries as RemoteDirEntry[]) {
|
||||
const entryPath = paths.resolve(path, entry.name);
|
||||
if (entry.error !== undefined) {
|
||||
// Removed between enumeration and lstat: not part of the listing any more.
|
||||
if (entry.error.code === "ENOENT") continue;
|
||||
return err(toFileError(new RemoteError(entry.error), entryPath));
|
||||
}
|
||||
const info = toInfo(entryPath, entry.info!, paths);
|
||||
if (info.ok) entries.push(info.value);
|
||||
}
|
||||
this.#done = json.done === true;
|
||||
}
|
||||
return ok({ entries, done: this.#done });
|
||||
} catch (error) {
|
||||
return err(toFileError(error, path));
|
||||
}
|
||||
}
|
||||
|
||||
async close(_context: Context): Promise<void> {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
await this.#handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** `NodeTextLineReader`: positional reads of a file opened like `open(path, "r")`. */
|
||||
class RemoteTextLineReader implements TextLineReader {
|
||||
readonly #handle: Handle;
|
||||
readonly #decoder = new StreamDecoder();
|
||||
#offset = 0;
|
||||
#buffered = "";
|
||||
#ended = false;
|
||||
#closed = false;
|
||||
|
||||
constructor(handle: Handle) {
|
||||
this.#handle = handle;
|
||||
}
|
||||
|
||||
async readLine(context: Context): Promise<Result<TextLine | undefined, FileError>> {
|
||||
const path = this.#handle.path;
|
||||
const aborted = abortResult<TextLine | undefined>(context.abortSignal, path);
|
||||
if (aborted) return aborted;
|
||||
if (this.#closed) return err(new FileError("invalid", "Text line reader is closed", path));
|
||||
try {
|
||||
while (true) {
|
||||
const newline = this.#buffered.indexOf("\n");
|
||||
if (newline !== -1) {
|
||||
const text = this.#buffered.slice(0, newline);
|
||||
this.#buffered = this.#buffered.slice(newline + 1);
|
||||
return ok({ text, terminated: true });
|
||||
}
|
||||
if (this.#ended) {
|
||||
if (this.#buffered.length === 0) return ok(undefined);
|
||||
const text = this.#buffered;
|
||||
this.#buffered = "";
|
||||
return ok({ text, terminated: false });
|
||||
}
|
||||
const bytes = await this.#handle.pread(this.#offset, LINE_CHUNK);
|
||||
const afterReadAbort = abortResult<TextLine | undefined>(context.abortSignal, path);
|
||||
if (afterReadAbort) return afterReadAbort;
|
||||
this.#offset += bytes.length;
|
||||
if (bytes.length === 0) {
|
||||
this.#buffered += this.#decoder.decode();
|
||||
this.#ended = true;
|
||||
} else {
|
||||
this.#buffered += this.#decoder.decode(bytes);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return err(toFileError(error, path));
|
||||
}
|
||||
}
|
||||
|
||||
async close(_context: Context): Promise<void> {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#buffered = "";
|
||||
await this.#handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An `ExecutionEnv` on another machine, reached through a pi-env daemon. Results match `NodeExecutionEnv` running on
|
||||
* that machine: the daemon performs the system calls, and this class applies Node's path, error, and result rules.
|
||||
*/
|
||||
export class RemoteExecutionEnv implements ExecutionEnv {
|
||||
readonly id: string;
|
||||
cwd: string;
|
||||
readonly connection: Connection;
|
||||
readonly #shellPath: string | undefined;
|
||||
readonly #shellEnv: Record<string, string> | undefined;
|
||||
readonly #watchOptions: RemoteWatchOptions;
|
||||
/** Running commands this environment started, for `cleanup()`. */
|
||||
readonly #running = new Map<number, number>();
|
||||
|
||||
constructor(options: RemoteExecutionEnvOptions) {
|
||||
this.id = options.id;
|
||||
this.cwd = options.cwd;
|
||||
this.connection = options.connection;
|
||||
this.#shellPath = options.shellPath;
|
||||
this.#shellEnv = options.shellEnv;
|
||||
this.#watchOptions = options.watch ?? {};
|
||||
}
|
||||
|
||||
/** Node's `resolvePath` on the remote system, with its home directory and working directories. */
|
||||
async #resolve(path: string): Promise<string> {
|
||||
const info = await this.connection.info();
|
||||
const windows = nodePlatform(info).platform === "win32";
|
||||
const paths = windows ? win32 : posix;
|
||||
let normalized = path;
|
||||
if (normalized === "~") {
|
||||
normalized = info.home;
|
||||
} else if (normalized.startsWith("~/") || (windows && normalized.startsWith("~\\"))) {
|
||||
normalized = paths.join(info.home, normalized.slice(2));
|
||||
} else if (normalized.startsWith("file://")) {
|
||||
try {
|
||||
normalized = fileURLToPath(normalized, { windows });
|
||||
} catch {
|
||||
// Keep malformed URLs as ordinary paths, as Node does.
|
||||
}
|
||||
}
|
||||
if (paths.isAbsolute(normalized)) return paths.resolve(normalized);
|
||||
if (windows) {
|
||||
// A drive-relative path on another drive: Node on Windows resolves it against that drive's working directory
|
||||
// (`=D:`), else its own working directory if on that drive, else the drive's root.
|
||||
const drive = /^([a-zA-Z]:)(?![\\/])/.exec(normalized)?.[1];
|
||||
if (drive !== undefined && drive.toLowerCase() !== this.cwd.slice(0, 2).toLowerCase()) {
|
||||
let base = info.driveCwds[drive.toUpperCase()] ?? info.cwd;
|
||||
if (base.slice(0, 2).toLowerCase() !== drive.toLowerCase() && base[2] === "\\") base = `${drive}\\`;
|
||||
return win32.resolve(base, normalized);
|
||||
}
|
||||
}
|
||||
return paths.resolve(this.cwd, normalized);
|
||||
}
|
||||
|
||||
async #fileOp<T>(
|
||||
path: string,
|
||||
context: Context,
|
||||
run: (resolved: string) => Promise<T>,
|
||||
check: "before" | "both" = "before",
|
||||
): Promise<Result<T, FileError>> {
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = await this.#resolve(path);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, path));
|
||||
}
|
||||
const aborted = abortResult<T>(context.abortSignal, resolved);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
const value = await run(resolved);
|
||||
if (check === "both") {
|
||||
const after = abortResult<T>(context.abortSignal, resolved);
|
||||
if (after) return after;
|
||||
}
|
||||
return ok(value);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async #open(resolved: string, json: Json): Promise<{ handle: Handle; json: Json }> {
|
||||
const reply = await this.connection.request("open", { path: resolved, ...json });
|
||||
return { handle: new Handle(this, reply.json.handle as number, reply.session, resolved), json: reply.json };
|
||||
}
|
||||
|
||||
async absolutePath(path: string, _context: Context): Promise<Result<string, FileError>> {
|
||||
try {
|
||||
return ok(await this.#resolve(path));
|
||||
} catch (error) {
|
||||
return err(toFileError(error, path));
|
||||
}
|
||||
}
|
||||
|
||||
async joinPath(parts: string[], _context: Context): Promise<Result<string, FileError>> {
|
||||
try {
|
||||
return ok((await remotePath(this.connection)).join(...parts));
|
||||
} catch (error) {
|
||||
return err(toFileError(error));
|
||||
}
|
||||
}
|
||||
|
||||
/** Node's `readFile`, with or without UTF-8 decoding, over a file opened like `open(path, "r")`. */
|
||||
async #readFile(path: string, encoding: boolean, context: Context): Promise<Result<Uint8Array | string, FileError>> {
|
||||
const signal = context.abortSignal;
|
||||
return this.#fileOp(path, context, async (resolved) => {
|
||||
const { handle, json } = await this.#open(resolved, { mode: "read" });
|
||||
const aborted = () => signal?.aborted === true;
|
||||
const abort = () => new FileError("aborted", "The operation was aborted", resolved);
|
||||
try {
|
||||
if (json.statError !== undefined) throw new RemoteError(json.statError as Json);
|
||||
if (aborted()) throw abort();
|
||||
const stat = json.stat as RemoteFileInfo;
|
||||
const size = stat.kind === "file" ? stat.size : 0;
|
||||
if (size > READ_FILE_MAX) {
|
||||
throw new FileError("unknown", `File size (${size}) is greater than 2 GiB`, resolved);
|
||||
}
|
||||
if (size === 0) {
|
||||
// Unknown size: sequential reads until the end, as for FIFOs and devices.
|
||||
const decoder = new StringDecoder("utf8");
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
let text = "";
|
||||
while (true) {
|
||||
if (aborted()) throw abort();
|
||||
const bytes = await handle.pread(undefined, READ_FILE_UNKNOWN_CHUNK);
|
||||
if (bytes.length === 0) break;
|
||||
total += bytes.length;
|
||||
chunks.push(bytes);
|
||||
if (encoding) text += decoder.write(bytes);
|
||||
}
|
||||
if (!encoding) return Buffer.concat(chunks, total);
|
||||
return total === 0 ? "" : text + decoder.end();
|
||||
}
|
||||
if (!encoding) {
|
||||
// A single read of the whole size, or reads of 512 KiB until the size or the end of the file.
|
||||
if (size <= READ_FILE_CHUNK) return Buffer.from(await handle.pread(0, size));
|
||||
const read = await readRange(
|
||||
(at, length) => handle.pread(at, length),
|
||||
0,
|
||||
size,
|
||||
READ_FILE_CHUNK,
|
||||
aborted,
|
||||
);
|
||||
if (read === undefined) throw abort();
|
||||
return Buffer.concat(read.chunks, read.total);
|
||||
}
|
||||
// Decoding: reads of min(size, 512 KiB) until the size, a short read, or the end of the file.
|
||||
const length = Math.min(size, READ_FILE_CHUNK);
|
||||
const decoder = new StringDecoder("utf8");
|
||||
let text = "";
|
||||
let total = 0;
|
||||
let first = true;
|
||||
const inFlight: Promise<Uint8Array>[] = [];
|
||||
let next = 0;
|
||||
while (true) {
|
||||
while (inFlight.length < READ_DEPTH) {
|
||||
const bytes = handle.pread(next, length);
|
||||
bytes.catch(() => {});
|
||||
inFlight.push(bytes);
|
||||
next += length;
|
||||
}
|
||||
const bytes = await inFlight.shift()!;
|
||||
if (aborted()) throw abort();
|
||||
total += bytes.length;
|
||||
if (bytes.length === 0 || total === size || bytes.length !== length) {
|
||||
return first ? Buffer.from(bytes).toString("utf8") : text + decoder.end(Buffer.from(bytes));
|
||||
}
|
||||
text += decoder.write(Buffer.from(bytes));
|
||||
first = false;
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async readTextFile(path: string, context: Context): Promise<Result<string, FileError>> {
|
||||
return (await this.#readFile(path, true, context)) as Result<string, FileError>;
|
||||
}
|
||||
|
||||
async readBinaryFile(path: string, context: Context): Promise<Result<Uint8Array, FileError>> {
|
||||
return (await this.#readFile(path, false, context)) as Result<Uint8Array, FileError>;
|
||||
}
|
||||
|
||||
async openBinaryReader(
|
||||
path: string,
|
||||
options: { noFollow?: boolean } | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<BinaryReader, FileError>> {
|
||||
const opened = await this.#fileOp(path, context, async (resolved) => {
|
||||
return (await this.#open(resolved, { noFollow: options?.noFollow === true })).handle;
|
||||
});
|
||||
if (!opened.ok) return opened;
|
||||
const aborted = abortResult<BinaryReader>(context.abortSignal, opened.value.path);
|
||||
if (aborted) {
|
||||
await opened.value.close();
|
||||
return aborted;
|
||||
}
|
||||
return ok(new RemoteBinaryReader(opened.value));
|
||||
}
|
||||
|
||||
async openTextLineReader(path: string, context: Context): Promise<Result<TextLineReader, FileError>> {
|
||||
const opened = await this.#fileOp(path, context, async (resolved) => {
|
||||
return (await this.#open(resolved, { mode: "read" })).handle;
|
||||
});
|
||||
if (!opened.ok) return opened;
|
||||
const aborted = abortResult<TextLineReader>(context.abortSignal, opened.value.path);
|
||||
if (aborted) {
|
||||
await opened.value.close();
|
||||
return aborted;
|
||||
}
|
||||
return ok(new RemoteTextLineReader(opened.value));
|
||||
}
|
||||
|
||||
async readTextLines(
|
||||
path: string,
|
||||
options: { maxLines?: number } | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<string[], FileError>> {
|
||||
if (options?.maxLines !== undefined && options.maxLines <= 0) return ok([]);
|
||||
const opened = await this.openTextLineReader(path, context);
|
||||
if (!opened.ok) return opened;
|
||||
const lines: string[] = [];
|
||||
try {
|
||||
while (options?.maxLines === undefined || lines.length < options.maxLines) {
|
||||
const line = await opened.value.readLine(context);
|
||||
if (!line.ok) return line;
|
||||
if (line.value === undefined) break;
|
||||
lines.push(line.value.text);
|
||||
}
|
||||
return ok(lines);
|
||||
} finally {
|
||||
await opened.value.close(context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Node's `writeFile` (abort checked before each 512 KiB write) or `appendFile` (no checks, one after): the first
|
||||
* request creates parents, opens, and writes the first chunk; later chunks go to the same open file.
|
||||
*/
|
||||
async #write(path: string, content: string | Uint8Array, append: boolean, context: Context) {
|
||||
const bytes = typeof content === "string" ? Buffer.from(content, "utf8") : content;
|
||||
const signal = append ? undefined : context.abortSignal;
|
||||
return this.#fileOp(
|
||||
path,
|
||||
context,
|
||||
async (resolved) => {
|
||||
const first = bytes.subarray(0, WRITE_CHUNK);
|
||||
const keep = bytes.length > WRITE_CHUNK;
|
||||
const reply = await this.connection.request("write", { path: resolved, append, keep }, { payload: first });
|
||||
if (!keep) return;
|
||||
const handle = new Handle(this, reply.json.handle as number, reply.session, resolved);
|
||||
const inFlight: Promise<unknown>[] = [];
|
||||
try {
|
||||
for (let offset = WRITE_CHUNK; offset < bytes.length; offset += WRITE_CHUNK) {
|
||||
if (signal?.aborted) {
|
||||
// A chunk that failed before this check fails the write, as it would have in sequence.
|
||||
const failed = (await Promise.allSettled(inFlight)).find((result) => result.status === "rejected");
|
||||
if (failed !== undefined) throw failed.reason;
|
||||
throw new FileError("aborted", "The operation was aborted", resolved);
|
||||
}
|
||||
const written = handle.request(
|
||||
"writeChunk",
|
||||
{},
|
||||
{ payload: bytes.subarray(offset, offset + WRITE_CHUNK) },
|
||||
);
|
||||
written.catch(() => {});
|
||||
inFlight.push(written);
|
||||
if (inFlight.length >= WRITE_DEPTH) await inFlight.shift();
|
||||
}
|
||||
// The first failure; later chunks are refused by the daemon, so the file has no gaps.
|
||||
for (const written of inFlight) await written;
|
||||
} finally {
|
||||
await Promise.allSettled(inFlight);
|
||||
await handle.close();
|
||||
}
|
||||
},
|
||||
append ? "both" : "before",
|
||||
);
|
||||
}
|
||||
|
||||
writeFile(path: string, content: string | Uint8Array, context: Context): Promise<Result<void, FileError>> {
|
||||
return this.#write(path, content, false, context);
|
||||
}
|
||||
|
||||
appendFile(path: string, content: string | Uint8Array, context: Context): Promise<Result<void, FileError>> {
|
||||
return this.#write(path, content, true, context);
|
||||
}
|
||||
|
||||
async truncateFile(path: string, size: number, context: Context): Promise<Result<void, FileError>> {
|
||||
return this.#fileOp(
|
||||
path,
|
||||
context,
|
||||
async (resolved) => {
|
||||
if (!Number.isSafeInteger(size) || size < 0) {
|
||||
throw new FileError("invalid", "File size must be a non-negative safe integer", resolved);
|
||||
}
|
||||
await this.connection.request("truncate", { path: resolved, size });
|
||||
},
|
||||
"both",
|
||||
);
|
||||
}
|
||||
|
||||
flushFile(path: string, context: Context): Promise<Result<void, FileError>> {
|
||||
return this.#fileOp(
|
||||
path,
|
||||
context,
|
||||
async (resolved) => {
|
||||
await this.connection.request("fsync", { path: resolved });
|
||||
},
|
||||
"both",
|
||||
);
|
||||
}
|
||||
|
||||
async renameFile(sourcePath: string, destinationPath: string, context: Context): Promise<Result<void, FileError>> {
|
||||
let source: string;
|
||||
let destination: string;
|
||||
try {
|
||||
source = await this.#resolve(sourcePath);
|
||||
destination = await this.#resolve(destinationPath);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, sourcePath));
|
||||
}
|
||||
const aborted = abortResult<void>(context.abortSignal, destination);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
await this.connection.request("rename", { path: source, to: destination });
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, source));
|
||||
}
|
||||
}
|
||||
|
||||
async fileInfo(path: string, context: Context): Promise<Result<FileInfo, FileError>> {
|
||||
const result = await this.#fileOp(path, context, async (resolved) => {
|
||||
const { json } = await this.connection.request("lstat", { path: resolved });
|
||||
return toInfo(resolved, json as unknown as RemoteFileInfo, await remotePath(this.connection));
|
||||
});
|
||||
return result.ok ? result.value : result;
|
||||
}
|
||||
|
||||
listDir(path: string, context: Context): Promise<Result<FileInfo[], FileError>> {
|
||||
return this.#fileOp(path, context, async (resolved) => {
|
||||
const { handle } = await this.#openDir(resolved);
|
||||
const entries: RemoteDirEntry[] = [];
|
||||
try {
|
||||
for (let done = false; !done; ) {
|
||||
const { json } = await handle.request("readdir", { max: 1000 });
|
||||
entries.push(...(json.entries as RemoteDirEntry[]));
|
||||
done = json.done === true;
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
// Node's `readdir` fails on any entry it cannot lstat. libuv sorts names by their bytes on POSIX; on Windows it
|
||||
// keeps the file system's order, which the daemon reports.
|
||||
const paths = await remotePath(this.connection);
|
||||
if (paths === posix) {
|
||||
const key = (entry: RemoteDirEntry) => Buffer.from(entry.raw ?? Buffer.from(entry.name, "utf8"));
|
||||
entries.sort((a, b) => Buffer.compare(key(a), key(b)));
|
||||
}
|
||||
const infos: FileInfo[] = [];
|
||||
for (const entry of entries) {
|
||||
if (context.abortSignal?.aborted) throw new FileError("aborted", "aborted", resolved);
|
||||
const entryPath = paths.resolve(resolved, entry.name);
|
||||
if (entry.error !== undefined) throw toFileError(new RemoteError(entry.error), entryPath);
|
||||
const info = toInfo(entryPath, entry.info!, paths);
|
||||
if (info.ok) infos.push(info.value);
|
||||
}
|
||||
return infos;
|
||||
});
|
||||
}
|
||||
|
||||
/** Through the daemon; `not_supported` with a daemon released before `listFiles`. */
|
||||
async listFiles(
|
||||
path: string,
|
||||
options: ListFilesOptions | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<FileListing, FileError>> {
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = await this.#resolve(path);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, path));
|
||||
}
|
||||
return listFilesOver(this.connection, resolved, options, context);
|
||||
}
|
||||
|
||||
async #openDir(resolved: string): Promise<{ handle: Handle }> {
|
||||
const reply = await this.connection.request("opendir", { path: resolved });
|
||||
return { handle: new Handle(this, reply.json.handle as number, reply.session, resolved) };
|
||||
}
|
||||
|
||||
async openDirReader(path: string, context: Context): Promise<Result<DirReader, FileError>> {
|
||||
const opened = await this.#fileOp(path, context, async (resolved) => (await this.#openDir(resolved)).handle);
|
||||
if (!opened.ok) return opened;
|
||||
const aborted = abortResult<DirReader>(context.abortSignal, opened.value.path);
|
||||
if (aborted) {
|
||||
await opened.value.close();
|
||||
return aborted;
|
||||
}
|
||||
return ok(new RemoteDirReader(opened.value));
|
||||
}
|
||||
|
||||
canonicalPath(path: string, context: Context): Promise<Result<string, FileError>> {
|
||||
return this.#fileOp(path, context, async (resolved) => {
|
||||
const { json } = await this.connection.request("realpath", { path: resolved });
|
||||
return json.path as string;
|
||||
});
|
||||
}
|
||||
|
||||
async exists(path: string, context: Context): Promise<Result<boolean, FileError>> {
|
||||
const result = await this.fileInfo(path, context);
|
||||
if (result.ok) return ok(true);
|
||||
if (result.error.code === "not_found") return ok(false);
|
||||
return err(result.error);
|
||||
}
|
||||
|
||||
createDir(
|
||||
path: string,
|
||||
options: { recursive?: boolean } | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<void, FileError>> {
|
||||
return this.#fileOp(path, context, async (resolved) => {
|
||||
await this.connection.request("mkdir", { path: resolved, recursive: options?.recursive ?? true });
|
||||
});
|
||||
}
|
||||
|
||||
remove(
|
||||
path: string,
|
||||
options: { recursive?: boolean; force?: boolean } | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<void, FileError>> {
|
||||
return this.#fileOp(path, context, async (resolved) => {
|
||||
await this.connection.request("rm", {
|
||||
path: resolved,
|
||||
recursive: options?.recursive ?? false,
|
||||
force: options?.force ?? false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async createTempDir(prefix: string | undefined, context: Context): Promise<Result<string, FileError>> {
|
||||
const aborted = abortResult<string>(context.abortSignal);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
const { tmpdir } = await this.connection.info();
|
||||
const paths = await remotePath(this.connection);
|
||||
const { json } = await this.connection.request("mkdtemp", { path: paths.join(tmpdir, prefix ?? "tmp-") });
|
||||
return ok(json.path as string);
|
||||
} catch (error) {
|
||||
return err(toFileError(error));
|
||||
}
|
||||
}
|
||||
|
||||
async createTempFile(
|
||||
options: { prefix?: string; suffix?: string } | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<string, FileError>> {
|
||||
const dir = await this.createTempDir("tmp-", context);
|
||||
if (!dir.ok) return dir;
|
||||
let filePath = "";
|
||||
try {
|
||||
const paths = await remotePath(this.connection);
|
||||
filePath = paths.join(dir.value, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`);
|
||||
// Node's `writeFile(filePath, "")`, which does not create parents.
|
||||
await this.connection.request(
|
||||
"write",
|
||||
{ path: filePath, append: false, parents: false },
|
||||
{ payload: new Uint8Array(0) },
|
||||
);
|
||||
return ok(filePath);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, filePath));
|
||||
}
|
||||
}
|
||||
|
||||
async watch(
|
||||
targets: readonly WatchTarget[],
|
||||
onChange: (change: WatchChange) => void,
|
||||
context: Context,
|
||||
): Promise<Result<FileWatcher, FileError>> {
|
||||
const aborted = abortResult<FileWatcher>(context.abortSignal);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
const resolved = await Promise.all(
|
||||
targets.map(async (target) => ({ ...target, path: await this.#resolve(target.path) })),
|
||||
);
|
||||
const afterResolve = abortResult<FileWatcher>(context.abortSignal);
|
||||
if (afterResolve) return afterResolve;
|
||||
const watcher = await RemoteWatcher.open(this.connection, resolved, onChange, this.#watchOptions);
|
||||
const afterOpen = abortResult<FileWatcher>(context.abortSignal);
|
||||
if (afterOpen) {
|
||||
await watcher.close(context);
|
||||
return afterOpen;
|
||||
}
|
||||
return ok(watcher);
|
||||
} catch (error) {
|
||||
return err(toFileError(error));
|
||||
}
|
||||
}
|
||||
|
||||
async exec(
|
||||
command: string | readonly string[],
|
||||
options: ShellExecOptions | undefined,
|
||||
context: Context,
|
||||
): Promise<Result<ShellExecResult, ExecutionError>> {
|
||||
const signal = context.abortSignal;
|
||||
if (signal?.aborted) return err(new ExecutionError("aborted", "aborted"));
|
||||
const timeout = options?.timeout;
|
||||
if (timeout !== undefined) {
|
||||
if (!Number.isFinite(timeout) || timeout <= 0) {
|
||||
return err(new ExecutionError("timeout", "Invalid timeout: must be a finite number of seconds"));
|
||||
}
|
||||
if (timeout * 1000 > MAX_TIMEOUT_MS) {
|
||||
return err(new ExecutionError("timeout", `Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`));
|
||||
}
|
||||
}
|
||||
const inheritEnv = options?.inheritEnv ?? true;
|
||||
let cwd: string;
|
||||
try {
|
||||
cwd = options?.cwd ? await this.#resolve(options.cwd) : this.cwd;
|
||||
} catch (error) {
|
||||
return err(new ExecutionError("unknown", error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
if (signal?.aborted) return err(new ExecutionError("aborted", "aborted"));
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(
|
||||
inheritEnv ? { ...this.#shellEnv, ...options?.env } : { ...options?.env },
|
||||
)) {
|
||||
if (value !== undefined) env[key.toWellFormed()] = value;
|
||||
}
|
||||
let callbackError: ExecutionError | undefined;
|
||||
let settled = false;
|
||||
let running: number | undefined;
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort();
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
const { json } = await this.connection.request(
|
||||
"exec",
|
||||
{
|
||||
...(typeof command === "string" ? { command } : { argv: [...command] }),
|
||||
cwd,
|
||||
env,
|
||||
inheritEnv,
|
||||
...(this.#shellPath === undefined ? {} : { shellPath: this.#shellPath }),
|
||||
...(timeout === undefined ? {} : { timeoutMs: timeout * 1000 }),
|
||||
...(options?.spill === undefined ? {} : { spill: options.spill }),
|
||||
...(options?.window === undefined ? {} : { window: options.window }),
|
||||
},
|
||||
{
|
||||
signal: controller.signal,
|
||||
onStart: (requestId, session) => {
|
||||
running = requestId;
|
||||
this.#running.set(requestId, session);
|
||||
},
|
||||
onEvent: (event, payload) => {
|
||||
if (settled || callbackError !== undefined || event.kind !== "output") return;
|
||||
const text = Buffer.from(payload.buffer, payload.byteOffset, payload.length).toString("utf8");
|
||||
if (text === "" || options?.onOutput === undefined) return;
|
||||
const info: ShellOutputInfo = { stream: event.stream === "stderr" ? "stderr" : "stdout" };
|
||||
if (event.skipped !== undefined) info.skipped = event.skipped as ShellOutputSkip;
|
||||
try {
|
||||
options.onOutput(text, context, info);
|
||||
} catch (error) {
|
||||
const cause = error instanceof Error ? error : new Error(String(error));
|
||||
callbackError = new ExecutionError("callback_error", cause.message, cause);
|
||||
controller.abort();
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
if (callbackError) return err(callbackError);
|
||||
return ok({
|
||||
exitCode: json.exitCode as number,
|
||||
...(typeof json.spillPath === "string" ? { spillPath: json.spillPath } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (callbackError) return err(callbackError);
|
||||
if (!(error instanceof RemoteError)) {
|
||||
return err(new ExecutionError("unknown", error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
const failure =
|
||||
error.code === "timeout"
|
||||
? new ExecutionError("timeout", `timeout:${timeout}`)
|
||||
: error.code === "aborted"
|
||||
? new ExecutionError("aborted", "aborted")
|
||||
: error.code === "shell_unavailable" || error.code === "spawn_error"
|
||||
? new ExecutionError(error.code, error.message)
|
||||
: new ExecutionError("unknown", error.message);
|
||||
if (typeof error.fields.spillPath === "string") failure.spillPath = error.fields.spillPath;
|
||||
return err(failure);
|
||||
} finally {
|
||||
settled = true;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
if (running !== undefined) this.#running.delete(running);
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup(_context: Context): Promise<void> {
|
||||
// Kill without aborting, so the commands settle with their killed status, as `NodeExecutionEnv` does.
|
||||
for (const [id, session] of this.#running) this.connection.kill(id, session);
|
||||
this.#running.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export type { RemoteInfo };
|
||||
539
third_party/pi/packages/env/src/ssh.ts
vendored
Normal file
539
third_party/pi/packages/env/src/ssh.ts
vendored
Normal file
@@ -0,0 +1,539 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Connection, type ConnectionOptions } from "./connection.ts";
|
||||
|
||||
/** A remote system the package ships a daemon for. */
|
||||
export type RemotePlatform = {
|
||||
platform: "linux" | "android" | "darwin" | "windows";
|
||||
arch: "x64" | "arm64";
|
||||
/** The remote home directory, in the remote system's own spelling. */
|
||||
home: string;
|
||||
/** Windows: the shell `sshd` runs commands with (`DefaultShell`). */
|
||||
shell?: "cmd" | "powershell";
|
||||
/** Problems worth telling the owner about, such as Termux without `termux-exec`. */
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
/** How to reach a machine with the system `ssh`. */
|
||||
export interface SshTarget {
|
||||
/** Host name or `~/.ssh/config` alias. */
|
||||
host: string;
|
||||
user?: string;
|
||||
port?: number;
|
||||
identityFile?: string;
|
||||
/** Host keys this application trusts; `acceptHostKey` adds to it. */
|
||||
knownHostsFile: string;
|
||||
/** The name keys are stored under, independent of aliases, ports and jump hosts, e.g. `pi-env-<env name>`. */
|
||||
hostKeyAlias: string;
|
||||
/** The `ssh` program; default `ssh`. */
|
||||
ssh?: string;
|
||||
/** An `ssh` configuration file instead of `~/.ssh/config` (`-F`). */
|
||||
configFile?: string;
|
||||
}
|
||||
|
||||
/** The remote host's key is not in `knownHostsFile`; `scanHostKey` shows it so the owner can accept it. */
|
||||
export class HostKeyUnknownError extends Error {}
|
||||
|
||||
/** The remote host's key differs from the one in `knownHostsFile`; it is never accepted automatically. */
|
||||
export class HostKeyChangedError extends Error {}
|
||||
|
||||
/** An `ssh` invocation that failed for another reason, with its exit code and diagnostics. */
|
||||
export class SshError extends Error {
|
||||
readonly exitCode: number | null;
|
||||
readonly stderr: string;
|
||||
|
||||
constructor(message: string, exitCode: number | null, stderr: string) {
|
||||
super(message);
|
||||
this.exitCode = exitCode;
|
||||
this.stderr = stderr;
|
||||
}
|
||||
}
|
||||
|
||||
const packageRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
/** No leading `-` (it would be read as an `ssh` option) and no whitespace or control characters. */
|
||||
function checkField(name: string, value: string): void {
|
||||
if (value === "" || value.startsWith("-") || /[\s\x00-\x1f\x7f]/.test(value)) {
|
||||
throw new Error(`Invalid ${name}: ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** A path for an `ssh` option that expands `%` tokens: quoted, with `%` literal. */
|
||||
function configPath(name: string, path: string): string {
|
||||
if (path === "" || /["\x00-\x1f\x7f]/.test(path)) throw new Error(`Invalid ${name}: ${JSON.stringify(path)}`);
|
||||
return `"${path.replaceAll("%", "%%")}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arguments for `ssh` up to the host: no prompts, no forwarding of any kind, no shared connections, no commands from
|
||||
* the configuration, no locale forwarding (the remote uses its own), and host keys checked strictly against the
|
||||
* application's own file under a fixed alias.
|
||||
*/
|
||||
export function sshArguments(
|
||||
target: SshTarget,
|
||||
strictHostKeys = true,
|
||||
knownHostsFile = target.knownHostsFile,
|
||||
): string[] {
|
||||
checkField("host", target.host);
|
||||
checkField("host key alias", target.hostKeyAlias);
|
||||
if (target.user !== undefined) checkField("user", target.user);
|
||||
if (target.port !== undefined && (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535)) {
|
||||
throw new Error(`Invalid port: ${target.port}`);
|
||||
}
|
||||
const option = (setting: string) => ["-o", setting];
|
||||
return [
|
||||
...(target.configFile === undefined ? [] : ["-F", target.configFile]),
|
||||
"-T",
|
||||
"-a",
|
||||
"-x",
|
||||
...option("BatchMode=yes"),
|
||||
...option("ClearAllForwardings=yes"),
|
||||
...option("ForwardAgent=no"),
|
||||
...option("ForwardX11=no"),
|
||||
...option("ControlMaster=no"),
|
||||
...option("ControlPath=none"),
|
||||
...option("RemoteCommand=none"),
|
||||
...option("PermitLocalCommand=no"),
|
||||
...option("SendEnv=-*"),
|
||||
...option("ServerAliveInterval=15"),
|
||||
...option(`StrictHostKeyChecking=${strictHostKeys ? "yes" : "accept-new"}`),
|
||||
...option(`UserKnownHostsFile=${configPath("known hosts file", knownHostsFile)}`),
|
||||
...option("GlobalKnownHostsFile=none"),
|
||||
...option("HashKnownHosts=no"),
|
||||
...option(`HostKeyAlias=${target.hostKeyAlias}`),
|
||||
...(target.user === undefined ? [] : ["-l", target.user]),
|
||||
...(target.port === undefined ? [] : ["-p", String(target.port)]),
|
||||
...(target.identityFile === undefined
|
||||
? []
|
||||
: [
|
||||
...option(`IdentityFile=${configPath("identity file", target.identityFile)}`),
|
||||
...option("IdentitiesOnly=yes"),
|
||||
]),
|
||||
"--",
|
||||
target.host,
|
||||
];
|
||||
}
|
||||
|
||||
/** How long a detection, check or upload over `ssh` may take before it is abandoned. */
|
||||
const SSH_TIMEOUT_MS = 60_000;
|
||||
const UPLOAD_TIMEOUT_MS = 300_000;
|
||||
|
||||
/** Run one remote command over `ssh`, optionally feeding stdin; resolves with stdout, rejects on failure. */
|
||||
function runSsh(
|
||||
target: SshTarget,
|
||||
command: string,
|
||||
options: { stdin?: Uint8Array; args?: string[]; timeoutMs?: number } = {},
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = options.args ?? sshArguments(target);
|
||||
const child = spawn(target.ssh ?? "ssh", [...args, command], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
const timeoutMs = options.timeoutMs ?? SSH_TIMEOUT_MS;
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
const diagnostics = Buffer.concat(stderr).toString("utf8");
|
||||
reject(
|
||||
new SshError(
|
||||
`ssh ${target.host} did not finish within ${timeoutMs / 1000} s: ${diagnostics.trim()}`,
|
||||
null,
|
||||
diagnostics,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
|
||||
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
|
||||
child.stdin.on("error", () => {});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(new SshError(error.message, null, ""));
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
const output = Buffer.concat(stdout).toString("utf8");
|
||||
const diagnostics = Buffer.concat(stderr).toString("utf8");
|
||||
if (code === 0) return resolve(output);
|
||||
if (/REMOTE HOST IDENTIFICATION HAS CHANGED/.test(diagnostics)) {
|
||||
return reject(
|
||||
new HostKeyChangedError(`The host key of ${target.host} changed; remove the old key to continue`),
|
||||
);
|
||||
}
|
||||
if (/Host key verification failed|No .* host key is known/.test(diagnostics)) {
|
||||
return reject(new HostKeyUnknownError(`The host key of ${target.host} is not trusted yet`));
|
||||
}
|
||||
reject(
|
||||
new SshError(`ssh ${target.host} failed with exit code ${code}: ${diagnostics.trim()}`, code, diagnostics),
|
||||
);
|
||||
});
|
||||
child.stdin.end(options.stdin ?? new Uint8Array(0));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect once with `accept-new` against a temporary known-hosts file to capture the key the host presents, through
|
||||
* the same route (`~/.ssh/config`, jump hosts) as real connections. Returns its known-hosts lines and fingerprints.
|
||||
* Showing a fingerprint is not authentication: compare it with one obtained out of band before accepting it.
|
||||
*/
|
||||
export async function scanHostKey(target: SshTarget): Promise<{ lines: string[]; fingerprints: string[] }> {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-env-hostkey-"));
|
||||
try {
|
||||
const scanned = join(directory, "known_hosts");
|
||||
// Authentication may fail without the key; the key is recorded before authentication.
|
||||
await runSsh(target, "exit 0", { args: sshArguments(target, false, scanned) }).catch((error: unknown) => {
|
||||
if (!(error instanceof SshError) && !(error instanceof HostKeyUnknownError)) throw error;
|
||||
});
|
||||
const lines = existsSync(scanned)
|
||||
? (await readFile(scanned, "utf8")).split("\n").filter((line) => line.trim() !== "")
|
||||
: [];
|
||||
if (lines.length === 0) throw new Error(`No host key received from ${target.host}`);
|
||||
const fingerprints = await new Promise<string[]>((resolve, reject) => {
|
||||
const child = spawn("ssh-keygen", ["-lf", scanned], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString("utf8");
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", () => resolve(output.split("\n").filter((line) => line.trim() !== "")));
|
||||
});
|
||||
return { lines, fingerprints };
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** A known-hosts line for `alias`: `alias type key [comment]`, no markers, patterns or hashed names. */
|
||||
function parseHostKeyLine(alias: string, line: string): { type: string; key: string } {
|
||||
const [host, type, key] = line.trim().split(/\s+/);
|
||||
if (
|
||||
host !== alias ||
|
||||
type === undefined ||
|
||||
key === undefined ||
|
||||
!/^(ssh-[a-z0-9-]+|ecdsa-sha2-[a-z0-9-]+|sk-[a-z0-9@.-]+)$/.test(type) ||
|
||||
!/^[A-Za-z0-9+/]+={0,2}$/.test(key)
|
||||
) {
|
||||
throw new Error(`Not a host key line for ${alias}: ${JSON.stringify(line)}`);
|
||||
}
|
||||
return { type, key };
|
||||
}
|
||||
|
||||
/** Known-hosts files being changed, so changes to one file happen one at a time. */
|
||||
const knownHostsChanges = new Map<string, Promise<unknown>>();
|
||||
|
||||
/** Rewrite the known-hosts file one change at a time, atomically. */
|
||||
async function changeKnownHosts(file: string, change: (lines: string[]) => string[]): Promise<void> {
|
||||
const previous = knownHostsChanges.get(file) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
||||
const existing = existsSync(file)
|
||||
? (await readFile(file, "utf8")).split("\n").filter((line) => line !== "")
|
||||
: [];
|
||||
const lines = change(existing);
|
||||
const temporary = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
||||
await writeFile(temporary, lines.length === 0 ? "" : `${lines.join("\n")}\n`, { mode: 0o600, flag: "wx" });
|
||||
await rename(temporary, file).catch(async (error: unknown) => {
|
||||
await rm(temporary, { force: true });
|
||||
throw error;
|
||||
});
|
||||
});
|
||||
knownHostsChanges.set(file, next);
|
||||
try {
|
||||
await next;
|
||||
} finally {
|
||||
if (knownHostsChanges.get(file) === next) knownHostsChanges.delete(file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust host-key lines from `scanHostKey` by adding them to the target's known-hosts file. Only plain lines for the
|
||||
* target's alias are accepted. A key that differs from a trusted key of the same type is refused with
|
||||
* `HostKeyChangedError`; `forgetHostKey` must remove the old keys first.
|
||||
*/
|
||||
export async function acceptHostKey(target: SshTarget, lines: readonly string[]): Promise<void> {
|
||||
const alias = target.hostKeyAlias;
|
||||
const accepted = lines.map((line) => ({ line: line.trim(), ...parseHostKeyLine(alias, line) }));
|
||||
await changeKnownHosts(target.knownHostsFile, (existing) => {
|
||||
const trusted = new Map<string, string>();
|
||||
for (const line of existing) {
|
||||
const [host, type, key] = line.trim().split(/\s+/);
|
||||
if (host === alias && type !== undefined && key !== undefined) trusted.set(type, key);
|
||||
}
|
||||
const added: string[] = [];
|
||||
for (const { line, type, key } of accepted) {
|
||||
const known = trusted.get(type);
|
||||
if (known === key) continue;
|
||||
if (known !== undefined) {
|
||||
throw new HostKeyChangedError(`The ${type} host key of ${target.host} changed; forget the old key first`);
|
||||
}
|
||||
trusted.set(type, key);
|
||||
added.push(line);
|
||||
}
|
||||
return [...existing, ...added];
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop trusting every key stored for the target's alias, e.g. after the owner confirmed a changed host key. */
|
||||
export async function forgetHostKey(target: SshTarget): Promise<void> {
|
||||
await changeKnownHosts(target.knownHostsFile, (existing) =>
|
||||
existing.filter((line) => line.trim().split(/\s+/)[0] !== target.hostKeyAlias),
|
||||
);
|
||||
}
|
||||
|
||||
/** POSIX detection, run by the remote login shell; a Windows host answers through PowerShell instead. */
|
||||
const POSIX_PROBE = `sh -c 'echo PI-ENV-PROBE; uname -s; uname -m; uname -o 2>/dev/null || echo -; printf "%s\\n" "$HOME" "\${TMPDIR:--}" "\${LD_PRELOAD:--}"'`;
|
||||
|
||||
function powershell(script: string): string {
|
||||
return `powershell -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(script, "utf16le").toString("base64")}`;
|
||||
}
|
||||
|
||||
const WINDOWS_PROBE = powershell(
|
||||
"'PI-ENV-PROBE'; 'Windows'; [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString(); '-'; $HOME; '-'; '-'",
|
||||
);
|
||||
|
||||
function normalizeArch(machine: string): RemotePlatform["arch"] {
|
||||
const lower = machine.toLowerCase();
|
||||
if (lower === "x86_64" || lower === "amd64" || lower === "x64") return "x64";
|
||||
if (lower === "aarch64" || lower === "arm64") return "arm64";
|
||||
throw new Error(`Unsupported remote architecture: ${machine}`);
|
||||
}
|
||||
|
||||
type Probe = { system: string; machine: string; os: string; home: string; tmpdir: string; preload: string };
|
||||
|
||||
function parseProbe(target: SshTarget, output: string): Probe {
|
||||
// Login shells may print a banner first.
|
||||
const lines = output.split(/\r?\n/);
|
||||
const start = lines.indexOf("PI-ENV-PROBE");
|
||||
if (start === -1) throw new Error(`Unexpected answer from ${target.host}: ${output.trim()}`);
|
||||
const [system = "", machine = "", os = "", home = "", tmpdir = "-", preload = "-"] = lines.slice(start + 1);
|
||||
return { system, machine, os, home, tmpdir, preload };
|
||||
}
|
||||
|
||||
/** Termux works only with its own `TMPDIR` and with `termux-exec`, which makes `#!/usr/bin/env` shebangs work. */
|
||||
function termuxWarnings(probe: Probe): string[] {
|
||||
const warnings: string[] = [];
|
||||
if (probe.tmpdir === "-") warnings.push("TMPDIR is not set; Termux's sshd normally sets it to $PREFIX/tmp.");
|
||||
if (!probe.preload.includes("termux-exec")) {
|
||||
warnings.push("termux-exec is not loaded (LD_PRELOAD); scripts with #!/usr/bin/env shebangs will fail.");
|
||||
}
|
||||
warnings.push("Android may suspend Termux; run termux-wake-lock on the device to keep the connection alive.");
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/** Which system the target runs: `uname` through the login shell, or PowerShell on Windows. */
|
||||
export async function detectPlatform(target: SshTarget): Promise<RemotePlatform> {
|
||||
let probe: Probe;
|
||||
try {
|
||||
probe = parseProbe(target, await runSsh(target, POSIX_PROBE));
|
||||
} catch (error) {
|
||||
// cmd.exe or PowerShell as the remote shell: no `sh`, or one that gets the probe's quotes wrong.
|
||||
if (error instanceof HostKeyUnknownError || error instanceof HostKeyChangedError) throw error;
|
||||
probe = parseProbe(target, await runSsh(target, WINDOWS_PROBE));
|
||||
}
|
||||
// Git Bash as Windows' default SSH shell: ask PowerShell for Windows' own architecture and home spelling.
|
||||
if (/^(MINGW|MSYS|CYGWIN)/.test(probe.system)) probe = parseProbe(target, await runSsh(target, WINDOWS_PROBE));
|
||||
const arch = normalizeArch(probe.machine);
|
||||
const { system, os, home } = probe;
|
||||
if (system === "Windows") {
|
||||
// cmd.exe expands %OS%; PowerShell prints it as is.
|
||||
const shell = (await runSsh(target, "echo %OS%")).includes("Windows_NT") ? "cmd" : "powershell";
|
||||
return { platform: "windows", arch, home, shell, warnings: [] };
|
||||
}
|
||||
if (system === "Darwin") return { platform: "darwin", arch, home, warnings: [] };
|
||||
if (system === "Linux") {
|
||||
const android = os === "Android";
|
||||
return { platform: android ? "android" : "linux", arch, home, warnings: android ? termuxWarnings(probe) : [] };
|
||||
}
|
||||
throw new Error(`Unsupported remote system: ${system}`);
|
||||
}
|
||||
|
||||
/** The daemon binary this package ships for a remote system. */
|
||||
export function packagedDaemon(remote: Pick<RemotePlatform, "platform" | "arch">): string {
|
||||
const name = `pi-env-${remote.platform}-${remote.arch}`;
|
||||
return join(packageRoot, "bin", name, remote.platform === "windows" ? "pi-env.exe" : "pi-env");
|
||||
}
|
||||
|
||||
/** The daemon this package ships for the machine it runs on, e.g. for terminals of local sessions. */
|
||||
export function localDaemon(): string {
|
||||
const platform = process.platform === "win32" ? "windows" : process.platform;
|
||||
if (platform !== "linux" && platform !== "android" && platform !== "darwin" && platform !== "windows") {
|
||||
throw new Error(`No pi-env daemon for ${process.platform}`);
|
||||
}
|
||||
return packagedDaemon({ platform, arch: normalizeArch(process.arch) });
|
||||
}
|
||||
|
||||
function quotePosix(text: string): string {
|
||||
return `'${text.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function quotePowerShell(text: string): string {
|
||||
return `'${text.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
/** Where a daemon with this content lives on the remote machine: named by its SHA-256, so versions never collide. */
|
||||
function daemonPath(remote: RemotePlatform, sha256: string): string {
|
||||
const name = `pi-env-${sha256.slice(0, 32)}`;
|
||||
return remote.platform === "windows"
|
||||
? `${remote.home}\\.pi\\mobile\\tools\\${name}.exe`
|
||||
: `${remote.home}/.pi/mobile/tools/${name}`;
|
||||
}
|
||||
|
||||
const POSIX_HASH = `hash() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1; elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | sed 's/.*= //'; else echo none; fi; }`;
|
||||
|
||||
/** Whether the remote file has this content: `present`, `missing`, or (POSIX without a hash tool) `nohash`. */
|
||||
async function checkDaemon(target: SshTarget, remote: RemotePlatform, file: string, sha256: string): Promise<string> {
|
||||
if (remote.platform === "windows") {
|
||||
const check = powershell(
|
||||
`$f = ${quotePowerShell(file)}; if ((Test-Path -LiteralPath $f) -and ((Get-FileHash -Algorithm SHA256 -LiteralPath $f).Hash.ToLower() -eq '${sha256}')) { 'present' } else { 'missing' }`,
|
||||
);
|
||||
return (await runSsh(target, check)).trim().endsWith("present") ? "present" : "missing";
|
||||
}
|
||||
const check = `${POSIX_HASH}; f=${quotePosix(file)}; if [ -f "$f" ] && [ "$(hash "$f")" = ${sha256} ]; then echo present; elif [ "$(hash /dev/null)" = none ]; then echo nohash; else echo missing; fi`;
|
||||
return (await runSsh(target, `sh -c ${quotePosix(check)}`)).trim().split("\n").at(-1) ?? "missing";
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure the daemon is on the remote machine, verified by its SHA-256 before it ever runs, and remove daemons of
|
||||
* other contents. The upload goes to a new temporary file and is renamed into place only once its hash matches.
|
||||
* Returns the remote path of the binary.
|
||||
*/
|
||||
export async function deployDaemon(
|
||||
target: SshTarget,
|
||||
remote: RemotePlatform,
|
||||
binary = packagedDaemon(remote),
|
||||
): Promise<string> {
|
||||
if (!existsSync(binary)) throw new Error(`No pi-env daemon for ${remote.platform}-${remote.arch} at ${binary}`);
|
||||
const bytes = await readFile(binary);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
const file = daemonPath(remote, sha256);
|
||||
const state = await checkDaemon(target, remote, file, sha256);
|
||||
if (state === "present") return file;
|
||||
if (state === "nohash") throw new Error(`${target.host} has no sha256sum, shasum or openssl to verify pi-env`);
|
||||
if (remote.platform === "windows") {
|
||||
const upload = powershell(
|
||||
[
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
`$f = ${quotePowerShell(file)}`,
|
||||
"$d = Split-Path -Parent $f",
|
||||
"New-Item -ItemType Directory -Force -Path $d | Out-Null",
|
||||
"$t = Join-Path $d ('.pi-env-' + [guid]::NewGuid().ToString() + '.tmp')",
|
||||
// PowerShell reads redirected stdin itself, as text lines for `$input`, so the binary comes as base64 lines
|
||||
// up to an end marker: Windows' sshd may never pass on the end of stdin.
|
||||
"$text = New-Object System.Text.StringBuilder",
|
||||
"foreach ($line in $input) { if ($line -eq 'PI-ENV-END') { break }; [void]$text.Append($line) }",
|
||||
"$bytes = [Convert]::FromBase64String($text.ToString())",
|
||||
"$out = [IO.File]::Open($t, 'CreateNew', 'Write', 'None'); $out.Write($bytes, 0, $bytes.Length); $out.Close()",
|
||||
`if ((Get-FileHash -Algorithm SHA256 -LiteralPath $t).Hash.ToLower() -ne '${sha256}') { Remove-Item -LiteralPath $t; throw 'pi-env upload is corrupt' }`,
|
||||
// A running daemon or a virus scanner can hold the old file for a moment.
|
||||
"for ($i = 0; ; $i++) { try { Move-Item -Force -LiteralPath $t -Destination $f; break } catch { if ($i -ge 20) { throw }; Start-Sleep -Milliseconds 250 } }",
|
||||
// Older daemons; one that is running stays until it exits.
|
||||
"Get-ChildItem -LiteralPath $d -Filter 'pi-env-*.exe' | Where-Object { $_.FullName -ne $f } | ForEach-Object { Remove-Item -LiteralPath $_.FullName -ErrorAction SilentlyContinue }",
|
||||
"'deployed'",
|
||||
].join("; "),
|
||||
);
|
||||
const lines = bytes.toString("base64").replace(/.{1,76}/g, "$&\n");
|
||||
await runSsh(target, upload, { stdin: Buffer.from(`${lines}PI-ENV-END\n`), timeoutMs: UPLOAD_TIMEOUT_MS });
|
||||
return file;
|
||||
}
|
||||
const upload = [
|
||||
"set -e",
|
||||
POSIX_HASH,
|
||||
`f=${quotePosix(file)}`,
|
||||
'd=$(dirname "$f")',
|
||||
'mkdir -p "$d"',
|
||||
'chmod 700 "$d"',
|
||||
't=$(mktemp "$d/.pi-env.XXXXXX")',
|
||||
'cat > "$t"',
|
||||
`if [ "$(hash "$t")" != ${sha256} ]; then rm -f "$t"; echo "pi-env upload is corrupt" >&2; exit 1; fi`,
|
||||
'chmod 700 "$t"',
|
||||
'mv -f "$t" "$f"',
|
||||
// Older daemons; running ones keep their file open until they exit.
|
||||
'for old in "$d"/pi-env-*; do [ "$old" = "$f" ] || rm -f "$old"; done',
|
||||
"echo deployed",
|
||||
].join("\n");
|
||||
await runSsh(target, `sh -c ${quotePosix(upload)}`, { stdin: bytes, timeoutMs: UPLOAD_TIMEOUT_MS });
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Options for `sshConnection` and `connectSsh`: the target, plus the binary to deploy (default: the one this package ships). */
|
||||
export interface SshConnectOptions extends SshTarget {
|
||||
binary?: string;
|
||||
/**
|
||||
* POSIX: start the daemon through the user's login shell (`$SHELL -l`), so commands see the environment of
|
||||
* `~/.profile` and similar files. Off by default: `ssh` runs commands without a login shell.
|
||||
*/
|
||||
loginShell?: boolean;
|
||||
onLog?: ConnectionOptions["onLog"];
|
||||
}
|
||||
|
||||
/** The remote command that starts the daemon at `file`, before the `serve` arguments. */
|
||||
function launchCommand(remote: RemotePlatform, file: string, loginShell: boolean): string {
|
||||
if (remote.platform === "windows") {
|
||||
// PowerShell runs a quoted path only with the call operator; cmd.exe keeps one pair of quotes around a program.
|
||||
if (remote.shell === "powershell") return `& ${quotePowerShell(file)}`;
|
||||
return file.includes(" ") ? `"${file}"` : file;
|
||||
}
|
||||
// `$0` is the daemon, `"$@"` its arguments; `$SHELL` is the login shell's own name for itself.
|
||||
if (loginShell) return `exec "$SHELL" -lc 'exec "$0" "$@"' ${quotePosix(file)}`;
|
||||
return quotePosix(file);
|
||||
}
|
||||
|
||||
/** What an SSH connection learned about its remote system, and the daemon it verified last. */
|
||||
type SshState = { remote?: RemotePlatform; verified?: string };
|
||||
|
||||
/** A `Connection` whose every start detects the remote system (once), verifies or deploys the daemon, then starts it. */
|
||||
function sshConnectionFor(options: SshConnectOptions, state: SshState): Connection {
|
||||
return new Connection({
|
||||
command: async () => {
|
||||
if (state.remote === undefined) {
|
||||
const remote = await detectPlatform(options);
|
||||
for (const warning of remote.warnings) options.onLog?.(`${warning}\n`);
|
||||
state.remote = remote;
|
||||
}
|
||||
// A deployment that just verified the binary counts for the first start.
|
||||
const file =
|
||||
state.verified ??
|
||||
(await deployDaemon(options, state.remote, options.binary ?? packagedDaemon(state.remote)));
|
||||
state.verified = undefined;
|
||||
return [
|
||||
options.ssh ?? "ssh",
|
||||
...sshArguments(options),
|
||||
launchCommand(state.remote, file, options.loginShell === true),
|
||||
];
|
||||
},
|
||||
...(options.onLog === undefined ? {} : { onLog: options.onLog }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A `Connection` to the target that does nothing until its first request. Each start detects the remote system (the
|
||||
* first time), verifies the daemon and deploys it if it is missing or changed, then starts it over `ssh`. A failure
|
||||
* (no network, an untrusted or changed host key) fails the requests waiting for that start with code `spawn_error`
|
||||
* and the `ssh` diagnostics as message; the next request tries again. `remote()` is the detected system, once known.
|
||||
*/
|
||||
export function sshConnection(options: SshConnectOptions): {
|
||||
connection: Connection;
|
||||
remote(): RemotePlatform | undefined;
|
||||
} {
|
||||
const state: SshState = {};
|
||||
return { connection: sshConnectionFor(options, state), remote: () => state.remote };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the remote system and deploy the daemon now, then return a `Connection` that starts it over `ssh`, like
|
||||
* `sshConnection`. Failures reject here: host keys must already be trusted (`scanHostKey`, `acceptHostKey`), otherwise
|
||||
* this rejects with `HostKeyUnknownError`.
|
||||
*/
|
||||
export async function connectSsh(
|
||||
options: SshConnectOptions,
|
||||
): Promise<{ connection: Connection; remote: RemotePlatform }> {
|
||||
const remote = await detectPlatform(options);
|
||||
const verified = await deployDaemon(options, remote, options.binary ?? packagedDaemon(remote));
|
||||
for (const warning of remote.warnings) options.onLog?.(`${warning}\n`);
|
||||
return { connection: sshConnectionFor(options, { remote, verified }), remote };
|
||||
}
|
||||
127
third_party/pi/packages/env/src/terminal.ts
vendored
Normal file
127
third_party/pi/packages/env/src/terminal.ts
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { Connection, Json } from "./connection.ts";
|
||||
|
||||
/** Input larger than this goes to the daemon in several writes; frames are at most 16 MiB. */
|
||||
const WRITE_CHUNK = 1024 * 1024;
|
||||
|
||||
export interface TerminalOptions {
|
||||
/** Working directory of the program; it must exist. */
|
||||
cwd: string;
|
||||
/** Program and arguments. Default: the user's shell (`SHELL` in `env` or the daemon's environment) as a login shell. */
|
||||
argv?: readonly string[];
|
||||
/** Added to the daemon's environment, or the whole environment with `inheritEnv: false`. Set `TERM` here. */
|
||||
env?: Record<string, string>;
|
||||
inheritEnv?: boolean;
|
||||
cols: number;
|
||||
rows: number;
|
||||
/** The terminal's raw output, in order. */
|
||||
onData: (bytes: Uint8Array) => void;
|
||||
}
|
||||
|
||||
/** The foreground job of a terminal: its process group id, and its leader's name if the system tells. */
|
||||
export interface TerminalForeground {
|
||||
pid: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A program running in a pseudo-terminal of a pi-env daemon. It ends when the program exits, `close()` kills it, or
|
||||
* the connection is lost (the daemon then kills it).
|
||||
*/
|
||||
export class RemoteTerminal {
|
||||
readonly pid: number;
|
||||
/** The exit code; rejects with the daemon's error, with code `aborted` after `close()`. */
|
||||
readonly exited: Promise<number>;
|
||||
readonly #connection: Connection;
|
||||
readonly #id: number;
|
||||
readonly #session: number;
|
||||
readonly #abort: AbortController;
|
||||
|
||||
constructor(
|
||||
connection: Connection,
|
||||
id: number,
|
||||
session: number,
|
||||
pid: number,
|
||||
exited: Promise<number>,
|
||||
abort: AbortController,
|
||||
) {
|
||||
this.#connection = connection;
|
||||
this.#id = id;
|
||||
this.#session = session;
|
||||
this.pid = pid;
|
||||
this.exited = exited;
|
||||
this.#abort = abort;
|
||||
}
|
||||
|
||||
/** Send input; once the terminal has ended, input is dropped. */
|
||||
write(data: Uint8Array | string): void {
|
||||
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
||||
for (let offset = 0; offset < bytes.length; offset += WRITE_CHUNK) {
|
||||
this.#request("ptyWrite", {}, bytes.subarray(offset, offset + WRITE_CHUNK));
|
||||
}
|
||||
}
|
||||
|
||||
/** Change the size; the program receives `SIGWINCH`. */
|
||||
resize(cols: number, rows: number): void {
|
||||
this.#request("ptyResize", { cols, rows });
|
||||
}
|
||||
|
||||
/** Kill the program and everything in its session. */
|
||||
close(): void {
|
||||
this.#abort.abort();
|
||||
}
|
||||
|
||||
/**
|
||||
* The job in the terminal's foreground when it is not the program itself: what a shell runs, such as an editor.
|
||||
* Undefined while the program is in front (a shell at its prompt). Rejects once the terminal has ended.
|
||||
*/
|
||||
async foreground(): Promise<TerminalForeground | undefined> {
|
||||
const reply = await this.#connection.request("ptyForeground", { target: this.#id }, { session: this.#session });
|
||||
const foreground = reply.json.foreground as { pid: number; name: string | null } | null;
|
||||
if (!foreground) return undefined;
|
||||
return { pid: foreground.pid, ...(foreground.name ? { name: foreground.name } : {}) };
|
||||
}
|
||||
|
||||
#request(op: string, json: Json, payload?: Uint8Array): void {
|
||||
this.#connection
|
||||
.request(op, { ...json, target: this.#id }, { session: this.#session, ...(payload ? { payload } : {}) })
|
||||
// A terminal that ended or a lost connection: `exited` reports it.
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a program in a new pseudo-terminal of the daemon behind `connection`. */
|
||||
export function openTerminal(connection: Connection, options: TerminalOptions): Promise<RemoteTerminal> {
|
||||
const abort = new AbortController();
|
||||
const started = Promise.withResolvers<RemoteTerminal>();
|
||||
let request: { id: number; session: number } | undefined;
|
||||
const exited = connection
|
||||
.request(
|
||||
"pty",
|
||||
{
|
||||
cwd: options.cwd,
|
||||
...(options.argv === undefined ? {} : { argv: options.argv }),
|
||||
...(options.env === undefined ? {} : { env: options.env }),
|
||||
...(options.inheritEnv === undefined ? {} : { inheritEnv: options.inheritEnv }),
|
||||
cols: options.cols,
|
||||
rows: options.rows,
|
||||
},
|
||||
{
|
||||
signal: abort.signal,
|
||||
onStart: (id, session) => {
|
||||
request = { id, session };
|
||||
},
|
||||
onEvent: (json, payload) => {
|
||||
if (json.kind === "output") options.onData(payload);
|
||||
else if (json.kind === "started" && request) {
|
||||
started.resolve(
|
||||
new RemoteTerminal(connection, request.id, request.session, Number(json.pid), exited, abort),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((reply) => Number(reply.json.exitCode));
|
||||
// Failing before it started rejects the open; afterwards, `exited` carries it.
|
||||
exited.catch(started.reject);
|
||||
return started.promise;
|
||||
}
|
||||
158
third_party/pi/packages/env/src/watch.ts
vendored
Normal file
158
third_party/pi/packages/env/src/watch.ts
vendored
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import { FileError, type FileWatcher, type WatchChange, type WatchTarget } from "@earendil-works/pi-durable/env";
|
||||
import { type Connection, isConnectionLost } from "./connection.ts";
|
||||
import { toFileError } from "./errors.ts";
|
||||
|
||||
/** How the daemon watches, like `NodeExecutionEnv`'s `NodeWatchOptions`. */
|
||||
export interface RemoteWatchOptions {
|
||||
/** Force a mode; by default Windows and file systems that do not report remote changes poll. */
|
||||
mode?: "native" | "polling";
|
||||
/** Interval between snapshots in `polling` mode; default 2000 ms. */
|
||||
pollIntervalMs?: number;
|
||||
/** Most directories one watcher covers; default 10,000. */
|
||||
maxDirectories?: number;
|
||||
}
|
||||
|
||||
const RECONNECT_FIRST_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
|
||||
/**
|
||||
* A watcher running in the daemon (Durable's `NodeFileWatcher` next to the files). When the connection is lost, it is
|
||||
* opened again once the connection can be started again, and reports `overflow`: changes in between were not seen.
|
||||
*/
|
||||
export class RemoteWatcher implements FileWatcher {
|
||||
readonly #connection: Connection;
|
||||
readonly #targets: readonly WatchTarget[];
|
||||
readonly #onChange: (change: WatchChange) => void;
|
||||
readonly #options: RemoteWatchOptions;
|
||||
#mode: "native" | "polling" = "native";
|
||||
#closed = false;
|
||||
#controller: AbortController | undefined;
|
||||
#request: Promise<void> | undefined;
|
||||
#wake: (() => void) | undefined;
|
||||
|
||||
private constructor(
|
||||
connection: Connection,
|
||||
targets: readonly WatchTarget[],
|
||||
onChange: (change: WatchChange) => void,
|
||||
options: RemoteWatchOptions,
|
||||
) {
|
||||
this.#connection = connection;
|
||||
this.#targets = targets;
|
||||
this.#onChange = onChange;
|
||||
this.#options = options;
|
||||
}
|
||||
|
||||
/** Watch `targets` (resolved paths); fails like `NodeFileWatcher.open` if coverage cannot be established. */
|
||||
static async open(
|
||||
connection: Connection,
|
||||
targets: readonly WatchTarget[],
|
||||
onChange: (change: WatchChange) => void,
|
||||
options: RemoteWatchOptions = {},
|
||||
): Promise<RemoteWatcher> {
|
||||
const watcher = new RemoteWatcher(connection, targets, onChange, options);
|
||||
await watcher.#start();
|
||||
return watcher;
|
||||
}
|
||||
|
||||
get mode(): "native" | "polling" {
|
||||
return this.#mode;
|
||||
}
|
||||
|
||||
async close(_context: Context): Promise<void> {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#wake?.();
|
||||
this.#controller?.abort();
|
||||
await this.#request;
|
||||
}
|
||||
|
||||
#deliver(change: WatchChange): void {
|
||||
if (this.#closed) return;
|
||||
try {
|
||||
this.#onChange(change);
|
||||
} catch {
|
||||
// A throwing callback must not stop watching.
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the daemon's watcher; resolves once it reports coverage. */
|
||||
#start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let ready = false;
|
||||
const controller = new AbortController();
|
||||
this.#controller = controller;
|
||||
const request = this.#connection.request(
|
||||
"watch",
|
||||
{
|
||||
targets: this.#targets.map((target) => ({
|
||||
path: target.path,
|
||||
recursive: target.recursive === true,
|
||||
exclude: { hidden: target.exclude?.hidden === true, names: [...(target.exclude?.names ?? [])] },
|
||||
})),
|
||||
...this.#options,
|
||||
},
|
||||
{
|
||||
signal: controller.signal,
|
||||
onEvent: (event) => {
|
||||
if (event.kind === "ready") {
|
||||
ready = true;
|
||||
this.#mode = event.mode === "polling" ? "polling" : "native";
|
||||
resolve();
|
||||
} else if (event.kind === "change") {
|
||||
if (event.mode === "polling") this.#mode = "polling";
|
||||
if (event.overflow === true) this.#deliver({ overflow: true });
|
||||
else this.#deliver({ paths: event.paths as string[] });
|
||||
} else if (event.kind === "error") {
|
||||
const code = event.code === "permission_denied" ? "permission_denied" : "invalid";
|
||||
this.#deliver({ error: new FileError(code, String(event.message)) });
|
||||
this.#closed = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
this.#request = request.then(
|
||||
() => {},
|
||||
(error: unknown) => {
|
||||
if (!ready) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
if (this.#closed) return;
|
||||
if (isConnectionLost(error)) {
|
||||
void this.#reconnect();
|
||||
return;
|
||||
}
|
||||
this.#deliver({ error: toFileError(error) });
|
||||
this.#closed = true;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async #reconnect(): Promise<void> {
|
||||
for (let delay = RECONNECT_FIRST_MS; !this.#closed; delay = Math.min(delay * 2, RECONNECT_MAX_MS)) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, delay);
|
||||
timer.unref();
|
||||
this.#wake = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
this.#wake = undefined;
|
||||
if (this.#closed) return;
|
||||
try {
|
||||
await this.#start();
|
||||
// Changes while disconnected were not seen.
|
||||
this.#deliver({ overflow: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (isConnectionLost(error)) continue;
|
||||
this.#deliver({ error: toFileError(error) });
|
||||
this.#closed = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user