mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 16:02:03 +00:00
fix(opencode): keep OpenCode 2 panes Working across plugin reloads (#23700)
* fix(opencode): keep OpenCode 2 panes Working across plugin reloads OpenCode 2 disposes and re-sets-up every plugin whenever its plugins dir changes, while sessions keep running. The status plugin published a final Idle on dispose, so a pane read Done mid-turn. Orca also rewrote the plugin file on every PTY spawn, so opening any terminal triggered that reload. Dispose now releases the factory's bookkeeping without publishing a verdict; the next lifecycle event settles the pane, and Orca's ended-process reconciliation still retires panes whose agent exited. The plugin file is written only when its bytes differ. * fix(opencode): skip rewriting an unchanged plugin in the SSH relay install too The relay's canonical-config install still unlinked and rewrote the status plugin on every OpenCode launch over SSH, which restarts every plugin in a remote OpenCode 2 server. Share one install-currency check (lstat + the existing byte comparison) between the local and relay writers, and pin write-if-changed with mtime so the tests also fail on filesystems that reuse a freed inode. * fix(opencode): keep the final Idle when OpenCode 1 tears its instance down OpenCode 1 disposes a plugin only when it tears the instance down, and that teardown cancels every running session, so the Idle published on dispose is true there; the cancelled run's own idle may never reach the plugin. Only OpenCode 2 disposes on a hot reload while turns keep running. The generated module serves both hosts, so the OpenCode 2 setup() entry point now tells the shared factory that sessions outlive disposal; the server() path keeps the previous disposal behaviour, including the hand-off to a surviving factory. * fix(opencode): compare a symlinked plugin by its target before rewriting OpenCode 2 loads plugins through file-level symlinks and reads the revision from the target's mtime, so a user whose Orca plugin file is a symlink (per-file dotfile managers) failed the regular-file check and got a write through the link, and a reload, on every spawn. The config-dir and relay installs now skip the write when the resolved target already has Orca's bytes; when stale they behave as before. Only the per-source overlay keeps the regular-file check, since a link there mirrors a user entry. Installers also skip the write inside a guarded block rather than returning early, so later install steps still run. * test(opencode): skip the plugin symlink tests on Windows like their neighbours Creating a file symlink on Windows needs Developer Mode or admin rights. * test(opencode): stub fetch without a type assertion in the dispose host test
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Executes the generated plugin through each OpenCode host entry point, because
|
||||
* what disposal means differs by host: OpenCode 1 disposes only on instance
|
||||
* teardown (which cancels every run), OpenCode 2 also on a plugin hot reload.
|
||||
*/
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getPathMock } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn<(name: string) => string>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: getPathMock }
|
||||
}))
|
||||
|
||||
import { _internals } from './hook-service'
|
||||
|
||||
type PluginHooks = {
|
||||
event: (input: { event: unknown }) => Promise<void>
|
||||
dispose?: () => Promise<void>
|
||||
}
|
||||
type HostEvent = { type: string; data: Record<string, unknown> }
|
||||
type PluginModule = {
|
||||
default?: {
|
||||
server?: (ctx: unknown) => Promise<PluginHooks>
|
||||
setup?: (ctx: unknown) => Promise<() => Promise<void>>
|
||||
}
|
||||
}
|
||||
|
||||
const ENV_KEYS = [
|
||||
'ORCA_PANE_KEY',
|
||||
'ORCA_OPENCODE_AGENT',
|
||||
'ORCA_AGENT_HOOK_ENDPOINT',
|
||||
'ORCA_AGENT_HOOK_PORT',
|
||||
'ORCA_AGENT_HOOK_TOKEN'
|
||||
] as const
|
||||
|
||||
// A live OpenCode 2 event bus: stays open until the subscriber aborts.
|
||||
function createEventBus(): {
|
||||
push: (event: HostEvent) => void
|
||||
subscribe: (input: { signal: AbortSignal }) => AsyncGenerator<HostEvent>
|
||||
} {
|
||||
const queue: HostEvent[] = []
|
||||
let wake: (() => void) | null = null
|
||||
return {
|
||||
push(event) {
|
||||
queue.push(event)
|
||||
wake?.()
|
||||
},
|
||||
async *subscribe({ signal }) {
|
||||
while (!signal.aborted) {
|
||||
const next = queue.shift()
|
||||
if (next) {
|
||||
yield next
|
||||
continue
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve
|
||||
signal.addEventListener('abort', () => resolve(), { once: true })
|
||||
})
|
||||
wake = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe.each(['opencode', 'opencode2'] as const)('%s plugin disposal by host', (agent) => {
|
||||
let tempDir: string
|
||||
let savedEnv: Record<string, string | undefined>
|
||||
let names: string[]
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-dispose-host-'))
|
||||
savedEnv = {}
|
||||
for (const key of ENV_KEYS) {
|
||||
savedEnv[key] = process.env[key]
|
||||
}
|
||||
process.env.ORCA_PANE_KEY = 'tab-1:leaf-1'
|
||||
process.env.ORCA_OPENCODE_AGENT = agent
|
||||
delete process.env.ORCA_AGENT_HOOK_ENDPOINT
|
||||
process.env.ORCA_AGENT_HOOK_PORT = '59999'
|
||||
process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token'
|
||||
names = []
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
names.push(String(JSON.parse(String(init?.body)).payload?.hook_event_name))
|
||||
return new Response('{}', { status: 200 })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
for (const key of ENV_KEYS) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = savedEnv[key]
|
||||
}
|
||||
}
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function loadPluginModule(): Promise<PluginModule> {
|
||||
const pluginPath = join(tempDir, `orca-${agent}-status.mjs`)
|
||||
writeFileSync(
|
||||
pluginPath,
|
||||
agent === 'opencode2'
|
||||
? _internals.getOpenCode2PluginSource()
|
||||
: _internals.getOpenCodePluginSource()
|
||||
)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the generated plugin module exports this shape.
|
||||
return (await import(pathToFileURL(pluginPath).href)) as PluginModule
|
||||
}
|
||||
|
||||
function setupContext(bus: ReturnType<typeof createEventBus>): unknown {
|
||||
return {
|
||||
session: {
|
||||
get: async ({ sessionID }: { sessionID: string }) => ({ id: sessionID }),
|
||||
hook: async () => ({ dispose: async () => {} })
|
||||
},
|
||||
event: { subscribe: bus.subscribe }
|
||||
}
|
||||
}
|
||||
|
||||
it('publishes a final Idle when the OpenCode 1 instance tears down mid-turn', async () => {
|
||||
const module = await loadPluginModule()
|
||||
const hooks = await module.default?.server?.({
|
||||
client: { session: { get: async () => ({ data: { id: 'ses_root' } }) } }
|
||||
})
|
||||
await hooks?.event({
|
||||
event: {
|
||||
type: 'session.status',
|
||||
properties: { sessionID: 'ses_root', status: { type: 'busy' } }
|
||||
}
|
||||
})
|
||||
await hooks?.dispose?.()
|
||||
|
||||
expect(names).toEqual(['SessionBusy', 'SessionIdle'])
|
||||
})
|
||||
|
||||
it('keeps the pane Working when OpenCode 2 reloads the plugin mid-turn', async () => {
|
||||
const module = await loadPluginModule()
|
||||
const firstBus = createEventBus()
|
||||
const firstCleanup = await module.default?.setup?.(setupContext(firstBus))
|
||||
firstBus.push({ type: 'session.execution.started', data: { sessionID: 'ses_root' } })
|
||||
await vi.waitFor(() => expect(names).toEqual(['SessionBusy']))
|
||||
|
||||
await firstCleanup?.()
|
||||
expect(names).toEqual(['SessionBusy'])
|
||||
|
||||
// The turn kept running; its end reaches the reloaded plugin.
|
||||
const secondBus = createEventBus()
|
||||
const secondCleanup = await module.default?.setup?.(setupContext(secondBus))
|
||||
secondBus.push({ type: 'session.execution.succeeded', data: { sessionID: 'ses_root' } })
|
||||
await vi.waitFor(() => expect(names).toEqual(['SessionBusy', 'SessionIdle']))
|
||||
await secondCleanup?.()
|
||||
expect(names).toEqual(['SessionBusy', 'SessionIdle'])
|
||||
})
|
||||
})
|
||||
@@ -97,11 +97,11 @@ describe('OpenCode hook plugin source', () => {
|
||||
const digest = (source: string): string => createHash('sha256').update(source).digest('hex')
|
||||
|
||||
expect(digest(getOpenCodePluginSource())).toBe(
|
||||
'c81893814f08bfb2f9b5c133c5355b53d485982402d89e92ff15fc4e7d543079'
|
||||
'655d603f31c3e4462b4c600861833cbdba9dd541f693e11a349e7a57b8c059d7'
|
||||
)
|
||||
expect(
|
||||
digest(getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false }))
|
||||
).toBe('2267e2ab6e854e71c9bae12afed97e464f93b2b14f3e25dca133ca6666c98752')
|
||||
).toBe('d36d869823cc3dd506565e0512beb278fe4fd6533bd12be30bd98db140ca9f3e')
|
||||
})
|
||||
|
||||
it('filters child sessions via parentID lookup before forwarding events', () => {
|
||||
@@ -294,6 +294,51 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
|
||||
expect(pluginSource).toContain('messageID: part.messageID')
|
||||
})
|
||||
|
||||
// Why: OpenCode 2 reloads a plugin whose file mtime changed, which restarted status mid-turn.
|
||||
it('leaves a current installed plugin untouched and replaces a stale one', () => {
|
||||
const service = new OpenCodeHookService()
|
||||
service.buildPtyEnv(daemonSessionId)
|
||||
const pluginPath = join(resolveOpenCodeConfigDirectory(), 'plugins', 'orca-opencode-status.js')
|
||||
const past = new Date('2020-01-01T00:00:00Z')
|
||||
utimesSync(pluginPath, past, past)
|
||||
|
||||
service.buildPtyEnv(daemonSessionId)
|
||||
expect(statSync(pluginPath).mtimeMs).toBe(past.getTime())
|
||||
|
||||
writeFileSync(pluginPath, 'stale plugin')
|
||||
service.buildPtyEnv(daemonSessionId)
|
||||
expect(readFileSync(pluginPath, 'utf8')).toBe(_internals.getOpenCodePluginSource())
|
||||
})
|
||||
|
||||
// Why: OpenCode 2 loads through a file-level symlink (dotfile managers) and stats its target.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'compares a symlinked plugin by its target and writes through only when stale',
|
||||
() => {
|
||||
const service = new OpenCodeHookService()
|
||||
const pluginPath = join(
|
||||
resolveOpenCodeConfigDirectory(),
|
||||
'plugins',
|
||||
'orca-opencode-status.js'
|
||||
)
|
||||
const targetPath = join(userDataDir, 'dotfiles-orca-opencode-status.js')
|
||||
writeFileSync(targetPath, _internals.getOpenCodePluginSource())
|
||||
rmSync(pluginPath, { force: true })
|
||||
symlinkSync(targetPath, pluginPath)
|
||||
const past = new Date('2020-01-01T00:00:00Z')
|
||||
utimesSync(targetPath, past, past)
|
||||
|
||||
service.buildPtyEnv(daemonSessionId)
|
||||
expect(statSync(targetPath).mtimeMs).toBe(past.getTime())
|
||||
|
||||
writeFileSync(targetPath, 'stale plugin')
|
||||
service.buildPtyEnv(daemonSessionId)
|
||||
expect(lstatSync(pluginPath).isSymbolicLink()).toBe(true)
|
||||
expect(readFileSync(targetPath, 'utf8')).toBe(_internals.getOpenCodePluginSource())
|
||||
rmSync(pluginPath, { force: true })
|
||||
rmSync(targetPath, { force: true })
|
||||
}
|
||||
)
|
||||
|
||||
// Why: #22234 — OpenCode 2 installs under the plain `opencode` name, and its loader
|
||||
// rejects a default export that only has server(). Asserting the emitted *source* is
|
||||
// not enough; the installed file is what the v2 server validates, so load it.
|
||||
@@ -700,6 +745,19 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () =
|
||||
expectUserConfigIntact()
|
||||
})
|
||||
|
||||
it('leaves a current overlay plugin file in place across spawns', () => {
|
||||
const service = new OpenCodeHookService()
|
||||
const overlayDir = service.buildPtyEnv(ptyId, userConfigDir).OPENCODE_CONFIG_DIR!
|
||||
const pluginPath = join(overlayDir, 'plugins', 'orca-opencode-status.js')
|
||||
const past = new Date('2020-01-01T00:00:00Z')
|
||||
utimesSync(pluginPath, past, past)
|
||||
|
||||
service.buildPtyEnv(ptyId, userConfigDir)
|
||||
|
||||
expect(statSync(pluginPath).mtimeMs).toBe(past.getTime())
|
||||
expect(readFileSync(pluginPath, 'utf8')).toBe(_internals.getOpenCodePluginSource())
|
||||
})
|
||||
|
||||
it('reconciles stale mirrored entries while preserving OpenCode runtime files', () => {
|
||||
const service = new OpenCodeHookService()
|
||||
const firstEnv = service.buildPtyEnv(ptyId, userConfigDir)
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
OPENCODE2_LEGACY_HOOKS_DIR,
|
||||
OPENCODE_LEGACY_HOOKS_DIR
|
||||
} from './legacy-shared-config-dir'
|
||||
import {
|
||||
isInstalledOpenCodePluginCurrent,
|
||||
isOverlayOpenCodePluginCurrent
|
||||
} from '../../shared/opencode-installed-plugin'
|
||||
|
||||
const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js'
|
||||
const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays'
|
||||
@@ -278,18 +282,25 @@ export class OpenCodeHookService {
|
||||
const pluginsDir = join(overlayDir, 'plugins')
|
||||
mkdirSync(pluginsDir, { recursive: true })
|
||||
const pluginPath = join(pluginsDir, this.pluginFileName)
|
||||
try {
|
||||
unlinkSync(pluginPath)
|
||||
} catch {
|
||||
// File may not exist on a fresh overlay; a real failure surfaces on writeFileSync below.
|
||||
const source = this.pluginSource()
|
||||
if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) {
|
||||
try {
|
||||
unlinkSync(pluginPath)
|
||||
} catch {
|
||||
// File may not exist on a fresh overlay; a real failure surfaces on writeFileSync below.
|
||||
}
|
||||
writeFileSync(pluginPath, source)
|
||||
}
|
||||
writeFileSync(pluginPath, this.pluginSource())
|
||||
}
|
||||
|
||||
private writePluginToConfigDir(configDir: string): void {
|
||||
const pluginsDir = join(configDir, 'plugins')
|
||||
mkdirSync(pluginsDir, { recursive: true })
|
||||
writeFileSync(join(pluginsDir, this.pluginFileName), this.pluginSource())
|
||||
const pluginPath = join(pluginsDir, this.pluginFileName)
|
||||
const source = this.pluginSource()
|
||||
if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) {
|
||||
writeFileSync(pluginPath, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export function getStatusPluginFactorySource(options: {
|
||||
'export const OrcaOpenCodeStatusPlugin = async (_ctx) => {',
|
||||
` if (process.env.ORCA_OPENCODE_AGENT && process.env.ORCA_OPENCODE_AGENT !== '${expectedAgent}') return {};`,
|
||||
' const client = _ctx?.client;',
|
||||
' const sessionsOutliveDispose = _ctx?.sessionsOutliveDispose === true;',
|
||||
' const factoryID = ++nextFactoryID;',
|
||||
' activeFactoryIDs.add(factoryID);',
|
||||
' let disposed = false;',
|
||||
@@ -242,7 +243,14 @@ export function getStatusPluginFactorySource(options: {
|
||||
' pendingAssistantPart = null;',
|
||||
' }',
|
||||
' const ownsDeliveredMessagePart = deliveredMessagePartFactoryID === factoryID;',
|
||||
' if (desiredFactoryID === factoryID || ownsDeliveredMessagePart) {',
|
||||
' // Why: OpenCode 1 disposes only on instance teardown, which cancels every run, so a final',
|
||||
' // Idle is true. OpenCode 2 also disposes on a hot reload mid-turn, so it publishes nothing.',
|
||||
' if (sessionsOutliveDispose) {',
|
||||
' if (desiredFactoryID === factoryID) {',
|
||||
' clearStatusRetry();',
|
||||
' statusRevision += 1;',
|
||||
' }',
|
||||
' } else if (desiredFactoryID === factoryID || ownsDeliveredMessagePart) {',
|
||||
' clearStatusRetry();',
|
||||
' statusRevision += 1;',
|
||||
' // A MessagePart may have changed the listener to Working after the',
|
||||
@@ -250,17 +258,13 @@ export function getStatusPluginFactorySource(options: {
|
||||
' statusDeliveryDirty = ownsDeliveredMessagePart;',
|
||||
' busyRecoveryUsed = false;',
|
||||
' busyRecoveryEndpointKey = "";',
|
||||
' const fallbackFactoryID = Array.from(activeFactoryIDs).find(',
|
||||
' (id) => id !== factoryID',
|
||||
' );',
|
||||
' const fallbackFactoryID = Array.from(activeFactoryIDs).find((id) => id !== factoryID);',
|
||||
' if (fallbackFactoryID !== undefined) {',
|
||||
' await publishAggregateStatus(',
|
||||
' fallbackFactoryID,',
|
||||
' desiredStatusProperties?.sessionID',
|
||||
' );',
|
||||
' } else {',
|
||||
' // Why: Instance disposal can happen while the PTY stays alive;',
|
||||
' // publish a final idle so Orca does not retain a dead owner.',
|
||||
' if (!deliveredStatusKey.startsWith("idle:") || ownsDeliveredMessagePart) {',
|
||||
' await setStatus(',
|
||||
' "idle",',
|
||||
|
||||
@@ -24,7 +24,8 @@ async function setupOpenCode2Status(ctx) {
|
||||
// Without it, resolveRootSessionID returns null for every session and a
|
||||
// subagent's work publishes as if it were the root's.
|
||||
const client = { session: { get: async (input, options) => { const result = await ctx.session.get(input, options); return result && typeof result.id === "string" ? { data: result } : result; } } };
|
||||
hooks = await OrcaOpenCodeStatusPlugin({ client });
|
||||
// Why: this host disposes plugins on a hot reload while turns keep running.
|
||||
hooks = await OrcaOpenCodeStatusPlugin({ client, sessionsOutliveDispose: true });
|
||||
if (!hooks || typeof hooks.event !== "function") return noop;
|
||||
const promptRegistration = await ctx.session.hook("prompt", async (properties) => {
|
||||
await hooks.event({ event: { type: "session.next.prompt.admitted", properties } });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory'
|
||||
import { isInstalledOpenCodePluginCurrent } from '../shared/opencode-installed-plugin'
|
||||
|
||||
const RELAY_HOOKS_DIR = '.orca-relay'
|
||||
|
||||
@@ -18,12 +19,14 @@ export function installOpenCodePluginInCanonicalConfig(
|
||||
agent === 'opencode2' ? 'orca-opencode2-status.js' : 'orca-opencode-status.js'
|
||||
const pluginPath = join(configDir, 'plugins', pluginFileName)
|
||||
mkdirSync(join(configDir, 'plugins'), { recursive: true })
|
||||
try {
|
||||
unlinkSync(pluginPath)
|
||||
} catch {
|
||||
// The file may not exist on the first install.
|
||||
if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) {
|
||||
try {
|
||||
unlinkSync(pluginPath)
|
||||
} catch {
|
||||
// The file may not exist on the first install.
|
||||
}
|
||||
writeFileSync(pluginPath, source)
|
||||
}
|
||||
writeFileSync(pluginPath, source)
|
||||
return true
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
utimesSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -74,6 +77,50 @@ describe('PluginOverlayManager', () => {
|
||||
).toBe('v2 plugin')
|
||||
})
|
||||
|
||||
// Why: OpenCode 2 reloads a plugin whose file mtime changed, even with unchanged bytes.
|
||||
it('leaves a current canonical plugin untouched and replaces a stale one', () => {
|
||||
const env = { XDG_CONFIG_HOME: join(homeDir, 'xdg') }
|
||||
const pluginPath = join(homeDir, 'xdg', 'opencode', 'plugins', 'orca-opencode2-status.js')
|
||||
manager.setSources({ opencode2PluginSource: 'v2 plugin' })
|
||||
manager.installOpenCodePlugin('opencode2', env)
|
||||
const past = new Date('2020-01-01T00:00:00Z')
|
||||
utimesSync(pluginPath, past, past)
|
||||
|
||||
expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true)
|
||||
expect(statSync(pluginPath).mtimeMs).toBe(past.getTime())
|
||||
|
||||
manager.setSources({ opencode2PluginSource: 'v2 plugin, next release' })
|
||||
expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true)
|
||||
expect(readFileSync(pluginPath, 'utf8')).toBe('v2 plugin, next release')
|
||||
})
|
||||
|
||||
// Why: OpenCode 2 loads through a file-level symlink (dotfile managers) and stats its target.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'leaves a symlinked canonical plugin with current bytes untouched',
|
||||
() => {
|
||||
const env = { XDG_CONFIG_HOME: join(homeDir, 'xdg') }
|
||||
const pluginsDir = join(homeDir, 'xdg', 'opencode', 'plugins')
|
||||
const pluginPath = join(pluginsDir, 'orca-opencode2-status.js')
|
||||
const targetPath = join(homeDir, 'dotfiles-orca-opencode2-status.js')
|
||||
mkdirSync(pluginsDir, { recursive: true })
|
||||
writeFileSync(targetPath, 'v2 plugin')
|
||||
symlinkSync(targetPath, pluginPath)
|
||||
const past = new Date('2020-01-01T00:00:00Z')
|
||||
utimesSync(targetPath, past, past)
|
||||
manager.setSources({ opencode2PluginSource: 'v2 plugin' })
|
||||
|
||||
expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true)
|
||||
expect(lstatSync(pluginPath).isSymbolicLink()).toBe(true)
|
||||
expect(statSync(targetPath).mtimeMs).toBe(past.getTime())
|
||||
|
||||
manager.setSources({ opencode2PluginSource: 'v2 plugin, next release' })
|
||||
expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true)
|
||||
expect(lstatSync(pluginPath).isFile()).toBe(true)
|
||||
expect(readFileSync(pluginPath, 'utf8')).toBe('v2 plugin, next release')
|
||||
expect(readFileSync(targetPath, 'utf8')).toBe('v2 plugin')
|
||||
}
|
||||
)
|
||||
|
||||
it('mirrors a preexisting remote OpenCode config dir before adding Orca plugin', () => {
|
||||
const userConfigDir = join(homeDir, 'company-opencode')
|
||||
mkdirSync(join(userConfigDir, 'plugins'), { recursive: true })
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isInstalledOpenCodePluginCurrent,
|
||||
isOverlayOpenCodePluginCurrent
|
||||
} from './opencode-installed-plugin'
|
||||
|
||||
describe('installed OpenCode plugin currency', () => {
|
||||
let dir: string
|
||||
let pluginPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'orca-opencode-installed-plugin-'))
|
||||
pluginPath = join(dir, 'orca-opencode-status.js')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('treats a missing or different file as stale', () => {
|
||||
expect(isInstalledOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(false)
|
||||
expect(isOverlayOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(false)
|
||||
writeFileSync(pluginPath, 'older plugin')
|
||||
expect(isInstalledOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(false)
|
||||
expect(isOverlayOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a regular file with the same bytes as current', () => {
|
||||
writeFileSync(pluginPath, 'plugin')
|
||||
expect(isInstalledOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(true)
|
||||
expect(isOverlayOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(true)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'follows a symlink except in an overlay, where a link is a mirrored user entry',
|
||||
() => {
|
||||
const targetPath = join(dir, 'target.js')
|
||||
writeFileSync(targetPath, 'plugin')
|
||||
symlinkSync(targetPath, pluginPath)
|
||||
expect(isInstalledOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(true)
|
||||
expect(isOverlayOpenCodePluginCurrent(pluginPath, 'plugin')).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { lstatSync } from 'node:fs'
|
||||
import { nodeFileContentsEqualSync } from './node-file-content-equality'
|
||||
|
||||
// Why: OpenCode 2 reloads a plugin (and every plugin loaded after it) when its file's mtime changes,
|
||||
// even with identical bytes, so installers must skip the write when Orca's plugin is already current.
|
||||
// Follows symlinks: OpenCode 2 loads through them, so a linked file with Orca's bytes is current.
|
||||
export function isInstalledOpenCodePluginCurrent(pluginPath: string, source: string): boolean {
|
||||
try {
|
||||
return nodeFileContentsEqualSync(pluginPath, source)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay variant: a symlink there mirrors a user entry, never Orca's file, even when the bytes match.
|
||||
export function isOverlayOpenCodePluginCurrent(pluginPath: string, source: string): boolean {
|
||||
try {
|
||||
return lstatSync(pluginPath).isFile() && nodeFileContentsEqualSync(pluginPath, source)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user