feat: write plugins, themes, approvals and branding back to local files on export #1066

This commit is contained in:
Linus Rath
2026-11-01 00:30:41 +01:00
parent 355e57ca6c
commit 26f0b72b2e
2 changed files with 61 additions and 3 deletions
+20 -1
View File
@@ -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<string, string>) =>
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');
+41 -2
View File
@@ -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;
},
},
];