feat: 品牌替换 + 启动优化 + AGENTS.md 模板定制
- 品牌替换:OpenCode/opencode → AirCoding/aircoding(16+ 文件) - Logo ASCII art:修复 left/right 行数不匹配导致的启动崩溃 - 启动诊断:添加 OPENCODE_PRINT_TIMING 计时探针 - dev 模式默认 --pure 跳过外部插件加载 - AGENTS.md 模板:追加 AirCoding 多 Agent 专项段落 - architect prompt + plugin:强化 AGENTS.md 产出验证
This commit is contained in:
179
packages/http-recorder/src/cassette.ts
Normal file
179
packages/http-recorder/src/cassette.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Context, Effect, FileSystem, Layer, Schema, Semaphore } from "effect"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { secretFindings, SecretFindingSchema, type SecretFinding } from "./redaction.js"
|
||||
import { CassetteSchema, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema.js"
|
||||
|
||||
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
||||
|
||||
export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", {
|
||||
cassetteName: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Cassette "${this.cassetteName}" not found`
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
|
||||
cassetteName: Schema.String,
|
||||
findings: Schema.Array(SecretFindingSchema),
|
||||
}) {
|
||||
override get message() {
|
||||
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
|
||||
.map((finding) => `${finding.path} (${finding.reason})`)
|
||||
.join(", ")}`
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
|
||||
readonly append: (
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
metadata?: CassetteMetadata,
|
||||
) => Effect.Effect<void, UnsafeCassetteError>
|
||||
readonly exists: (name: string) => Effect.Effect<boolean>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<string>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
|
||||
|
||||
const cassettePath = (directory: string, name: string) => {
|
||||
if (!name || path.isAbsolute(name) || path.win32.isAbsolute(name) || name.split(/[\\/]/).includes(".."))
|
||||
throw new Error(`Invalid cassette name "${name}"`)
|
||||
const root = path.resolve(directory)
|
||||
const target = path.resolve(root, `${name}.json`)
|
||||
const relative = path.relative(root, target)
|
||||
if (!relative || relative.startsWith("..") || path.isAbsolute(relative))
|
||||
throw new Error(`Invalid cassette name "${name}"`)
|
||||
return target
|
||||
}
|
||||
|
||||
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
|
||||
fs.existsSync(cassettePath(options.directory ?? DEFAULT_RECORDINGS_DIR, name))
|
||||
|
||||
const buildCassette = (
|
||||
name: string,
|
||||
interactions: ReadonlyArray<Interaction>,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
): Cassette => ({
|
||||
version: 1,
|
||||
metadata: { name, recordedAt: new Date().toISOString(), ...metadata },
|
||||
interactions,
|
||||
})
|
||||
|
||||
const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
|
||||
|
||||
const parseCassette = Schema.decodeUnknownSync(Schema.fromJsonString(CassetteSchema))
|
||||
|
||||
const failIfUnsafe = (name: string, findings: ReadonlyArray<SecretFinding>) =>
|
||||
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings }))
|
||||
|
||||
export const fileSystem = (
|
||||
options: { readonly directory?: string } = {},
|
||||
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
|
||||
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
|
||||
const appendLock = yield* Semaphore.make(1)
|
||||
|
||||
const pathFor = (name: string) => cassettePath(directory, name)
|
||||
|
||||
const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
const nested = yield* Effect.forEach(entries, (entry) => {
|
||||
const full = path.join(current, entry)
|
||||
return fs.stat(full).pipe(
|
||||
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
|
||||
Effect.catch(() => Effect.succeed([] as string[])),
|
||||
)
|
||||
})
|
||||
return nested.flat()
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
read: (name) =>
|
||||
fs.readFileString(pathFor(name)).pipe(
|
||||
Effect.map((raw) => parseCassette(raw).interactions),
|
||||
Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
|
||||
),
|
||||
append: (name, interaction, metadata) =>
|
||||
appendLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
|
||||
const interactions = [...entry.interactions, interaction]
|
||||
const interactionFindings = [...entry.findings, ...secretFindings(interaction)]
|
||||
const cassette = buildCassette(name, interactions, metadata)
|
||||
const findings = [...interactionFindings, ...secretFindings(cassette.metadata ?? {})]
|
||||
yield* failIfUnsafe(name, findings)
|
||||
const target = pathFor(name)
|
||||
yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.orDie)
|
||||
const temporary = `${target}.${crypto.randomUUID()}.tmp`
|
||||
yield* fs.writeFileString(temporary, formatCassette(cassette)).pipe(
|
||||
Effect.flatMap(() => fs.rename(temporary, target)),
|
||||
Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.catch(() => Effect.void))),
|
||||
Effect.orDie,
|
||||
)
|
||||
recorded.set(name, { interactions, findings: interactionFindings })
|
||||
}),
|
||||
),
|
||||
exists: (name) =>
|
||||
fs.access(pathFor(name)).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
),
|
||||
list: () =>
|
||||
walk(directory).pipe(
|
||||
Effect.map((files) =>
|
||||
files
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) =>
|
||||
path
|
||||
.relative(directory, file)
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\.json$/, ""),
|
||||
)
|
||||
.toSorted((a, b) => a.localeCompare(b)),
|
||||
),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
|
||||
Layer.sync(Service, () => {
|
||||
const stored = new Map<string, Interaction[]>(
|
||||
Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
|
||||
)
|
||||
const accumulatedFindings = new Map<string, SecretFinding[]>()
|
||||
const appendLock = Semaphore.makeUnsafe(1)
|
||||
|
||||
return Service.of({
|
||||
read: (name) =>
|
||||
stored.has(name)
|
||||
? Effect.succeed(stored.get(name) ?? [])
|
||||
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
|
||||
append: (name, interaction, metadata) =>
|
||||
appendLock.withPermit(
|
||||
Effect.suspend(() => {
|
||||
const interactions = [...(stored.get(name) ?? []), interaction]
|
||||
const findings = [...(accumulatedFindings.get(name) ?? []), ...secretFindings(interaction)]
|
||||
const allFindings = metadata ? [...findings, ...secretFindings({ name, ...metadata })] : findings
|
||||
return failIfUnsafe(name, allFindings).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() => {
|
||||
stored.set(name, interactions)
|
||||
accumulatedFindings.set(name, findings)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
exists: (name) => Effect.sync(() => stored.has(name)),
|
||||
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user