From 26f0b72b2e9a472b78273e9719e8f2987bbf944e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:30:41 +0100 Subject: [PATCH] feat: write plugins, themes, approvals and branding back to local files on export #1066 --- lib/shared-store/__tests__/import-ext.test.ts | 21 ++++++++- lib/shared-store/import/ext.ts | 43 ++++++++++++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/lib/shared-store/__tests__/import-ext.test.ts b/lib/shared-store/__tests__/import-ext.test.ts index 33d27badd..c1d7ebb2e 100644 --- a/lib/shared-store/__tests__/import-ext.test.ts +++ b/lib/shared-store/__tests__/import-ext.test.ts @@ -13,8 +13,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // branding uploads come along; a second run writes nothing, and the local // files stay as they were. -import { steps } from '../import/ext'; +import { exportSteps, steps } from '../import/ext'; import { importLocalData } from '../import'; +import { exportSharedData } from '../export'; import { getStore, setStoreForTests } from '../index'; import { resetLocalStoreForTests } from '../local'; import { separateFakeStore } from '@/lib/admin/__tests__/shared-store-fake'; @@ -115,6 +116,24 @@ describe('importing plugins, themes and branding into the shared store', () => { expect(snapshot()).toEqual(before); }); + it('writes the same files back into an empty install on export', async () => { + const before = snapshot(); + await importLocalData(steps); + // A fresh volume for the instance that goes back to local mode. + rmSync(path.join(dir, 'config'), { recursive: true, force: true }); + rmSync(path.join(dir, 'state'), { recursive: true, force: true }); + const written = await exportSharedData(exportSteps); + expect(written).toMatchObject({ plugins: 5, themes: 3, branding: 1, 'plugin-signing-key': 1, 'directory-keyrings': 1 }); + + const bytesOnly = (snap: Record) => + Object.fromEntries(Object.entries(snap).filter(([f]) => !f.endsWith('config.json')).map(([f, v]) => [f, v.split(' ')[0]])); + expect(bytesOnly(snapshot())).toEqual(bytesOnly(before)); + if (process.platform !== 'win32') { + expect(statSync(path.join(dir, 'state', 'directory-keyrings.json')).mode & 0o777).toBe(0o600); + expect(statSync(path.join(dir, 'config', 'plugin-signing.key')).mode & 0o777).toBe(0o600); + } + }); + it('keeps what the store already holds', async () => { await pluginDocs().put('demo.js', 'already shared()'); await getStore().kv.set('plugin-signing-key', 'stored key'); diff --git a/lib/shared-store/import/ext.ts b/lib/shared-store/import/ext.ts index ec3ab0eb7..4755db9c0 100644 --- a/lib/shared-store/import/ext.ts +++ b/lib/shared-store/import/ext.ts @@ -1,5 +1,6 @@ -import { readFile } from 'node:fs/promises'; +import { chmod, readFile, writeFile } from 'node:fs/promises'; import type { ImportStep } from '../import'; +import { exportCollection, type ExportStep } from '../export'; import { getStore, type CollectionOptions } from '../index'; import { localCollection } from '../local'; import { @@ -18,7 +19,7 @@ import { } from '@/lib/admin/extension-store'; import { ADMIN_STATE_COLLECTION } from '@/lib/admin/admin-store'; import { SHARED_KEY_NAME } from '@/lib/admin/plugin-signing'; -import { getConfigPath } from '@/lib/admin/paths'; +import { ensureConfigDir, getConfigPath, isConfigReadOnly } from '@/lib/admin/paths'; /** * Part `ext` of the import (#1066): installed plugins and themes (registry, @@ -85,3 +86,41 @@ export const steps: ImportStep[] = [ }, }, ]; + +/** + * The way back (SHARED_STORE_EXPORT, lib/shared-store/export.ts): the same + * items from the store into the local layout. Run after the admin-state + * export, so directory-keyrings.json ends up owner-only as local mode + * writes it. The stored signing key replaces a key file on the volume, as + * every other file of the same name is replaced: the exported signatures + * were made with it. + */ +export const exportSteps: ExportStep[] = [ + exportCollection(PLUGINS_COLLECTION, pluginsOptions), + exportCollection(THEMES_COLLECTION, themesOptions), + exportCollection(PLUGIN_CONFIG_COLLECTION, pluginConfigOptions), + exportCollection(PLUGIN_APPROVALS_COLLECTION, pluginApprovalsOptions), + exportCollection(BRANDING_COLLECTION, brandingOptions), + { + name: 'directory-keyrings', + async run() { + const doc = await getStore().collection(ADMIN_STATE_COLLECTION, directoryKeyringsOptions).get(DIRECTORY_KEYRINGS_ID); + if (!doc) return 0; + await localCollection(ADMIN_STATE_COLLECTION, directoryKeyringsOptions).put(DIRECTORY_KEYRINGS_ID, doc.data); + return 1; + }, + }, + { + name: 'plugin-signing-key', + async run() { + if (isConfigReadOnly()) return 0; + const pem = await getStore().kv.get(SHARED_KEY_NAME); + if (!pem) return 0; + await ensureConfigDir(); + const file = getConfigPath('plugin-signing.key'); + await writeFile(file, pem, { encoding: 'utf-8', mode: 0o600 }); + await chmod(file, 0o600).catch(() => {}); + return 1; + }, + }, +];