mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-01 22:10:23 +00:00
shared package (#22626)
Some checks are pending
containers / build (push) Waiting to run
deploy / deploy (push) Waiting to run
generate / generate (push) Waiting to run
nix-eval / nix-eval (push) Waiting to run
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Waiting to run
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Waiting to run
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Waiting to run
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Waiting to run
nix-hashes / update-hashes (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-electron (map[host:macos-latest platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[host:macos-latest platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Blocked by required conditions
publish / version (push) Waiting to run
publish / build-cli (push) Blocked by required conditions
publish / sign-cli-windows (push) Blocked by required conditions
publish / build-tauri (map[host:blacksmith-4vcpu-ubuntu-2404 target:x86_64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-tauri (map[host:blacksmith-4vcpu-windows-2025 target:x86_64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-tauri (map[host:blacksmith-8vcpu-ubuntu-2404-arm target:aarch64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-tauri (map[host:macos-latest target:aarch64-apple-darwin]) (push) Blocked by required conditions
publish / build-tauri (map[host:macos-latest target:x86_64-apple-darwin]) (push) Blocked by required conditions
publish / build-tauri (map[host:windows-2025 target:aarch64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:aarch64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / publish (push) Blocked by required conditions
storybook / storybook build (push) Waiting to run
test / unit (linux) (push) Waiting to run
test / unit (windows) (push) Waiting to run
test / e2e (linux) (push) Waiting to run
test / e2e (windows) (push) Waiting to run
typecheck / typecheck (push) Waiting to run
Some checks are pending
containers / build (push) Waiting to run
deploy / deploy (push) Waiting to run
generate / generate (push) Waiting to run
nix-eval / nix-eval (push) Waiting to run
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Waiting to run
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Waiting to run
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Waiting to run
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Waiting to run
nix-hashes / update-hashes (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-electron (map[host:macos-latest platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[host:macos-latest platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Blocked by required conditions
publish / version (push) Waiting to run
publish / build-cli (push) Blocked by required conditions
publish / sign-cli-windows (push) Blocked by required conditions
publish / build-tauri (map[host:blacksmith-4vcpu-ubuntu-2404 target:x86_64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-tauri (map[host:blacksmith-4vcpu-windows-2025 target:x86_64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-tauri (map[host:blacksmith-8vcpu-ubuntu-2404-arm target:aarch64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-tauri (map[host:macos-latest target:aarch64-apple-darwin]) (push) Blocked by required conditions
publish / build-tauri (map[host:macos-latest target:x86_64-apple-darwin]) (push) Blocked by required conditions
publish / build-tauri (map[host:windows-2025 target:aarch64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:aarch64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / publish (push) Blocked by required conditions
storybook / storybook build (push) Waiting to run
test / unit (linux) (push) Waiting to run
test / unit (windows) (push) Waiting to run
test / e2e (linux) (push) Waiting to run
test / e2e (windows) (push) Waiting to run
typecheck / typecheck (push) Waiting to run
This commit is contained in:
parent
af20191d1c
commit
be9432a893
144 changed files with 246 additions and 242 deletions
236
packages/shared/src/filesystem.ts
Normal file
236
packages/shared/src/filesystem.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||
import { realpathSync } from "fs"
|
||||
import * as NFS from "fs/promises"
|
||||
import { lookup } from "mime-types"
|
||||
import { Effect, FileSystem, Layer, Schema, Context } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { Glob } from "./util/glob"
|
||||
|
||||
export namespace AppFileSystem {
|
||||
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
||||
method: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export type Error = PlatformError | FileSystemError
|
||||
|
||||
export interface DirEntry {
|
||||
readonly name: string
|
||||
readonly type: "file" | "directory" | "symlink" | "other"
|
||||
}
|
||||
|
||||
export interface Interface extends FileSystem.FileSystem {
|
||||
readonly isDir: (path: string) => Effect.Effect<boolean>
|
||||
readonly isFile: (path: string) => Effect.Effect<boolean>
|
||||
readonly existsSafe: (path: string) => Effect.Effect<boolean>
|
||||
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
|
||||
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
|
||||
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
|
||||
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
|
||||
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
|
||||
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
|
||||
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||
readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
|
||||
readonly globMatch: (pattern: string, filepath: string) => boolean
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
|
||||
const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
|
||||
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
|
||||
})
|
||||
|
||||
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
|
||||
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
||||
return info?.type === "Directory"
|
||||
})
|
||||
|
||||
const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
|
||||
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
||||
return info?.type === "File"
|
||||
})
|
||||
|
||||
const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const entries = await NFS.readdir(dirPath, { withFileTypes: true })
|
||||
return entries.map(
|
||||
(e): DirEntry => ({
|
||||
name: e.name,
|
||||
type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
|
||||
}),
|
||||
)
|
||||
},
|
||||
catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
|
||||
const text = yield* fs.readFileString(path)
|
||||
return JSON.parse(text)
|
||||
})
|
||||
|
||||
const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
|
||||
const content = JSON.stringify(data, null, 2)
|
||||
yield* fs.writeFileString(path, content)
|
||||
if (mode) yield* fs.chmod(path, mode)
|
||||
})
|
||||
|
||||
const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
|
||||
yield* fs.makeDirectory(path, { recursive: true })
|
||||
})
|
||||
|
||||
const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
|
||||
path: string,
|
||||
content: string | Uint8Array,
|
||||
mode?: number,
|
||||
) {
|
||||
const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
|
||||
|
||||
yield* write.pipe(
|
||||
Effect.catchIf(
|
||||
(e) => e.reason._tag === "NotFound",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* fs.makeDirectory(dirname(path), { recursive: true })
|
||||
yield* write
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (mode) yield* fs.chmod(path, mode)
|
||||
})
|
||||
|
||||
const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => Glob.scan(pattern, options),
|
||||
catch: (cause) => new FileSystemError({ method: "glob", cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
|
||||
const result: string[] = []
|
||||
let current = start
|
||||
while (true) {
|
||||
const search = join(current, target)
|
||||
if (yield* fs.exists(search)) result.push(search)
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
|
||||
const result: string[] = []
|
||||
let current = options.start
|
||||
while (true) {
|
||||
for (const target of options.targets) {
|
||||
const search = join(current, target)
|
||||
if (yield* fs.exists(search)) result.push(search)
|
||||
}
|
||||
if (options.stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
|
||||
const result: string[] = []
|
||||
let current = start
|
||||
while (true) {
|
||||
const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
|
||||
Effect.catch(() => Effect.succeed([] as string[])),
|
||||
)
|
||||
result.push(...matches)
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
...fs,
|
||||
existsSafe,
|
||||
isDir,
|
||||
isFile,
|
||||
readDirectoryEntries,
|
||||
readJson,
|
||||
writeJson,
|
||||
ensureDir,
|
||||
writeWithDirs,
|
||||
findUp,
|
||||
up,
|
||||
globUp,
|
||||
glob,
|
||||
globMatch: Glob.match,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
||||
|
||||
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
||||
export function mimeType(p: string): string {
|
||||
return lookup(p) || "application/octet-stream"
|
||||
}
|
||||
|
||||
export function normalizePath(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
const resolved = pathResolve(windowsPath(p))
|
||||
try {
|
||||
return realpathSync.native(resolved)
|
||||
} catch {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePathPattern(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
if (p === "*") return p
|
||||
const match = p.match(/^(.*)[\\/]\*$/)
|
||||
if (!match) return normalizePath(p)
|
||||
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
|
||||
return join(normalizePath(dir), "*")
|
||||
}
|
||||
|
||||
export function resolve(p: string): string {
|
||||
const resolved = pathResolve(windowsPath(p))
|
||||
try {
|
||||
return normalizePath(realpathSync(resolved))
|
||||
} catch (e: any) {
|
||||
if (e?.code === "ENOENT") 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()}:/`)
|
||||
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
.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("..")
|
||||
}
|
||||
}
|
||||
10
packages/shared/src/util/array.ts
Normal file
10
packages/shared/src/util/array.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export function findLast<T>(
|
||||
items: readonly T[],
|
||||
predicate: (item: T, index: number, items: readonly T[]) => boolean,
|
||||
): T | undefined {
|
||||
for (let i = items.length - 1; i >= 0; i -= 1) {
|
||||
const item = items[i]
|
||||
if (predicate(item, i, items)) return item
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
41
packages/shared/src/util/binary.ts
Normal file
41
packages/shared/src/util/binary.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export namespace Binary {
|
||||
export function search<T>(array: T[], id: string, compare: (item: T) => string): { found: boolean; index: number } {
|
||||
let left = 0
|
||||
let right = array.length - 1
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2)
|
||||
const midId = compare(array[mid])
|
||||
|
||||
if (midId === id) {
|
||||
return { found: true, index: mid }
|
||||
} else if (midId < id) {
|
||||
left = mid + 1
|
||||
} else {
|
||||
right = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
return { found: false, index: left }
|
||||
}
|
||||
|
||||
export function insert<T>(array: T[], item: T, compare: (item: T) => string): T[] {
|
||||
const id = compare(item)
|
||||
let left = 0
|
||||
let right = array.length
|
||||
|
||||
while (left < right) {
|
||||
const mid = Math.floor((left + right) / 2)
|
||||
const midId = compare(array[mid])
|
||||
|
||||
if (midId < id) {
|
||||
left = mid + 1
|
||||
} else {
|
||||
right = mid
|
||||
}
|
||||
}
|
||||
|
||||
array.splice(left, 0, item)
|
||||
return array
|
||||
}
|
||||
}
|
||||
51
packages/shared/src/util/encode.ts
Normal file
51
packages/shared/src/util/encode.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
export function base64Encode(value: string) {
|
||||
const bytes = new TextEncoder().encode(value)
|
||||
const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join("")
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
export function base64Decode(value: string) {
|
||||
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"))
|
||||
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0))
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
export async function hash(content: string, algorithm = "SHA-256"): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(content)
|
||||
const hashBuffer = await crypto.subtle.digest(algorithm, data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
return hashHex
|
||||
}
|
||||
|
||||
export function checksum(content: string): string | undefined {
|
||||
if (!content) return undefined
|
||||
let hash = 0x811c9dc5
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
hash ^= content.charCodeAt(i)
|
||||
hash = Math.imul(hash, 0x01000193)
|
||||
}
|
||||
return (hash >>> 0).toString(36)
|
||||
}
|
||||
|
||||
export function sampledChecksum(content: string, limit = 500_000): string | undefined {
|
||||
if (!content) return undefined
|
||||
if (content.length <= limit) return checksum(content)
|
||||
|
||||
const size = 4096
|
||||
const points = [
|
||||
0,
|
||||
Math.floor(content.length * 0.25),
|
||||
Math.floor(content.length * 0.5),
|
||||
Math.floor(content.length * 0.75),
|
||||
content.length - size,
|
||||
]
|
||||
const hashes = points
|
||||
.map((point) => {
|
||||
const start = Math.max(0, Math.min(content.length - size, point - Math.floor(size / 2)))
|
||||
return checksum(content.slice(start, start + size)) ?? ""
|
||||
})
|
||||
.join(":")
|
||||
return `${content.length}:${hashes}`
|
||||
}
|
||||
54
packages/shared/src/util/error.ts
Normal file
54
packages/shared/src/util/error.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import z from "zod"
|
||||
|
||||
export abstract class NamedError extends Error {
|
||||
abstract schema(): z.core.$ZodType
|
||||
abstract toObject(): { name: string; data: any }
|
||||
|
||||
static create<Name extends string, Data extends z.core.$ZodType>(name: Name, data: Data) {
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.literal(name),
|
||||
data,
|
||||
})
|
||||
.meta({
|
||||
ref: name,
|
||||
})
|
||||
const result = class extends NamedError {
|
||||
public static readonly Schema = schema
|
||||
|
||||
public override readonly name = name as Name
|
||||
|
||||
constructor(
|
||||
public readonly data: z.input<Data>,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(name, options)
|
||||
this.name = name
|
||||
}
|
||||
|
||||
static isInstance(input: any): input is InstanceType<typeof result> {
|
||||
return typeof input === "object" && "name" in input && input.name === name
|
||||
}
|
||||
|
||||
schema() {
|
||||
return schema
|
||||
}
|
||||
|
||||
toObject() {
|
||||
return {
|
||||
name: name,
|
||||
data: this.data,
|
||||
}
|
||||
}
|
||||
}
|
||||
Object.defineProperty(result, "name", { value: name })
|
||||
return result
|
||||
}
|
||||
|
||||
public static readonly Unknown = NamedError.create(
|
||||
"UnknownError",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
11
packages/shared/src/util/fn.ts
Normal file
11
packages/shared/src/util/fn.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
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>) => {
|
||||
const parsed = schema.parse(input)
|
||||
return cb(parsed)
|
||||
}
|
||||
result.force = (input: z.infer<T>) => cb(input)
|
||||
result.schema = schema
|
||||
return result
|
||||
}
|
||||
34
packages/shared/src/util/glob.ts
Normal file
34
packages/shared/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 })
|
||||
}
|
||||
}
|
||||
48
packages/shared/src/util/identifier.ts
Normal file
48
packages/shared/src/util/identifier.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { randomBytes } from "crypto"
|
||||
|
||||
export namespace Identifier {
|
||||
const LENGTH = 26
|
||||
|
||||
// State for monotonic ID generation
|
||||
let lastTimestamp = 0
|
||||
let counter = 0
|
||||
|
||||
export function ascending() {
|
||||
return create(false)
|
||||
}
|
||||
|
||||
export function descending() {
|
||||
return create(true)
|
||||
}
|
||||
|
||||
function randomBase62(length: number): string {
|
||||
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
let result = ""
|
||||
const bytes = randomBytes(length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[bytes[i] % 62]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function create(descending: boolean, timestamp?: number): string {
|
||||
const currentTimestamp = timestamp ?? Date.now()
|
||||
|
||||
if (currentTimestamp !== lastTimestamp) {
|
||||
lastTimestamp = currentTimestamp
|
||||
counter = 0
|
||||
}
|
||||
counter++
|
||||
|
||||
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
|
||||
|
||||
now = descending ? ~now : now
|
||||
|
||||
const timeBytes = Buffer.alloc(6)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
|
||||
}
|
||||
|
||||
return timeBytes.toString("hex") + randomBase62(LENGTH - 12)
|
||||
}
|
||||
}
|
||||
3
packages/shared/src/util/iife.ts
Normal file
3
packages/shared/src/util/iife.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function iife<T>(fn: () => T) {
|
||||
return fn()
|
||||
}
|
||||
11
packages/shared/src/util/lazy.ts
Normal file
11
packages/shared/src/util/lazy.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export function lazy<T>(fn: () => T) {
|
||||
let value: T | undefined
|
||||
let loaded = false
|
||||
|
||||
return (): T => {
|
||||
if (loaded) return value as T
|
||||
loaded = true
|
||||
value = fn()
|
||||
return value as T
|
||||
}
|
||||
}
|
||||
10
packages/shared/src/util/module.ts
Normal file
10
packages/shared/src/util/module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { createRequire } from "node:module"
|
||||
import path from "node:path"
|
||||
|
||||
export namespace Module {
|
||||
export function resolve(id: string, dir: string) {
|
||||
try {
|
||||
return createRequire(path.join(dir, "package.json")).resolve(id)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
37
packages/shared/src/util/path.ts
Normal file
37
packages/shared/src/util/path.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
export function getFilename(path: string | undefined) {
|
||||
if (!path) return ""
|
||||
const trimmed = path.replace(/[\/\\]+$/, "")
|
||||
const parts = trimmed.split(/[\/\\]/)
|
||||
return parts[parts.length - 1] ?? ""
|
||||
}
|
||||
|
||||
export function getDirectory(path: string | undefined) {
|
||||
if (!path) return ""
|
||||
const trimmed = path.replace(/[\/\\]+$/, "")
|
||||
const parts = trimmed.split(/[\/\\]/)
|
||||
return parts.slice(0, parts.length - 1).join("/") + "/"
|
||||
}
|
||||
|
||||
export function getFileExtension(path: string | undefined) {
|
||||
if (!path) return ""
|
||||
const parts = path.split(".")
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
export function getFilenameTruncated(path: string | undefined, maxLength: number = 20) {
|
||||
const filename = getFilename(path)
|
||||
if (filename.length <= maxLength) return filename
|
||||
const lastDot = filename.lastIndexOf(".")
|
||||
const ext = lastDot <= 0 ? "" : filename.slice(lastDot)
|
||||
const available = maxLength - ext.length - 1 // -1 for ellipsis
|
||||
if (available <= 0) return filename.slice(0, maxLength - 1) + "…"
|
||||
return filename.slice(0, available) + "…" + ext
|
||||
}
|
||||
|
||||
export function truncateMiddle(text: string, maxLength: number = 20) {
|
||||
if (text.length <= maxLength) return text
|
||||
const available = maxLength - 1 // -1 for ellipsis
|
||||
const start = Math.ceil(available / 2)
|
||||
const end = Math.floor(available / 2)
|
||||
return text.slice(0, start) + "…" + text.slice(-end)
|
||||
}
|
||||
41
packages/shared/src/util/retry.ts
Normal file
41
packages/shared/src/util/retry.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export interface RetryOptions {
|
||||
attempts?: number
|
||||
delay?: number
|
||||
factor?: number
|
||||
maxDelay?: number
|
||||
retryIf?: (error: unknown) => boolean
|
||||
}
|
||||
|
||||
const TRANSIENT_MESSAGES = [
|
||||
"load failed",
|
||||
"network connection was lost",
|
||||
"network request failed",
|
||||
"failed to fetch",
|
||||
"econnreset",
|
||||
"econnrefused",
|
||||
"etimedout",
|
||||
"socket hang up",
|
||||
]
|
||||
|
||||
function isTransientError(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
const message = String(error instanceof Error ? error.message : error).toLowerCase()
|
||||
return TRANSIENT_MESSAGES.some((m) => message.includes(m))
|
||||
}
|
||||
|
||||
export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
|
||||
const { attempts = 3, delay = 500, factor = 2, maxDelay = 10000, retryIf = isTransientError } = options
|
||||
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt === attempts - 1 || !retryIf(error)) throw error
|
||||
const wait = Math.min(delay * Math.pow(factor, attempt), maxDelay)
|
||||
await new Promise((resolve) => setTimeout(resolve, wait))
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
74
packages/shared/src/util/slug.ts
Normal file
74
packages/shared/src/util/slug.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
export namespace Slug {
|
||||
const ADJECTIVES = [
|
||||
"brave",
|
||||
"calm",
|
||||
"clever",
|
||||
"cosmic",
|
||||
"crisp",
|
||||
"curious",
|
||||
"eager",
|
||||
"gentle",
|
||||
"glowing",
|
||||
"happy",
|
||||
"hidden",
|
||||
"jolly",
|
||||
"kind",
|
||||
"lucky",
|
||||
"mighty",
|
||||
"misty",
|
||||
"neon",
|
||||
"nimble",
|
||||
"playful",
|
||||
"proud",
|
||||
"quick",
|
||||
"quiet",
|
||||
"shiny",
|
||||
"silent",
|
||||
"stellar",
|
||||
"sunny",
|
||||
"swift",
|
||||
"tidy",
|
||||
"witty",
|
||||
] as const
|
||||
|
||||
const NOUNS = [
|
||||
"cabin",
|
||||
"cactus",
|
||||
"canyon",
|
||||
"circuit",
|
||||
"comet",
|
||||
"eagle",
|
||||
"engine",
|
||||
"falcon",
|
||||
"forest",
|
||||
"garden",
|
||||
"harbor",
|
||||
"island",
|
||||
"knight",
|
||||
"lagoon",
|
||||
"meadow",
|
||||
"moon",
|
||||
"mountain",
|
||||
"nebula",
|
||||
"orchid",
|
||||
"otter",
|
||||
"panda",
|
||||
"pixel",
|
||||
"planet",
|
||||
"river",
|
||||
"rocket",
|
||||
"sailor",
|
||||
"squid",
|
||||
"star",
|
||||
"tiger",
|
||||
"wizard",
|
||||
"wolf",
|
||||
] as const
|
||||
|
||||
export function create() {
|
||||
return [
|
||||
ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)],
|
||||
NOUNS[Math.floor(Math.random() * NOUNS.length)],
|
||||
].join("-")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue