guest@6admin.io:~$

Migrer OpenCode de v1 à v2 : le script de bascule et des plugins qui tiennent les deux versions

OpenCode est passé en v2 et le changement n’est pas cosmétique : le format de config a bougé, l’API serveur et ses clients ont de nouveaux contrats et surtout les plugins v1 ne s’exécutent plus dans v2. Déplacer un fichier ou renommer une entrée de config ne suffit pas, il faut porter le code. La doc de migration officielle décrit les renommages, mais elle laisse de côté ce qui fait mal en pratique : la base de données locale, le shim d’un plugin installé en copie locale et le fait de vouloir, pendant quelques semaines, qu’un plugin continue de servir les deux versions. Deux chantiers, donc, que je traite ici dans l’ordre où je les ai vécus : d’abord basculer mon poste, ensuite porter mes plugins.

1. Basculer son poste : un script, pas une suite de gestes

Une migration qui touche à une base de données se fait avec un backup qu’on a testé, pas avec un upgrade lancé en croisant les doigts. J’ai donc écrit un script, opencode-v2-switch.sh, qui enchaîne les étapes dans un ordre où chacune peut échouer proprement. Il tourne en --dry-run pour montrer les éditions de config sans rien casser, refuse de démarrer si un prérequis manque, --skip-backup saute le backup et --yes enlève la confirmation. Le voici en entier :

#!/usr/bin/env bash
# opencode v1 -> v2 switch: fresh db backup (xz max), upgrade --major,
# tui.json v1 plugin cleanup, plugins config -> v2 npm packages, caveman regen.
#
# Usage: opencode-v2-switch.sh [--dry-run] [--skip-backup] [--yes]
#   --dry-run     show the config edits, run nothing destructive
#   --skip-backup skip the fresh db backup (rollback = the previous .xz only)
#   --yes         no confirmation prompt
#
# Rollback after a real run: xz -d the newest opencode-v1-backup-db-pre-v2-*.db.xz
# and copy it over ~/.local/share/opencode/opencode.db, reinstall opencode 1.x.
set -euo pipefail

DRY_RUN=false SKIP_BACKUP=false ASSUME_YES=false
for arg in "$@"; do
	case "$arg" in
	--dry-run) DRY_RUN=true ;;
	--skip-backup) SKIP_BACKUP=true ;;
	--yes) ASSUME_YES=true ;;
	*)
		echo "unknown arg: $arg"
		exit 1
		;;
	esac
done

OC_BIN="${OPENCODE_BIN:-opencode}"
CONFIG_DIR="${HOME}/.config/opencode"
DATA_DIR="${HOME}/.local/share/opencode"
DATE=$(date +%Y%m%d)
DB="$DATA_DIR/opencode.db"
V2_PLUGINS='["@slkiser/opencode-quota", "@log0u7/opencode-mesh", "@log0u7/opencode-memory", "@log0u7/opencode-watt"]'

step() { printf '\n== %s ==\n' "$*"; }
die() {
	printf 'ABORT: %s\n' "$*" >&2
	exit 1
}

step "preflight"
command -v "$OC_BIN" >/dev/null || die "opencode not found"
command -v xz >/dev/null || die "xz not found"
command -v bun >/dev/null || die "bun not found (db backup + jsonc edit)"
command -v jq >/dev/null || die "jq not found"
free_kb=$(df -Pk "$HOME" | awk 'NR==2 {print $4}')
if [ "$free_kb" -lt 8388608 ]; then
	die "less than 8G free on $HOME (need ~5G db temp + ~2G xz)"
fi
echo "disk ok: ${free_kb} kB free"
echo "current opencode: $("$OC_BIN" --version 2>/dev/null | head -1)"

if [ "$ASSUME_YES" = false ] && [ "$DRY_RUN" = false ]; then
	printf 'This migrates %s/opencode.db IN PLACE by OpenCode 2 (rollback = the .xz backup).\nProceed? [y/N] ' "$DATA_DIR"
	read -r reply
	case "$reply" in y | Y) ;; *) die "user declined" ;; esac
fi

if [ "$SKIP_BACKUP" = true ]; then
	step "backup SKIPPED (--skip-backup)"
elif [ "$DRY_RUN" = true ]; then
	step "backup SKIPPED (dry-run)"
else
	step "fresh db backup: VACUUM INTO + integrity/prefix checks + xz -9e"
	TMPDB="$HOME/opencode-v1-backup-db-pre-v2-$DATE.db"
	bun -e "
import { Database } from 'bun:sqlite';
const db = new Database('$DB', { readonly: true });
db.run(\"VACUUM INTO '$TMPDB'\");
db.close();
console.log('vacuum done');
"
	bun -e "
import { Database } from 'bun:sqlite';
const src = new Database('$DB', { readonly: true });
const bak = new Database('$TMPDB', { readonly: true });
console.log('integrity:', bak.query('PRAGMA integrity_check').get().integrity_check);
for (const t of ['event', 'message', 'part', 'session']) {
  const maxBak = bak.query(\`SELECT max(rowid) m FROM \${t}\`).get().m;
  const bakN = bak.query(\`SELECT count(*) n FROM \${t}\`).get().n;
  const srcN = src.query(\`SELECT count(*) n FROM \${t} WHERE rowid <= \${maxBak}\`).get().n;
  if (srcN !== bakN) {
    console.error(\`\${t}: prefix mismatch (src \${srcN} vs bak \${bakN})\`);
    process.exit(1);
  }
  console.log(\`\${t}: prefix OK (\${bakN} rows)\`);
}
src.close();
bak.close();
"
	xz -9e -T0 -c "$TMPDB" >"$HOME/opencode-v1-backup-db-pre-v2-$DATE.db.xz"
	xz -t "$HOME/opencode-v1-backup-db-pre-v2-$DATE.db.xz" || die "xz archive corrupt"
	rm "$TMPDB"
	echo "backup written: $HOME/opencode-v1-backup-db-pre-v2-$DATE.db.xz"
fi

if [ "$DRY_RUN" = true ]; then
	step "upgrade SKIPPED (dry-run)"
else
	step "upgrade: 1.x latest, then 2.0"
	"$OC_BIN" upgrade
	"$OC_BIN" upgrade --major
	"$OC_BIN" --version
fi

step "tui.json: drop the v1 TUI plugin entry (it breaks v2 plugin list)"
if [ -f "$CONFIG_DIR/tui.json" ]; then
	cp "$CONFIG_DIR/tui.json" "$CONFIG_DIR/tui.json.bak-$DATE"
	if [ "$DRY_RUN" = true ]; then
		echo "would run: jq 'del(.plugin)' on tui.json"
		cat "$CONFIG_DIR/tui.json"
	else
		jq 'del(.plugin)' "$CONFIG_DIR/tui.json" >"$CONFIG_DIR/tui.json.new"
		mv "$CONFIG_DIR/tui.json.new" "$CONFIG_DIR/tui.json"
		echo "tui.json cleaned (backup: tui.json.bak-$DATE)"
	fi
else
	echo "no tui.json (nothing to clean)"
fi

step "opencode.json: plugins -> v2 npm packages (comments preserved)"
bun -e "
import { readFileSync, writeFileSync } from 'node:fs';
const path = '$CONFIG_DIR/opencode.json';
const src = readFileSync(path, 'utf8');
const idx = src.search(/\"plugin\":\s*\[/);
if (idx === -1) {
  console.error('plugin block not found in opencode.json');
  process.exit(1);
}
const match = src.slice(idx).match(/^\"plugin\":\s*\[[\s\S]*?\]/);
// keep commented-out entries (suspended plugins) for future re-enabling
const kept = match[0]
  .split('\n')
  .filter((l) => l.trim().startsWith('//'))
  .join('\n');
// closed array + comma, then the kept comments; the original trailing comma
// after the matched block is dropped to avoid a doubled comma.
const rest = src.slice(idx + match[0].length).replace(/^\s*,/, '');
const replacement = '\"plugin\": $V2_PLUGINS,\n' + kept;
if ($DRY_RUN) {
  console.log('--- current block ---');
  console.log(match[0]);
  console.log('--- replacement + following lines ---');
  console.log(replacement + rest.split('\n').slice(0, 2).join('\n'));
} else {
  writeFileSync(path, src.slice(0, idx) + replacement + rest);
  console.log('opencode.json updated (v1 format kept, v2 normalizes it at load)');
}
"

step "caveman: regenerate the shim for the running opencode version"
if command -v caveman >/dev/null; then
	if [ "$DRY_RUN" = true ]; then
		echo "would run: caveman enable opencode"
	else
		caveman enable opencode
	fi
else
	echo "caveman CLI not on PATH - run its installer manually after the switch"
fi

step "post checks"
if [ "$DRY_RUN" = false ]; then
	"$OC_BIN" --version
	"$OC_BIN" auth list || echo "auth empty? fix with: opencode auth login"
	"$OC_BIN" plugin list || true
	"$OC_BIN" mcp list || true
fi
echo
echo "done. next: start the TUI - check theme, sessions v1 (Carry the disk), MCP context7, agents."
echo "rollback: xz -d the newest opencode-v1-backup-db-pre-v2-*.db.xz over opencode.db + reinstall opencode 1.x."

1.1. Préflight et backup

Le script commence par vérifier ce dont il a besoin, opencode, xz, bun et jq, puis qu’il reste au moins 8 Go sur $HOME, parce que le backup passe par une copie temporaire de la base avant compression. Le backup lui-même ne se contente pas d’un cp :

bun -e "import { Database } from 'bun:sqlite';
  const db = new Database('\$DB', { readonly: true });
  db.run(\"VACUUM INTO '\$TMPDB'\"); db.close();"

Le VACUUM INTO produit une base propre et cohérente même si OpenCode écrit pendant la copie, ce qu’un cp ne garantit pas sur du SQLite vivant. Vient ensuite un PRAGMA integrity_check sur la copie, puis une vérification de préfixe : pour chaque table (event, message, part, session), le nombre de lignes de la sauvegarde doit correspondre au même préfixe de rowid dans la source. Si ça diverge, on s’arrête. Le tout finit en xz -9e et l’archive est elle-même testée avec xz -t avant que la copie temporaire ne soit supprimée. Le rollback tient alors en une phrase : décompresser le .xz le plus récent par-dessus opencode.db et réinstaller une 1.x.

1.2. L’upgrade en deux temps

OpenCode ne saute pas directement en 2.0 depuis n’importe quelle 1.x. Le script passe donc par la dernière 1.x avant de franchir le cap majeur :

opencode upgrade
opencode upgrade --major

À noter, les deux versions ne cohabitent plus côte à côte par défaut : l’installeur v2 remplace le binaire. Si une v1 avait été posée par un gestionnaire de paquets, il faut la retirer d’abord, sinon les deux installations se marchent dessus.

1.3. La config : laisser v2 normaliser, nettoyer ce qui casse

La bonne nouvelle, c’est que v2 lit les mêmes emplacements que v1 et normalise les champs en mémoire sans réécrire vos fichiers. Les renommages sont nombreux (autoshare devient share, permission devient permissions, plugin devient plugins, le tuple paquet plus options devient un objet), mais on n’est pas obligé de tout réécrire à la main : un opencode.json resté en forme v1 est accepté, v2 le normalise au chargement. Le script en profite, il remplace juste la liste des plugins par les paquets v2 et garde la clé plugin en l’état, en préservant au passage les entrées commentées, celles des plugins suspendus qu’on voudra réactiver plus tard.

Un point mérite l’attention, parce qu’il casse silencieusement la liste des plugins v2 : l’entrée de plugin TUI dans tui.json. Le client terminal de v2 migre ce qu’il peut de tui.json vers un cli.json global au premier démarrage, mais une entrée plugin restée dans l’ancien fichier perturbe l’énumération. Le script la retire, backup à l’appui :

jq 'del(.plugin)' "$CONFIG_DIR/tui.json" > "$CONFIG_DIR/tui.json.new"
mv "$CONFIG_DIR/tui.json.new" "$CONFIG_DIR/tui.json"

1.4. La subtilité caveman

Le cas le moins évident est celui de caveman . Son installeur ne référence pas un paquet npm distant, il déclare une copie locale du plugin dans la config d’OpenCode. Cette copie est un shim lié au format et à la version pour lesquels elle a été générée. Après la bascule, elle pointe donc sur l’ancien monde et fait échouer la liste de plugins de v2. La correction n’est pas de l’éditer à la main mais de la régénérer, en laissant caveman réécrire son shim pour la version qui tourne maintenant :

command -v caveman >/dev/null && caveman enable opencode

La règle générale vaut pour tout plugin installé en copie locale : ce qui a été généré pour un hôte donné doit être régénéré après un changement de version, pas recyclé.

1.5. Contrôles de sortie

Le script se termine par les vérifications qui disent si la bascule a pris : version, auth list, plugin list, mcp list. Rien de magique, mais c’est là qu’on voit si l’authentification d’un provider est à refaire ou si un plugin manque à l’appel. Le reste (le thème, les sessions v1 reprises, les agents) se vérifie en ouvrant la TUI.

2. Porter ses plugins : un paquet, deux API

La partie serveur d’un plugin v1 et celle d’un plugin v2 sont deux API différentes et rien n’est traduit automatiquement de l’une à l’autre. Mais on n’est pas obligé de maintenir deux paquets : un seul module peut exposer les deux implémentations, l’hôte choisissant la bonne. C’est le pattern que j’ai retenu pour opencode-mesh, opencode-memory et opencode-watt.

2.1. L’entrée double

Tout tient dans le point d’entrée. L’implémentation v2 est construite avec Plugin.define, la v1 reste une fonction qui renvoie des hooks et le module par défaut fusionne les deux : l’hôte v2 appelle setup(), l’hôte v1 appelle server().

import { MeshV2 } from "./plugin-v2.js";
import { MeshPlugin } from "./plugin.js";

// v1 appelle server(), v2 appelle setup()
export const pluginModule = {
  ...MeshV2,
  server: MeshPlugin,
} satisfies { id: string; server: typeof MeshPlugin; setup: (typeof MeshV2)["setup"] };

export default pluginModule;

Côté package.json, les deux API cohabitent en devDependencies, l’ancienne @opencode-ai/plugin et la nouvelle @opencode/plugin et le champ oc-plugin plus l’export ./server indiquent à l’hôte v1 où trouver le point d’entrée serveur :

"exports": {
  ".":        { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
  "./server": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"oc-plugin": ["server"]

L’entrée objet de v1 fonctionne sur OpenCode 1.19.0 et plus récent. Pour des 1.x antérieures, il faut des points d’entrée ou des versions de paquet distincts et le jour où la fenêtre de support v1 se ferme, on retire l’implémentation v1 sans toucher au reste.

2.2. Ce qui change, côté code

Le gros du travail est de remapper le contexte et les hooks. En v1, l’import vient de @opencode-ai/plugin, le plugin est une fonction async qui reçoit input et renvoie un objet de hooks indexés par chaîne :

import type { Hooks, Plugin } from "@opencode-ai/plugin";

export const MeshPlugin: Plugin = async (input, rawOptions) => {
  const hooks: Hooks = {
    tool: { mesh_status: tool({ /* ... */ }) },
    event: async ({ event }) => { /* ... */ },
    "chat.message": async (msg) => { /* ... */ },
    dispose: shutdown,
  };
  return hooks;
};

En v2, l’import vient de @opencode/plugin, chaque plugin porte un id stable qui scope son stockage et on enregistre les comportements sur le domaine qui les possède plutôt que sous une clé :

import { Plugin } from "@opencode/plugin";

export const MeshV2 = Plugin.define({
  id: "@log0u7/opencode-mesh",
  async setup(ctx) {
    const options = ctx.options;              // ex-argument d'options
    await ctx.tool.transform((editor) => {    // ex-map tool
      editor.add({ name: "mesh_status", input: { /* JSON Schema */ }, async execute(input, context) { /* ... */ } });
    });
    await ctx.session.hook("model.request", (event) => { /* ex-chat.message */ });
    const controller = new AbortController();
    void (async () => {
      for await (const event of ctx.event.subscribe({ signal: controller.signal })) { /* ex-event */ }
    })();
    return async () => { controller.abort(); await shutdown(); }; // ex-dispose
  },
});

Les correspondances qui reviennent le plus souvent :

  • input.client disparaît au profit de méthodes de domaine, ctx.session, ctx.permission, ctx.tool et le directory ou project passent sous ctx.location.
  • la map tool construite avec le helper tool() devient un ctx.tool.transform synchrone, à l’input décrit en JSON Schema, qui renvoie un contenu structuré. Comme le transform doit rester synchrone, les données externes se chargent avant l’enregistrement.
  • les hooks changent de forme : le callback reçoit un seul event mutable au lieu d’un input et d’un output séparés. tool.execute.before devient ctx.tool.hook("execute.before", ...), chat.message devient ctx.session.hook("prompt", ...), permission.ask devient ctx.permission.hook("evaluate", ...).
  • le hook event devient une boucle for await sur ctx.event.subscribe({ signal }) et le dispose() de v1 devient la fonction de nettoyage renvoyée par setup, qui abort() la boucle et libère timers, sous-process et sockets.
  • l’état se persiste avec ctx.storage.get et ctx.storage.set, du JSON durable scopé au plugin.

Deux pièges que les trois implémentations réelles ont ajoutés à cette carte :

  • Un require nu ne survit pas à un hôte Node. Un require("node:sqlite") depuis un module ES vaut undefined chez un hôte Node ; Bun, qui hot-charge les plugins, masque le problème et le require injecté par Vitest a caché l’échec à la suite. Le correctif tient en une ligne, createRequire(import.meta.url) et la vraie garde est un smoke test qui charge le paquet construit sous Node nu, désormais dans le pipeline de vérification.
  • Les domaines v2 ne parlent pas l’enveloppe REST. Les domaines en-process de v2 prennent des inputs plats scopés projet et résolvent les payloads directement, sans l’enveloppe { data, error } de v1 ; passer un client à forme REST directement à un domaine (le cas du worktree dans opencode-mesh) casse. Ré-enveloppez le domaine dans la forme attendue ou injectez un client (options.worktreeClient).

Tous les détails des signatures sont dans le guide de portage des plugins ; l’intérêt du pattern à double entrée, c’est qu’il ne demande pas de choisir tout de suite. Le paquet publie les deux, les postes migrent à leur rythme et on supprime la v1 quand plus personne ne l’appelle.

Ce que je retiens

  • Un backup qu’on n’a pas testé n’est pas un backup. VACUUM INTO, integrity_check, vérification de préfixe, archive testée : chaque étape répond à une façon précise dont une copie de SQLite vivant peut mentir.
  • Laissez l’hôte normaliser la config. v2 lit le format v1 et le normalise en mémoire ; on ne réécrit à la main que ce qui casse réellement l’énumération, comme l’entrée plugin de tui.json.
  • Une copie locale se régénère, elle ne se recycle pas. Le shim caveman généré pour v1 doit être réécrit pour v2 par caveman lui-même, pas rafistolé.
  • Un plugin peut servir les deux versions depuis une seule entrée. Un module qui étale Plugin.define(...) et ajoute un server() v1 laisse les postes basculer sans coordination et la dette v1 se retire d’une ligne le moment venu.
  • Le paquet construit est le seul test qui compte. Vitest injecte un require qui masquait un module ES cassé sous Node ; le smoke test qui charge dist/ sous Node nu l’a attrapé.

Source : Migrate from V1 , V1 to V2 Plugin Migration .