hermes_cli/plugin_compat.py is now the single source of truth for the compat window:
COMPAT_REMOVAL_DATE = 2026-09-14; scan_plugin() statically finds `from F import n`, `import F` + `F.n`,
alias forms and string targets against compat_manifest.json; compat_report() aggregates over the user's
ENABLED external (non-bundled) plugins; disable_reason() decides the loader's skip.
Surfaces (all read from that one report):
* CLI: yellow block under the banner naming plugins + date + `hermes plugins compat` (red + DISABLED after)
* `hermes plugins compat [--json] [path]`: file:line, old -> new per hit; exit 1 while anything remains;
`path` lets a plugin author scan their own checkout
* `hermes doctor`: "Plugin import paths (removed Sep 14, 2026)" section next to the xAI retirement check
* `hermes update`: post-update notice alongside the FTS/curator notices
* Desktop: compat_report() writes HERMES_HOME/.plugin-compat-report.json (deleted when clean); Electron
shows ONE warning dialog per distinct report after the backend is up and persists the dismissal in
userData/plugin-compat-dismissed.json. A new affected plugin, or the date passing, is a new report.
From the date, PluginManager skips a hitting external plugin before importing it, with the reason in
LoadedPlugin.error ("uses N import path(s) removed on 2026-09-14; run `hermes plugins compat` ...") — the
same path a plugin with a broken register() takes, so nothing else is affected. Escape hatch:
plugins.allow_deprecated_imports: true (config_defaults), which only helps until the compat commit is
actually reverted.
Docs: COMPAT_MANIFEST.md (removal date, what-happens table, author instructions), plugin dev guide section.
Tests: tests/test_plugin_compat_notice.py (scanner forms, report scope, date gate + escape hatch, summary
text, report file lifecycle, loader skip via a real PluginManager), electron/plugin-compat-notice.test.ts
(show once, re-show on a different set or on the date passing, malformed file ignored).
Live A/B on this box with a demo plugin on old paths: before the date it loads and the banner/doctor/report
name it; with today=2026-09-14 it is skipped with the reason and the banner turns red; with the escape
hatch it loads again.
75 lines
3.2 KiB
TypeScript
75 lines
3.2 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { test } from 'vitest'
|
|
import { DISMISSED_FILE, REPORT_FILE, pendingNotice, recordDismissed, reportKey } from './plugin-compat-notice'
|
|
|
|
function tmp() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-compat-'))
|
|
}
|
|
|
|
const REPORT = {
|
|
removal_date: '2026-09-14',
|
|
in_effect: false,
|
|
plugins: {
|
|
alpha: [{ file: '__init__.py', line: 3, old: 'tools.web_tools.prefers_gateway', new: 'tools.tool_backend_helpers.prefers_gateway' }],
|
|
beta: [
|
|
{ file: 'a.py', line: 1, old: 'hermes_cli.kanban_db.connect', new: 'hermes_cli.kanban_db_connect.connect' },
|
|
{ file: 'a.py', line: 9, old: 'hermes_cli.kanban_db.connect_closing', new: 'hermes_cli.kanban_db_connect.connect_closing' }
|
|
]
|
|
},
|
|
lines: ['2 plugins use import paths that stop working on 2026-09-14 (10 days): alpha (1), beta (2)', 'Details: hermes plugins compat']
|
|
}
|
|
|
|
test('no report file → no notice', () => {
|
|
assert.equal(pendingNotice(tmp(), tmp()), null)
|
|
})
|
|
|
|
test('report → one notice naming plugins, date and the CLI command', () => {
|
|
const home = tmp()
|
|
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(REPORT))
|
|
const n = pendingNotice(home, tmp())
|
|
assert.ok(n)
|
|
assert.equal(n.title, 'Plugins need an update')
|
|
assert.match(n.message, /2 plugins import module paths that stop working on 2026-09-14/)
|
|
assert.match(n.detail, /• alpha — 1 import \(e\.g\. tools\.web_tools\.prefers_gateway → tools\.tool_backend_helpers\.prefers_gateway\)/)
|
|
assert.match(n.detail, /• beta — 2 imports/)
|
|
assert.match(n.detail, /hermes plugins compat/)
|
|
})
|
|
|
|
test('dismissal is remembered for the same report and forgotten for a different one', () => {
|
|
const home = tmp()
|
|
const userData = tmp()
|
|
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(REPORT))
|
|
const first = pendingNotice(home, userData)
|
|
assert.ok(first)
|
|
recordDismissed(userData, first.key)
|
|
assert.ok(fs.existsSync(path.join(userData, DISMISSED_FILE)))
|
|
assert.equal(pendingNotice(home, userData), null, 'same report must not show twice')
|
|
|
|
// a third affected plugin is new information
|
|
const grown = { ...REPORT, plugins: { ...REPORT.plugins, gamma: [{ file: 'g.py', line: 1, old: 'x.y', new: 'z.y' }] } }
|
|
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(grown))
|
|
const second = pendingNotice(home, userData)
|
|
assert.ok(second)
|
|
assert.notEqual(second.key, first.key)
|
|
|
|
// the date passing (plugins now disabled) is new information too, with different wording
|
|
const disabled = { ...REPORT, in_effect: true }
|
|
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(disabled))
|
|
const third = pendingNotice(home, userData)
|
|
assert.ok(third)
|
|
assert.equal(third.title, 'Some plugins were not loaded')
|
|
assert.match(third.detail, /allow_deprecated_imports/)
|
|
assert.notEqual(reportKey(disabled as any), reportKey(REPORT as any))
|
|
})
|
|
|
|
test('empty or malformed report is ignored', () => {
|
|
const home = tmp()
|
|
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify({ ...REPORT, plugins: {} }))
|
|
assert.equal(pendingNotice(home, tmp()), null)
|
|
fs.writeFileSync(path.join(home, REPORT_FILE), '{not json')
|
|
assert.equal(pendingNotice(home, tmp()), null)
|
|
})
|