|
@@ -1,62 +1,80 @@
|
|
|
-import { Context, Effect, FileSystem, Layer, PlatformError } from "effect"
|
|
|
|
|
|
|
+import { Context, Data, Effect, FileSystem, Layer } from "effect"
|
|
|
|
|
+import * as fs from "node:fs"
|
|
|
import * as path from "node:path"
|
|
import * as path from "node:path"
|
|
|
-import { cassetteSecretFindings, secretFindings, type SecretFinding } from "./redaction"
|
|
|
|
|
-import type { Cassette, CassetteMetadata, Interaction } from "./schema"
|
|
|
|
|
-import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
|
|
|
|
|
|
|
+import { secretFindings, type SecretFinding } from "./redaction"
|
|
|
|
|
+import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
|
|
|
|
|
|
|
|
-export interface Entry {
|
|
|
|
|
- readonly name: string
|
|
|
|
|
- readonly path: string
|
|
|
|
|
|
|
+const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
|
|
|
|
+
|
|
|
|
|
+export class CassetteNotFoundError extends Data.TaggedError("CassetteNotFoundError")<{
|
|
|
|
|
+ readonly cassetteName: string
|
|
|
|
|
+}> {
|
|
|
|
|
+ override get message() {
|
|
|
|
|
+ return `Cassette "${this.cassetteName}" not found`
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface AppendResult {
|
|
|
|
|
+ readonly findings: ReadonlyArray<SecretFinding>
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export interface Interface {
|
|
export interface Interface {
|
|
|
- readonly path: (name: string) => string
|
|
|
|
|
- readonly read: (name: string) => Effect.Effect<Cassette, PlatformError.PlatformError>
|
|
|
|
|
- readonly write: (name: string, cassette: Cassette) => Effect.Effect<void, PlatformError.PlatformError>
|
|
|
|
|
|
|
+ readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
|
|
|
readonly append: (
|
|
readonly append: (
|
|
|
name: string,
|
|
name: string,
|
|
|
interaction: Interaction,
|
|
interaction: Interaction,
|
|
|
- metadata: CassetteMetadata | undefined,
|
|
|
|
|
- ) => Effect.Effect<
|
|
|
|
|
- {
|
|
|
|
|
- readonly cassette: Cassette
|
|
|
|
|
- readonly findings: ReadonlyArray<SecretFinding>
|
|
|
|
|
- },
|
|
|
|
|
- PlatformError.PlatformError
|
|
|
|
|
- >
|
|
|
|
|
|
|
+ metadata?: CassetteMetadata,
|
|
|
|
|
+ ) => Effect.Effect<AppendResult>
|
|
|
readonly exists: (name: string) => Effect.Effect<boolean>
|
|
readonly exists: (name: string) => Effect.Effect<boolean>
|
|
|
- readonly list: () => Effect.Effect<ReadonlyArray<Entry>, PlatformError.PlatformError>
|
|
|
|
|
- readonly scan: (cassette: Cassette) => ReadonlyArray<SecretFinding>
|
|
|
|
|
|
|
+ readonly list: () => Effect.Effect<ReadonlyArray<string>>
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
|
|
|
|
|
|
|
|
-export const layer = (options: { readonly directory?: string } = {}) =>
|
|
|
|
|
|
|
+export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
|
|
|
|
|
+ fs.existsSync(path.join(options.directory ?? DEFAULT_RECORDINGS_DIR, `${name}.json`))
|
|
|
|
|
+
|
|
|
|
|
+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 = (raw: string) => decodeCassette(JSON.parse(raw))
|
|
|
|
|
+
|
|
|
|
|
+export const fileSystem = (
|
|
|
|
|
+ options: { readonly directory?: string } = {},
|
|
|
|
|
+): Layer.Layer<Service, never, FileSystem.FileSystem> =>
|
|
|
Layer.effect(
|
|
Layer.effect(
|
|
|
Service,
|
|
Service,
|
|
|
Effect.gen(function* () {
|
|
Effect.gen(function* () {
|
|
|
- const fileSystem = yield* FileSystem.FileSystem
|
|
|
|
|
|
|
+ const fs = yield* FileSystem.FileSystem
|
|
|
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
|
|
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
|
|
|
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
|
|
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
|
|
|
const directoriesEnsured = new Set<string>()
|
|
const directoriesEnsured = new Set<string>()
|
|
|
|
|
|
|
|
- const pathFor = (name: string) => cassettePath(name, directory)
|
|
|
|
|
|
|
+ const cassettePath = (name: string) => path.join(directory, `${name}.json`)
|
|
|
|
|
|
|
|
- const ensureDirectory = Effect.fn("Cassette.ensureDirectory")(function* (name: string) {
|
|
|
|
|
- const dir = path.dirname(pathFor(name))
|
|
|
|
|
- if (directoriesEnsured.has(dir)) return
|
|
|
|
|
- yield* fileSystem.makeDirectory(dir, { recursive: true })
|
|
|
|
|
- directoriesEnsured.add(dir)
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ const ensureDirectory = (name: string) =>
|
|
|
|
|
+ Effect.gen(function* () {
|
|
|
|
|
+ const dir = path.dirname(cassettePath(name))
|
|
|
|
|
+ if (directoriesEnsured.has(dir)) return
|
|
|
|
|
+ yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
|
|
|
|
|
+ directoriesEnsured.add(dir)
|
|
|
|
|
+ })
|
|
|
|
|
|
|
|
- const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
|
|
|
|
|
|
|
+ const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
|
|
|
Effect.gen(function* () {
|
|
Effect.gen(function* () {
|
|
|
- const entries = yield* fileSystem
|
|
|
|
|
- .readDirectory(directory)
|
|
|
|
|
- .pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
|
|
|
|
|
|
+ const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
|
|
const nested = yield* Effect.forEach(entries, (entry) => {
|
|
const nested = yield* Effect.forEach(entries, (entry) => {
|
|
|
- const full = path.join(directory, entry)
|
|
|
|
|
- return fileSystem.stat(full).pipe(
|
|
|
|
|
|
|
+ const full = path.join(current, entry)
|
|
|
|
|
+ return fs.stat(full).pipe(
|
|
|
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
|
|
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
|
|
|
Effect.catch(() => Effect.succeed([] as string[])),
|
|
Effect.catch(() => Effect.succeed([] as string[])),
|
|
|
)
|
|
)
|
|
@@ -64,50 +82,68 @@ export const layer = (options: { readonly directory?: string } = {}) =>
|
|
|
return nested.flat()
|
|
return nested.flat()
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- const read = Effect.fn("Cassette.read")(function* (name: string) {
|
|
|
|
|
- return parseCassette(yield* fileSystem.readFileString(pathFor(name)))
|
|
|
|
|
|
|
+ return Service.of({
|
|
|
|
|
+ read: (name) =>
|
|
|
|
|
+ fs.readFileString(cassettePath(name)).pipe(
|
|
|
|
|
+ Effect.map((raw) => parseCassette(raw).interactions),
|
|
|
|
|
+ Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
|
|
|
|
|
+ ),
|
|
|
|
|
+ append: (name, interaction, metadata) =>
|
|
|
|
|
+ Effect.gen(function* () {
|
|
|
|
|
+ const entry = recorded.get(name) ?? { interactions: [], findings: [] }
|
|
|
|
|
+ if (!recorded.has(name)) recorded.set(name, entry)
|
|
|
|
|
+ entry.interactions.push(interaction)
|
|
|
|
|
+ entry.findings.push(...secretFindings(interaction))
|
|
|
|
|
+ const cassette = buildCassette(name, entry.interactions, metadata)
|
|
|
|
|
+ const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
|
|
|
|
|
+ if (findings.length === 0) {
|
|
|
|
|
+ yield* ensureDirectory(name)
|
|
|
|
|
+ yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
|
|
|
|
|
+ }
|
|
|
|
|
+ return { findings }
|
|
|
|
|
+ }),
|
|
|
|
|
+ exists: (name) =>
|
|
|
|
|
+ fs.access(cassettePath(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)),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
})
|
|
})
|
|
|
-
|
|
|
|
|
- const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
|
|
|
|
|
- yield* ensureDirectory(name)
|
|
|
|
|
- yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- const append = Effect.fn("Cassette.append")(function* (
|
|
|
|
|
- name: string,
|
|
|
|
|
- interaction: Interaction,
|
|
|
|
|
- metadata: CassetteMetadata | undefined,
|
|
|
|
|
- ) {
|
|
|
|
|
- const entry = recorded.get(name) ?? { interactions: [], findings: [] }
|
|
|
|
|
- entry.interactions.push(interaction)
|
|
|
|
|
- entry.findings.push(...secretFindings(interaction))
|
|
|
|
|
- recorded.set(name, entry)
|
|
|
|
|
- const cassette = cassetteFor(name, entry.interactions, metadata)
|
|
|
|
|
- const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
|
|
|
|
|
- if (findings.length === 0) yield* write(name, cassette)
|
|
|
|
|
- return { cassette, findings }
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- const exists = Effect.fn("Cassette.exists")(function* (name: string) {
|
|
|
|
|
- return yield* fileSystem.access(pathFor(name)).pipe(
|
|
|
|
|
- Effect.as(true),
|
|
|
|
|
- Effect.catch(() => Effect.succeed(false)),
|
|
|
|
|
- )
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- const list = Effect.fn("Cassette.list")(function* () {
|
|
|
|
|
- return (yield* walk(directory))
|
|
|
|
|
- .filter((file) => file.endsWith(".json"))
|
|
|
|
|
- .map((file) => ({
|
|
|
|
|
- name: path
|
|
|
|
|
- .relative(directory, file)
|
|
|
|
|
- .replace(/\\/g, "/")
|
|
|
|
|
- .replace(/\.json$/, ""),
|
|
|
|
|
- path: file,
|
|
|
|
|
- }))
|
|
|
|
|
- .toSorted((a, b) => a.name.localeCompare(b.name))
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings })
|
|
|
|
|
}),
|
|
}),
|
|
|
)
|
|
)
|
|
|
|
|
+
|
|
|
|
|
+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[]>()
|
|
|
|
|
+
|
|
|
|
|
+ return Service.of({
|
|
|
|
|
+ read: (name) =>
|
|
|
|
|
+ stored.has(name)
|
|
|
|
|
+ ? Effect.succeed(stored.get(name) ?? [])
|
|
|
|
|
+ : Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
|
|
|
|
|
+ append: (name, interaction, metadata) =>
|
|
|
|
|
+ Effect.sync(() => {
|
|
|
|
|
+ const existing = stored.get(name)
|
|
|
|
|
+ if (existing) existing.push(interaction)
|
|
|
|
|
+ else stored.set(name, [interaction])
|
|
|
|
|
+ const findings = accumulatedFindings.get(name)
|
|
|
|
|
+ if (findings) findings.push(...secretFindings(interaction))
|
|
|
|
|
+ else accumulatedFindings.set(name, [...secretFindings(interaction)])
|
|
|
|
|
+ if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata }))
|
|
|
|
|
+ return { findings: accumulatedFindings.get(name) ?? [] }
|
|
|
|
|
+ }),
|
|
|
|
|
+ exists: (name) => Effect.sync(() => stored.has(name)),
|
|
|
|
|
+ list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|