diff --git a/README.md b/README.md index 1254a87..9453f82 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,17 @@ $container->registerService(AppRoot::class, new AppRoot(__DIR__ . '/..')); the graph resolves and in what order plugins would boot, and what enabling one *would* do — named provider by provider — without enabling it. +### Catalogue relationships + +`plugins.list` declares the shape of its actual result: a `plugins` array with registry names, +metadata, activation state and a nullable installation timestamp. `plugins.simulate.plugin` and +`plugins.verify.plugin` name `plugins.list.name` through `x-milpa-source`, so a catalogue reader +can locate their producer. Call the list without filters to discover the initial values. + +Registry names are not source directory names. A vendor plugin can be registered without any +local source directory, and local code can exist before registration. These relationships are +discovery hints; they grant no permission and do not select or register a plugin. + ### Declared in code, switched at runtime A host has plugins from two places, and `ActivePlugins` decides which of them diff --git a/src/Operations/PluginOperations.php b/src/Operations/PluginOperations.php index 018debb..900ec4d 100644 --- a/src/Operations/PluginOperations.php +++ b/src/Operations/PluginOperations.php @@ -127,6 +127,33 @@ public function operations(): array 'default' => false, ], ], + 'required' => [], + ], + // Registry names feed management operations; they are not local source directories. + // Greenhouse decisions/0324, evidence/0640. + outputSchema: [ + 'type' => 'object', + 'properties' => [ + 'plugins' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'version' => ['type' => 'string'], + 'author' => ['type' => 'string'], + 'site' => ['type' => 'string'], + 'type' => ['type' => 'string'], + 'installed' => ['type' => 'boolean'], + 'enabled' => ['type' => 'boolean'], + 'source' => ['type' => 'string'], + 'installedAt' => ['type' => ['string', 'null']], + ], + 'required' => ['name', 'version', 'author', 'site', 'type', 'installed', 'enabled', 'source', 'installedAt'], + ], + ], + ], + 'required' => ['plugins'], ], scopes: ['plugins:read'], path: '/plugins', @@ -298,7 +325,11 @@ public function operations(): array handler: fn (array $input): array => $this->inspection()->simulate($input), inputSchema: [ 'type' => 'object', - 'properties' => ['plugin' => ['type' => 'string', 'description' => 'Plugin name, e.g. "MailPlugin".']], + 'properties' => ['plugin' => [ + 'type' => 'string', + 'description' => 'Plugin name, e.g. "MailPlugin".', + 'x-milpa-source' => ['tool' => 'plugins.list', 'key' => 'name'], + ]], 'required' => ['plugin'], ], scopes: ['plugins:read'], @@ -355,7 +386,11 @@ public function operations(): array handler: fn (array $input): array => $this->inspection()->verify($input), inputSchema: [ 'type' => 'object', - 'properties' => ['plugin' => ['type' => 'string', 'description' => 'Plugin name, e.g. "MailPlugin".']], + 'properties' => ['plugin' => [ + 'type' => 'string', + 'description' => 'Plugin name, e.g. "MailPlugin".', + 'x-milpa-source' => ['tool' => 'plugins.list', 'key' => 'name'], + ]], 'required' => ['plugin'], ], scopes: ['plugins:read'], diff --git a/tests/Operations/PluginOperationsTest.php b/tests/Operations/PluginOperationsTest.php index 71a7a59..5584c41 100644 --- a/tests/Operations/PluginOperationsTest.php +++ b/tests/Operations/PluginOperationsTest.php @@ -123,6 +123,32 @@ private function operations(?PluginInstallerInterface $installer = null, array $ return $byName; } + /** The catalogue describes the list's real rows and points management reads at registry names. */ + public function testTheListPublishesItsResultsForManagementConsumers(): void + { + $this->registry->register($this->record('Catalogued')); + $operations = $this->operationsWithRoot(sys_get_temp_dir()); + $list = $operations['plugins.list']; + self::assertSame([], $list->inputSchema['required'] ?? null); + self::assertNotNull($list->outputSchema); + $rowSchema = $list->outputSchema['properties']['plugins']['items']; + $rows = ($list->handler)([])['plugins']; + self::assertCount(1, $rows); + self::assertEqualsCanonicalizing(array_keys($rows[0]), $rowSchema['required']); + self::assertEqualsCanonicalizing(array_keys($rows[0]), array_keys($rowSchema['properties'])); + self::assertSame(['string', 'null'], $rowSchema['properties']['installedAt']['type']); + self::assertNull($rows[0]['installedAt']); + self::assertSame('Catalogued', $this->call('plugins.show', ['name' => $rows[0]['name']])['name']); + foreach (['plugins.simulate', 'plugins.verify'] as $name) { + self::assertSame( + ['tool' => 'plugins.list', 'key' => 'name'], + $operations[$name]->inputSchema['properties']['plugin']['x-milpa-source'] ?? null, + ); + } + // Registration can name a new, unregistered class: registry membership is not its source. + self::assertArrayNotHasKey('x-milpa-source', $operations['plugins.register']->inputSchema['properties']['name']); + } + /** * Las mismas, pero con una raíz de app — para ver aparecer las dos que tocan disco. *