mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 07:29:33 +00:00
`graphify opencode install` wrote a plugin with only a named export, the
OpenCode 1 shape. OpenCode 2 rejects it ("Plugin must export a default
definition with an id and an effect or setup function") and reads the bare
path registered in opencode.json as an npm package name, so it fails twice
on every start.
The plugin is now one default export with `id`, `setup()` for OpenCode 2
and `server()` for OpenCode 1 (1.3.4 and later). The installer no longer
writes the opencode.json entry, because both runtimes discover
.opencode/plugins/ on their own, and it removes the entry older releases
wrote.
OpenCode loads .opencode/plugins/ from the project and from every directory
above it, and OpenCode 2 fails a second plugin with the same id ("Duplicate
plugin ID"). An install in ~ and one in a project are both normal, so the id
carries the file's location and the copies share one record of who was
reminded: both load, and a command still gets a single reminder.
Upgrading graphify does not touch a plugin already on disk. So the CLI now
rewrites an old-shape plugin in the working directory or in the home
directory on its next run, and drops the stale entry there, next to the
stale-skill refresh from #1805. It does so only when the `opencode` on PATH
reports 1.3.4 or later, because older OpenCode 1 releases cannot load the
new plugin and the one they have still works. The rewrite takes the same
per-directory lock as the skill refresh and replaces both files atomically,
because OpenCode watches them. GRAPHIFY_NO_AUTO_REFRESH=1 turns it off.
(cherry picked from commit 0e539e1f7a)
411 lines
15 KiB
Python
411 lines
15 KiB
Python
"""#3554 / #3732: an OpenCode upgrade must not leave a dead graphify plugin behind.
|
|
|
|
Until 0.9.74 `graphify opencode install` wrote a plugin with only a named
|
|
export, the OpenCode 1 shape. OpenCode 2 refuses it ("Plugin must export a
|
|
default definition ...") and also fails on the opencode.json entry written next
|
|
to it. Upgrading graphify did not touch either file, so the user had to know to
|
|
run the install again in every project. The CLI now rewrites such a plugin on
|
|
its next run.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import graphify.__main__ as mainmod
|
|
from graphify.install import __version__, _OPENCODE_PLUGIN_JS
|
|
|
|
# The plugin exactly as 0.9.74 wrote it.
|
|
V1_PLUGIN = '''\
|
|
// graphify OpenCode plugin
|
|
// Injects a knowledge graph reminder before bash tool calls when the graph exists.
|
|
//
|
|
// IMPORTANT: keep the reminder string free of backticks and $(...) constructs.
|
|
// The hook prepends `echo "<reminder>" && <cmd>` to the user's bash command;
|
|
// backticks inside the double-quoted echo trigger bash command substitution,
|
|
// which both corrupts tool output and silently executes the very graphify
|
|
// command we are only suggesting. Plain words render fine in opencode's TUI.
|
|
import { existsSync } from "fs";
|
|
import { join } from "path";
|
|
|
|
export const GraphifyPlugin = async ({ directory }) => {
|
|
let reminded = false;
|
|
|
|
return {
|
|
"tool.execute.before": async (input, output) => {
|
|
if (reminded) return;
|
|
if (!existsSync(join(directory, "graphify-out", "graph.json"))) return;
|
|
|
|
if (input.tool === "bash") {
|
|
// ';' not '&&' — Windows PowerShell 5.1 rejects '&&' as a statement
|
|
// separator, breaking the first bash command of the session (#1646).
|
|
output.args.command =
|
|
'echo "[graphify] knowledge graph at graphify-out/. For focused questions, run graphify query with your question (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context." ; ' +
|
|
output.args.command;
|
|
reminded = true;
|
|
}
|
|
},
|
|
};
|
|
};
|
|
'''
|
|
ENTRY = ".opencode/plugins/graphify.js"
|
|
|
|
|
|
# getattr: on a checkout without the fix the tests fail one by one, not at import.
|
|
_real_version_check = getattr(mainmod, "_opencode_loads_current_plugin", None)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _refresh_enabled(monkeypatch):
|
|
"""Refresh on, and an OpenCode that can load the current plugin, unless a
|
|
test says otherwise. No test runs whatever `opencode` is on the real PATH."""
|
|
monkeypatch.delenv("GRAPHIFY_NO_AUTO_REFRESH", raising=False)
|
|
monkeypatch.setattr(mainmod, "_opencode_loads_current_plugin", lambda: True, raising=False)
|
|
|
|
|
|
def _old_install(directory: Path, body: str = V1_PLUGIN, entry: bool = True) -> tuple[Path, Path]:
|
|
"""Lay down the plugin and the config entry as an older release left them."""
|
|
plugin = directory / ".opencode" / "plugins" / "graphify.js"
|
|
plugin.parent.mkdir(parents=True, exist_ok=True)
|
|
plugin.write_text(body, encoding="utf-8")
|
|
config = directory / ".opencode" / "opencode.json"
|
|
settings = {"model": "some/model", **({"plugin": [ENTRY]} if entry else {})}
|
|
config.write_text(json.dumps(settings), encoding="utf-8")
|
|
return plugin, config
|
|
|
|
|
|
def test_the_old_plugin_is_what_opencode_2_rejects():
|
|
"""Guard the fixture: the 0.9.74 file has no default export, and both
|
|
files open with the line the refresh recognises graphify's plugin by."""
|
|
assert "export default" not in V1_PLUGIN
|
|
assert "export default" in _OPENCODE_PLUGIN_JS
|
|
assert V1_PLUGIN.startswith(mainmod._OPENCODE_PLUGIN_HEADER)
|
|
assert _OPENCODE_PLUGIN_JS.startswith(mainmod._OPENCODE_PLUGIN_HEADER)
|
|
|
|
|
|
def test_a_v1_plugin_in_the_working_directory_is_rewritten(tmp_path, monkeypatch, capsys):
|
|
plugin, config = _old_install(tmp_path)
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
|
|
assert plugin.read_text(encoding="utf-8") == _OPENCODE_PLUGIN_JS
|
|
assert json.loads(config.read_text(encoding="utf-8")) == {"model": "some/model"}
|
|
out, err = capsys.readouterr()
|
|
assert out == "", "stdout stays clean for --json and the MCP stdio server"
|
|
assert f"graphify: refreshed {plugin.resolve()}" in err
|
|
assert "GRAPHIFY_NO_AUTO_REFRESH=1" in err
|
|
|
|
|
|
def test_a_v1_plugin_in_the_home_directory_is_rewritten(tmp_path, monkeypatch, capsys):
|
|
"""`graphify opencode install` run from ~ puts the plugin in ~/.opencode,
|
|
and OpenCode loads that for every project below home."""
|
|
plugin, config = _old_install(Path.home())
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
|
|
assert plugin.read_text(encoding="utf-8") == _OPENCODE_PLUGIN_JS
|
|
assert "plugin" not in json.loads(config.read_text(encoding="utf-8"))
|
|
assert "graphify: refreshed" in capsys.readouterr().err
|
|
|
|
|
|
def test_a_second_run_is_silent(tmp_path, monkeypatch, capsys):
|
|
_old_install(tmp_path)
|
|
monkeypatch.chdir(tmp_path)
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
capsys.readouterr()
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
|
|
assert capsys.readouterr().err == ""
|
|
|
|
|
|
def test_running_from_home_checks_it_once(monkeypatch, capsys):
|
|
plugin, _config = _old_install(Path.home())
|
|
monkeypatch.chdir(Path.home())
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
|
|
assert plugin.read_text(encoding="utf-8") == _OPENCODE_PLUGIN_JS
|
|
assert capsys.readouterr().err.count("graphify: refreshed") == 1
|
|
|
|
|
|
def test_a_stale_entry_beside_a_current_plugin_is_dropped(tmp_path, monkeypatch, capsys):
|
|
"""The entry can come back without the old plugin: a checkout that restores
|
|
opencode.json, or an earlier run that wrote the plugin and then failed."""
|
|
plugin, config = _old_install(tmp_path, _OPENCODE_PLUGIN_JS)
|
|
stamp = plugin.stat().st_mtime_ns
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
|
|
assert plugin.stat().st_mtime_ns == stamp
|
|
assert json.loads(config.read_text(encoding="utf-8")) == {"model": "some/model"}
|
|
out, err = capsys.readouterr()
|
|
assert out == ""
|
|
assert f"graphify: removed the stale graphify entry from {config.resolve()}" in err
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
assert capsys.readouterr().err == ""
|
|
|
|
|
|
def test_a_config_with_comments_gets_the_hint_once(tmp_path, monkeypatch, capsys):
|
|
plugin, config = _old_install(tmp_path)
|
|
commented = '{ // mine\n "plugin": ["' + ENTRY + '"]\n}'
|
|
config.write_text(commented, encoding="utf-8")
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
|
|
assert plugin.read_text(encoding="utf-8") == _OPENCODE_PLUGIN_JS
|
|
assert config.read_text(encoding="utf-8") == commented
|
|
out, err = capsys.readouterr()
|
|
assert out == ""
|
|
assert "graphify: refreshed" in err
|
|
assert f'remove "{ENTRY}" from its plugin list by hand' in err
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
assert capsys.readouterr().err == "", "no reminder on every later run"
|
|
|
|
|
|
def test_a_current_plugin_is_untouched_and_silent(tmp_path, monkeypatch, capsys):
|
|
plugin, config = _old_install(tmp_path, _OPENCODE_PLUGIN_JS, entry=False)
|
|
before = config.read_text(encoding="utf-8")
|
|
stamp = plugin.stat().st_mtime_ns
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
|
|
assert plugin.stat().st_mtime_ns == stamp
|
|
assert config.read_text(encoding="utf-8") == before
|
|
assert capsys.readouterr().err == ""
|
|
|
|
|
|
def test_a_plugin_graphify_did_not_write_is_left_alone(tmp_path, monkeypatch, capsys):
|
|
mine = "// my own plugin\nexport const Mine = async () => ({});\n"
|
|
plugin, config = _old_install(tmp_path, mine)
|
|
before = config.read_text(encoding="utf-8")
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
|
|
assert plugin.read_text(encoding="utf-8") == mine
|
|
assert config.read_text(encoding="utf-8") == before
|
|
assert capsys.readouterr().err == ""
|
|
|
|
|
|
def test_no_plugin_means_nothing_is_created(tmp_path, monkeypatch, capsys):
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
|
|
assert not (tmp_path / ".opencode").exists()
|
|
assert not (Path.home() / ".opencode").exists()
|
|
assert capsys.readouterr().err == ""
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["1", "true", "YES"])
|
|
def test_the_env_opt_out_disables_it(tmp_path, monkeypatch, capsys, value):
|
|
plugin, _config = _old_install(tmp_path)
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("GRAPHIFY_NO_AUTO_REFRESH", value)
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
|
|
assert plugin.read_text(encoding="utf-8") == V1_PLUGIN
|
|
assert capsys.readouterr().err == ""
|
|
|
|
|
|
def test_a_failed_rewrite_never_breaks_the_command(tmp_path, monkeypatch, capsys):
|
|
plugin, _config = _old_install(tmp_path)
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
def boom(_directory):
|
|
raise PermissionError("read-only checkout")
|
|
|
|
monkeypatch.setattr(mainmod, "_install_opencode_plugin", boom)
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
|
|
assert plugin.read_text(encoding="utf-8") == V1_PLUGIN
|
|
err = capsys.readouterr().err
|
|
assert f"graphify: could not refresh {plugin.resolve()}: read-only checkout" in err
|
|
assert "graphify: refreshed" not in err
|
|
|
|
|
|
def test_an_interrupted_rewrite_leaves_the_old_plugin_whole(tmp_path, monkeypatch, capsys):
|
|
"""OpenCode watches the plugin file, so it must go from the old content to
|
|
the new one in a single rename, never through a half-written file."""
|
|
import graphify.paths as pathsmod
|
|
|
|
plugin, _config = _old_install(tmp_path)
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
def killed(_tmp, _dst):
|
|
raise OSError("disk full")
|
|
|
|
monkeypatch.setattr(pathsmod, "os_replace_with_fallback", killed)
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
|
|
assert plugin.read_text(encoding="utf-8") == V1_PLUGIN
|
|
assert [p.name for p in plugin.parent.iterdir()] == ["graphify.js"], "no temp file left behind"
|
|
assert "graphify: could not refresh" in capsys.readouterr().err
|
|
|
|
|
|
def test_a_symlinked_plugin_is_rewritten_through_the_link(tmp_path, monkeypatch, requires_symlinks):
|
|
shared = tmp_path / "dotfiles" / "graphify.js"
|
|
shared.parent.mkdir()
|
|
shared.write_text(V1_PLUGIN, encoding="utf-8")
|
|
project = tmp_path / "project"
|
|
plugin = project / ".opencode" / "plugins" / "graphify.js"
|
|
plugin.parent.mkdir(parents=True)
|
|
plugin.symlink_to(shared)
|
|
monkeypatch.chdir(project)
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
|
|
assert plugin.is_symlink()
|
|
assert shared.read_text(encoding="utf-8") == _OPENCODE_PLUGIN_JS
|
|
|
|
|
|
def test_an_opencode_too_old_for_the_new_plugin_keeps_its_plugin(tmp_path, monkeypatch, capsys):
|
|
"""OpenCode 1 before 1.3.4 calls every export as a function. The plugin it
|
|
has works there and the new one would not, so the refresh leaves it."""
|
|
plugin, config = _old_install(tmp_path)
|
|
before = config.read_text(encoding="utf-8")
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setattr(mainmod, "_opencode_loads_current_plugin", lambda: False)
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
|
|
assert plugin.read_text(encoding="utf-8") == V1_PLUGIN
|
|
assert config.read_text(encoding="utf-8") == before
|
|
assert capsys.readouterr().err == ""
|
|
|
|
|
|
@pytest.mark.parametrize("output, loads", [
|
|
("1.3.3\n", False),
|
|
("1.3.4\n", True),
|
|
("1.18.34\n", True),
|
|
("opencode v2.0.22\n", True),
|
|
("0.15.31\n", False),
|
|
("", False),
|
|
("command not understood\n", False),
|
|
])
|
|
def test_the_installed_opencode_version_decides(monkeypatch, output, loads):
|
|
calls = []
|
|
|
|
def run(argv, **kwargs):
|
|
calls.append((argv, kwargs.get("timeout")))
|
|
return subprocess.CompletedProcess(argv, 0, stdout=output, stderr="")
|
|
|
|
monkeypatch.setattr(mainmod.shutil, "which", lambda name: f"/opt/bin/{name}")
|
|
monkeypatch.setattr(mainmod.subprocess, "run", run)
|
|
|
|
assert _real_version_check() is loads
|
|
assert calls == [(["/opt/bin/opencode", "--version"], 10)]
|
|
|
|
|
|
@pytest.mark.parametrize("failure", [OSError("gone"), subprocess.TimeoutExpired("opencode", 10)])
|
|
def test_an_opencode_that_cannot_be_asked_counts_as_too_old(monkeypatch, failure):
|
|
def run(*_a, **_k):
|
|
raise failure
|
|
|
|
monkeypatch.setattr(mainmod.shutil, "which", lambda name: f"/opt/bin/{name}")
|
|
monkeypatch.setattr(mainmod.subprocess, "run", run)
|
|
assert _real_version_check() is False
|
|
|
|
monkeypatch.setattr(mainmod.shutil, "which", lambda name: None)
|
|
assert _real_version_check() is False
|
|
|
|
|
|
def test_opencode_is_only_asked_when_an_old_plugin_is_found(tmp_path, monkeypatch, capsys):
|
|
def asked():
|
|
raise AssertionError("no old plugin here, the version does not matter")
|
|
|
|
monkeypatch.setattr(mainmod, "_opencode_loads_current_plugin", asked)
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
mainmod._refresh_v1_opencode_plugins() # no plugin at all
|
|
_plugin, config = _old_install(tmp_path, _OPENCODE_PLUGIN_JS)
|
|
mainmod._refresh_v1_opencode_plugins() # current plugin, stale entry
|
|
|
|
assert json.loads(config.read_text(encoding="utf-8")) == {"model": "some/model"}
|
|
assert "removed the stale graphify entry" in capsys.readouterr().err
|
|
|
|
|
|
@pytest.mark.parametrize("held", [False, None])
|
|
def test_a_directory_another_process_is_refreshing_is_skipped(tmp_path, monkeypatch, capsys, held):
|
|
"""Same rule as the skill refresh: without the lock (another graphify
|
|
process has it, or locking is unavailable) leave the directory alone."""
|
|
plugin, config = _old_install(tmp_path)
|
|
before = config.read_text(encoding="utf-8")
|
|
monkeypatch.chdir(tmp_path)
|
|
locked = []
|
|
|
|
@contextlib.contextmanager
|
|
def lock(directory, *, wait):
|
|
locked.append((directory, wait))
|
|
yield held
|
|
|
|
monkeypatch.setattr(mainmod, "_skill_lock", lock)
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
|
|
assert locked == [(plugin.resolve().parent, False)]
|
|
assert plugin.read_text(encoding="utf-8") == V1_PLUGIN
|
|
assert config.read_text(encoding="utf-8") == before
|
|
assert capsys.readouterr().err == ""
|
|
|
|
|
|
def test_no_lock_file_is_made_where_there_is_nothing_to_refresh(tmp_path, monkeypatch):
|
|
_old_install(tmp_path, _OPENCODE_PLUGIN_JS, entry=False)
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
mainmod._refresh_v1_opencode_plugins()
|
|
|
|
assert not (Path.home() / ".cache" / "graphify").exists()
|
|
|
|
|
|
def test_the_first_cli_run_after_an_upgrade_rewrites_it(tmp_path, monkeypatch, capsys):
|
|
plugin, _config = _old_install(tmp_path)
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setattr(sys, "argv", ["graphify", "--version"])
|
|
|
|
mainmod._run_cli()
|
|
|
|
out, err = capsys.readouterr()
|
|
assert out.strip() == f"graphify {__version__}"
|
|
assert plugin.read_text(encoding="utf-8") == _OPENCODE_PLUGIN_JS
|
|
assert "graphify: refreshed" in err
|
|
|
|
|
|
@pytest.mark.parametrize("cmd", ["install", "uninstall", "hook-check", "hook-guard"])
|
|
def test_install_and_hook_commands_skip_it(monkeypatch, cmd):
|
|
calls = []
|
|
monkeypatch.setattr(mainmod, "_refresh_v1_opencode_plugins", lambda: calls.append(cmd))
|
|
monkeypatch.setattr(mainmod, "_refresh_stale_skills", lambda: None)
|
|
monkeypatch.setattr(mainmod, "_check_skill_version", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(sys, "argv", ["graphify", cmd, "--help"])
|
|
|
|
class _Stop(Exception):
|
|
pass
|
|
|
|
def stop(*_a, **_k):
|
|
raise _Stop
|
|
|
|
monkeypatch.setattr(mainmod, "dispatch_install_cli", stop)
|
|
monkeypatch.setattr(mainmod, "dispatch_command", stop)
|
|
try:
|
|
mainmod._run_cli()
|
|
except (_Stop, SystemExit):
|
|
pass
|
|
assert calls == []
|