chore: generate
This commit is contained in:
@@ -1136,376 +1136,380 @@ export namespace Config {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Auth.Service | Account.Service> = Layer.effect(
|
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Auth.Service | Account.Service> =
|
||||||
Service,
|
Layer.effect(
|
||||||
Effect.gen(function* () {
|
Service,
|
||||||
const fs = yield* AppFileSystem.Service
|
Effect.gen(function* () {
|
||||||
const authSvc = yield* Auth.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
const accountSvc = yield* Account.Service
|
const authSvc = yield* Auth.Service
|
||||||
|
const accountSvc = yield* Account.Service
|
||||||
|
|
||||||
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
|
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
|
||||||
return yield* fs.readFileString(filepath).pipe(
|
return yield* fs.readFileString(filepath).pipe(
|
||||||
Effect.catchIf(
|
Effect.catchIf(
|
||||||
(e) => e.reason._tag === "NotFound",
|
(e) => e.reason._tag === "NotFound",
|
||||||
() => Effect.succeed(undefined),
|
() => Effect.succeed(undefined),
|
||||||
),
|
),
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadConfig = Effect.fnUntraced(function* (
|
const loadConfig = Effect.fnUntraced(function* (
|
||||||
text: string,
|
text: string,
|
||||||
options: { path: string } | { dir: string; source: string },
|
options: { path: string } | { dir: string; source: string },
|
||||||
) {
|
) {
|
||||||
const original = text
|
const original = text
|
||||||
const source = "path" in options ? options.path : options.source
|
const source = "path" in options ? options.path : options.source
|
||||||
const isFile = "path" in options
|
const isFile = "path" in options
|
||||||
const data = yield* Effect.promise(() =>
|
const data = yield* Effect.promise(() =>
|
||||||
ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }),
|
ConfigPaths.parseText(
|
||||||
)
|
text,
|
||||||
|
"path" in options ? options.path : { source: options.source, dir: options.dir },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const normalized = (() => {
|
const normalized = (() => {
|
||||||
if (!data || typeof data !== "object" || Array.isArray(data)) return data
|
if (!data || typeof data !== "object" || Array.isArray(data)) return data
|
||||||
const copy = { ...(data as Record<string, unknown>) }
|
const copy = { ...(data as Record<string, unknown>) }
|
||||||
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
|
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
|
||||||
if (!hadLegacy) return copy
|
if (!hadLegacy) return copy
|
||||||
delete copy.theme
|
delete copy.theme
|
||||||
delete copy.keybinds
|
delete copy.keybinds
|
||||||
delete copy.tui
|
delete copy.tui
|
||||||
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
|
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
|
||||||
return copy
|
return copy
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const parsed = Info.safeParse(normalized)
|
const parsed = Info.safeParse(normalized)
|
||||||
if (parsed.success) {
|
if (parsed.success) {
|
||||||
if (!parsed.data.$schema && isFile) {
|
if (!parsed.data.$schema && isFile) {
|
||||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||||
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
|
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
|
||||||
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
|
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
|
||||||
}
|
}
|
||||||
const data = parsed.data
|
const data = parsed.data
|
||||||
if (data.plugin && isFile) {
|
if (data.plugin && isFile) {
|
||||||
for (let i = 0; i < data.plugin.length; i++) {
|
for (let i = 0; i < data.plugin.length; i++) {
|
||||||
const plugin = data.plugin[i]
|
const plugin = data.plugin[i]
|
||||||
try {
|
|
||||||
data.plugin[i] = import.meta.resolve!(plugin, options.path)
|
|
||||||
} catch (e) {
|
|
||||||
try {
|
try {
|
||||||
const require = createRequire(options.path)
|
data.plugin[i] = import.meta.resolve!(plugin, options.path)
|
||||||
const resolvedPath = require.resolve(plugin)
|
} catch (e) {
|
||||||
data.plugin[i] = pathToFileURL(resolvedPath).href
|
try {
|
||||||
} catch {
|
const require = createRequire(options.path)
|
||||||
// Ignore, plugin might be a generic string identifier like "mcp-server"
|
const resolvedPath = require.resolve(plugin)
|
||||||
|
data.plugin[i] = pathToFileURL(resolvedPath).href
|
||||||
|
} catch {
|
||||||
|
// Ignore, plugin might be a generic string identifier like "mcp-server"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new InvalidError({
|
throw new InvalidError({
|
||||||
path: source,
|
path: source,
|
||||||
issues: parsed.error.issues,
|
issues: parsed.error.issues,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||||
log.info("loading", { path: filepath })
|
log.info("loading", { path: filepath })
|
||||||
const text = yield* readConfigFile(filepath)
|
const text = yield* readConfigFile(filepath)
|
||||||
if (!text) return {} as Info
|
if (!text) return {} as Info
|
||||||
return yield* loadConfig(text, { path: filepath })
|
return yield* loadConfig(text, { path: filepath })
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadGlobal = Effect.fnUntraced(function* () {
|
const loadGlobal = Effect.fnUntraced(function* () {
|
||||||
let result: Info = pipe(
|
let result: Info = pipe(
|
||||||
{},
|
{},
|
||||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
|
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
|
||||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
|
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
|
||||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
|
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const legacy = path.join(Global.Path.config, "config")
|
||||||
|
if (existsSync(legacy)) {
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
import(pathToFileURL(legacy).href, { with: { type: "toml" } })
|
||||||
|
.then(async (mod) => {
|
||||||
|
const { provider, model, ...rest } = mod.default
|
||||||
|
if (provider && model) result.model = `${provider}/${model}`
|
||||||
|
result["$schema"] = "https://opencode.ai/config.json"
|
||||||
|
result = mergeDeep(result, rest)
|
||||||
|
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
|
||||||
|
await fsNode.unlink(legacy)
|
||||||
|
})
|
||||||
|
.catch(() => {}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
|
||||||
|
loadGlobal().pipe(
|
||||||
|
Effect.tapError((error) =>
|
||||||
|
Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
|
||||||
|
),
|
||||||
|
Effect.orElseSucceed((): Info => ({})),
|
||||||
|
),
|
||||||
|
Duration.infinity,
|
||||||
)
|
)
|
||||||
|
|
||||||
const legacy = path.join(Global.Path.config, "config")
|
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
|
||||||
if (existsSync(legacy)) {
|
return yield* cachedGlobal
|
||||||
yield* Effect.promise(() =>
|
})
|
||||||
import(pathToFileURL(legacy).href, { with: { type: "toml" } })
|
|
||||||
.then(async (mod) => {
|
|
||||||
const { provider, model, ...rest } = mod.default
|
|
||||||
if (provider && model) result.model = `${provider}/${model}`
|
|
||||||
result["$schema"] = "https://opencode.ai/config.json"
|
|
||||||
result = mergeDeep(result, rest)
|
|
||||||
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
|
|
||||||
await fsNode.unlink(legacy)
|
|
||||||
})
|
|
||||||
.catch(() => {}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
|
||||||
})
|
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
||||||
|
|
||||||
const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
|
let result: Info = {}
|
||||||
loadGlobal().pipe(
|
for (const [key, value] of Object.entries(auth)) {
|
||||||
Effect.tapError((error) =>
|
if (value.type === "wellknown") {
|
||||||
Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
|
const url = key.replace(/\/+$/, "")
|
||||||
),
|
process.env[value.key] = value.token
|
||||||
Effect.orElseSucceed((): Info => ({})),
|
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
|
||||||
),
|
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
|
||||||
Duration.infinity,
|
if (!response.ok) {
|
||||||
)
|
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
|
||||||
|
}
|
||||||
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
|
const wellknown = (yield* Effect.promise(() => response.json())) as any
|
||||||
return yield* cachedGlobal
|
const remoteConfig = wellknown.config ?? {}
|
||||||
})
|
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||||
|
|
||||||
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
|
|
||||||
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
|
||||||
|
|
||||||
let result: Info = {}
|
|
||||||
for (const [key, value] of Object.entries(auth)) {
|
|
||||||
if (value.type === "wellknown") {
|
|
||||||
const url = key.replace(/\/+$/, "")
|
|
||||||
process.env[value.key] = value.token
|
|
||||||
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
|
|
||||||
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
|
|
||||||
}
|
|
||||||
const wellknown = (yield* Effect.promise(() => response.json())) as any
|
|
||||||
const remoteConfig = wellknown.config ?? {}
|
|
||||||
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
|
||||||
result = mergeConfigConcatArrays(
|
|
||||||
result,
|
|
||||||
yield* loadConfig(JSON.stringify(remoteConfig), {
|
|
||||||
dir: path.dirname(`${url}/.well-known/opencode`),
|
|
||||||
source: `${url}/.well-known/opencode`,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
log.debug("loaded remote config from well-known", { url })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = mergeConfigConcatArrays(result, yield* getGlobal())
|
|
||||||
|
|
||||||
if (Flag.OPENCODE_CONFIG) {
|
|
||||||
result = mergeConfigConcatArrays(result, yield* loadFile(Flag.OPENCODE_CONFIG))
|
|
||||||
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
|
||||||
for (const file of yield* Effect.promise(() =>
|
|
||||||
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
|
|
||||||
)) {
|
|
||||||
result = mergeConfigConcatArrays(result, yield* loadFile(file))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.agent = result.agent || {}
|
|
||||||
result.mode = result.mode || {}
|
|
||||||
result.plugin = result.plugin || []
|
|
||||||
|
|
||||||
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
|
|
||||||
|
|
||||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
|
||||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
|
||||||
}
|
|
||||||
|
|
||||||
const deps: Promise<void>[] = []
|
|
||||||
|
|
||||||
for (const dir of unique(directories)) {
|
|
||||||
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
|
||||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
|
||||||
log.debug(`loading config from ${path.join(dir, file)}`)
|
|
||||||
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(dir, file)))
|
|
||||||
result.agent ??= {}
|
|
||||||
result.mode ??= {}
|
|
||||||
result.plugin ??= []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deps.push(
|
|
||||||
iife(async () => {
|
|
||||||
const shouldInstall = await needsInstall(dir)
|
|
||||||
if (shouldInstall) await installDependencies(dir)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
|
|
||||||
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
|
|
||||||
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
|
|
||||||
result.plugin.push(...(yield* Effect.promise(() => loadPlugin(dir))))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
|
||||||
result = mergeConfigConcatArrays(
|
|
||||||
result,
|
|
||||||
yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
|
|
||||||
dir: ctx.directory,
|
|
||||||
source: "OPENCODE_CONFIG_CONTENT",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
|
||||||
}
|
|
||||||
|
|
||||||
const active = Option.getOrUndefined(yield* accountSvc.active().pipe(Effect.orDie))
|
|
||||||
if (active?.active_org_id) {
|
|
||||||
yield* Effect.gen(function* () {
|
|
||||||
const [configOpt, tokenOpt] = yield* Effect.all(
|
|
||||||
[accountSvc.config(active.id, active.active_org_id!), accountSvc.token(active.id)],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
const token = Option.getOrUndefined(tokenOpt)
|
|
||||||
if (token) {
|
|
||||||
process.env["OPENCODE_CONSOLE_TOKEN"] = token
|
|
||||||
Env.set("OPENCODE_CONSOLE_TOKEN", token)
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = Option.getOrUndefined(configOpt)
|
|
||||||
if (config) {
|
|
||||||
result = mergeConfigConcatArrays(
|
result = mergeConfigConcatArrays(
|
||||||
result,
|
result,
|
||||||
yield* loadConfig(JSON.stringify(config), {
|
yield* loadConfig(JSON.stringify(remoteConfig), {
|
||||||
dir: path.dirname(`${active.url}/api/config`),
|
dir: path.dirname(`${url}/.well-known/opencode`),
|
||||||
source: `${active.url}/api/config`,
|
source: `${url}/.well-known/opencode`,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
log.debug("loaded remote config from well-known", { url })
|
||||||
}
|
}
|
||||||
}).pipe(
|
|
||||||
Effect.catch((err) => {
|
|
||||||
log.debug("failed to fetch remote account config", {
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
return Effect.void
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existsSync(managedDir)) {
|
|
||||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
|
||||||
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(managedDir, file)))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, mode] of Object.entries(result.mode ?? {})) {
|
result = mergeConfigConcatArrays(result, yield* getGlobal())
|
||||||
result.agent = mergeDeep(result.agent ?? {}, {
|
|
||||||
[name]: {
|
|
||||||
...mode,
|
|
||||||
mode: "primary" as const,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Flag.OPENCODE_PERMISSION) {
|
if (Flag.OPENCODE_CONFIG) {
|
||||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
result = mergeConfigConcatArrays(result, yield* loadFile(Flag.OPENCODE_CONFIG))
|
||||||
}
|
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||||
|
}
|
||||||
|
|
||||||
if (result.tools) {
|
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||||
const perms: Record<string, Config.PermissionAction> = {}
|
for (const file of yield* Effect.promise(() =>
|
||||||
for (const [tool, enabled] of Object.entries(result.tools)) {
|
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
|
||||||
const action: Config.PermissionAction = enabled ? "allow" : "deny"
|
)) {
|
||||||
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
result = mergeConfigConcatArrays(result, yield* loadFile(file))
|
||||||
perms.edit = action
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
perms[tool] = action
|
|
||||||
}
|
}
|
||||||
result.permission = mergeDeep(perms, result.permission ?? {})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.username) result.username = os.userInfo().username
|
result.agent = result.agent || {}
|
||||||
|
result.mode = result.mode || {}
|
||||||
|
result.plugin = result.plugin || []
|
||||||
|
|
||||||
if (result.autoshare === true && !result.share) {
|
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
|
||||||
result.share = "auto"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
|
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||||
result.compaction = { ...result.compaction, auto: false }
|
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||||
}
|
}
|
||||||
if (Flag.OPENCODE_DISABLE_PRUNE) {
|
|
||||||
result.compaction = { ...result.compaction, prune: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
result.plugin = deduplicatePlugins(result.plugin ?? [])
|
const deps: Promise<void>[] = []
|
||||||
|
|
||||||
return {
|
for (const dir of unique(directories)) {
|
||||||
config: result,
|
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
||||||
directories,
|
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||||
deps,
|
log.debug(`loading config from ${path.join(dir, file)}`)
|
||||||
}
|
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(dir, file)))
|
||||||
})
|
result.agent ??= {}
|
||||||
|
result.mode ??= {}
|
||||||
|
result.plugin ??= []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
deps.push(
|
||||||
Effect.fn("Config.state")(function* (ctx) {
|
iife(async () => {
|
||||||
return yield* loadInstanceState(ctx)
|
const shouldInstall = await needsInstall(dir)
|
||||||
}),
|
if (shouldInstall) await installDependencies(dir)
|
||||||
)
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const get = Effect.fn("Config.get")(function* () {
|
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
|
||||||
return yield* InstanceState.use(state, (s) => s.config)
|
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
|
||||||
})
|
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
|
||||||
|
result.plugin.push(...(yield* Effect.promise(() => loadPlugin(dir))))
|
||||||
|
}
|
||||||
|
|
||||||
const directories = Effect.fn("Config.directories")(function* () {
|
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
||||||
return yield* InstanceState.use(state, (s) => s.directories)
|
result = mergeConfigConcatArrays(
|
||||||
})
|
result,
|
||||||
|
yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
|
||||||
|
dir: ctx.directory,
|
||||||
|
source: "OPENCODE_CONFIG_CONTENT",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||||
|
}
|
||||||
|
|
||||||
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
|
const active = Option.getOrUndefined(yield* accountSvc.active().pipe(Effect.orDie))
|
||||||
yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined)))
|
if (active?.active_org_id) {
|
||||||
})
|
yield* Effect.gen(function* () {
|
||||||
|
const [configOpt, tokenOpt] = yield* Effect.all(
|
||||||
|
[accountSvc.config(active.id, active.active_org_id!), accountSvc.token(active.id)],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
const token = Option.getOrUndefined(tokenOpt)
|
||||||
|
if (token) {
|
||||||
|
process.env["OPENCODE_CONSOLE_TOKEN"] = token
|
||||||
|
Env.set("OPENCODE_CONSOLE_TOKEN", token)
|
||||||
|
}
|
||||||
|
|
||||||
const update = Effect.fn("Config.update")(function* (config: Info) {
|
const config = Option.getOrUndefined(configOpt)
|
||||||
const file = path.join(Instance.directory, "config.json")
|
if (config) {
|
||||||
const existing = yield* loadFile(file)
|
result = mergeConfigConcatArrays(
|
||||||
yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
|
result,
|
||||||
yield* Effect.promise(() => Instance.dispose())
|
yield* loadConfig(JSON.stringify(config), {
|
||||||
})
|
dir: path.dirname(`${active.url}/api/config`),
|
||||||
|
source: `${active.url}/api/config`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
Effect.catch((err) => {
|
||||||
|
log.debug("failed to fetch remote account config", {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
return Effect.void
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
|
if (existsSync(managedDir)) {
|
||||||
yield* invalidateGlobal
|
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||||
const task = Instance.disposeAll()
|
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(managedDir, file)))
|
||||||
.catch(() => undefined)
|
}
|
||||||
.finally(() =>
|
}
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory: "global",
|
for (const [name, mode] of Object.entries(result.mode ?? {})) {
|
||||||
payload: {
|
result.agent = mergeDeep(result.agent ?? {}, {
|
||||||
type: Event.Disposed.type,
|
[name]: {
|
||||||
properties: {},
|
...mode,
|
||||||
|
mode: "primary" as const,
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
)
|
}
|
||||||
if (wait) yield* Effect.promise(() => task)
|
|
||||||
else void task
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
|
if (Flag.OPENCODE_PERMISSION) {
|
||||||
const file = globalConfigFile()
|
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||||
const before = (yield* readConfigFile(file)) ?? "{}"
|
}
|
||||||
|
|
||||||
let next: Info
|
if (result.tools) {
|
||||||
if (!file.endsWith(".jsonc")) {
|
const perms: Record<string, Config.PermissionAction> = {}
|
||||||
const existing = parseConfig(before, file)
|
for (const [tool, enabled] of Object.entries(result.tools)) {
|
||||||
const merged = mergeDeep(existing, config)
|
const action: Config.PermissionAction = enabled ? "allow" : "deny"
|
||||||
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
||||||
next = merged
|
perms.edit = action
|
||||||
} else {
|
continue
|
||||||
const updated = patchJsonc(before, config)
|
}
|
||||||
next = parseConfig(updated, file)
|
perms[tool] = action
|
||||||
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
}
|
||||||
}
|
result.permission = mergeDeep(perms, result.permission ?? {})
|
||||||
|
}
|
||||||
|
|
||||||
yield* invalidate()
|
if (!result.username) result.username = os.userInfo().username
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
if (result.autoshare === true && !result.share) {
|
||||||
get,
|
result.share = "auto"
|
||||||
getGlobal,
|
}
|
||||||
update,
|
|
||||||
updateGlobal,
|
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
|
||||||
invalidate,
|
result.compaction = { ...result.compaction, auto: false }
|
||||||
directories,
|
}
|
||||||
waitForDependencies,
|
if (Flag.OPENCODE_DISABLE_PRUNE) {
|
||||||
})
|
result.compaction = { ...result.compaction, prune: false }
|
||||||
}),
|
}
|
||||||
)
|
|
||||||
|
result.plugin = deduplicatePlugins(result.plugin ?? [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: result,
|
||||||
|
directories,
|
||||||
|
deps,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Config.state")(function* (ctx) {
|
||||||
|
return yield* loadInstanceState(ctx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const get = Effect.fn("Config.get")(function* () {
|
||||||
|
return yield* InstanceState.use(state, (s) => s.config)
|
||||||
|
})
|
||||||
|
|
||||||
|
const directories = Effect.fn("Config.directories")(function* () {
|
||||||
|
return yield* InstanceState.use(state, (s) => s.directories)
|
||||||
|
})
|
||||||
|
|
||||||
|
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
|
||||||
|
yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = Effect.fn("Config.update")(function* (config: Info) {
|
||||||
|
const file = path.join(Instance.directory, "config.json")
|
||||||
|
const existing = yield* loadFile(file)
|
||||||
|
yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
|
||||||
|
yield* Effect.promise(() => Instance.dispose())
|
||||||
|
})
|
||||||
|
|
||||||
|
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
|
||||||
|
yield* invalidateGlobal
|
||||||
|
const task = Instance.disposeAll()
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() =>
|
||||||
|
GlobalBus.emit("event", {
|
||||||
|
directory: "global",
|
||||||
|
payload: {
|
||||||
|
type: Event.Disposed.type,
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (wait) yield* Effect.promise(() => task)
|
||||||
|
else void task
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
|
||||||
|
const file = globalConfigFile()
|
||||||
|
const before = (yield* readConfigFile(file)) ?? "{}"
|
||||||
|
|
||||||
|
let next: Info
|
||||||
|
if (!file.endsWith(".jsonc")) {
|
||||||
|
const existing = parseConfig(before, file)
|
||||||
|
const merged = mergeDeep(existing, config)
|
||||||
|
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
||||||
|
next = merged
|
||||||
|
} else {
|
||||||
|
const updated = patchJsonc(before, config)
|
||||||
|
next = parseConfig(updated, file)
|
||||||
|
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* invalidate()
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
get,
|
||||||
|
getGlobal,
|
||||||
|
update,
|
||||||
|
updateGlobal,
|
||||||
|
invalidate,
|
||||||
|
directories,
|
||||||
|
waitForDependencies,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
|||||||
@@ -477,10 +477,8 @@ export namespace MCP {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const cache = yield* InstanceState.make<State>(
|
const cache = yield* InstanceState.make<State>(
|
||||||
Effect.fn("MCP.state")(function* () {
|
Effect.fn("MCP.state")(function* () {
|
||||||
|
|
||||||
const cfg = yield* cfgSvc.get()
|
const cfg = yield* cfgSvc.get()
|
||||||
const config = cfg.mcp ?? {}
|
const config = cfg.mcp ?? {}
|
||||||
const s: State = {
|
const s: State = {
|
||||||
@@ -706,7 +704,6 @@ export namespace MCP {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
|
const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
|
||||||
|
|
||||||
const cfg = yield* cfgSvc.get()
|
const cfg = yield* cfgSvc.get()
|
||||||
const mcpConfig = cfg.mcp?.[mcpName]
|
const mcpConfig = cfg.mcp?.[mcpName]
|
||||||
if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined
|
if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined
|
||||||
|
|||||||
@@ -82,350 +82,341 @@ export namespace Snapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("Snapshot.state")(function* (ctx) {
|
Effect.fn("Snapshot.state")(function* (ctx) {
|
||||||
const state = {
|
const state = {
|
||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
worktree: ctx.worktree,
|
worktree: ctx.worktree,
|
||||||
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
|
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
|
||||||
vcs: ctx.project.vcs,
|
vcs: ctx.project.vcs,
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
|
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
|
||||||
|
|
||||||
const git = Effect.fnUntraced(
|
const git = Effect.fnUntraced(
|
||||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||||
const proc = ChildProcess.make("git", cmd, {
|
const proc = ChildProcess.make("git", cmd, {
|
||||||
cwd: opts?.cwd,
|
cwd: opts?.cwd,
|
||||||
env: opts?.env,
|
env: opts?.env,
|
||||||
extendEnv: true,
|
extendEnv: true,
|
||||||
})
|
|
||||||
const handle = yield* spawner.spawn(proc)
|
|
||||||
const [text, stderr] = yield* Effect.all(
|
|
||||||
[
|
|
||||||
Stream.mkString(Stream.decodeText(handle.stdout)),
|
|
||||||
Stream.mkString(Stream.decodeText(handle.stderr)),
|
|
||||||
],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
const code = yield* handle.exitCode
|
|
||||||
return { code, text, stderr } satisfies GitResult
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.catch((err) =>
|
|
||||||
Effect.succeed({
|
|
||||||
code: ChildProcessSpawner.ExitCode(1),
|
|
||||||
text: "",
|
|
||||||
stderr: String(err),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
|
||||||
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
|
||||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
|
||||||
const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
|
|
||||||
|
|
||||||
const enabled = Effect.fnUntraced(function* () {
|
|
||||||
if (state.vcs !== "git") return false
|
|
||||||
return (yield* config.get()).snapshot !== false
|
|
||||||
})
|
|
||||||
|
|
||||||
const excludes = Effect.fnUntraced(function* () {
|
|
||||||
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
|
||||||
cwd: state.worktree,
|
|
||||||
})
|
})
|
||||||
const file = result.text.trim()
|
const handle = yield* spawner.spawn(proc)
|
||||||
if (!file) return
|
const [text, stderr] = yield* Effect.all(
|
||||||
if (!(yield* exists(file))) return
|
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||||
return file
|
|
||||||
})
|
|
||||||
|
|
||||||
const sync = Effect.fnUntraced(function* (list: string[] = []) {
|
|
||||||
const file = yield* excludes()
|
|
||||||
const target = path.join(state.gitdir, "info", "exclude")
|
|
||||||
const text = [
|
|
||||||
file ? (yield* read(file)).trimEnd() : "",
|
|
||||||
...list.map((item) => `/${item.replaceAll("\\", "/")}`),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n")
|
|
||||||
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
|
|
||||||
yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
|
|
||||||
})
|
|
||||||
|
|
||||||
const add = Effect.fnUntraced(function* () {
|
|
||||||
yield* sync()
|
|
||||||
const [diff, other] = yield* Effect.all(
|
|
||||||
[
|
|
||||||
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
|
|
||||||
cwd: state.directory,
|
|
||||||
}),
|
|
||||||
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
|
|
||||||
cwd: state.directory,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
{ concurrency: 2 },
|
{ concurrency: 2 },
|
||||||
)
|
)
|
||||||
if (diff.code !== 0 || other.code !== 0) {
|
const code = yield* handle.exitCode
|
||||||
log.warn("failed to list snapshot files", {
|
return { code, text, stderr } satisfies GitResult
|
||||||
diffCode: diff.code,
|
},
|
||||||
diffStderr: diff.stderr,
|
Effect.scoped,
|
||||||
otherCode: other.code,
|
Effect.catch((err) =>
|
||||||
otherStderr: other.stderr,
|
Effect.succeed({
|
||||||
})
|
code: ChildProcessSpawner.ExitCode(1),
|
||||||
return
|
text: "",
|
||||||
}
|
stderr: String(err),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const tracked = diff.text.split("\0").filter(Boolean)
|
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
||||||
const all = Array.from(new Set([...tracked, ...other.text.split("\0").filter(Boolean)]))
|
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||||
if (!all.length) return
|
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||||
|
const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
|
||||||
|
|
||||||
const large = (yield* Effect.all(
|
const enabled = Effect.fnUntraced(function* () {
|
||||||
all.map((item) =>
|
if (state.vcs !== "git") return false
|
||||||
fs
|
return (yield* config.get()).snapshot !== false
|
||||||
.stat(path.join(state.directory, item))
|
})
|
||||||
.pipe(Effect.catch(() => Effect.void))
|
|
||||||
.pipe(
|
const excludes = Effect.fnUntraced(function* () {
|
||||||
Effect.map((stat) => {
|
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||||
if (!stat || stat.type !== "File") return
|
cwd: state.worktree,
|
||||||
const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
|
|
||||||
return size > limit ? item : undefined
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
{ concurrency: 8 },
|
|
||||||
)).filter((item): item is string => Boolean(item))
|
|
||||||
yield* sync(large)
|
|
||||||
const result = yield* git([...cfg, ...args(["add", "--sparse", "."])], { cwd: state.directory })
|
|
||||||
if (result.code !== 0) {
|
|
||||||
log.warn("failed to add snapshot files", {
|
|
||||||
exitCode: result.code,
|
|
||||||
stderr: result.stderr,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
const file = result.text.trim()
|
||||||
|
if (!file) return
|
||||||
|
if (!(yield* exists(file))) return
|
||||||
|
return file
|
||||||
|
})
|
||||||
|
|
||||||
const cleanup = Effect.fnUntraced(function* () {
|
const sync = Effect.fnUntraced(function* (list: string[] = []) {
|
||||||
return yield* locked(
|
const file = yield* excludes()
|
||||||
Effect.gen(function* () {
|
const target = path.join(state.gitdir, "info", "exclude")
|
||||||
if (!(yield* enabled())) return
|
const text = [
|
||||||
if (!(yield* exists(state.gitdir))) return
|
file ? (yield* read(file)).trimEnd() : "",
|
||||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
|
...list.map((item) => `/${item.replaceAll("\\", "/")}`),
|
||||||
if (result.code !== 0) {
|
]
|
||||||
log.warn("cleanup failed", {
|
.filter(Boolean)
|
||||||
exitCode: result.code,
|
.join("\n")
|
||||||
stderr: result.stderr,
|
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
|
||||||
})
|
yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
|
||||||
return
|
})
|
||||||
}
|
|
||||||
log.info("cleanup", { prune })
|
const add = Effect.fnUntraced(function* () {
|
||||||
|
yield* sync()
|
||||||
|
const [diff, other] = yield* Effect.all(
|
||||||
|
[
|
||||||
|
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
|
||||||
|
cwd: state.directory,
|
||||||
}),
|
}),
|
||||||
)
|
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
|
||||||
})
|
cwd: state.directory,
|
||||||
|
|
||||||
const track = Effect.fnUntraced(function* () {
|
|
||||||
return yield* locked(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (!(yield* enabled())) return
|
|
||||||
const existed = yield* exists(state.gitdir)
|
|
||||||
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
|
|
||||||
if (!existed) {
|
|
||||||
yield* git(["init"], {
|
|
||||||
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
|
|
||||||
})
|
|
||||||
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
|
|
||||||
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
|
|
||||||
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
|
|
||||||
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
|
|
||||||
log.info("initialized")
|
|
||||||
}
|
|
||||||
yield* add()
|
|
||||||
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
|
|
||||||
const hash = result.text.trim()
|
|
||||||
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
|
|
||||||
return hash
|
|
||||||
}),
|
}),
|
||||||
)
|
],
|
||||||
})
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
if (diff.code !== 0 || other.code !== 0) {
|
||||||
|
log.warn("failed to list snapshot files", {
|
||||||
|
diffCode: diff.code,
|
||||||
|
diffStderr: diff.stderr,
|
||||||
|
otherCode: other.code,
|
||||||
|
otherStderr: other.stderr,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const patch = Effect.fnUntraced(function* (hash: string) {
|
const tracked = diff.text.split("\0").filter(Boolean)
|
||||||
return yield* locked(
|
const all = Array.from(new Set([...tracked, ...other.text.split("\0").filter(Boolean)]))
|
||||||
Effect.gen(function* () {
|
if (!all.length) return
|
||||||
yield* add()
|
|
||||||
const result = yield* git(
|
|
||||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
|
||||||
{
|
|
||||||
cwd: state.directory,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (result.code !== 0) {
|
|
||||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
|
||||||
return { hash, files: [] }
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
hash,
|
|
||||||
files: result.text
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map((x) => x.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const restore = Effect.fnUntraced(function* (snapshot: string) {
|
const large = (yield* Effect.all(
|
||||||
return yield* locked(
|
all.map((item) =>
|
||||||
Effect.gen(function* () {
|
fs
|
||||||
log.info("restore", { commit: snapshot })
|
.stat(path.join(state.directory, item))
|
||||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
|
.pipe(Effect.catch(() => Effect.void))
|
||||||
if (result.code === 0) {
|
.pipe(
|
||||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
|
Effect.map((stat) => {
|
||||||
cwd: state.worktree,
|
if (!stat || stat.type !== "File") return
|
||||||
})
|
const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
|
||||||
if (checkout.code === 0) return
|
return size > limit ? item : undefined
|
||||||
log.error("failed to restore snapshot", {
|
}),
|
||||||
snapshot,
|
),
|
||||||
exitCode: checkout.code,
|
),
|
||||||
stderr: checkout.stderr,
|
{ concurrency: 8 },
|
||||||
})
|
)).filter((item): item is string => Boolean(item))
|
||||||
return
|
yield* sync(large)
|
||||||
}
|
const result = yield* git([...cfg, ...args(["add", "--sparse", "."])], { cwd: state.directory })
|
||||||
log.error("failed to restore snapshot", {
|
if (result.code !== 0) {
|
||||||
snapshot,
|
log.warn("failed to add snapshot files", {
|
||||||
|
exitCode: result.code,
|
||||||
|
stderr: result.stderr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const cleanup = Effect.fnUntraced(function* () {
|
||||||
|
return yield* locked(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!(yield* enabled())) return
|
||||||
|
if (!(yield* exists(state.gitdir))) return
|
||||||
|
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
|
||||||
|
if (result.code !== 0) {
|
||||||
|
log.warn("cleanup failed", {
|
||||||
exitCode: result.code,
|
exitCode: result.code,
|
||||||
stderr: result.stderr,
|
stderr: result.stderr,
|
||||||
})
|
})
|
||||||
}),
|
return
|
||||||
)
|
}
|
||||||
})
|
log.info("cleanup", { prune })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
|
const track = Effect.fnUntraced(function* () {
|
||||||
return yield* locked(
|
return yield* locked(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const seen = new Set<string>()
|
if (!(yield* enabled())) return
|
||||||
for (const item of patches) {
|
const existed = yield* exists(state.gitdir)
|
||||||
for (const file of item.files) {
|
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
|
||||||
if (seen.has(file)) continue
|
if (!existed) {
|
||||||
seen.add(file)
|
yield* git(["init"], {
|
||||||
log.info("reverting", { file, hash: item.hash })
|
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
|
||||||
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
|
})
|
||||||
|
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
|
||||||
|
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
|
||||||
|
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
|
||||||
|
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
|
||||||
|
log.info("initialized")
|
||||||
|
}
|
||||||
|
yield* add()
|
||||||
|
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
|
||||||
|
const hash = result.text.trim()
|
||||||
|
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
|
||||||
|
return hash
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const patch = Effect.fnUntraced(function* (hash: string) {
|
||||||
|
return yield* locked(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* add()
|
||||||
|
const result = yield* git(
|
||||||
|
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||||
|
{
|
||||||
|
cwd: state.directory,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (result.code !== 0) {
|
||||||
|
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||||
|
return { hash, files: [] }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
files: result.text
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const restore = Effect.fnUntraced(function* (snapshot: string) {
|
||||||
|
return yield* locked(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
log.info("restore", { commit: snapshot })
|
||||||
|
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
|
||||||
|
if (result.code === 0) {
|
||||||
|
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
|
||||||
|
cwd: state.worktree,
|
||||||
|
})
|
||||||
|
if (checkout.code === 0) return
|
||||||
|
log.error("failed to restore snapshot", {
|
||||||
|
snapshot,
|
||||||
|
exitCode: checkout.code,
|
||||||
|
stderr: checkout.stderr,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.error("failed to restore snapshot", {
|
||||||
|
snapshot,
|
||||||
|
exitCode: result.code,
|
||||||
|
stderr: result.stderr,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
|
||||||
|
return yield* locked(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const item of patches) {
|
||||||
|
for (const file of item.files) {
|
||||||
|
if (seen.has(file)) continue
|
||||||
|
seen.add(file)
|
||||||
|
log.info("reverting", { file, hash: item.hash })
|
||||||
|
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
|
||||||
|
cwd: state.worktree,
|
||||||
|
})
|
||||||
|
if (result.code !== 0) {
|
||||||
|
const rel = path.relative(state.worktree, file)
|
||||||
|
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
|
||||||
cwd: state.worktree,
|
cwd: state.worktree,
|
||||||
})
|
})
|
||||||
if (result.code !== 0) {
|
if (tree.code === 0 && tree.text.trim()) {
|
||||||
const rel = path.relative(state.worktree, file)
|
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||||
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
|
} else {
|
||||||
cwd: state.worktree,
|
log.info("file did not exist in snapshot, deleting", { file })
|
||||||
})
|
yield* remove(file)
|
||||||
if (tree.code === 0 && tree.text.trim()) {
|
|
||||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
|
||||||
} else {
|
|
||||||
log.info("file did not exist in snapshot, deleting", { file })
|
|
||||||
yield* remove(file)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const diff = Effect.fnUntraced(function* (hash: string) {
|
|
||||||
return yield* locked(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* add()
|
|
||||||
const result = yield* git(
|
|
||||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])],
|
|
||||||
{
|
|
||||||
cwd: state.worktree,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (result.code !== 0) {
|
|
||||||
log.warn("failed to get diff", {
|
|
||||||
hash,
|
|
||||||
exitCode: result.code,
|
|
||||||
stderr: result.stderr,
|
|
||||||
})
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return result.text.trim()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
|
|
||||||
return yield* locked(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const result: Snapshot.FileDiff[] = []
|
|
||||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
|
||||||
|
|
||||||
const statuses = yield* git(
|
|
||||||
[
|
|
||||||
...quote,
|
|
||||||
...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
|
|
||||||
],
|
|
||||||
{ cwd: state.directory },
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const line of statuses.text.trim().split("\n")) {
|
|
||||||
if (!line) continue
|
|
||||||
const [code, file] = line.split("\t")
|
|
||||||
if (!code || !file) continue
|
|
||||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
|
||||||
}
|
|
||||||
|
|
||||||
const numstat = yield* git(
|
|
||||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
|
||||||
{
|
|
||||||
cwd: state.directory,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const line of numstat.text.trim().split("\n")) {
|
|
||||||
if (!line) continue
|
|
||||||
const [adds, dels, file] = line.split("\t")
|
|
||||||
if (!file) continue
|
|
||||||
const binary = adds === "-" && dels === "-"
|
|
||||||
const [before, after] = binary
|
|
||||||
? ["", ""]
|
|
||||||
: yield* Effect.all(
|
|
||||||
[
|
|
||||||
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
|
||||||
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
|
||||||
],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
const additions = binary ? 0 : parseInt(adds)
|
|
||||||
const deletions = binary ? 0 : parseInt(dels)
|
|
||||||
result.push({
|
|
||||||
file,
|
|
||||||
before,
|
|
||||||
after,
|
|
||||||
additions: Number.isFinite(additions) ? additions : 0,
|
|
||||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
|
||||||
status: status.get(file) ?? "modified",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* cleanup().pipe(
|
|
||||||
Effect.catchCause((cause) => {
|
|
||||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
|
||||||
return Effect.void
|
|
||||||
}),
|
}),
|
||||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
|
||||||
Effect.delay(Duration.minutes(1)),
|
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
return { cleanup, track, patch, restore, revert, diff, diffFull }
|
const diff = Effect.fnUntraced(function* (hash: string) {
|
||||||
}),
|
return yield* locked(
|
||||||
)
|
Effect.gen(function* () {
|
||||||
|
yield* add()
|
||||||
|
const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
|
||||||
|
cwd: state.worktree,
|
||||||
|
})
|
||||||
|
if (result.code !== 0) {
|
||||||
|
log.warn("failed to get diff", {
|
||||||
|
hash,
|
||||||
|
exitCode: result.code,
|
||||||
|
stderr: result.stderr,
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return result.text.trim()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
|
||||||
|
return yield* locked(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const result: Snapshot.FileDiff[] = []
|
||||||
|
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||||
|
|
||||||
|
const statuses = yield* git(
|
||||||
|
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
||||||
|
{ cwd: state.directory },
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const line of statuses.text.trim().split("\n")) {
|
||||||
|
if (!line) continue
|
||||||
|
const [code, file] = line.split("\t")
|
||||||
|
if (!code || !file) continue
|
||||||
|
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||||
|
}
|
||||||
|
|
||||||
|
const numstat = yield* git(
|
||||||
|
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||||
|
{
|
||||||
|
cwd: state.directory,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const line of numstat.text.trim().split("\n")) {
|
||||||
|
if (!line) continue
|
||||||
|
const [adds, dels, file] = line.split("\t")
|
||||||
|
if (!file) continue
|
||||||
|
const binary = adds === "-" && dels === "-"
|
||||||
|
const [before, after] = binary
|
||||||
|
? ["", ""]
|
||||||
|
: yield* Effect.all(
|
||||||
|
[
|
||||||
|
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||||
|
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||||
|
],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
const additions = binary ? 0 : parseInt(adds)
|
||||||
|
const deletions = binary ? 0 : parseInt(dels)
|
||||||
|
result.push({
|
||||||
|
file,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
additions: Number.isFinite(additions) ? additions : 0,
|
||||||
|
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||||
|
status: status.get(file) ?? "modified",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* cleanup().pipe(
|
||||||
|
Effect.catchCause((cause) => {
|
||||||
|
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
||||||
|
return Effect.void
|
||||||
|
}),
|
||||||
|
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||||
|
Effect.delay(Duration.minutes(1)),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { cleanup, track, patch, restore, revert, diff, diffFull }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
init: Effect.fn("Snapshot.init")(function* () {
|
init: Effect.fn("Snapshot.init")(function* () {
|
||||||
|
|||||||
Reference in New Issue
Block a user