A plugin is a new SURFACE arriving at runtime, which is exactly the case hand-written prose could never cover: install gitea and no agent learns that `tea` is authenticated, or that plain `git` is the better tool for what git already does. The plugin knows; nothing carried it across. An `affordances/` directory in the plugin repository is copied to capabilities/affordances/<publisher>/<plugin>/ on install and removed on uninstall — the same shape as assets/ and publishAssets, deliberately, because two mechanisms for "the plugin ships some files" would drift and that one is already understood. Namespaced by publisher because two plugins named `gitea` from different publishers must not overwrite each other, and because an affordance in the tree should say whose it is without being opened. It mirrors mountPrefix. Uninstall removes the PLUGIN's directory, never the publisher's: every first-party plugin publishes under one officerdev/, and taking that level out because one was uninstalled would strip the rest silently. That is what the tests are mostly about — a copy going wrong is obvious, this would not be. Publishing is not fatal to an install. An affordance that failed to copy costs an agent some knowledge; refusing the install over it costs the owner the plugin. Verified against the real gitea plugin: published, the loader saw officerdev/gitea/gitea beside officer/core/image-rendering, uninstall removed it and left core alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
81 lines
3.4 KiB
TypeScript
81 lines
3.4 KiB
TypeScript
import { describe, expect, test, afterEach } from 'bun:test';
|
|
import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { affordanceDirFor, affordanceSourceFor, publishAffordances, unpublishAffordances } from './publish';
|
|
import { AFFORDANCES_DIR } from './load';
|
|
import type { DiscoveredPlugin } from '../plugins/manifest';
|
|
|
|
// The failure that matters is not a copy going wrong — it is uninstall taking somebody else's files with
|
|
// it. Every first-party plugin publishes under one `officerdev/` directory, so removing the wrong level
|
|
// would strip them all and nobody would notice until an agent stopped knowing something.
|
|
|
|
const root = join(tmpdir(), `aff-pub-${process.pid}`);
|
|
|
|
const plugin = (appName: string, publisher = 'officerdev'): DiscoveredPlugin =>
|
|
({ appName, dir: join(root, appName), manifest: { publisher } }) as DiscoveredPlugin;
|
|
|
|
const withAffordance = (appName: string, publisher = 'officerdev') => {
|
|
const p = plugin(appName, publisher);
|
|
mkdirSync(affordanceSourceFor(p), { recursive: true });
|
|
writeFileSync(join(affordanceSourceFor(p), `${appName}.md`), `---\nname: ${appName}\n---\n\nbody\n`);
|
|
return p;
|
|
};
|
|
|
|
afterEach(() => {
|
|
rmSync(root, { recursive: true, force: true });
|
|
for (const pub of ['officerdev', 'alice']) rmSync(join(AFFORDANCES_DIR, pub), { recursive: true, force: true });
|
|
});
|
|
|
|
describe('publishing', () => {
|
|
test('lands under <publisher>/<appName>, mirroring mountPrefix', () => {
|
|
const p = withAffordance('test-gitea');
|
|
expect(affordanceDirFor(p)).toBe(join(AFFORDANCES_DIR, 'officerdev', 'test-gitea'));
|
|
});
|
|
|
|
test('copies what the plugin ships and counts it', async () => {
|
|
expect(await publishAffordances(withAffordance('test-gitea'))).toBe(1);
|
|
});
|
|
|
|
test('a plugin shipping none is not a failure — most ship none', async () => {
|
|
const bare = plugin('test-bare');
|
|
mkdirSync(bare.dir, { recursive: true });
|
|
expect(await publishAffordances(bare)).toBe(0);
|
|
});
|
|
|
|
test('re-publishing overwrites rather than erroring, so re-install works', async () => {
|
|
const p = withAffordance('test-gitea');
|
|
expect(await publishAffordances(p)).toBe(1);
|
|
expect(await publishAffordances(p)).toBe(1);
|
|
});
|
|
|
|
test('two publishers with the same plugin name do not collide', async () => {
|
|
await publishAffordances(withAffordance('test-gitea', 'officerdev'));
|
|
await publishAffordances(withAffordance('test-gitea', 'alice'));
|
|
expect(existsSync(join(AFFORDANCES_DIR, 'officerdev', 'test-gitea', 'test-gitea.md'))).toBe(true);
|
|
expect(existsSync(join(AFFORDANCES_DIR, 'alice', 'test-gitea', 'test-gitea.md'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('unpublishing', () => {
|
|
test('removes the plugin', async () => {
|
|
const p = withAffordance('test-gitea');
|
|
await publishAffordances(p);
|
|
await unpublishAffordances(p);
|
|
expect(existsSync(affordanceDirFor(p))).toBe(false);
|
|
});
|
|
|
|
test('leaves the PUBLISHER alone — the thing that would silently strip every sibling', async () => {
|
|
const a = withAffordance('test-gitea');
|
|
const b = withAffordance('test-music');
|
|
await publishAffordances(a);
|
|
await publishAffordances(b);
|
|
await unpublishAffordances(a);
|
|
expect(existsSync(affordanceDirFor(b))).toBe(true);
|
|
});
|
|
|
|
test('is safe when nothing was ever published', async () => {
|
|
await unpublishAffordances(plugin('test-never'));
|
|
});
|
|
});
|