summaryrefslogtreecommitdiffhomepage
path: root/packages/shared/src
diff options
context:
space:
mode:
authorDax <[email protected]>2026-04-15 10:26:20 -0400
committerGitHub <[email protected]>2026-04-15 14:26:20 +0000
commitbe9432a893dd1662c10ff41c7ab552bcba8f3e1b (patch)
treef49000b3dd9c3bea5247d319e8fcbd4fb879b7b0 /packages/shared/src
parentaf20191d1cd60a7f4a421ad81eca5053f7deace1 (diff)
downloadopencode-be9432a893dd1662c10ff41c7ab552bcba8f3e1b.tar.gz
opencode-be9432a893dd1662c10ff41c7ab552bcba8f3e1b.zip
shared package (#22626)
Diffstat (limited to 'packages/shared/src')
-rw-r--r--packages/shared/src/filesystem.ts236
-rw-r--r--packages/shared/src/util/array.ts10
-rw-r--r--packages/shared/src/util/binary.ts41
-rw-r--r--packages/shared/src/util/encode.ts51
-rw-r--r--packages/shared/src/util/error.ts54
-rw-r--r--packages/shared/src/util/fn.ts11
-rw-r--r--packages/shared/src/util/glob.ts34
-rw-r--r--packages/shared/src/util/identifier.ts48
-rw-r--r--packages/shared/src/util/iife.ts3
-rw-r--r--packages/shared/src/util/lazy.ts11
-rw-r--r--packages/shared/src/util/module.ts10
-rw-r--r--packages/shared/src/util/path.ts37
-rw-r--r--packages/shared/src/util/retry.ts41
-rw-r--r--packages/shared/src/util/slug.ts74
14 files changed, 661 insertions, 0 deletions
diff --git a/packages/shared/src/filesystem.ts b/packages/shared/src/filesystem.ts
new file mode 100644
index 000000000..44346be8f
--- /dev/null
+++ b/packages/shared/src/filesystem.ts
@@ -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("..")
+ }
+}
diff --git a/packages/shared/src/util/array.ts b/packages/shared/src/util/array.ts
new file mode 100644
index 000000000..1fb8ac69e
--- /dev/null
+++ b/packages/shared/src/util/array.ts
@@ -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
+}
diff --git a/packages/shared/src/util/binary.ts b/packages/shared/src/util/binary.ts
new file mode 100644
index 000000000..3d8f61851
--- /dev/null
+++ b/packages/shared/src/util/binary.ts
@@ -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
+ }
+}
diff --git a/packages/shared/src/util/encode.ts b/packages/shared/src/util/encode.ts
new file mode 100644
index 000000000..e4c6e70ac
--- /dev/null
+++ b/packages/shared/src/util/encode.ts
@@ -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}`
+}
diff --git a/packages/shared/src/util/error.ts b/packages/shared/src/util/error.ts
new file mode 100644
index 000000000..12c27a0a7
--- /dev/null
+++ b/packages/shared/src/util/error.ts
@@ -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(),
+ }),
+ )
+}
diff --git a/packages/shared/src/util/fn.ts b/packages/shared/src/util/fn.ts
new file mode 100644
index 000000000..9efe4622f
--- /dev/null
+++ b/packages/shared/src/util/fn.ts
@@ -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
+}
diff --git a/packages/shared/src/util/glob.ts b/packages/shared/src/util/glob.ts
new file mode 100644
index 000000000..febf062da
--- /dev/null
+++ b/packages/shared/src/util/glob.ts
@@ -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 })
+ }
+}
diff --git a/packages/shared/src/util/identifier.ts b/packages/shared/src/util/identifier.ts
new file mode 100644
index 000000000..ba28a351b
--- /dev/null
+++ b/packages/shared/src/util/identifier.ts
@@ -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)
+ }
+}
diff --git a/packages/shared/src/util/iife.ts b/packages/shared/src/util/iife.ts
new file mode 100644
index 000000000..ca9ae6c10
--- /dev/null
+++ b/packages/shared/src/util/iife.ts
@@ -0,0 +1,3 @@
+export function iife<T>(fn: () => T) {
+ return fn()
+}
diff --git a/packages/shared/src/util/lazy.ts b/packages/shared/src/util/lazy.ts
new file mode 100644
index 000000000..935ebe0f9
--- /dev/null
+++ b/packages/shared/src/util/lazy.ts
@@ -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
+ }
+}
diff --git a/packages/shared/src/util/module.ts b/packages/shared/src/util/module.ts
new file mode 100644
index 000000000..6ed3b23d7
--- /dev/null
+++ b/packages/shared/src/util/module.ts
@@ -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 {}
+ }
+}
diff --git a/packages/shared/src/util/path.ts b/packages/shared/src/util/path.ts
new file mode 100644
index 000000000..bb191f512
--- /dev/null
+++ b/packages/shared/src/util/path.ts
@@ -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)
+}
diff --git a/packages/shared/src/util/retry.ts b/packages/shared/src/util/retry.ts
new file mode 100644
index 000000000..0014a604c
--- /dev/null
+++ b/packages/shared/src/util/retry.ts
@@ -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
+}
diff --git a/packages/shared/src/util/slug.ts b/packages/shared/src/util/slug.ts
new file mode 100644
index 000000000..62cf0e57b
--- /dev/null
+++ b/packages/shared/src/util/slug.ts
@@ -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("-")
+ }
+}