mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-15 21:24:48 +00:00
refactor: apply minimal tfcode branding
- Rename packages/opencode → packages/tfcode (directory only) - Rename bin/opencode → bin/tfcode (CLI binary) - Rename .opencode → .tfcode (config directory) - Update package.json name and bin field - Update config directory path references (.tfcode) - Keep internal code references as 'opencode' for easy upstream sync - Keep @opencode-ai/* workspace package names This minimal branding approach allows clean merges from upstream opencode repository while providing tfcode branding for users.
This commit is contained in:
35
packages/tfcode/src/util/abort.ts
Normal file
35
packages/tfcode/src/util/abort.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Creates an AbortController that automatically aborts after a timeout.
|
||||
*
|
||||
* Uses bind() instead of arrow functions to avoid capturing the surrounding
|
||||
* scope in closures. Arrow functions like `() => controller.abort()` capture
|
||||
* request bodies and other large objects, preventing GC for the timer lifetime.
|
||||
*
|
||||
* @param ms Timeout in milliseconds
|
||||
* @returns Object with controller, signal, and clearTimeout function
|
||||
*/
|
||||
export function abortAfter(ms: number) {
|
||||
const controller = new AbortController()
|
||||
const id = setTimeout(controller.abort.bind(controller), ms)
|
||||
return {
|
||||
controller,
|
||||
signal: controller.signal,
|
||||
clearTimeout: () => globalThis.clearTimeout(id),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines multiple AbortSignals with a timeout.
|
||||
*
|
||||
* @param ms Timeout in milliseconds
|
||||
* @param signals Additional signals to combine
|
||||
* @returns Combined signal that aborts on timeout or when any input signal aborts
|
||||
*/
|
||||
export function abortAfterAny(ms: number, ...signals: AbortSignal[]) {
|
||||
const timeout = abortAfter(ms)
|
||||
const signal = AbortSignal.any([timeout.signal, ...signals])
|
||||
return {
|
||||
signal,
|
||||
clearTimeout: timeout.clearTimeout,
|
||||
}
|
||||
}
|
||||
17
packages/tfcode/src/util/archive.ts
Normal file
17
packages/tfcode/src/util/archive.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import path from "path"
|
||||
import { Process } from "./process"
|
||||
|
||||
export namespace Archive {
|
||||
export async function extractZip(zipPath: string, destDir: string) {
|
||||
if (process.platform === "win32") {
|
||||
const winZipPath = path.resolve(zipPath)
|
||||
const winDestDir = path.resolve(destDir)
|
||||
// $global:ProgressPreference suppresses PowerShell's blue progress bar popup
|
||||
const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force`
|
||||
await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
|
||||
return
|
||||
}
|
||||
|
||||
await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir])
|
||||
}
|
||||
}
|
||||
19
packages/tfcode/src/util/color.ts
Normal file
19
packages/tfcode/src/util/color.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export namespace Color {
|
||||
export function isValidHex(hex?: string): hex is string {
|
||||
if (!hex) return false
|
||||
return /^#[0-9a-fA-F]{6}$/.test(hex)
|
||||
}
|
||||
|
||||
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return { r, g, b }
|
||||
}
|
||||
|
||||
export function hexToAnsiBold(hex?: string): string | undefined {
|
||||
if (!isValidHex(hex)) return undefined
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
return `\x1b[38;2;${r};${g};${b}m\x1b[1m`
|
||||
}
|
||||
}
|
||||
25
packages/tfcode/src/util/context.ts
Normal file
25
packages/tfcode/src/util/context.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { AsyncLocalStorage } from "async_hooks"
|
||||
|
||||
export namespace Context {
|
||||
export class NotFound extends Error {
|
||||
constructor(public override readonly name: string) {
|
||||
super(`No context found for ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function create<T>(name: string) {
|
||||
const storage = new AsyncLocalStorage<T>()
|
||||
return {
|
||||
use() {
|
||||
const result = storage.getStore()
|
||||
if (!result) {
|
||||
throw new NotFound(name)
|
||||
}
|
||||
return result
|
||||
},
|
||||
provide<R>(value: T, fn: () => R) {
|
||||
return storage.run(value, fn)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
9
packages/tfcode/src/util/data-url.ts
Normal file
9
packages/tfcode/src/util/data-url.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function decodeDataUrl(url: string) {
|
||||
const idx = url.indexOf(",")
|
||||
if (idx === -1) return ""
|
||||
|
||||
const head = url.slice(0, idx)
|
||||
const body = url.slice(idx + 1)
|
||||
if (head.includes(";base64")) return Buffer.from(body, "base64").toString("utf8")
|
||||
return decodeURIComponent(body)
|
||||
}
|
||||
12
packages/tfcode/src/util/defer.ts
Normal file
12
packages/tfcode/src/util/defer.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function defer<T extends () => void | Promise<void>>(
|
||||
fn: T,
|
||||
): T extends () => Promise<void> ? { [Symbol.asyncDispose]: () => Promise<void> } : { [Symbol.dispose]: () => void } {
|
||||
return {
|
||||
[Symbol.dispose]() {
|
||||
fn()
|
||||
},
|
||||
[Symbol.asyncDispose]() {
|
||||
return Promise.resolve(fn())
|
||||
},
|
||||
} as any
|
||||
}
|
||||
11
packages/tfcode/src/util/effect-http-client.ts
Normal file
11
packages/tfcode/src/util/effect-http-client.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Schedule } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
|
||||
export const withTransientReadRetry = <E, R>(client: HttpClient.HttpClient.With<E, R>) =>
|
||||
client.pipe(
|
||||
HttpClient.retryTransient({
|
||||
retryOn: "errors-and-responses",
|
||||
times: 2,
|
||||
schedule: Schedule.exponential(200).pipe(Schedule.jittered),
|
||||
}),
|
||||
)
|
||||
98
packages/tfcode/src/util/effect-zod.ts
Normal file
98
packages/tfcode/src/util/effect-zod.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Schema, SchemaAST } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
|
||||
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
|
||||
}
|
||||
|
||||
function walk(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const out = body(ast)
|
||||
const desc = SchemaAST.resolveDescription(ast)
|
||||
const ref = SchemaAST.resolveIdentifier(ast)
|
||||
const next = desc ? out.describe(desc) : out
|
||||
return ref ? next.meta({ ref }) : next
|
||||
}
|
||||
|
||||
function body(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
if (SchemaAST.isOptional(ast)) return opt(ast)
|
||||
|
||||
switch (ast._tag) {
|
||||
case "String":
|
||||
return z.string()
|
||||
case "Number":
|
||||
return z.number()
|
||||
case "Boolean":
|
||||
return z.boolean()
|
||||
case "Null":
|
||||
return z.null()
|
||||
case "Undefined":
|
||||
return z.undefined()
|
||||
case "Any":
|
||||
case "Unknown":
|
||||
return z.unknown()
|
||||
case "Never":
|
||||
return z.never()
|
||||
case "Literal":
|
||||
return z.literal(ast.literal)
|
||||
case "Union":
|
||||
return union(ast)
|
||||
case "Objects":
|
||||
return object(ast)
|
||||
case "Arrays":
|
||||
return array(ast)
|
||||
case "Declaration":
|
||||
return decl(ast)
|
||||
default:
|
||||
return fail(ast)
|
||||
}
|
||||
}
|
||||
|
||||
function opt(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
if (ast._tag !== "Union") return fail(ast)
|
||||
const items = ast.types.filter((item) => item._tag !== "Undefined")
|
||||
if (items.length === 1) return walk(items[0]).optional()
|
||||
if (items.length > 1)
|
||||
return z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>]).optional()
|
||||
return z.undefined().optional()
|
||||
}
|
||||
|
||||
function union(ast: SchemaAST.Union): z.ZodTypeAny {
|
||||
const items = ast.types.map(walk)
|
||||
if (items.length === 1) return items[0]
|
||||
if (items.length < 2) return fail(ast)
|
||||
|
||||
const discriminator = (ast as any).annotations?.discriminator
|
||||
if (discriminator) {
|
||||
return z.discriminatedUnion(discriminator, items as [z.ZodObject<any>, z.ZodObject<any>, ...z.ZodObject<any>[]])
|
||||
}
|
||||
|
||||
return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
|
||||
}
|
||||
|
||||
function object(ast: SchemaAST.Objects): z.ZodTypeAny {
|
||||
if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
|
||||
const sig = ast.indexSignatures[0]
|
||||
if (sig.parameter._tag !== "String") return fail(ast)
|
||||
return z.record(z.string(), walk(sig.type))
|
||||
}
|
||||
|
||||
if (ast.indexSignatures.length > 0) return fail(ast)
|
||||
|
||||
return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
|
||||
}
|
||||
|
||||
function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
|
||||
if (ast.elements.length > 0) return fail(ast)
|
||||
if (ast.rest.length !== 1) return fail(ast)
|
||||
return z.array(walk(ast.rest[0]))
|
||||
}
|
||||
|
||||
function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {
|
||||
if (ast.typeParameters.length !== 1) return fail(ast)
|
||||
return walk(ast.typeParameters[0])
|
||||
}
|
||||
|
||||
function fail(ast: SchemaAST.AST): never {
|
||||
const ref = SchemaAST.resolveIdentifier(ast)
|
||||
throw new Error(`unsupported effect schema: ${ref ?? ast._tag}`)
|
||||
}
|
||||
203
packages/tfcode/src/util/filesystem.ts
Normal file
203
packages/tfcode/src/util/filesystem.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { chmod, mkdir, readFile, writeFile } from "fs/promises"
|
||||
import { createWriteStream, existsSync, statSync } from "fs"
|
||||
import { lookup } from "mime-types"
|
||||
import { realpathSync } from "fs"
|
||||
import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||
import { Readable } from "stream"
|
||||
import { pipeline } from "stream/promises"
|
||||
import { Glob } from "./glob"
|
||||
|
||||
export namespace Filesystem {
|
||||
// Fast sync version for metadata checks
|
||||
export async function exists(p: string): Promise<boolean> {
|
||||
return existsSync(p)
|
||||
}
|
||||
|
||||
export async function isDir(p: string): Promise<boolean> {
|
||||
try {
|
||||
return statSync(p).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function stat(p: string): ReturnType<typeof statSync> | undefined {
|
||||
return statSync(p, { throwIfNoEntry: false }) ?? undefined
|
||||
}
|
||||
|
||||
export async function size(p: string): Promise<number> {
|
||||
const s = stat(p)?.size ?? 0
|
||||
return typeof s === "bigint" ? Number(s) : s
|
||||
}
|
||||
|
||||
export async function readText(p: string): Promise<string> {
|
||||
return readFile(p, "utf-8")
|
||||
}
|
||||
|
||||
export async function readJson<T = any>(p: string): Promise<T> {
|
||||
return JSON.parse(await readFile(p, "utf-8"))
|
||||
}
|
||||
|
||||
export async function readBytes(p: string): Promise<Buffer> {
|
||||
return readFile(p)
|
||||
}
|
||||
|
||||
export async function readArrayBuffer(p: string): Promise<ArrayBuffer> {
|
||||
const buf = await readFile(p)
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
function isEnoent(e: unknown): e is { code: "ENOENT" } {
|
||||
return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT"
|
||||
}
|
||||
|
||||
export async function write(p: string, content: string | Buffer | Uint8Array, mode?: number): Promise<void> {
|
||||
try {
|
||||
if (mode) {
|
||||
await writeFile(p, content, { mode })
|
||||
} else {
|
||||
await writeFile(p, content)
|
||||
}
|
||||
} catch (e) {
|
||||
if (isEnoent(e)) {
|
||||
await mkdir(dirname(p), { recursive: true })
|
||||
if (mode) {
|
||||
await writeFile(p, content, { mode })
|
||||
} else {
|
||||
await writeFile(p, content)
|
||||
}
|
||||
return
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJson(p: string, data: unknown, mode?: number): Promise<void> {
|
||||
return write(p, JSON.stringify(data, null, 2), mode)
|
||||
}
|
||||
|
||||
export async function writeStream(
|
||||
p: string,
|
||||
stream: ReadableStream<Uint8Array> | Readable,
|
||||
mode?: number,
|
||||
): Promise<void> {
|
||||
const dir = dirname(p)
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true })
|
||||
}
|
||||
|
||||
const nodeStream = stream instanceof ReadableStream ? Readable.fromWeb(stream as any) : stream
|
||||
const writeStream = createWriteStream(p)
|
||||
await pipeline(nodeStream, writeStream)
|
||||
|
||||
if (mode) {
|
||||
await chmod(p, mode)
|
||||
}
|
||||
}
|
||||
|
||||
export function mimeType(p: string): string {
|
||||
return lookup(p) || "application/octet-stream"
|
||||
}
|
||||
|
||||
/**
|
||||
* On Windows, normalize a path to its canonical casing using the filesystem.
|
||||
* This is needed because Windows paths are case-insensitive but LSP servers
|
||||
* may return paths with different casing than what we send them.
|
||||
*/
|
||||
export function normalizePath(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
try {
|
||||
return realpathSync.native(p)
|
||||
} catch {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
// We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary.
|
||||
// Also resolves symlinks so that callers using the result as a cache key
|
||||
// always get the same canonical path for a given physical directory.
|
||||
export function resolve(p: string): string {
|
||||
const resolved = pathResolve(windowsPath(p))
|
||||
try {
|
||||
return normalizePath(realpathSync(resolved))
|
||||
} catch (e) {
|
||||
if (isEnoent(e)) return normalizePath(resolved)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export function windowsPath(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
return (
|
||||
p
|
||||
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
// Git Bash for Windows paths are typically /<drive>/...
|
||||
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
// Cygwin git paths are typically /cygdrive/<drive>/...
|
||||
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
// WSL paths are typically /mnt/<drive>/...
|
||||
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
)
|
||||
}
|
||||
export function overlaps(a: string, b: string) {
|
||||
const relA = relative(a, b)
|
||||
const relB = relative(b, a)
|
||||
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
|
||||
}
|
||||
|
||||
export function contains(parent: string, child: string) {
|
||||
return !relative(parent, child).startsWith("..")
|
||||
}
|
||||
|
||||
export async function findUp(target: string, start: string, stop?: string) {
|
||||
let current = start
|
||||
const result = []
|
||||
while (true) {
|
||||
const search = join(current, target)
|
||||
if (await exists(search)) result.push(search)
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function* up(options: { targets: string[]; start: string; stop?: string }) {
|
||||
const { targets, start, stop } = options
|
||||
let current = start
|
||||
while (true) {
|
||||
for (const target of targets) {
|
||||
const search = join(current, target)
|
||||
if (await exists(search)) yield search
|
||||
}
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
export async function globUp(pattern: string, start: string, stop?: string) {
|
||||
let current = start
|
||||
const result = []
|
||||
while (true) {
|
||||
try {
|
||||
const matches = await Glob.scan(pattern, {
|
||||
cwd: current,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
})
|
||||
result.push(...matches)
|
||||
} catch {
|
||||
// Skip invalid glob patterns
|
||||
}
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
21
packages/tfcode/src/util/fn.ts
Normal file
21
packages/tfcode/src/util/fn.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
|
||||
const result = (input: z.infer<T>) => {
|
||||
let parsed
|
||||
try {
|
||||
parsed = schema.parse(input)
|
||||
} catch (e) {
|
||||
console.trace("schema validation failure stack trace:")
|
||||
if (e instanceof z.ZodError) {
|
||||
console.error("schema validation issues:", JSON.stringify(e.issues, null, 2))
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
return cb(parsed)
|
||||
}
|
||||
result.force = (input: z.infer<T>) => cb(input)
|
||||
result.schema = schema
|
||||
return result
|
||||
}
|
||||
20
packages/tfcode/src/util/format.ts
Normal file
20
packages/tfcode/src/util/format.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export function formatDuration(secs: number) {
|
||||
if (secs <= 0) return ""
|
||||
if (secs < 60) return `${secs}s`
|
||||
if (secs < 3600) {
|
||||
const mins = Math.floor(secs / 60)
|
||||
const remaining = secs % 60
|
||||
return remaining > 0 ? `${mins}m ${remaining}s` : `${mins}m`
|
||||
}
|
||||
if (secs < 86400) {
|
||||
const hours = Math.floor(secs / 3600)
|
||||
const remaining = Math.floor((secs % 3600) / 60)
|
||||
return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h`
|
||||
}
|
||||
if (secs < 604800) {
|
||||
const days = Math.floor(secs / 86400)
|
||||
return days === 1 ? "~1 day" : `~${days} days`
|
||||
}
|
||||
const weeks = Math.floor(secs / 604800)
|
||||
return weeks === 1 ? "~1 week" : `~${weeks} weeks`
|
||||
}
|
||||
35
packages/tfcode/src/util/git.ts
Normal file
35
packages/tfcode/src/util/git.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Process } from "./process"
|
||||
|
||||
export interface GitResult {
|
||||
exitCode: number
|
||||
text(): string
|
||||
stdout: Buffer
|
||||
stderr: Buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a git command.
|
||||
*
|
||||
* Uses Process helpers with stdin ignored to avoid protocol pipe inheritance
|
||||
* issues in embedded/client environments.
|
||||
*/
|
||||
export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
|
||||
return Process.run(["git", ...args], {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
stdin: "ignore",
|
||||
nothrow: true,
|
||||
})
|
||||
.then((result) => ({
|
||||
exitCode: result.code,
|
||||
text: () => result.stdout.toString(),
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
}))
|
||||
.catch((error) => ({
|
||||
exitCode: 1,
|
||||
text: () => "",
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.from(error instanceof Error ? error.message : String(error)),
|
||||
}))
|
||||
}
|
||||
34
packages/tfcode/src/util/glob.ts
Normal file
34
packages/tfcode/src/util/glob.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { glob, globSync, type GlobOptions } from "glob"
|
||||
import { minimatch } from "minimatch"
|
||||
|
||||
export namespace Glob {
|
||||
export interface Options {
|
||||
cwd?: string
|
||||
absolute?: boolean
|
||||
include?: "file" | "all"
|
||||
dot?: boolean
|
||||
symlink?: boolean
|
||||
}
|
||||
|
||||
function toGlobOptions(options: Options): GlobOptions {
|
||||
return {
|
||||
cwd: options.cwd,
|
||||
absolute: options.absolute,
|
||||
dot: options.dot,
|
||||
follow: options.symlink ?? false,
|
||||
nodir: options.include !== "all",
|
||||
}
|
||||
}
|
||||
|
||||
export async function scan(pattern: string, options: Options = {}): Promise<string[]> {
|
||||
return glob(pattern, toGlobOptions(options)) as Promise<string[]>
|
||||
}
|
||||
|
||||
export function scanSync(pattern: string, options: Options = {}): string[] {
|
||||
return globSync(pattern, toGlobOptions(options)) as string[]
|
||||
}
|
||||
|
||||
export function match(pattern: string, filepath: string): boolean {
|
||||
return minimatch(filepath, pattern, { dot: true })
|
||||
}
|
||||
}
|
||||
7
packages/tfcode/src/util/hash.ts
Normal file
7
packages/tfcode/src/util/hash.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createHash } from "crypto"
|
||||
|
||||
export namespace Hash {
|
||||
export function fast(input: string | Buffer): string {
|
||||
return createHash("sha1").update(input).digest("hex")
|
||||
}
|
||||
}
|
||||
3
packages/tfcode/src/util/iife.ts
Normal file
3
packages/tfcode/src/util/iife.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function iife<T>(fn: () => T) {
|
||||
return fn()
|
||||
}
|
||||
103
packages/tfcode/src/util/keybind.ts
Normal file
103
packages/tfcode/src/util/keybind.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { isDeepEqual } from "remeda"
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
|
||||
export namespace Keybind {
|
||||
/**
|
||||
* Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field.
|
||||
* This ensures type compatibility and catches missing fields at compile time.
|
||||
*/
|
||||
export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super"> & {
|
||||
leader: boolean // our custom field
|
||||
}
|
||||
|
||||
export function match(a: Info | undefined, b: Info): boolean {
|
||||
if (!a) return false
|
||||
const normalizedA = { ...a, super: a.super ?? false }
|
||||
const normalizedB = { ...b, super: b.super ?? false }
|
||||
return isDeepEqual(normalizedA, normalizedB)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OpenTUI's ParsedKey to our Keybind.Info format.
|
||||
* This helper ensures all required fields are present and avoids manual object creation.
|
||||
*/
|
||||
export function fromParsedKey(key: ParsedKey, leader = false): Info {
|
||||
return {
|
||||
name: key.name === " " ? "space" : key.name,
|
||||
ctrl: key.ctrl,
|
||||
meta: key.meta,
|
||||
shift: key.shift,
|
||||
super: key.super ?? false,
|
||||
leader,
|
||||
}
|
||||
}
|
||||
|
||||
export function toString(info: Info | undefined): string {
|
||||
if (!info) return ""
|
||||
const parts: string[] = []
|
||||
|
||||
if (info.ctrl) parts.push("ctrl")
|
||||
if (info.meta) parts.push("alt")
|
||||
if (info.super) parts.push("super")
|
||||
if (info.shift) parts.push("shift")
|
||||
if (info.name) {
|
||||
if (info.name === "delete") parts.push("del")
|
||||
else parts.push(info.name)
|
||||
}
|
||||
|
||||
let result = parts.join("+")
|
||||
|
||||
if (info.leader) {
|
||||
result = result ? `<leader> ${result}` : `<leader>`
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function parse(key: string): Info[] {
|
||||
if (key === "none") return []
|
||||
|
||||
return key.split(",").map((combo) => {
|
||||
// Handle <leader> syntax by replacing with leader+
|
||||
const normalized = combo.replace(/<leader>/g, "leader+")
|
||||
const parts = normalized.toLowerCase().split("+")
|
||||
const info: Info = {
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
leader: false,
|
||||
name: "",
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
switch (part) {
|
||||
case "ctrl":
|
||||
info.ctrl = true
|
||||
break
|
||||
case "alt":
|
||||
case "meta":
|
||||
case "option":
|
||||
info.meta = true
|
||||
break
|
||||
case "super":
|
||||
info.super = true
|
||||
break
|
||||
case "shift":
|
||||
info.shift = true
|
||||
break
|
||||
case "leader":
|
||||
info.leader = true
|
||||
break
|
||||
case "esc":
|
||||
info.name = "escape"
|
||||
break
|
||||
default:
|
||||
info.name = part
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
})
|
||||
}
|
||||
}
|
||||
23
packages/tfcode/src/util/lazy.ts
Normal file
23
packages/tfcode/src/util/lazy.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export function lazy<T>(fn: () => T) {
|
||||
let value: T | undefined
|
||||
let loaded = false
|
||||
|
||||
const result = (): T => {
|
||||
if (loaded) return value as T
|
||||
try {
|
||||
value = fn()
|
||||
loaded = true
|
||||
return value as T
|
||||
} catch (e) {
|
||||
// Don't mark as loaded if initialization failed
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
result.reset = () => {
|
||||
loaded = false
|
||||
value = undefined
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
81
packages/tfcode/src/util/locale.ts
Normal file
81
packages/tfcode/src/util/locale.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export namespace Locale {
|
||||
export function titlecase(str: string) {
|
||||
return str.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function time(input: number): string {
|
||||
const date = new Date(input)
|
||||
return date.toLocaleTimeString(undefined, { timeStyle: "short" })
|
||||
}
|
||||
|
||||
export function datetime(input: number): string {
|
||||
const date = new Date(input)
|
||||
const localTime = time(input)
|
||||
const localDate = date.toLocaleDateString()
|
||||
return `${localTime} · ${localDate}`
|
||||
}
|
||||
|
||||
export function todayTimeOrDateTime(input: number): string {
|
||||
const date = new Date(input)
|
||||
const now = new Date()
|
||||
const isToday =
|
||||
date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
|
||||
|
||||
if (isToday) {
|
||||
return time(input)
|
||||
} else {
|
||||
return datetime(input)
|
||||
}
|
||||
}
|
||||
|
||||
export function number(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + "M"
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + "K"
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
export function duration(input: number) {
|
||||
if (input < 1000) {
|
||||
return `${input}ms`
|
||||
}
|
||||
if (input < 60000) {
|
||||
return `${(input / 1000).toFixed(1)}s`
|
||||
}
|
||||
if (input < 3600000) {
|
||||
const minutes = Math.floor(input / 60000)
|
||||
const seconds = Math.floor((input % 60000) / 1000)
|
||||
return `${minutes}m ${seconds}s`
|
||||
}
|
||||
if (input < 86400000) {
|
||||
const hours = Math.floor(input / 3600000)
|
||||
const minutes = Math.floor((input % 3600000) / 60000)
|
||||
return `${hours}h ${minutes}m`
|
||||
}
|
||||
const hours = Math.floor(input / 3600000)
|
||||
const days = Math.floor((input % 3600000) / 86400000)
|
||||
return `${days}d ${hours}h`
|
||||
}
|
||||
|
||||
export function truncate(str: string, len: number): string {
|
||||
if (str.length <= len) return str
|
||||
return str.slice(0, len - 1) + "…"
|
||||
}
|
||||
|
||||
export function truncateMiddle(str: string, maxLength: number = 35): string {
|
||||
if (str.length <= maxLength) return str
|
||||
|
||||
const ellipsis = "…"
|
||||
const keepStart = Math.ceil((maxLength - ellipsis.length) / 2)
|
||||
const keepEnd = Math.floor((maxLength - ellipsis.length) / 2)
|
||||
|
||||
return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd)
|
||||
}
|
||||
|
||||
export function pluralize(count: number, singular: string, plural: string): string {
|
||||
const template = count === 1 ? singular : plural
|
||||
return template.replace("{}", count.toString())
|
||||
}
|
||||
}
|
||||
98
packages/tfcode/src/util/lock.ts
Normal file
98
packages/tfcode/src/util/lock.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export namespace Lock {
|
||||
const locks = new Map<
|
||||
string,
|
||||
{
|
||||
readers: number
|
||||
writer: boolean
|
||||
waitingReaders: (() => void)[]
|
||||
waitingWriters: (() => void)[]
|
||||
}
|
||||
>()
|
||||
|
||||
function get(key: string) {
|
||||
if (!locks.has(key)) {
|
||||
locks.set(key, {
|
||||
readers: 0,
|
||||
writer: false,
|
||||
waitingReaders: [],
|
||||
waitingWriters: [],
|
||||
})
|
||||
}
|
||||
return locks.get(key)!
|
||||
}
|
||||
|
||||
function process(key: string) {
|
||||
const lock = locks.get(key)
|
||||
if (!lock || lock.writer || lock.readers > 0) return
|
||||
|
||||
// Prioritize writers to prevent starvation
|
||||
if (lock.waitingWriters.length > 0) {
|
||||
const nextWriter = lock.waitingWriters.shift()!
|
||||
nextWriter()
|
||||
return
|
||||
}
|
||||
|
||||
// Wake up all waiting readers
|
||||
while (lock.waitingReaders.length > 0) {
|
||||
const nextReader = lock.waitingReaders.shift()!
|
||||
nextReader()
|
||||
}
|
||||
|
||||
// Clean up empty locks
|
||||
if (lock.readers === 0 && !lock.writer && lock.waitingReaders.length === 0 && lock.waitingWriters.length === 0) {
|
||||
locks.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export async function read(key: string): Promise<Disposable> {
|
||||
const lock = get(key)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (!lock.writer && lock.waitingWriters.length === 0) {
|
||||
lock.readers++
|
||||
resolve({
|
||||
[Symbol.dispose]: () => {
|
||||
lock.readers--
|
||||
process(key)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
lock.waitingReaders.push(() => {
|
||||
lock.readers++
|
||||
resolve({
|
||||
[Symbol.dispose]: () => {
|
||||
lock.readers--
|
||||
process(key)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function write(key: string): Promise<Disposable> {
|
||||
const lock = get(key)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (!lock.writer && lock.readers === 0) {
|
||||
lock.writer = true
|
||||
resolve({
|
||||
[Symbol.dispose]: () => {
|
||||
lock.writer = false
|
||||
process(key)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
lock.waitingWriters.push(() => {
|
||||
lock.writer = true
|
||||
resolve({
|
||||
[Symbol.dispose]: () => {
|
||||
lock.writer = false
|
||||
process(key)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
182
packages/tfcode/src/util/log.ts
Normal file
182
packages/tfcode/src/util/log.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { createWriteStream } from "fs"
|
||||
import { Global } from "../global"
|
||||
import z from "zod"
|
||||
import { Glob } from "./glob"
|
||||
|
||||
export namespace Log {
|
||||
export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" })
|
||||
export type Level = z.infer<typeof Level>
|
||||
|
||||
const levelPriority: Record<Level, number> = {
|
||||
DEBUG: 0,
|
||||
INFO: 1,
|
||||
WARN: 2,
|
||||
ERROR: 3,
|
||||
}
|
||||
|
||||
let level: Level = "INFO"
|
||||
|
||||
function shouldLog(input: Level): boolean {
|
||||
return levelPriority[input] >= levelPriority[level]
|
||||
}
|
||||
|
||||
export type Logger = {
|
||||
debug(message?: any, extra?: Record<string, any>): void
|
||||
info(message?: any, extra?: Record<string, any>): void
|
||||
error(message?: any, extra?: Record<string, any>): void
|
||||
warn(message?: any, extra?: Record<string, any>): void
|
||||
tag(key: string, value: string): Logger
|
||||
clone(): Logger
|
||||
time(
|
||||
message: string,
|
||||
extra?: Record<string, any>,
|
||||
): {
|
||||
stop(): void
|
||||
[Symbol.dispose](): void
|
||||
}
|
||||
}
|
||||
|
||||
const loggers = new Map<string, Logger>()
|
||||
|
||||
export const Default = create({ service: "default" })
|
||||
|
||||
export interface Options {
|
||||
print: boolean
|
||||
dev?: boolean
|
||||
level?: Level
|
||||
}
|
||||
|
||||
let logpath = ""
|
||||
export function file() {
|
||||
return logpath
|
||||
}
|
||||
let write = (msg: any) => {
|
||||
process.stderr.write(msg)
|
||||
return msg.length
|
||||
}
|
||||
|
||||
export async function init(options: Options) {
|
||||
if (options.level) level = options.level
|
||||
cleanup(Global.Path.log)
|
||||
if (options.print) return
|
||||
logpath = path.join(
|
||||
Global.Path.log,
|
||||
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
|
||||
)
|
||||
await fs.truncate(logpath).catch(() => {})
|
||||
const stream = createWriteStream(logpath, { flags: "a" })
|
||||
write = async (msg: any) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.write(msg, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve(msg.length)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(dir: string) {
|
||||
const files = await Glob.scan("????-??-??T??????.log", {
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
})
|
||||
if (files.length <= 5) return
|
||||
|
||||
const filesToDelete = files.slice(0, -10)
|
||||
await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {})))
|
||||
}
|
||||
|
||||
function formatError(error: Error, depth = 0): string {
|
||||
const result = error.message
|
||||
return error.cause instanceof Error && depth < 10
|
||||
? result + " Caused by: " + formatError(error.cause, depth + 1)
|
||||
: result
|
||||
}
|
||||
|
||||
let last = Date.now()
|
||||
export function create(tags?: Record<string, any>) {
|
||||
tags = tags || {}
|
||||
|
||||
const service = tags["service"]
|
||||
if (service && typeof service === "string") {
|
||||
const cached = loggers.get(service)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
function build(message: any, extra?: Record<string, any>) {
|
||||
const prefix = Object.entries({
|
||||
...tags,
|
||||
...extra,
|
||||
})
|
||||
.filter(([_, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => {
|
||||
const prefix = `${key}=`
|
||||
if (value instanceof Error) return prefix + formatError(value)
|
||||
if (typeof value === "object") return prefix + JSON.stringify(value)
|
||||
return prefix + value
|
||||
})
|
||||
.join(" ")
|
||||
const next = new Date()
|
||||
const diff = next.getTime() - last
|
||||
last = next.getTime()
|
||||
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
|
||||
}
|
||||
const result: Logger = {
|
||||
debug(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("DEBUG")) {
|
||||
write("DEBUG " + build(message, extra))
|
||||
}
|
||||
},
|
||||
info(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("INFO")) {
|
||||
write("INFO " + build(message, extra))
|
||||
}
|
||||
},
|
||||
error(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("ERROR")) {
|
||||
write("ERROR " + build(message, extra))
|
||||
}
|
||||
},
|
||||
warn(message?: any, extra?: Record<string, any>) {
|
||||
if (shouldLog("WARN")) {
|
||||
write("WARN " + build(message, extra))
|
||||
}
|
||||
},
|
||||
tag(key: string, value: string) {
|
||||
if (tags) tags[key] = value
|
||||
return result
|
||||
},
|
||||
clone() {
|
||||
return Log.create({ ...tags })
|
||||
},
|
||||
time(message: string, extra?: Record<string, any>) {
|
||||
const now = Date.now()
|
||||
result.info(message, { status: "started", ...extra })
|
||||
function stop() {
|
||||
result.info(message, {
|
||||
status: "completed",
|
||||
duration: Date.now() - now,
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
return {
|
||||
stop,
|
||||
[Symbol.dispose]() {
|
||||
stop()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if (service && typeof service === "string") {
|
||||
loggers.set(service, result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
171
packages/tfcode/src/util/process.ts
Normal file
171
packages/tfcode/src/util/process.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { type ChildProcess } from "child_process"
|
||||
import launch from "cross-spawn"
|
||||
import { buffer } from "node:stream/consumers"
|
||||
|
||||
export namespace Process {
|
||||
export type Stdio = "inherit" | "pipe" | "ignore"
|
||||
export type Shell = boolean | string
|
||||
|
||||
export interface Options {
|
||||
cwd?: string
|
||||
env?: NodeJS.ProcessEnv | null
|
||||
stdin?: Stdio
|
||||
stdout?: Stdio
|
||||
stderr?: Stdio
|
||||
shell?: Shell
|
||||
abort?: AbortSignal
|
||||
kill?: NodeJS.Signals | number
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export interface RunOptions extends Omit<Options, "stdout" | "stderr"> {
|
||||
nothrow?: boolean
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
code: number
|
||||
stdout: Buffer
|
||||
stderr: Buffer
|
||||
}
|
||||
|
||||
export interface TextResult extends Result {
|
||||
text: string
|
||||
}
|
||||
|
||||
export class RunFailedError extends Error {
|
||||
readonly cmd: string[]
|
||||
readonly code: number
|
||||
readonly stdout: Buffer
|
||||
readonly stderr: Buffer
|
||||
|
||||
constructor(cmd: string[], code: number, stdout: Buffer, stderr: Buffer) {
|
||||
const text = stderr.toString().trim()
|
||||
super(
|
||||
text
|
||||
? `Command failed with code ${code}: ${cmd.join(" ")}\n${text}`
|
||||
: `Command failed with code ${code}: ${cmd.join(" ")}`,
|
||||
)
|
||||
this.name = "ProcessRunFailedError"
|
||||
this.cmd = [...cmd]
|
||||
this.code = code
|
||||
this.stdout = stdout
|
||||
this.stderr = stderr
|
||||
}
|
||||
}
|
||||
|
||||
export type Child = ChildProcess & { exited: Promise<number> }
|
||||
|
||||
export function spawn(cmd: string[], opts: Options = {}): Child {
|
||||
if (cmd.length === 0) throw new Error("Command is required")
|
||||
opts.abort?.throwIfAborted()
|
||||
|
||||
const proc = launch(cmd[0], cmd.slice(1), {
|
||||
cwd: opts.cwd,
|
||||
shell: opts.shell,
|
||||
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
|
||||
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
|
||||
windowsHide: process.platform === "win32",
|
||||
})
|
||||
|
||||
let closed = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const abort = () => {
|
||||
if (closed) return
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) return
|
||||
closed = true
|
||||
|
||||
proc.kill(opts.kill ?? "SIGTERM")
|
||||
|
||||
const ms = opts.timeout ?? 5_000
|
||||
if (ms <= 0) return
|
||||
timer = setTimeout(() => proc.kill("SIGKILL"), ms)
|
||||
}
|
||||
|
||||
const exited = new Promise<number>((resolve, reject) => {
|
||||
const done = () => {
|
||||
opts.abort?.removeEventListener("abort", abort)
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
|
||||
proc.once("exit", (code, signal) => {
|
||||
done()
|
||||
resolve(code ?? (signal ? 1 : 0))
|
||||
})
|
||||
|
||||
proc.once("error", (error) => {
|
||||
done()
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
void exited.catch(() => undefined)
|
||||
|
||||
if (opts.abort) {
|
||||
opts.abort.addEventListener("abort", abort, { once: true })
|
||||
if (opts.abort.aborted) abort()
|
||||
}
|
||||
|
||||
const child = proc as Child
|
||||
child.exited = exited
|
||||
return child
|
||||
}
|
||||
|
||||
export async function run(cmd: string[], opts: RunOptions = {}): Promise<Result> {
|
||||
const proc = spawn(cmd, {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
stdin: opts.stdin,
|
||||
shell: opts.shell,
|
||||
abort: opts.abort,
|
||||
kill: opts.kill,
|
||||
timeout: opts.timeout,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
if (!proc.stdout || !proc.stderr) throw new Error("Process output not available")
|
||||
|
||||
const out = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
|
||||
.then(([code, stdout, stderr]) => ({
|
||||
code,
|
||||
stdout,
|
||||
stderr,
|
||||
}))
|
||||
.catch((err: unknown) => {
|
||||
if (!opts.nothrow) throw err
|
||||
return {
|
||||
code: 1,
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
|
||||
}
|
||||
})
|
||||
if (out.code === 0 || opts.nothrow) return out
|
||||
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
|
||||
}
|
||||
|
||||
export async function stop(proc: ChildProcess) {
|
||||
if (process.platform !== "win32" || !proc.pid) {
|
||||
proc.kill()
|
||||
return
|
||||
}
|
||||
|
||||
const out = await run(["taskkill", "/pid", String(proc.pid), "/T", "/F"], {
|
||||
nothrow: true,
|
||||
})
|
||||
|
||||
if (out.code === 0) return
|
||||
proc.kill()
|
||||
}
|
||||
|
||||
export async function text(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
|
||||
const out = await run(cmd, opts)
|
||||
return {
|
||||
...out,
|
||||
text: out.stdout.toString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function lines(cmd: string[], opts: RunOptions = {}): Promise<string[]> {
|
||||
return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean)
|
||||
}
|
||||
}
|
||||
3
packages/tfcode/src/util/proxied.ts
Normal file
3
packages/tfcode/src/util/proxied.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function proxied() {
|
||||
return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
|
||||
}
|
||||
32
packages/tfcode/src/util/queue.ts
Normal file
32
packages/tfcode/src/util/queue.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export class AsyncQueue<T> implements AsyncIterable<T> {
|
||||
private queue: T[] = []
|
||||
private resolvers: ((value: T) => void)[] = []
|
||||
|
||||
push(item: T) {
|
||||
const resolve = this.resolvers.shift()
|
||||
if (resolve) resolve(item)
|
||||
else this.queue.push(item)
|
||||
}
|
||||
|
||||
async next(): Promise<T> {
|
||||
if (this.queue.length > 0) return this.queue.shift()!
|
||||
return new Promise((resolve) => this.resolvers.push(resolve))
|
||||
}
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
while (true) yield await this.next()
|
||||
}
|
||||
}
|
||||
|
||||
export async function work<T>(concurrency: number, items: T[], fn: (item: T) => Promise<void>) {
|
||||
const pending = [...items]
|
||||
await Promise.all(
|
||||
Array.from({ length: concurrency }, async () => {
|
||||
while (true) {
|
||||
const item = pending.pop()
|
||||
if (item === undefined) return
|
||||
await fn(item)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
66
packages/tfcode/src/util/rpc.ts
Normal file
66
packages/tfcode/src/util/rpc.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export namespace Rpc {
|
||||
type Definition = {
|
||||
[method: string]: (input: any) => any
|
||||
}
|
||||
|
||||
export function listen(rpc: Definition) {
|
||||
onmessage = async (evt) => {
|
||||
const parsed = JSON.parse(evt.data)
|
||||
if (parsed.type === "rpc.request") {
|
||||
const result = await rpc[parsed.method](parsed.input)
|
||||
postMessage(JSON.stringify({ type: "rpc.result", result, id: parsed.id }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function emit(event: string, data: unknown) {
|
||||
postMessage(JSON.stringify({ type: "rpc.event", event, data }))
|
||||
}
|
||||
|
||||
export function client<T extends Definition>(target: {
|
||||
postMessage: (data: string) => void | null
|
||||
onmessage: ((this: Worker, ev: MessageEvent<any>) => any) | null
|
||||
}) {
|
||||
const pending = new Map<number, (result: any) => void>()
|
||||
const listeners = new Map<string, Set<(data: any) => void>>()
|
||||
let id = 0
|
||||
target.onmessage = async (evt) => {
|
||||
const parsed = JSON.parse(evt.data)
|
||||
if (parsed.type === "rpc.result") {
|
||||
const resolve = pending.get(parsed.id)
|
||||
if (resolve) {
|
||||
resolve(parsed.result)
|
||||
pending.delete(parsed.id)
|
||||
}
|
||||
}
|
||||
if (parsed.type === "rpc.event") {
|
||||
const handlers = listeners.get(parsed.event)
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
handler(parsed.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
call<Method extends keyof T>(method: Method, input: Parameters<T[Method]>[0]): Promise<ReturnType<T[Method]>> {
|
||||
const requestId = id++
|
||||
return new Promise((resolve) => {
|
||||
pending.set(requestId, resolve)
|
||||
target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId }))
|
||||
})
|
||||
},
|
||||
on<Data>(event: string, handler: (data: Data) => void) {
|
||||
let handlers = listeners.get(event)
|
||||
if (!handlers) {
|
||||
handlers = new Set()
|
||||
listeners.set(event, handlers)
|
||||
}
|
||||
handlers.add(handler)
|
||||
return () => {
|
||||
handlers!.delete(handler)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
53
packages/tfcode/src/util/schema.ts
Normal file
53
packages/tfcode/src/util/schema.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
/**
|
||||
* Attach static methods to a schema object. Designed to be used with `.pipe()`:
|
||||
*
|
||||
* @example
|
||||
* export const Foo = fooSchema.pipe(
|
||||
* withStatics((schema) => ({
|
||||
* zero: schema.makeUnsafe(0),
|
||||
* from: Schema.decodeUnknownOption(schema),
|
||||
* }))
|
||||
* )
|
||||
*/
|
||||
export const withStatics =
|
||||
<S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) =>
|
||||
(schema: S): S & M =>
|
||||
Object.assign(schema, methods(schema))
|
||||
|
||||
declare const NewtypeBrand: unique symbol
|
||||
type NewtypeBrand<Tag extends string> = { readonly [NewtypeBrand]: Tag }
|
||||
|
||||
/**
|
||||
* Nominal wrapper for scalar types. The class itself is a valid schema —
|
||||
* pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc.
|
||||
*
|
||||
* @example
|
||||
* class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
|
||||
* static make(id: string): QuestionID {
|
||||
* return this.makeUnsafe(id)
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Schema.decodeEffect(QuestionID)(input)
|
||||
*/
|
||||
export function Newtype<Self>() {
|
||||
return <const Tag extends string, S extends Schema.Top>(tag: Tag, schema: S) => {
|
||||
type Branded = NewtypeBrand<Tag>
|
||||
|
||||
abstract class Base {
|
||||
declare readonly [NewtypeBrand]: Tag
|
||||
|
||||
static makeUnsafe(value: Schema.Schema.Type<S>): Self {
|
||||
return value as unknown as Self
|
||||
}
|
||||
}
|
||||
|
||||
Object.setPrototypeOf(Base, schema)
|
||||
|
||||
return Base as unknown as (abstract new (_: never) => Branded) & {
|
||||
readonly makeUnsafe: (value: Schema.Schema.Type<S>) => Self
|
||||
} & Omit<Schema.Opaque<Self, S, {}>, "makeUnsafe">
|
||||
}
|
||||
}
|
||||
10
packages/tfcode/src/util/scrap.ts
Normal file
10
packages/tfcode/src/util/scrap.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const foo: string = "42"
|
||||
export const bar: number = 123
|
||||
|
||||
export function dummyFunction(): void {
|
||||
console.log("This is a dummy function")
|
||||
}
|
||||
|
||||
export function randomHelper(): boolean {
|
||||
return Math.random() > 0.5
|
||||
}
|
||||
12
packages/tfcode/src/util/signal.ts
Normal file
12
packages/tfcode/src/util/signal.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function signal() {
|
||||
let resolve: any
|
||||
const promise = new Promise((r) => (resolve = r))
|
||||
return {
|
||||
trigger() {
|
||||
return resolve()
|
||||
},
|
||||
wait() {
|
||||
return promise
|
||||
},
|
||||
}
|
||||
}
|
||||
14
packages/tfcode/src/util/timeout.ts
Normal file
14
packages/tfcode/src/util/timeout.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
let timeout: NodeJS.Timeout
|
||||
return Promise.race([
|
||||
promise.then((result) => {
|
||||
clearTimeout(timeout)
|
||||
return result
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error(`Operation timed out after ${ms}ms`))
|
||||
}, ms)
|
||||
}),
|
||||
])
|
||||
}
|
||||
7
packages/tfcode/src/util/token.ts
Normal file
7
packages/tfcode/src/util/token.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export namespace Token {
|
||||
const CHARS_PER_TOKEN = 4
|
||||
|
||||
export function estimate(input: string) {
|
||||
return Math.max(0, Math.round((input || "").length / CHARS_PER_TOKEN))
|
||||
}
|
||||
}
|
||||
14
packages/tfcode/src/util/which.ts
Normal file
14
packages/tfcode/src/util/which.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import whichPkg from "which"
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
|
||||
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
|
||||
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
|
||||
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
|
||||
const result = whichPkg.sync(cmd, {
|
||||
nothrow: true,
|
||||
path: full,
|
||||
pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
|
||||
})
|
||||
return typeof result === "string" ? result : null
|
||||
}
|
||||
59
packages/tfcode/src/util/wildcard.ts
Normal file
59
packages/tfcode/src/util/wildcard.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { sortBy, pipe } from "remeda"
|
||||
|
||||
export namespace Wildcard {
|
||||
export function match(str: string, pattern: string) {
|
||||
if (str) str = str.replaceAll("\\", "/")
|
||||
if (pattern) pattern = pattern.replaceAll("\\", "/")
|
||||
let escaped = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape special regex chars
|
||||
.replace(/\*/g, ".*") // * becomes .*
|
||||
.replace(/\?/g, ".") // ? becomes .
|
||||
|
||||
// If pattern ends with " *" (space + wildcard), make the trailing part optional
|
||||
// This allows "ls *" to match both "ls" and "ls -la"
|
||||
if (escaped.endsWith(" .*")) {
|
||||
escaped = escaped.slice(0, -3) + "( .*)?"
|
||||
}
|
||||
|
||||
const flags = process.platform === "win32" ? "si" : "s"
|
||||
return new RegExp("^" + escaped + "$", flags).test(str)
|
||||
}
|
||||
|
||||
export function all(input: string, patterns: Record<string, any>) {
|
||||
const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"]))
|
||||
let result = undefined
|
||||
for (const [pattern, value] of sorted) {
|
||||
if (match(input, pattern)) {
|
||||
result = value
|
||||
continue
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function allStructured(input: { head: string; tail: string[] }, patterns: Record<string, any>) {
|
||||
const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"]))
|
||||
let result = undefined
|
||||
for (const [pattern, value] of sorted) {
|
||||
const parts = pattern.split(/\s+/)
|
||||
if (!match(input.head, parts[0])) continue
|
||||
if (parts.length === 1 || matchSequence(input.tail, parts.slice(1))) {
|
||||
result = value
|
||||
continue
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function matchSequence(items: string[], patterns: string[]): boolean {
|
||||
if (patterns.length === 0) return true
|
||||
const [pattern, ...rest] = patterns
|
||||
if (pattern === "*") return matchSequence(items, rest)
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (match(items[i], pattern) && matchSequence(items.slice(i + 1), rest)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user