chore: drop non-code junk (pi-mono-reference, runtime state, root duplicates)
Remove files that should never have been tracked: - pi-mono-reference/: 688-file copy of an upstream monorepo, zero imports, used only as offline human reference - build/electron-builder.runtime.json: environment-specific build artifact with hardcoded local paths - state/cancel-signal-state.json: runtime state written by the app - prompt.txt, logo.svg (root): exact duplicates of the canonical copies under .planning/archive/ and assets/ Add state/ to .gitignore so future runtime state stays out.
This commit is contained in:
1 parent
34ca5b77f1
commit
1f7230fe87
691 files changed
+3
-190434
No files matched your search
@@ -1,99 +0,0 @@
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
|
||||
export interface ChangelogEntry {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse changelog entries from CHANGELOG.md
|
||||
* Scans for ## lines and collects content until next ## or EOF
|
||||
*/
|
||||
export function parseChangelog(changelogPath: string): ChangelogEntry[] {
|
||||
if (!existsSync(changelogPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(changelogPath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const entries: ChangelogEntry[] = [];
|
||||
|
||||
let currentLines: string[] = [];
|
||||
let currentVersion: { major: number; minor: number; patch: number } | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
// Check if this is a version header (## [x.y.z] ...)
|
||||
if (line.startsWith("## ")) {
|
||||
// Save previous entry if exists
|
||||
if (currentVersion && currentLines.length > 0) {
|
||||
entries.push({
|
||||
...currentVersion,
|
||||
content: currentLines.join("\n").trim(),
|
||||
});
|
||||
}
|
||||
|
||||
// Try to parse version from this line
|
||||
const versionMatch = line.match(/##\s+\[?(\d+)\.(\d+)\.(\d+)\]?/);
|
||||
if (versionMatch) {
|
||||
currentVersion = {
|
||||
major: Number.parseInt(versionMatch[1], 10),
|
||||
minor: Number.parseInt(versionMatch[2], 10),
|
||||
patch: Number.parseInt(versionMatch[3], 10),
|
||||
};
|
||||
currentLines = [line];
|
||||
} else {
|
||||
// Reset if we can't parse version
|
||||
currentVersion = null;
|
||||
currentLines = [];
|
||||
}
|
||||
} else if (currentVersion) {
|
||||
// Collect lines for current version
|
||||
currentLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Save last entry
|
||||
if (currentVersion && currentLines.length > 0) {
|
||||
entries.push({
|
||||
...currentVersion,
|
||||
content: currentLines.join("\n").trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
} catch (error) {
|
||||
console.error(`Warning: Could not parse changelog: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare versions. Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2
|
||||
*/
|
||||
export function compareVersions(v1: ChangelogEntry, v2: ChangelogEntry): number {
|
||||
if (v1.major !== v2.major) return v1.major - v2.major;
|
||||
if (v1.minor !== v2.minor) return v1.minor - v2.minor;
|
||||
return v1.patch - v2.patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entries newer than lastVersion
|
||||
*/
|
||||
export function getNewEntries(entries: ChangelogEntry[], lastVersion: string): ChangelogEntry[] {
|
||||
// Parse lastVersion
|
||||
const parts = lastVersion.split(".").map(Number);
|
||||
const last: ChangelogEntry = {
|
||||
major: parts[0] || 0,
|
||||
minor: parts[1] || 0,
|
||||
patch: parts[2] || 0,
|
||||
content: "",
|
||||
};
|
||||
|
||||
return entries.filter((entry) => compareVersions(entry, last) > 0);
|
||||
}
|
||||
|
||||
// Re-export getChangelogPath from paths.ts for convenience
|
||||
export { getChangelogPath } from "../config.js";
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
const EXIT_STDIO_GRACE_MS = 100;
|
||||
|
||||
/**
|
||||
* Wait for a child process to terminate without hanging on inherited stdio handles.
|
||||
*
|
||||
* On Windows, daemonized descendants can inherit the child's stdout/stderr pipe
|
||||
* handles. In that case the child emits `exit`, but `close` can hang forever even
|
||||
* though the original process is already gone. We wait briefly for stdio to end,
|
||||
* then forcibly stop tracking the inherited handles.
|
||||
*/
|
||||
export function waitForChildProcess(child: ChildProcess): Promise<number | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let exited = false;
|
||||
let exitCode: number | null = null;
|
||||
let postExitTimer: NodeJS.Timeout | undefined;
|
||||
let stdoutEnded = child.stdout === null;
|
||||
let stderrEnded = child.stderr === null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (postExitTimer) {
|
||||
clearTimeout(postExitTimer);
|
||||
postExitTimer = undefined;
|
||||
}
|
||||
child.removeListener("error", onError);
|
||||
child.removeListener("exit", onExit);
|
||||
child.removeListener("close", onClose);
|
||||
child.stdout?.removeListener("end", onStdoutEnd);
|
||||
child.stderr?.removeListener("end", onStderrEnd);
|
||||
};
|
||||
|
||||
const finalize = (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
resolve(code);
|
||||
};
|
||||
|
||||
const maybeFinalizeAfterExit = () => {
|
||||
if (!exited || settled) return;
|
||||
if (stdoutEnded && stderrEnded) {
|
||||
finalize(exitCode);
|
||||
}
|
||||
};
|
||||
|
||||
const onStdoutEnd = () => {
|
||||
stdoutEnded = true;
|
||||
maybeFinalizeAfterExit();
|
||||
};
|
||||
|
||||
const onStderrEnd = () => {
|
||||
stderrEnded = true;
|
||||
maybeFinalizeAfterExit();
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const onExit = (code: number | null) => {
|
||||
exited = true;
|
||||
exitCode = code;
|
||||
maybeFinalizeAfterExit();
|
||||
if (!settled) {
|
||||
postExitTimer = setTimeout(() => finalize(code), EXIT_STDIO_GRACE_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = (code: number | null) => {
|
||||
finalize(code);
|
||||
};
|
||||
|
||||
child.stdout?.once("end", onStdoutEnd);
|
||||
child.stderr?.once("end", onStderrEnd);
|
||||
child.once("error", onError);
|
||||
child.once("exit", onExit);
|
||||
child.once("close", onClose);
|
||||
});
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
import { spawnSync } from "child_process";
|
||||
import { randomUUID } from "crypto";
|
||||
import { readFileSync, unlinkSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
import { clipboard } from "./clipboard-native.js";
|
||||
import { loadPhoton } from "./photon.js";
|
||||
|
||||
export type ClipboardImage = {
|
||||
bytes: Uint8Array;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
const SUPPORTED_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
|
||||
|
||||
const DEFAULT_LIST_TIMEOUT_MS = 1000;
|
||||
const DEFAULT_READ_TIMEOUT_MS = 3000;
|
||||
const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000;
|
||||
const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
export function isWaylandSession(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return Boolean(env.WAYLAND_DISPLAY) || env.XDG_SESSION_TYPE === "wayland";
|
||||
}
|
||||
|
||||
function baseMimeType(mimeType: string): string {
|
||||
return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
|
||||
}
|
||||
|
||||
export function extensionForImageMimeType(mimeType: string): string | null {
|
||||
switch (baseMimeType(mimeType)) {
|
||||
case "image/png":
|
||||
return "png";
|
||||
case "image/jpeg":
|
||||
return "jpg";
|
||||
case "image/webp":
|
||||
return "webp";
|
||||
case "image/gif":
|
||||
return "gif";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function selectPreferredImageMimeType(mimeTypes: string[]): string | null {
|
||||
const normalized = mimeTypes
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.map((t) => ({ raw: t, base: baseMimeType(t) }));
|
||||
|
||||
for (const preferred of SUPPORTED_IMAGE_MIME_TYPES) {
|
||||
const match = normalized.find((t) => t.base === preferred);
|
||||
if (match) {
|
||||
return match.raw;
|
||||
}
|
||||
}
|
||||
|
||||
const anyImage = normalized.find((t) => t.base.startsWith("image/"));
|
||||
return anyImage?.raw ?? null;
|
||||
}
|
||||
|
||||
function isSupportedImageMimeType(mimeType: string): boolean {
|
||||
const base = baseMimeType(mimeType);
|
||||
return SUPPORTED_IMAGE_MIME_TYPES.some((t) => t === base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert unsupported image formats to PNG using Photon.
|
||||
* Returns null if conversion is unavailable or fails.
|
||||
*/
|
||||
async function convertToPng(bytes: Uint8Array): Promise<Uint8Array | null> {
|
||||
const photon = await loadPhoton();
|
||||
if (!photon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const image = photon.PhotonImage.new_from_byteslice(bytes);
|
||||
try {
|
||||
return image.get_bytes();
|
||||
} finally {
|
||||
image.free();
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: { timeoutMs?: number; maxBufferBytes?: number; env?: NodeJS.ProcessEnv },
|
||||
): { stdout: Buffer; ok: boolean } {
|
||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS;
|
||||
const maxBufferBytes = options?.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
|
||||
|
||||
const result = spawnSync(command, args, {
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: maxBufferBytes,
|
||||
env: options?.env,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return { ok: false, stdout: Buffer.alloc(0) };
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
return { ok: false, stdout: Buffer.alloc(0) };
|
||||
}
|
||||
|
||||
const stdout = Buffer.isBuffer(result.stdout)
|
||||
? result.stdout
|
||||
: Buffer.from(result.stdout ?? "", typeof result.stdout === "string" ? "utf-8" : undefined);
|
||||
|
||||
return { ok: true, stdout };
|
||||
}
|
||||
|
||||
function readClipboardImageViaWlPaste(): ClipboardImage | null {
|
||||
const list = runCommand("wl-paste", ["--list-types"], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
|
||||
if (!list.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const types = list.stdout
|
||||
.toString("utf-8")
|
||||
.split(/\r?\n/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const selectedType = selectPreferredImageMimeType(types);
|
||||
if (!selectedType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = runCommand("wl-paste", ["--type", selectedType, "--no-newline"]);
|
||||
if (!data.ok || data.stdout.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { bytes: data.stdout, mimeType: baseMimeType(selectedType) };
|
||||
}
|
||||
|
||||
function isWSL(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if (env.WSL_DISTRO_NAME || env.WSLENV) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const release = readFileSync("/proc/version", "utf-8");
|
||||
return /microsoft|wsl/i.test(release);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On WSL, the Linux clipboard (Wayland/X11) does not receive image data from
|
||||
* Windows screenshots (Win+Shift+S). PowerShell can access the Windows clipboard
|
||||
* directly, so we use it as a fallback.
|
||||
*/
|
||||
function readClipboardImageViaPowerShell(): ClipboardImage | null {
|
||||
const tmpFile = join(tmpdir(), `pi-wsl-clip-${randomUUID()}.png`);
|
||||
|
||||
try {
|
||||
const winPathResult = runCommand("wslpath", ["-w", tmpFile], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
|
||||
if (!winPathResult.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const winPath = winPathResult.stdout.toString("utf-8").trim();
|
||||
if (!winPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const psScript = [
|
||||
"Add-Type -AssemblyName System.Windows.Forms",
|
||||
"Add-Type -AssemblyName System.Drawing",
|
||||
"$path = $env:PI_WSL_CLIPBOARD_IMAGE_PATH",
|
||||
"$img = [System.Windows.Forms.Clipboard]::GetImage()",
|
||||
"if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }",
|
||||
].join("; ");
|
||||
|
||||
const result = runCommand("powershell.exe", ["-NoProfile", "-Command", psScript], {
|
||||
timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS,
|
||||
env: { ...process.env, PI_WSL_CLIPBOARD_IMAGE_PATH: winPath },
|
||||
});
|
||||
if (!result.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output = result.stdout.toString("utf-8").trim();
|
||||
if (output !== "ok") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = readFileSync(tmpFile);
|
||||
if (bytes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { bytes: new Uint8Array(bytes), mimeType: "image/png" };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(tmpFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readClipboardImageViaXclip(): ClipboardImage | null {
|
||||
const targets = runCommand("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], {
|
||||
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
let candidateTypes: string[] = [];
|
||||
if (targets.ok) {
|
||||
candidateTypes = targets.stdout
|
||||
.toString("utf-8")
|
||||
.split(/\r?\n/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const preferred = candidateTypes.length > 0 ? selectPreferredImageMimeType(candidateTypes) : null;
|
||||
const tryTypes = preferred ? [preferred, ...SUPPORTED_IMAGE_MIME_TYPES] : [...SUPPORTED_IMAGE_MIME_TYPES];
|
||||
|
||||
for (const mimeType of tryTypes) {
|
||||
const data = runCommand("xclip", ["-selection", "clipboard", "-t", mimeType, "-o"]);
|
||||
if (data.ok && data.stdout.length > 0) {
|
||||
return { bytes: data.stdout, mimeType: baseMimeType(mimeType) };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readClipboardImageViaNativeClipboard(): Promise<ClipboardImage | null> {
|
||||
if (!clipboard || !clipboard.hasImage()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageData = await clipboard.getImageBinary();
|
||||
if (!imageData || imageData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData);
|
||||
return { bytes, mimeType: "image/png" };
|
||||
}
|
||||
|
||||
export async function readClipboardImage(options?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: NodeJS.Platform;
|
||||
}): Promise<ClipboardImage | null> {
|
||||
const env = options?.env ?? process.env;
|
||||
const platform = options?.platform ?? process.platform;
|
||||
|
||||
if (env.TERMUX_VERSION) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let image: ClipboardImage | null = null;
|
||||
|
||||
if (platform === "linux") {
|
||||
const wsl = isWSL(env);
|
||||
const wayland = isWaylandSession(env);
|
||||
|
||||
if (wayland || wsl) {
|
||||
image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip();
|
||||
}
|
||||
|
||||
if (!image && wsl) {
|
||||
image = readClipboardImageViaPowerShell();
|
||||
}
|
||||
|
||||
if (!image && !wayland) {
|
||||
image = await readClipboardImageViaNativeClipboard();
|
||||
}
|
||||
} else {
|
||||
image = await readClipboardImageViaNativeClipboard();
|
||||
}
|
||||
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert unsupported formats (e.g., BMP from WSLg) to PNG
|
||||
if (!isSupportedImageMimeType(image.mimeType)) {
|
||||
const pngBytes = await convertToPng(image.bytes);
|
||||
if (!pngBytes) {
|
||||
return null;
|
||||
}
|
||||
return { bytes: pngBytes, mimeType: "image/png" };
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { createRequire } from "module";
|
||||
|
||||
export type ClipboardModule = {
|
||||
setText: (text: string) => Promise<void>;
|
||||
hasImage: () => boolean;
|
||||
getImageBinary: () => Promise<Array<number>>;
|
||||
};
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
let clipboard: ClipboardModule | null = null;
|
||||
|
||||
const hasDisplay = process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
||||
|
||||
if (!process.env.TERMUX_VERSION && hasDisplay) {
|
||||
try {
|
||||
clipboard = require("@mariozechner/clipboard") as ClipboardModule;
|
||||
} catch {
|
||||
clipboard = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { clipboard };
|
||||
@@ -1,81 +0,0 @@
|
||||
import { execSync, spawn } from "child_process";
|
||||
import { platform } from "os";
|
||||
import { isWaylandSession } from "./clipboard-image.js";
|
||||
import { clipboard } from "./clipboard-native.js";
|
||||
|
||||
type NativeClipboardExecOptions = {
|
||||
input: string;
|
||||
timeout: number;
|
||||
stdio: ["pipe", "ignore", "ignore"];
|
||||
};
|
||||
|
||||
function copyToX11Clipboard(options: NativeClipboardExecOptions): void {
|
||||
try {
|
||||
execSync("xclip -selection clipboard", options);
|
||||
} catch {
|
||||
execSync("xsel --clipboard --input", options);
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
// Always emit OSC 52 - works over SSH/mosh, harmless locally
|
||||
const encoded = Buffer.from(text).toString("base64");
|
||||
process.stdout.write(`\x1b]52;c;${encoded}\x07`);
|
||||
|
||||
try {
|
||||
if (clipboard) {
|
||||
await clipboard.setText(text);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to platform-specific clipboard tools.
|
||||
}
|
||||
|
||||
// Also try native tools (best effort for local sessions)
|
||||
const p = platform();
|
||||
const options: NativeClipboardExecOptions = { input: text, timeout: 5000, stdio: ["pipe", "ignore", "ignore"] };
|
||||
|
||||
try {
|
||||
if (p === "darwin") {
|
||||
execSync("pbcopy", options);
|
||||
} else if (p === "win32") {
|
||||
execSync("clip", options);
|
||||
} else {
|
||||
// Linux. Try Termux, Wayland, or X11 clipboard tools.
|
||||
if (process.env.TERMUX_VERSION) {
|
||||
try {
|
||||
execSync("termux-clipboard-set", options);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to Wayland or X11 tools.
|
||||
}
|
||||
}
|
||||
|
||||
const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY);
|
||||
const hasX11Display = Boolean(process.env.DISPLAY);
|
||||
const isWayland = isWaylandSession();
|
||||
if (isWayland && hasWaylandDisplay) {
|
||||
try {
|
||||
// Verify wl-copy exists (spawn errors are async and won't be caught)
|
||||
execSync("which wl-copy", { stdio: "ignore" });
|
||||
// wl-copy with execSync hangs due to fork behavior; use spawn instead
|
||||
const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
|
||||
proc.stdin.on("error", () => {
|
||||
// Ignore EPIPE errors if wl-copy exits early
|
||||
});
|
||||
proc.stdin.write(text);
|
||||
proc.stdin.end();
|
||||
proc.unref();
|
||||
} catch {
|
||||
if (hasX11Display) {
|
||||
copyToX11Clipboard(options);
|
||||
}
|
||||
}
|
||||
} else if (hasX11Display) {
|
||||
copyToX11Clipboard(options);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore - OSC 52 already emitted as fallback
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import type { PhotonImageType } from "./photon.js";
|
||||
|
||||
type Photon = typeof import("@silvia-odwyer/photon-node");
|
||||
|
||||
function readOrientationFromTiff(bytes: Uint8Array, tiffStart: number): number {
|
||||
if (tiffStart + 8 > bytes.length) return 1;
|
||||
|
||||
const byteOrder = (bytes[tiffStart] << 8) | bytes[tiffStart + 1];
|
||||
const le = byteOrder === 0x4949;
|
||||
|
||||
const read16 = (pos: number): number => {
|
||||
if (le) return bytes[pos] | (bytes[pos + 1] << 8);
|
||||
return (bytes[pos] << 8) | bytes[pos + 1];
|
||||
};
|
||||
|
||||
const read32 = (pos: number): number => {
|
||||
if (le) return bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16) | (bytes[pos + 3] << 24);
|
||||
return ((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0;
|
||||
};
|
||||
|
||||
const ifdOffset = read32(tiffStart + 4);
|
||||
const ifdStart = tiffStart + ifdOffset;
|
||||
if (ifdStart + 2 > bytes.length) return 1;
|
||||
|
||||
const entryCount = read16(ifdStart);
|
||||
for (let i = 0; i < entryCount; i++) {
|
||||
const entryPos = ifdStart + 2 + i * 12;
|
||||
if (entryPos + 12 > bytes.length) return 1;
|
||||
|
||||
if (read16(entryPos) === 0x0112) {
|
||||
const value = read16(entryPos + 8);
|
||||
return value >= 1 && value <= 8 ? value : 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
function findJpegTiffOffset(bytes: Uint8Array): number {
|
||||
let offset = 2;
|
||||
while (offset < bytes.length - 1) {
|
||||
if (bytes[offset] !== 0xff) return -1;
|
||||
const marker = bytes[offset + 1];
|
||||
if (marker === 0xff) {
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (marker === 0xe1) {
|
||||
if (offset + 4 >= bytes.length) return -1;
|
||||
const segmentStart = offset + 4;
|
||||
if (segmentStart + 6 > bytes.length) return -1;
|
||||
if (!hasExifHeader(bytes, segmentStart)) return -1;
|
||||
return segmentStart + 6;
|
||||
}
|
||||
|
||||
if (offset + 4 > bytes.length) return -1;
|
||||
const length = (bytes[offset + 2] << 8) | bytes[offset + 3];
|
||||
offset += 2 + length;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findWebpTiffOffset(bytes: Uint8Array): number {
|
||||
let offset = 12;
|
||||
while (offset + 8 <= bytes.length) {
|
||||
const chunkId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
|
||||
const chunkSize =
|
||||
bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24);
|
||||
const dataStart = offset + 8;
|
||||
|
||||
if (chunkId === "EXIF") {
|
||||
if (dataStart + chunkSize > bytes.length) return -1;
|
||||
// Some WebP files have "Exif\0\0" prefix before the TIFF header
|
||||
const tiffStart = chunkSize >= 6 && hasExifHeader(bytes, dataStart) ? dataStart + 6 : dataStart;
|
||||
return tiffStart;
|
||||
}
|
||||
|
||||
// RIFF chunks are padded to even size
|
||||
offset = dataStart + chunkSize + (chunkSize % 2);
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function hasExifHeader(bytes: Uint8Array, offset: number): boolean {
|
||||
return (
|
||||
bytes[offset] === 0x45 &&
|
||||
bytes[offset + 1] === 0x78 &&
|
||||
bytes[offset + 2] === 0x69 &&
|
||||
bytes[offset + 3] === 0x66 &&
|
||||
bytes[offset + 4] === 0x00 &&
|
||||
bytes[offset + 5] === 0x00
|
||||
);
|
||||
}
|
||||
|
||||
function getExifOrientation(bytes: Uint8Array): number {
|
||||
let tiffOffset = -1;
|
||||
|
||||
// JPEG: starts with FF D8
|
||||
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
||||
tiffOffset = findJpegTiffOffset(bytes);
|
||||
}
|
||||
// WebP: starts with RIFF....WEBP
|
||||
else if (
|
||||
bytes.length >= 12 &&
|
||||
bytes[0] === 0x52 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 &&
|
||||
bytes[9] === 0x45 &&
|
||||
bytes[10] === 0x42 &&
|
||||
bytes[11] === 0x50
|
||||
) {
|
||||
tiffOffset = findWebpTiffOffset(bytes);
|
||||
}
|
||||
|
||||
if (tiffOffset === -1) return 1;
|
||||
return readOrientationFromTiff(bytes, tiffOffset);
|
||||
}
|
||||
|
||||
type DstIndexFn = (x: number, y: number, w: number, h: number) => number;
|
||||
|
||||
function rotate90(photon: Photon, image: PhotonImageType, dstIndex: DstIndexFn): PhotonImageType {
|
||||
const w = image.get_width();
|
||||
const h = image.get_height();
|
||||
const src = image.get_raw_pixels();
|
||||
const dst = new Uint8Array(src.length);
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const srcIdx = (y * w + x) * 4;
|
||||
const dstIdx = dstIndex(x, y, w, h) * 4;
|
||||
dst[dstIdx] = src[srcIdx];
|
||||
dst[dstIdx + 1] = src[srcIdx + 1];
|
||||
dst[dstIdx + 2] = src[srcIdx + 2];
|
||||
dst[dstIdx + 3] = src[srcIdx + 3];
|
||||
}
|
||||
}
|
||||
|
||||
return new photon.PhotonImage(dst, h, w);
|
||||
}
|
||||
|
||||
// Flip orientations mutate in-place. Rotations return a new image (caller must free the old one if different).
|
||||
export function applyExifOrientation(
|
||||
photon: Photon,
|
||||
image: PhotonImageType,
|
||||
originalBytes: Uint8Array,
|
||||
): PhotonImageType {
|
||||
const orientation = getExifOrientation(originalBytes);
|
||||
if (orientation === 1) return image;
|
||||
|
||||
switch (orientation) {
|
||||
case 2:
|
||||
photon.fliph(image);
|
||||
return image;
|
||||
case 3:
|
||||
photon.fliph(image);
|
||||
photon.flipv(image);
|
||||
return image;
|
||||
case 4:
|
||||
photon.flipv(image);
|
||||
return image;
|
||||
case 5: {
|
||||
const rotated = rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y));
|
||||
photon.fliph(rotated);
|
||||
return rotated;
|
||||
}
|
||||
case 6:
|
||||
return rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y));
|
||||
case 7: {
|
||||
const rotated = rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y);
|
||||
photon.fliph(rotated);
|
||||
return rotated;
|
||||
}
|
||||
case 8:
|
||||
return rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y);
|
||||
default:
|
||||
return image;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { parse } from "yaml";
|
||||
|
||||
type ParsedFrontmatter<T extends Record<string, unknown>> = {
|
||||
frontmatter: T;
|
||||
body: string;
|
||||
};
|
||||
|
||||
const normalizeNewlines = (value: string): string => value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
|
||||
const extractFrontmatter = (content: string): { yamlString: string | null; body: string } => {
|
||||
const normalized = normalizeNewlines(content);
|
||||
|
||||
if (!normalized.startsWith("---")) {
|
||||
return { yamlString: null, body: normalized };
|
||||
}
|
||||
|
||||
const endIndex = normalized.indexOf("\n---", 3);
|
||||
if (endIndex === -1) {
|
||||
return { yamlString: null, body: normalized };
|
||||
}
|
||||
|
||||
return {
|
||||
yamlString: normalized.slice(4, endIndex),
|
||||
body: normalized.slice(endIndex + 4).trim(),
|
||||
};
|
||||
};
|
||||
|
||||
export const parseFrontmatter = <T extends Record<string, unknown> = Record<string, unknown>>(
|
||||
content: string,
|
||||
): ParsedFrontmatter<T> => {
|
||||
const { yamlString, body } = extractFrontmatter(content);
|
||||
if (!yamlString) {
|
||||
return { frontmatter: {} as T, body };
|
||||
}
|
||||
const parsed = parse(yamlString);
|
||||
return { frontmatter: (parsed ?? {}) as T, body };
|
||||
};
|
||||
|
||||
export const stripFrontmatter = (content: string): string => parseFrontmatter(content).body;
|
||||
@@ -1,192 +0,0 @@
|
||||
import hostedGitInfo from "hosted-git-info";
|
||||
|
||||
/**
|
||||
* Parsed git URL information.
|
||||
*/
|
||||
export type GitSource = {
|
||||
/** Always "git" for git sources */
|
||||
type: "git";
|
||||
/** Clone URL (always valid for git clone, without ref suffix) */
|
||||
repo: string;
|
||||
/** Git host domain (e.g., "github.com") */
|
||||
host: string;
|
||||
/** Repository path (e.g., "user/repo") */
|
||||
path: string;
|
||||
/** Git ref (branch, tag, commit) if specified */
|
||||
ref?: string;
|
||||
/** True if ref was specified (package won't be auto-updated) */
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
function splitRef(url: string): { repo: string; ref?: string } {
|
||||
const scpLikeMatch = url.match(/^git@([^:]+):(.+)$/);
|
||||
if (scpLikeMatch) {
|
||||
const pathWithMaybeRef = scpLikeMatch[2] ?? "";
|
||||
const refSeparator = pathWithMaybeRef.indexOf("@");
|
||||
if (refSeparator < 0) return { repo: url };
|
||||
const repoPath = pathWithMaybeRef.slice(0, refSeparator);
|
||||
const ref = pathWithMaybeRef.slice(refSeparator + 1);
|
||||
if (!repoPath || !ref) return { repo: url };
|
||||
return {
|
||||
repo: `git@${scpLikeMatch[1] ?? ""}:${repoPath}`,
|
||||
ref,
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes("://")) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const pathWithMaybeRef = parsed.pathname.replace(/^\/+/, "");
|
||||
const refSeparator = pathWithMaybeRef.indexOf("@");
|
||||
if (refSeparator < 0) return { repo: url };
|
||||
const repoPath = pathWithMaybeRef.slice(0, refSeparator);
|
||||
const ref = pathWithMaybeRef.slice(refSeparator + 1);
|
||||
if (!repoPath || !ref) return { repo: url };
|
||||
parsed.pathname = `/${repoPath}`;
|
||||
return {
|
||||
repo: parsed.toString().replace(/\/$/, ""),
|
||||
ref,
|
||||
};
|
||||
} catch {
|
||||
return { repo: url };
|
||||
}
|
||||
}
|
||||
|
||||
const slashIndex = url.indexOf("/");
|
||||
if (slashIndex < 0) {
|
||||
return { repo: url };
|
||||
}
|
||||
const host = url.slice(0, slashIndex);
|
||||
const pathWithMaybeRef = url.slice(slashIndex + 1);
|
||||
const refSeparator = pathWithMaybeRef.indexOf("@");
|
||||
if (refSeparator < 0) {
|
||||
return { repo: url };
|
||||
}
|
||||
const repoPath = pathWithMaybeRef.slice(0, refSeparator);
|
||||
const ref = pathWithMaybeRef.slice(refSeparator + 1);
|
||||
if (!repoPath || !ref) {
|
||||
return { repo: url };
|
||||
}
|
||||
return {
|
||||
repo: `${host}/${repoPath}`,
|
||||
ref,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGenericGitUrl(url: string): GitSource | null {
|
||||
const { repo: repoWithoutRef, ref } = splitRef(url);
|
||||
let repo = repoWithoutRef;
|
||||
let host = "";
|
||||
let path = "";
|
||||
|
||||
const scpLikeMatch = repoWithoutRef.match(/^git@([^:]+):(.+)$/);
|
||||
if (scpLikeMatch) {
|
||||
host = scpLikeMatch[1] ?? "";
|
||||
path = scpLikeMatch[2] ?? "";
|
||||
} else if (
|
||||
repoWithoutRef.startsWith("https://") ||
|
||||
repoWithoutRef.startsWith("http://") ||
|
||||
repoWithoutRef.startsWith("ssh://") ||
|
||||
repoWithoutRef.startsWith("git://")
|
||||
) {
|
||||
try {
|
||||
const parsed = new URL(repoWithoutRef);
|
||||
host = parsed.hostname;
|
||||
path = parsed.pathname.replace(/^\/+/, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const slashIndex = repoWithoutRef.indexOf("/");
|
||||
if (slashIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
host = repoWithoutRef.slice(0, slashIndex);
|
||||
path = repoWithoutRef.slice(slashIndex + 1);
|
||||
if (!host.includes(".") && host !== "localhost") {
|
||||
return null;
|
||||
}
|
||||
repo = `https://${repoWithoutRef}`;
|
||||
}
|
||||
|
||||
const normalizedPath = path.replace(/\.git$/, "").replace(/^\/+/, "");
|
||||
if (!host || !normalizedPath || normalizedPath.split("/").length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "git",
|
||||
repo,
|
||||
host,
|
||||
path: normalizedPath,
|
||||
ref,
|
||||
pinned: Boolean(ref),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse git source into a GitSource.
|
||||
*
|
||||
* Rules:
|
||||
* - With git: prefix, accept all historical shorthand forms.
|
||||
* - Without git: prefix, only accept explicit protocol URLs.
|
||||
*/
|
||||
export function parseGitUrl(source: string): GitSource | null {
|
||||
const trimmed = source.trim();
|
||||
const hasGitPrefix = trimmed.startsWith("git:");
|
||||
const url = hasGitPrefix ? trimmed.slice(4).trim() : trimmed;
|
||||
|
||||
if (!hasGitPrefix && !/^(https?|ssh|git):\/\//i.test(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const split = splitRef(url);
|
||||
|
||||
const hostedCandidates = [split.ref ? `${split.repo}#${split.ref}` : undefined, url].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
for (const candidate of hostedCandidates) {
|
||||
const info = hostedGitInfo.fromUrl(candidate);
|
||||
if (info) {
|
||||
if (split.ref && info.project?.includes("@")) {
|
||||
continue;
|
||||
}
|
||||
const useHttpsPrefix =
|
||||
!split.repo.startsWith("http://") &&
|
||||
!split.repo.startsWith("https://") &&
|
||||
!split.repo.startsWith("ssh://") &&
|
||||
!split.repo.startsWith("git://") &&
|
||||
!split.repo.startsWith("git@");
|
||||
return {
|
||||
type: "git",
|
||||
repo: useHttpsPrefix ? `https://${split.repo}` : split.repo,
|
||||
host: info.domain || "",
|
||||
path: `${info.user}/${info.project}`.replace(/\.git$/, ""),
|
||||
ref: info.committish || split.ref || undefined,
|
||||
pinned: Boolean(info.committish || split.ref),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const httpsCandidates = [split.ref ? `https://${split.repo}#${split.ref}` : undefined, `https://${url}`].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
for (const candidate of httpsCandidates) {
|
||||
const info = hostedGitInfo.fromUrl(candidate);
|
||||
if (info) {
|
||||
if (split.ref && info.project?.includes("@")) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
type: "git",
|
||||
repo: `https://${split.repo}`,
|
||||
host: info.domain || "",
|
||||
path: `${info.user}/${info.project}`.replace(/\.git$/, ""),
|
||||
ref: info.committish || split.ref || undefined,
|
||||
pinned: Boolean(info.committish || split.ref),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return parseGenericGitUrl(url);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { applyExifOrientation } from "./exif-orientation.js";
|
||||
import { loadPhoton } from "./photon.js";
|
||||
|
||||
/**
|
||||
* Convert image to PNG format for terminal display.
|
||||
* Kitty graphics protocol requires PNG format (f=100).
|
||||
*/
|
||||
export async function convertToPng(
|
||||
base64Data: string,
|
||||
mimeType: string,
|
||||
): Promise<{ data: string; mimeType: string } | null> {
|
||||
// Already PNG, no conversion needed
|
||||
if (mimeType === "image/png") {
|
||||
return { data: base64Data, mimeType };
|
||||
}
|
||||
|
||||
const photon = await loadPhoton();
|
||||
if (!photon) {
|
||||
// Photon not available, can't convert
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const bytes = new Uint8Array(Buffer.from(base64Data, "base64"));
|
||||
const rawImage = photon.PhotonImage.new_from_byteslice(bytes);
|
||||
const image = applyExifOrientation(photon, rawImage, bytes);
|
||||
if (image !== rawImage) rawImage.free();
|
||||
try {
|
||||
const pngBuffer = image.get_bytes();
|
||||
return {
|
||||
data: Buffer.from(pngBuffer).toString("base64"),
|
||||
mimeType: "image/png",
|
||||
};
|
||||
} finally {
|
||||
image.free();
|
||||
}
|
||||
} catch {
|
||||
// Conversion failed
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import { applyExifOrientation } from "./exif-orientation.js";
|
||||
import { loadPhoton } from "./photon.js";
|
||||
|
||||
export interface ImageResizeOptions {
|
||||
maxWidth?: number; // Default: 2000
|
||||
maxHeight?: number; // Default: 2000
|
||||
maxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit)
|
||||
jpegQuality?: number; // Default: 80
|
||||
}
|
||||
|
||||
export interface ResizedImage {
|
||||
data: string; // base64
|
||||
mimeType: string;
|
||||
originalWidth: number;
|
||||
originalHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
wasResized: boolean;
|
||||
}
|
||||
|
||||
// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.
|
||||
const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
|
||||
|
||||
const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
|
||||
maxWidth: 2000,
|
||||
maxHeight: 2000,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
jpegQuality: 80,
|
||||
};
|
||||
|
||||
interface EncodedCandidate {
|
||||
data: string;
|
||||
encodedSize: number;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate {
|
||||
const data = Buffer.from(buffer).toString("base64");
|
||||
return {
|
||||
data,
|
||||
encodedSize: Buffer.byteLength(data, "utf-8"),
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an image to fit within the specified max dimensions and encoded file size.
|
||||
* Returns null if the image cannot be resized below maxBytes.
|
||||
*
|
||||
* Uses Photon (Rust/WASM) for image processing. If Photon is not available,
|
||||
* returns null.
|
||||
*
|
||||
* Strategy for staying under maxBytes:
|
||||
* 1. First resize to maxWidth/maxHeight
|
||||
* 2. Try both PNG and JPEG formats, pick the smaller one
|
||||
* 3. If still too large, try JPEG with decreasing quality
|
||||
* 4. If still too large, progressively reduce dimensions until 1x1
|
||||
*/
|
||||
export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage | null> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const inputBuffer = Buffer.from(img.data, "base64");
|
||||
const inputBase64Size = Buffer.byteLength(img.data, "utf-8");
|
||||
|
||||
const photon = await loadPhoton();
|
||||
if (!photon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;
|
||||
try {
|
||||
const inputBytes = new Uint8Array(inputBuffer);
|
||||
const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);
|
||||
image = applyExifOrientation(photon, rawImage, inputBytes);
|
||||
if (image !== rawImage) rawImage.free();
|
||||
|
||||
const originalWidth = image.get_width();
|
||||
const originalHeight = image.get_height();
|
||||
const format = img.mimeType?.split("/")[1] ?? "png";
|
||||
|
||||
// Check if already within all limits (dimensions AND encoded size)
|
||||
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {
|
||||
return {
|
||||
data: img.data,
|
||||
mimeType: img.mimeType ?? `image/${format}`,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
wasResized: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate initial dimensions respecting max limits
|
||||
let targetWidth = originalWidth;
|
||||
let targetHeight = originalHeight;
|
||||
|
||||
if (targetWidth > opts.maxWidth) {
|
||||
targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
|
||||
targetWidth = opts.maxWidth;
|
||||
}
|
||||
if (targetHeight > opts.maxHeight) {
|
||||
targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
|
||||
targetHeight = opts.maxHeight;
|
||||
}
|
||||
|
||||
function tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] {
|
||||
const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);
|
||||
|
||||
try {
|
||||
const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")];
|
||||
for (const quality of jpegQualities) {
|
||||
candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg"));
|
||||
}
|
||||
return candidates;
|
||||
} finally {
|
||||
resized.free();
|
||||
}
|
||||
}
|
||||
|
||||
const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));
|
||||
let currentWidth = targetWidth;
|
||||
let currentHeight = targetHeight;
|
||||
|
||||
while (true) {
|
||||
const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.encodedSize < opts.maxBytes) {
|
||||
return {
|
||||
data: candidate.data,
|
||||
mimeType: candidate.mimeType,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
width: currentWidth,
|
||||
height: currentHeight,
|
||||
wasResized: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (currentWidth === 1 && currentHeight === 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));
|
||||
const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));
|
||||
if (nextWidth === currentWidth && nextHeight === currentHeight) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentWidth = nextWidth;
|
||||
currentHeight = nextHeight;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (image) {
|
||||
image.free();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a dimension note for resized images.
|
||||
* This helps the model understand the coordinate mapping.
|
||||
*/
|
||||
export function formatDimensionNote(result: ResizedImage): string | undefined {
|
||||
if (!result.wasResized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scale = result.originalWidth / result.width;
|
||||
return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { open } from "node:fs/promises";
|
||||
import { fileTypeFromBuffer } from "file-type";
|
||||
|
||||
const IMAGE_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
||||
|
||||
const FILE_TYPE_SNIFF_BYTES = 4100;
|
||||
|
||||
export async function detectSupportedImageMimeTypeFromFile(filePath: string): Promise<string | null> {
|
||||
const fileHandle = await open(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(FILE_TYPE_SNIFF_BYTES);
|
||||
const { bytesRead } = await fileHandle.read(buffer, 0, FILE_TYPE_SNIFF_BYTES, 0);
|
||||
if (bytesRead === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileType = await fileTypeFromBuffer(buffer.subarray(0, bytesRead));
|
||||
if (!fileType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!IMAGE_MIME_TYPES.has(fileType.mime)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fileType.mime;
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Returns true if the value is NOT a package source (npm:, git:, etc.)
|
||||
* or a URL protocol. Bare names and relative paths without ./ prefix
|
||||
* are considered local.
|
||||
*/
|
||||
export function isLocalPath(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
// Known non-local prefixes
|
||||
if (
|
||||
trimmed.startsWith("npm:") ||
|
||||
trimmed.startsWith("git:") ||
|
||||
trimmed.startsWith("github:") ||
|
||||
trimmed.startsWith("http:") ||
|
||||
trimmed.startsWith("https:") ||
|
||||
trimmed.startsWith("ssh:")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* Photon image processing wrapper.
|
||||
*
|
||||
* This module provides a unified interface to @silvia-odwyer/photon-node that works in:
|
||||
* 1. Node.js (development, npm run build)
|
||||
* 2. Bun compiled binaries (standalone distribution)
|
||||
*
|
||||
* The challenge: photon-node's CJS entry uses fs.readFileSync(__dirname + '/photon_rs_bg.wasm')
|
||||
* which bakes the build machine's absolute path into Bun compiled binaries.
|
||||
*
|
||||
* Solution:
|
||||
* 1. Patch fs.readFileSync to redirect missing photon_rs_bg.wasm reads
|
||||
* 2. Copy photon_rs_bg.wasm next to the executable in build:binary
|
||||
*/
|
||||
|
||||
import type { PathOrFileDescriptor } from "fs";
|
||||
import { createRequire } from "module";
|
||||
import * as path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const fs = require("fs") as typeof import("fs");
|
||||
|
||||
// Re-export types from the main package
|
||||
export type { PhotonImage as PhotonImageType } from "@silvia-odwyer/photon-node";
|
||||
|
||||
type ReadFileSync = typeof fs.readFileSync;
|
||||
|
||||
const WASM_FILENAME = "photon_rs_bg.wasm";
|
||||
|
||||
// Lazy-loaded photon module
|
||||
let photonModule: typeof import("@silvia-odwyer/photon-node") | null = null;
|
||||
let loadPromise: Promise<typeof import("@silvia-odwyer/photon-node") | null> | null = null;
|
||||
|
||||
function pathOrNull(file: PathOrFileDescriptor): string | null {
|
||||
if (typeof file === "string") {
|
||||
return file;
|
||||
}
|
||||
if (file instanceof URL) {
|
||||
return fileURLToPath(file);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getFallbackWasmPaths(): string[] {
|
||||
const execDir = path.dirname(process.execPath);
|
||||
return [
|
||||
path.join(execDir, WASM_FILENAME),
|
||||
path.join(execDir, "photon", WASM_FILENAME),
|
||||
path.join(process.cwd(), WASM_FILENAME),
|
||||
];
|
||||
}
|
||||
|
||||
function patchPhotonWasmRead(): () => void {
|
||||
const originalReadFileSync: ReadFileSync = fs.readFileSync.bind(fs);
|
||||
const fallbackPaths = getFallbackWasmPaths();
|
||||
const mutableFs = fs as { readFileSync: ReadFileSync };
|
||||
|
||||
const patchedReadFileSync: ReadFileSync = ((...args: Parameters<ReadFileSync>) => {
|
||||
const [file, options] = args;
|
||||
const resolvedPath = pathOrNull(file);
|
||||
|
||||
if (resolvedPath?.endsWith(WASM_FILENAME)) {
|
||||
try {
|
||||
return originalReadFileSync(...args);
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err?.code && err.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const fallbackPath of fallbackPaths) {
|
||||
if (!fs.existsSync(fallbackPath)) {
|
||||
continue;
|
||||
}
|
||||
if (options === undefined) {
|
||||
return originalReadFileSync(fallbackPath);
|
||||
}
|
||||
return originalReadFileSync(fallbackPath, options);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return originalReadFileSync(...args);
|
||||
}) as ReadFileSync;
|
||||
|
||||
try {
|
||||
mutableFs.readFileSync = patchedReadFileSync;
|
||||
} catch {
|
||||
Object.defineProperty(fs, "readFileSync", {
|
||||
value: patchedReadFileSync,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
try {
|
||||
mutableFs.readFileSync = originalReadFileSync;
|
||||
} catch {
|
||||
Object.defineProperty(fs, "readFileSync", {
|
||||
value: originalReadFileSync,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the photon module asynchronously.
|
||||
* Returns cached module on subsequent calls.
|
||||
*/
|
||||
export async function loadPhoton(): Promise<typeof import("@silvia-odwyer/photon-node") | null> {
|
||||
if (photonModule) {
|
||||
return photonModule;
|
||||
}
|
||||
|
||||
if (loadPromise) {
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
loadPromise = (async () => {
|
||||
const restoreReadFileSync = patchPhotonWasmRead();
|
||||
try {
|
||||
photonModule = await import("@silvia-odwyer/photon-node");
|
||||
return photonModule;
|
||||
} catch {
|
||||
photonModule = null;
|
||||
return photonModule;
|
||||
} finally {
|
||||
restoreReadFileSync();
|
||||
}
|
||||
})();
|
||||
|
||||
return loadPromise;
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { delimiter } from "node:path";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import { getBinDir, getSettingsPath } from "../config.js";
|
||||
import { SettingsManager } from "../core/settings-manager.js";
|
||||
|
||||
let cachedShellConfig: { shell: string; args: string[] } | null = null;
|
||||
|
||||
/**
|
||||
* Find bash executable on PATH (cross-platform)
|
||||
*/
|
||||
function findBashOnPath(): string | null {
|
||||
if (process.platform === "win32") {
|
||||
// Windows: Use 'where' and verify file exists (where can return non-existent paths)
|
||||
try {
|
||||
const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 });
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
|
||||
if (firstMatch && existsSync(firstMatch)) {
|
||||
return firstMatch;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unix: Use 'which' and trust its output (handles Termux and special filesystems)
|
||||
try {
|
||||
const result = spawnSync("which", ["bash"], { encoding: "utf-8", timeout: 5000 });
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
|
||||
if (firstMatch) {
|
||||
return firstMatch;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shell configuration based on platform.
|
||||
* Resolution order:
|
||||
* 1. User-specified shellPath in settings.json
|
||||
* 2. On Windows: Git Bash in known locations, then bash on PATH
|
||||
* 3. On Unix: /bin/bash, then bash on PATH, then fallback to sh
|
||||
*/
|
||||
export function getShellConfig(): { shell: string; args: string[] } {
|
||||
if (cachedShellConfig) {
|
||||
return cachedShellConfig;
|
||||
}
|
||||
|
||||
const settings = SettingsManager.create();
|
||||
const customShellPath = settings.getShellPath();
|
||||
|
||||
// 1. Check user-specified shell path
|
||||
if (customShellPath) {
|
||||
if (existsSync(customShellPath)) {
|
||||
cachedShellConfig = { shell: customShellPath, args: ["-c"] };
|
||||
return cachedShellConfig;
|
||||
}
|
||||
throw new Error(
|
||||
`Custom shell path not found: ${customShellPath}\nPlease update shellPath in ${getSettingsPath()}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// 2. Try Git Bash in known locations
|
||||
const paths: string[] = [];
|
||||
const programFiles = process.env.ProgramFiles;
|
||||
if (programFiles) {
|
||||
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
|
||||
}
|
||||
const programFilesX86 = process.env["ProgramFiles(x86)"];
|
||||
if (programFilesX86) {
|
||||
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
if (existsSync(path)) {
|
||||
cachedShellConfig = { shell: path, args: ["-c"] };
|
||||
return cachedShellConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
|
||||
const bashOnPath = findBashOnPath();
|
||||
if (bashOnPath) {
|
||||
cachedShellConfig = { shell: bashOnPath, args: ["-c"] };
|
||||
return cachedShellConfig;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No bash shell found. Options:\n` +
|
||||
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
|
||||
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
|
||||
` 3. Set shellPath in ${getSettingsPath()}\n\n` +
|
||||
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unix: try /bin/bash, then bash on PATH, then fallback to sh
|
||||
if (existsSync("/bin/bash")) {
|
||||
cachedShellConfig = { shell: "/bin/bash", args: ["-c"] };
|
||||
return cachedShellConfig;
|
||||
}
|
||||
|
||||
const bashOnPath = findBashOnPath();
|
||||
if (bashOnPath) {
|
||||
cachedShellConfig = { shell: bashOnPath, args: ["-c"] };
|
||||
return cachedShellConfig;
|
||||
}
|
||||
|
||||
cachedShellConfig = { shell: "sh", args: ["-c"] };
|
||||
return cachedShellConfig;
|
||||
}
|
||||
|
||||
export function getShellEnv(): NodeJS.ProcessEnv {
|
||||
const binDir = getBinDir();
|
||||
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
||||
const currentPath = process.env[pathKey] ?? "";
|
||||
const pathEntries = currentPath.split(delimiter).filter(Boolean);
|
||||
const hasBinDir = pathEntries.includes(binDir);
|
||||
const updatedPath = hasBinDir ? currentPath : [binDir, currentPath].filter(Boolean).join(delimiter);
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
[pathKey]: updatedPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize binary output for display/storage.
|
||||
* Removes characters that crash string-width or cause display issues:
|
||||
* - Control characters (except tab, newline, carriage return)
|
||||
* - Lone surrogates
|
||||
* - Unicode Format characters (crash string-width due to a bug)
|
||||
* - Characters with undefined code points
|
||||
*/
|
||||
export function sanitizeBinaryOutput(str: string): string {
|
||||
// Use Array.from to properly iterate over code points (not code units)
|
||||
// This handles surrogate pairs correctly and catches edge cases where
|
||||
// codePointAt() might return undefined
|
||||
return Array.from(str)
|
||||
.filter((char) => {
|
||||
// Filter out characters that cause string-width to crash
|
||||
// This includes:
|
||||
// - Unicode format characters
|
||||
// - Lone surrogates (already filtered by Array.from)
|
||||
// - Control chars except \t \n \r
|
||||
// - Characters with undefined code points
|
||||
|
||||
const code = char.codePointAt(0);
|
||||
|
||||
// Skip if code point is undefined (edge case with invalid strings)
|
||||
if (code === undefined) return false;
|
||||
|
||||
// Allow tab, newline, carriage return
|
||||
if (code === 0x09 || code === 0x0a || code === 0x0d) return true;
|
||||
|
||||
// Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d)
|
||||
if (code <= 0x1f) return false;
|
||||
|
||||
// Filter out Unicode format characters
|
||||
if (code >= 0xfff9 && code <= 0xfffb) return false;
|
||||
|
||||
return true;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Detached child processes must be tracked so they can be killed on parent
|
||||
* shutdown signals (SIGHUP/SIGTERM).
|
||||
*/
|
||||
const trackedDetachedChildPids = new Set<number>();
|
||||
|
||||
export function trackDetachedChildPid(pid: number): void {
|
||||
trackedDetachedChildPids.add(pid);
|
||||
}
|
||||
|
||||
export function untrackDetachedChildPid(pid: number): void {
|
||||
trackedDetachedChildPids.delete(pid);
|
||||
}
|
||||
|
||||
export function killTrackedDetachedChildren(): void {
|
||||
for (const pid of trackedDetachedChildPids) {
|
||||
killProcessTree(pid);
|
||||
}
|
||||
trackedDetachedChildPids.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a process and all its children (cross-platform)
|
||||
*/
|
||||
export function killProcessTree(pid: number): void {
|
||||
if (process.platform === "win32") {
|
||||
// Use taskkill on Windows to kill process tree
|
||||
try {
|
||||
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors if taskkill fails
|
||||
}
|
||||
} else {
|
||||
// Use SIGKILL on Unix/Linux/Mac
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL");
|
||||
} catch {
|
||||
// Fallback to killing just the child if process group kill fails
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Process already dead
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Sleep helper that respects abort signal.
|
||||
*/
|
||||
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(resolve, ms);
|
||||
|
||||
signal?.addEventListener("abort", () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Aborted"));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
import chalk from "chalk";
|
||||
import { spawnSync } from "child_process";
|
||||
import extractZip from "extract-zip";
|
||||
import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "fs";
|
||||
import { arch, platform } from "os";
|
||||
import { join } from "path";
|
||||
import { Readable } from "stream";
|
||||
import { pipeline } from "stream/promises";
|
||||
import { APP_NAME, getBinDir } from "../config.js";
|
||||
|
||||
const TOOLS_DIR = getBinDir();
|
||||
const NETWORK_TIMEOUT_MS = 10_000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
function isOfflineModeEnabled(): boolean {
|
||||
const value = process.env.PI_OFFLINE;
|
||||
if (!value) return false;
|
||||
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
|
||||
}
|
||||
|
||||
interface ToolConfig {
|
||||
name: string;
|
||||
repo: string; // GitHub repo (e.g., "sharkdp/fd")
|
||||
binaryName: string; // Name of the binary inside the archive
|
||||
tagPrefix: string; // Prefix for tags (e.g., "v" for v1.0.0, "" for 1.0.0)
|
||||
getAssetName: (version: string, plat: string, architecture: string) => string | null;
|
||||
}
|
||||
|
||||
const TOOLS: Record<string, ToolConfig> = {
|
||||
fd: {
|
||||
name: "fd",
|
||||
repo: "sharkdp/fd",
|
||||
binaryName: "fd",
|
||||
tagPrefix: "v",
|
||||
getAssetName: (version, plat, architecture) => {
|
||||
if (plat === "darwin") {
|
||||
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
|
||||
return `fd-v${version}-${archStr}-apple-darwin.tar.gz`;
|
||||
} else if (plat === "linux") {
|
||||
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
|
||||
return `fd-v${version}-${archStr}-unknown-linux-gnu.tar.gz`;
|
||||
} else if (plat === "win32") {
|
||||
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
|
||||
return `fd-v${version}-${archStr}-pc-windows-msvc.zip`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
rg: {
|
||||
name: "ripgrep",
|
||||
repo: "BurntSushi/ripgrep",
|
||||
binaryName: "rg",
|
||||
tagPrefix: "",
|
||||
getAssetName: (version, plat, architecture) => {
|
||||
if (plat === "darwin") {
|
||||
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
|
||||
return `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`;
|
||||
} else if (plat === "linux") {
|
||||
if (architecture === "arm64") {
|
||||
return `ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz`;
|
||||
}
|
||||
return `ripgrep-${version}-x86_64-unknown-linux-musl.tar.gz`;
|
||||
} else if (plat === "win32") {
|
||||
const archStr = architecture === "arm64" ? "aarch64" : "x86_64";
|
||||
return `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Check if a command exists in PATH by trying to run it
|
||||
function commandExists(cmd: string): boolean {
|
||||
try {
|
||||
const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
|
||||
// Check for ENOENT error (command not found)
|
||||
return result.error === undefined || result.error === null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the path to a tool (system-wide or in our tools dir)
|
||||
export function getToolPath(tool: "fd" | "rg"): string | null {
|
||||
const config = TOOLS[tool];
|
||||
if (!config) return null;
|
||||
|
||||
// Check our tools directory first
|
||||
const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : ""));
|
||||
if (existsSync(localPath)) {
|
||||
return localPath;
|
||||
}
|
||||
|
||||
// Check system PATH - if found, just return the command name (it's in PATH)
|
||||
if (commandExists(config.binaryName)) {
|
||||
return config.binaryName;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch latest release version from GitHub
|
||||
async function getLatestVersion(repo: string): Promise<string> {
|
||||
const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
||||
headers: { "User-Agent": `${APP_NAME}-coding-agent` },
|
||||
signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { tag_name: string };
|
||||
return data.tag_name.replace(/^v/, "");
|
||||
}
|
||||
|
||||
// Download a file from URL
|
||||
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("No response body");
|
||||
}
|
||||
|
||||
const fileStream = createWriteStream(dest);
|
||||
await pipeline(Readable.fromWeb(response.body as any), fileStream);
|
||||
}
|
||||
|
||||
function findBinaryRecursively(rootDir: string, binaryFileName: string): string | null {
|
||||
const stack: string[] = [rootDir];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const currentDir = stack.pop();
|
||||
if (!currentDir) continue;
|
||||
|
||||
const entries = readdirSync(currentDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(currentDir, entry.name);
|
||||
if (entry.isFile() && entry.name === binaryFileName) {
|
||||
return fullPath;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Download and install a tool
|
||||
async function downloadTool(tool: "fd" | "rg"): Promise<string> {
|
||||
const config = TOOLS[tool];
|
||||
if (!config) throw new Error(`Unknown tool: ${tool}`);
|
||||
|
||||
const plat = platform();
|
||||
const architecture = arch();
|
||||
|
||||
// Get latest version
|
||||
const version = await getLatestVersion(config.repo);
|
||||
|
||||
// Get asset name for this platform
|
||||
const assetName = config.getAssetName(version, plat, architecture);
|
||||
if (!assetName) {
|
||||
throw new Error(`Unsupported platform: ${plat}/${architecture}`);
|
||||
}
|
||||
|
||||
// Create tools directory
|
||||
mkdirSync(TOOLS_DIR, { recursive: true });
|
||||
|
||||
const downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`;
|
||||
const archivePath = join(TOOLS_DIR, assetName);
|
||||
const binaryExt = plat === "win32" ? ".exe" : "";
|
||||
const binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt);
|
||||
|
||||
// Download
|
||||
await downloadFile(downloadUrl, archivePath);
|
||||
|
||||
// Extract into a unique temp directory. fd and rg downloads can run concurrently
|
||||
// during startup, so sharing a fixed directory causes races.
|
||||
const extractDir = join(
|
||||
TOOLS_DIR,
|
||||
`extract_tmp_${config.binaryName}_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
|
||||
);
|
||||
mkdirSync(extractDir, { recursive: true });
|
||||
|
||||
try {
|
||||
if (assetName.endsWith(".tar.gz")) {
|
||||
const extractResult = spawnSync("tar", ["xzf", archivePath, "-C", extractDir], { stdio: "pipe" });
|
||||
if (extractResult.error || extractResult.status !== 0) {
|
||||
const errMsg = extractResult.error?.message ?? extractResult.stderr?.toString().trim() ?? "unknown error";
|
||||
throw new Error(`Failed to extract ${assetName}: ${errMsg}`);
|
||||
}
|
||||
} else if (assetName.endsWith(".zip")) {
|
||||
await extractZip(archivePath, { dir: extractDir });
|
||||
} else {
|
||||
throw new Error(`Unsupported archive format: ${assetName}`);
|
||||
}
|
||||
|
||||
// Find the binary in extracted files. Some archives contain files directly
|
||||
// at root, others nest under a versioned subdirectory.
|
||||
const binaryFileName = config.binaryName + binaryExt;
|
||||
const extractedDir = join(extractDir, assetName.replace(/\.(tar\.gz|zip)$/, ""));
|
||||
const extractedBinaryCandidates = [join(extractedDir, binaryFileName), join(extractDir, binaryFileName)];
|
||||
let extractedBinary = extractedBinaryCandidates.find((candidate) => existsSync(candidate));
|
||||
|
||||
if (!extractedBinary) {
|
||||
extractedBinary = findBinaryRecursively(extractDir, binaryFileName) ?? undefined;
|
||||
}
|
||||
|
||||
if (extractedBinary) {
|
||||
renameSync(extractedBinary, binaryPath);
|
||||
} else {
|
||||
throw new Error(`Binary not found in archive: expected ${binaryFileName} under ${extractDir}`);
|
||||
}
|
||||
|
||||
// Make executable (Unix only)
|
||||
if (plat !== "win32") {
|
||||
chmodSync(binaryPath, 0o755);
|
||||
}
|
||||
} finally {
|
||||
// Cleanup
|
||||
rmSync(archivePath, { force: true });
|
||||
rmSync(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
// Termux package names for tools
|
||||
const TERMUX_PACKAGES: Record<string, string> = {
|
||||
fd: "fd",
|
||||
rg: "ripgrep",
|
||||
};
|
||||
|
||||
// Ensure a tool is available, downloading if necessary
|
||||
// Returns the path to the tool, or null if unavailable
|
||||
export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise<string | undefined> {
|
||||
const existingPath = getToolPath(tool);
|
||||
if (existingPath) {
|
||||
return existingPath;
|
||||
}
|
||||
|
||||
const config = TOOLS[tool];
|
||||
if (!config) return undefined;
|
||||
|
||||
if (isOfflineModeEnabled()) {
|
||||
if (!silent) {
|
||||
console.log(chalk.yellow(`${config.name} not found. Offline mode enabled, skipping download.`));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility.
|
||||
// Users must install via pkg.
|
||||
if (platform() === "android") {
|
||||
const pkgName = TERMUX_PACKAGES[tool] ?? tool;
|
||||
if (!silent) {
|
||||
console.log(chalk.yellow(`${config.name} not found. Install with: pkg install ${pkgName}`));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Tool not found - download it
|
||||
if (!silent) {
|
||||
console.log(chalk.dim(`${config.name} not found. Downloading...`));
|
||||
}
|
||||
|
||||
try {
|
||||
const path = await downloadTool(tool);
|
||||
if (!silent) {
|
||||
console.log(chalk.dim(`${config.name} installed to ${path}`));
|
||||
}
|
||||
return path;
|
||||
} catch (e) {
|
||||
if (!silent) {
|
||||
console.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user