refactor: wave 2 - split atlas, auto-update-checker, session-recovery, todo-enforcer, background-task hooks
- Extract atlas/ into 15 focused modules (hook, event handler, tool policies, types, etc.) - Split auto-update-checker into checker/ and hook/ subdirectories with single-purpose files - Decompose session-recovery into separate recovery strategy files per error type - Extract todo-continuation-enforcer from monolith to directory with dedicated modules - Split background-task/tools.ts into individual tool creator files - Extract command-executor, tmux-utils into focused sub-modules - Split config/schema.ts into domain-specific schema files - Decompose cli/config-manager.ts into focused modules - Rollback skill-mcp-manager, model-availability, index.ts splits that broke tests - Fix all import path depths for moved files (../../ -> ../../../) - Add explicit type annotations to resolve TS7006 implicit any errors Typecheck: 0 errors Tests: 2359 pass, 5 fail (all pre-existing)
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { log } from "../../../shared/logger"
|
||||
import type { PackageJson } from "../types"
|
||||
import { INSTALLED_PACKAGE_JSON } from "../constants"
|
||||
import { findPackageJsonUp } from "./package-json-locator"
|
||||
|
||||
export function getCachedVersion(): string | null {
|
||||
try {
|
||||
if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
|
||||
const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.version) return pkg.version
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pkgPath = findPackageJsonUp(currentDir)
|
||||
if (pkgPath) {
|
||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.version) return pkg.version
|
||||
}
|
||||
} catch (err) {
|
||||
log("[auto-update-checker] Failed to resolve version from current directory:", err)
|
||||
}
|
||||
|
||||
try {
|
||||
const execDir = path.dirname(fs.realpathSync(process.execPath))
|
||||
const pkgPath = findPackageJsonUp(execDir)
|
||||
if (pkgPath) {
|
||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.version) return pkg.version
|
||||
}
|
||||
} catch (err) {
|
||||
log("[auto-update-checker] Failed to resolve version from execPath:", err)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { log } from "../../../shared/logger"
|
||||
import type { UpdateCheckResult } from "../types"
|
||||
import { extractChannel } from "../version-channel"
|
||||
import { isLocalDevMode } from "./local-dev-path"
|
||||
import { findPluginEntry } from "./plugin-entry"
|
||||
import { getCachedVersion } from "./cached-version"
|
||||
import { getLatestVersion } from "./latest-version"
|
||||
|
||||
export async function checkForUpdate(directory: string): Promise<UpdateCheckResult> {
|
||||
if (isLocalDevMode(directory)) {
|
||||
log("[auto-update-checker] Local dev mode detected, skipping update check")
|
||||
return {
|
||||
needsUpdate: false,
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
isLocalDev: true,
|
||||
isPinned: false,
|
||||
}
|
||||
}
|
||||
|
||||
const pluginInfo = findPluginEntry(directory)
|
||||
if (!pluginInfo) {
|
||||
log("[auto-update-checker] Plugin not found in config")
|
||||
return {
|
||||
needsUpdate: false,
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
}
|
||||
}
|
||||
|
||||
const currentVersion = getCachedVersion() ?? pluginInfo.pinnedVersion
|
||||
if (!currentVersion) {
|
||||
log("[auto-update-checker] No cached version found")
|
||||
return {
|
||||
needsUpdate: false,
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
}
|
||||
}
|
||||
|
||||
const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion)
|
||||
const latestVersion = await getLatestVersion(channel)
|
||||
if (!latestVersion) {
|
||||
log("[auto-update-checker] Failed to fetch latest version for channel:", channel)
|
||||
return {
|
||||
needsUpdate: false,
|
||||
currentVersion,
|
||||
latestVersion: null,
|
||||
isLocalDev: false,
|
||||
isPinned: pluginInfo.isPinned,
|
||||
}
|
||||
}
|
||||
|
||||
const needsUpdate = currentVersion !== latestVersion
|
||||
log(
|
||||
`[auto-update-checker] Current: ${currentVersion}, Latest (${channel}): ${latestVersion}, NeedsUpdate: ${needsUpdate}`
|
||||
)
|
||||
return {
|
||||
needsUpdate,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isLocalDev: false,
|
||||
isPinned: pluginInfo.isPinned,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
import {
|
||||
USER_CONFIG_DIR,
|
||||
USER_OPENCODE_CONFIG,
|
||||
USER_OPENCODE_CONFIG_JSONC,
|
||||
getWindowsAppdataDir,
|
||||
} from "../constants"
|
||||
|
||||
export function getConfigPaths(directory: string): string[] {
|
||||
const paths = [
|
||||
path.join(directory, ".opencode", "opencode.json"),
|
||||
path.join(directory, ".opencode", "opencode.jsonc"),
|
||||
USER_OPENCODE_CONFIG,
|
||||
USER_OPENCODE_CONFIG_JSONC,
|
||||
]
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const crossPlatformDir = path.join(os.homedir(), ".config")
|
||||
const appdataDir = getWindowsAppdataDir()
|
||||
|
||||
if (appdataDir) {
|
||||
const alternateDir = USER_CONFIG_DIR === crossPlatformDir ? appdataDir : crossPlatformDir
|
||||
const alternateConfig = path.join(alternateDir, "opencode", "opencode.json")
|
||||
const alternateConfigJsonc = path.join(alternateDir, "opencode", "opencode.jsonc")
|
||||
|
||||
if (!paths.includes(alternateConfig)) {
|
||||
paths.push(alternateConfig)
|
||||
}
|
||||
if (!paths.includes(alternateConfigJsonc)) {
|
||||
paths.push(alternateConfigJsonc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function stripJsonComments(json: string): string {
|
||||
return json
|
||||
.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (match, group) =>
|
||||
group ? "" : match
|
||||
)
|
||||
.replace(/,(\s*[}\]])/g, "$1")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NPM_FETCH_TIMEOUT, NPM_REGISTRY_URL } from "../constants"
|
||||
import type { NpmDistTags } from "../types"
|
||||
|
||||
export async function getLatestVersion(channel: string = "latest"): Promise<string | null> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT)
|
||||
|
||||
try {
|
||||
const response = await fetch(NPM_REGISTRY_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
|
||||
if (!response.ok) return null
|
||||
|
||||
const data = (await response.json()) as NpmDistTags
|
||||
return data[channel] ?? data.latest ?? null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as fs from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { OpencodeConfig } from "../types"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { getConfigPaths } from "./config-paths"
|
||||
import { stripJsonComments } from "./jsonc-strip"
|
||||
|
||||
export function isLocalDevMode(directory: string): boolean {
|
||||
return getLocalDevPath(directory) !== null
|
||||
}
|
||||
|
||||
export function getLocalDevPath(directory: string): string | null {
|
||||
for (const configPath of getConfigPaths(directory)) {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) continue
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
for (const entry of plugins) {
|
||||
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
|
||||
try {
|
||||
return fileURLToPath(entry)
|
||||
} catch {
|
||||
return entry.replace("file://", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as fs from "node:fs"
|
||||
import type { PackageJson } from "../types"
|
||||
import { getLocalDevPath } from "./local-dev-path"
|
||||
import { findPackageJsonUp } from "./package-json-locator"
|
||||
|
||||
export function getLocalDevVersion(directory: string): string | null {
|
||||
const localPath = getLocalDevPath(directory)
|
||||
if (!localPath) return null
|
||||
|
||||
try {
|
||||
const pkgPath = findPackageJsonUp(localPath)
|
||||
if (!pkgPath) return null
|
||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
return pkg.version ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import type { PackageJson } from "../types"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
|
||||
export function findPackageJsonUp(startPath: string): string | null {
|
||||
try {
|
||||
const stat = fs.statSync(startPath)
|
||||
let dir = stat.isDirectory() ? startPath : path.dirname(startPath)
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const pkgPath = path.join(dir, "package.json")
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.name === PACKAGE_NAME) return pkgPath
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(dir)
|
||||
if (parent === dir) break
|
||||
dir = parent
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as fs from "node:fs"
|
||||
import { log } from "../../../shared/logger"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
|
||||
export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean {
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const newEntry = `${PACKAGE_NAME}@${newVersion}`
|
||||
|
||||
const pluginMatch = content.match(/"plugin"\s*:\s*\[/)
|
||||
if (!pluginMatch || pluginMatch.index === undefined) {
|
||||
log(`[auto-update-checker] No "plugin" array found in ${configPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const startIndex = pluginMatch.index + pluginMatch[0].length
|
||||
let bracketCount = 1
|
||||
let endIndex = startIndex
|
||||
|
||||
for (let i = startIndex; i < content.length && bracketCount > 0; i++) {
|
||||
if (content[i] === "[") bracketCount++
|
||||
else if (content[i] === "]") bracketCount--
|
||||
endIndex = i
|
||||
}
|
||||
|
||||
const before = content.slice(0, startIndex)
|
||||
const pluginArrayContent = content.slice(startIndex, endIndex)
|
||||
const after = content.slice(endIndex)
|
||||
|
||||
const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
const regex = new RegExp(`["']${escapedOldEntry}["']`)
|
||||
|
||||
if (!regex.test(pluginArrayContent)) {
|
||||
log(`[auto-update-checker] Entry "${oldEntry}" not found in plugin array of ${configPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const updatedPluginArray = pluginArrayContent.replace(regex, `"${newEntry}"`)
|
||||
const updatedContent = before + updatedPluginArray + after
|
||||
|
||||
if (updatedContent === content) {
|
||||
log(`[auto-update-checker] No changes made to ${configPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, updatedContent, "utf-8")
|
||||
log(`[auto-update-checker] Updated ${configPath}: ${oldEntry} → ${newEntry}`)
|
||||
return true
|
||||
} catch (err) {
|
||||
log(`[auto-update-checker] Failed to update config file ${configPath}:`, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as fs from "node:fs"
|
||||
import type { OpencodeConfig } from "../types"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { getConfigPaths } from "./config-paths"
|
||||
import { stripJsonComments } from "./jsonc-strip"
|
||||
|
||||
export interface PluginEntryInfo {
|
||||
entry: string
|
||||
isPinned: boolean
|
||||
pinnedVersion: string | null
|
||||
configPath: string
|
||||
}
|
||||
|
||||
export function findPluginEntry(directory: string): PluginEntryInfo | null {
|
||||
for (const configPath of getConfigPaths(directory)) {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) continue
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
for (const entry of plugins) {
|
||||
if (entry === PACKAGE_NAME) {
|
||||
return { entry, isPinned: false, pinnedVersion: null, configPath }
|
||||
}
|
||||
if (entry.startsWith(`${PACKAGE_NAME}@`)) {
|
||||
const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1)
|
||||
const isPinned = pinnedVersion !== "latest"
|
||||
return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null, configPath }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user