diff --git a/docs/reference/plugin-manifest.md b/docs/reference/plugin-manifest.md index ae05b65cbe9..d7f27feb087 100644 --- a/docs/reference/plugin-manifest.md +++ b/docs/reference/plugin-manifest.md @@ -189,7 +189,7 @@ One screenshot of the plugin in use. ## Versions and compatibility - **What runs is strict.** Orca refuses a manifest with any key it does not know, at every level, so an older Orca never silently ignores something a plugin relies on. -- **`engines.orca` is the only version gate.** It must be at least the newest `Since` of every field and every value (capability, event, built-in action) the manifest uses. Orca checks this when it validates, lists or installs a plugin, so an older Orca can always say "requires Orca >=X (this is Y)" instead of listing unknown fields. +- **`engines.orca` is the only version gate.** It must be at least the newest `Since` of every field and every value (capability, event, built-in action) the manifest uses. `pnpm validate:plugin` and catalog CI enforce it, so an older Orca can always say "requires Orca >=X (this is Y)" instead of listing unknown fields. - **`pluginApi` is optional.** It names the major version of the host API ([reference](plugin-host-api.md)), which only grows within major 1; Orca refuses a major it does not implement. Only `sandboxed-ui` and `trusted-worker` plugins call that API, so `data` and `declarative` plugins must leave it out. - **`terminal:send` needs `trusted-worker`.** Panels get no host method that types into a terminal, so Orca refuses the capability on any other profile instead of asking the user to grant something the plugin can never use. - **New fields, contribution points and values are additive.** They arrive with a `Since` version; nothing else changes. `manifestVersion` rises only if an existing field changes meaning. diff --git a/src/main/plugins/installation/plugin-install-staging.ts b/src/main/plugins/installation/plugin-install-staging.ts index fc1fcddf2fa..a359377b035 100644 --- a/src/main/plugins/installation/plugin-install-staging.ts +++ b/src/main/plugins/installation/plugin-install-staging.ts @@ -3,7 +3,7 @@ import { cp, mkdir, rm } from 'node:fs/promises' import { join, relative, resolve, sep } from 'node:path' import { PLUGIN_MANIFEST_FILENAME, - parsePluginManifest, + parseHostPluginManifest, qualifiedPluginKey, satisfiesOrcaEngineRange, type PluginManifest @@ -71,7 +71,7 @@ async function readInstallManifest( error: `unreadable ${PLUGIN_MANIFEST_FILENAME}: ${error instanceof Error ? error.message : String(error)}` } } - const parsed = parsePluginManifest(raw) + const parsed = parseHostPluginManifest(raw) if (!parsed.ok) { const incompatible = pluginManifestCompatibilityError(raw, hostVersion) return { ok: false, error: incompatible ?? `invalid manifest: ${parsed.error}` } diff --git a/src/main/plugins/installation/plugin-rollback-candidate.ts b/src/main/plugins/installation/plugin-rollback-candidate.ts index 87815d55d17..7c522500473 100644 --- a/src/main/plugins/installation/plugin-rollback-candidate.ts +++ b/src/main/plugins/installation/plugin-rollback-candidate.ts @@ -1,6 +1,6 @@ import { readdir } from 'node:fs/promises' import { join } from 'node:path' -import { parsePluginManifest } from '../../../shared/plugins/manifest/plugin-manifest' +import { parseHostPluginManifest } from '../../../shared/plugins/manifest/plugin-manifest' import { readPluginCurrentPointer } from './plugin-current-pointer' import { readPluginManifestText } from '../plugin-manifest-file' import { PLUGIN_CONTENT_HASH_PATTERN } from '../../../shared/plugins/installation/plugin-install-lockfile' @@ -43,7 +43,7 @@ export async function readPluginRollbackVersion(pluginDir: string): Promise { ) }) + // Regression: Excalidraw 1.0.0 as installed from the catalog before the format 2 floor moved. + it('loads an installed manifest whose engines floor predates its fields, but not a dev copy', async () => { + const pluginsDir = await tempPluginsDir() + const devDir = await tempPluginsDir() + const hash = 'c'.repeat(64) + const versionDir = join(pluginsDir, 'stablyai.orca-excalidraw', hash) + const manifest = JSON.stringify({ + manifestVersion: 2, + id: 'orca-excalidraw', + publisher: 'stablyai', + name: 'Excalidraw', + version: '1.0.0', + execution: 'sandboxed-ui', + engines: { orca: '>=1.4.244' }, + contributes: { + customEditors: [ + { + id: 'excalidraw', + title: 'Excalidraw', + extensions: ['.excalidraw', '.excalidraw.json'], + entry: 'editor/index.html', + priority: 'default' + } + ] + }, + capabilities: [] + }) + for (const dir of [versionDir, devDir]) { + await mkdir(join(dir, 'editor'), { recursive: true }) + await writeFile(join(dir, 'orca-plugin.json'), manifest) + await writeFile(join(dir, 'editor', 'index.html'), '

editor

') + } + await writeFile(join(pluginsDir, 'stablyai.orca-excalidraw', 'current'), hash) + + const [installed] = await discoverPlugins({ + pluginsDir, + devPluginPaths: [], + hostVersion: '1.4.245' + }) + expect(installed && 'error' in installed ? installed.error : null).toBeNull() + expect(installed?.pluginKey).toBe('stablyai.orca-excalidraw') + + const [dev] = await discoverPlugins({ + pluginsDir: await tempPluginsDir(), + devPluginPaths: [devDir], + hostVersion: '1.4.245' + }) + expect(dev && 'error' in dev ? dev.error : '').toContain('engines.orca: must be at least') + + // An older host still names the version gap rather than a schema error. + const [old] = await discoverPlugins({ pluginsDir, devPluginPaths: [], hostVersion: '1.4.200' }) + expect(old && 'error' in old ? old.error : '').toBe('requires Orca >=1.4.244 (this is 1.4.200)') + }) + it('keeps a removable qualified identity when the current pointer is missing', async () => { const pluginsDir = await tempPluginsDir() const installedKey = 'samples.broken' diff --git a/src/main/plugins/plugin-discovery.ts b/src/main/plugins/plugin-discovery.ts index 1dd89da31c5..01e55c973dd 100644 --- a/src/main/plugins/plugin-discovery.ts +++ b/src/main/plugins/plugin-discovery.ts @@ -5,6 +5,7 @@ import { PLUGIN_CONTENT_HASH_PATTERN } from '../../shared/plugins/installation/p import { PLUGIN_MANIFEST_FILENAME, isQualifiedPluginKey, + parseHostPluginManifest, parsePluginManifest, qualifiedPluginKey, satisfiesOrcaEngineRange, @@ -92,7 +93,8 @@ async function readManifestDir( isDev } } - const parsed = parsePluginManifest(raw) + // Why strict for dev trees: they are the author's working copy, so authoring errors belong there. + const parsed = isDev ? parsePluginManifest(raw) : parseHostPluginManifest(raw) if (!parsed.ok) { const incompatible = pluginManifestCompatibilityError(raw, hostVersion) return { rootDir, error: incompatible ?? `invalid manifest: ${parsed.error}`, isDev } diff --git a/src/main/plugins/plugin-list-projection.ts b/src/main/plugins/plugin-list-projection.ts index 0a367d9d3c2..94abe29ac51 100644 --- a/src/main/plugins/plugin-list-projection.ts +++ b/src/main/plugins/plugin-list-projection.ts @@ -23,6 +23,40 @@ import { pluginExecutionProfile } from '../../shared/plugins/contributions/plugi const PLUGIN_LIST_PROJECTION_CONCURRENCY = 4 +type PluginLockEntry = PluginLockfile['plugins'][string] + +function lockEntrySource( + lockEntry: PluginLockEntry | undefined +): Pick { + if (!lockEntry) { + return {} + } + return { + source: { + kind: lockEntry.source.kind, + reference: + lockEntry.source.kind === 'local-path' + ? lockEntry.source.path + : lockEntry.source.kind === 'git' + ? lockEntry.source.url + : lockEntry.source.kind === 'marketplace' + ? pluginSourceLabel(lockEntry.source.plugin) + : `bundled:${lockEntry.source.bundleId}`, + resolvedCommit: lockEntry.resolvedCommit, + contentHash: lockEntry.contentHash, + ...(lockEntry.source.kind === 'marketplace' + ? { + marketplace: { + reference: lockEntry.source.marketplace.url, + ref: lockEntry.source.marketplace.ref, + resolvedCommit: lockEntry.source.marketplace.resolvedCommit + } + } + : {}) + } + } +} + const withImages = (images: PluginHostImages | undefined) => (images ? { images } : {}) export async function buildPluginList( @@ -41,11 +75,14 @@ export async function buildPluginList( if (isInvalidDiscoveredPlugin(plugin)) { // Invalid dev paths may expose private absolute paths; use opaque identities for clients. const fallbackKey = plugin.pluginKey ?? `invalid-development-plugin-${index + 1}` + // Why the lock entry: it says which catalog installed it, so the list can offer an update. + const lockEntry = + !plugin.isDev && plugin.pluginKey ? lock.plugins[plugin.pluginKey] : undefined return { pluginKey: fallbackKey, consentFingerprint: null, name: fallbackKey, - version: '0.0.0', + version: lockEntry?.version ?? '0.0.0', publisher: '', status: 'invalid' as const, needsReconsent: false, @@ -60,7 +97,8 @@ export async function buildPluginList( commands: [], hasWorker: false, vmRecipes: [], - restarts: 0 + restarts: 0, + ...lockEntrySource(lockEntry) } } const activation = service.activationState(plugin) @@ -170,32 +208,7 @@ export async function buildPluginList( } } : {}), - ...(lockEntry - ? { - source: { - kind: lockEntry.source.kind, - reference: - lockEntry.source.kind === 'local-path' - ? lockEntry.source.path - : lockEntry.source.kind === 'git' - ? lockEntry.source.url - : lockEntry.source.kind === 'marketplace' - ? pluginSourceLabel(lockEntry.source.plugin) - : `bundled:${lockEntry.source.bundleId}`, - resolvedCommit: lockEntry.resolvedCommit, - contentHash: lockEntry.contentHash, - ...(lockEntry.source.kind === 'marketplace' - ? { - marketplace: { - reference: lockEntry.source.marketplace.url, - ref: lockEntry.source.marketplace.ref, - resolvedCommit: lockEntry.source.marketplace.resolvedCommit - } - } - : {}) - } - } - : {}) + ...lockEntrySource(lockEntry) } } ) diff --git a/src/renderer/src/components/plugins/list/PluginManagerRows.tsx b/src/renderer/src/components/plugins/list/PluginManagerRows.tsx index f48c4b09507..e74f5bcc371 100644 --- a/src/renderer/src/components/plugins/list/PluginManagerRows.tsx +++ b/src/renderer/src/components/plugins/list/PluginManagerRows.tsx @@ -61,7 +61,8 @@ export function installedPluginControls( plugin, entry.updateVersion, busy, - entry.autoUpdate + entry.autoUpdate, + entry.reinstallVersion ?? null ) const needsReview = pluginNeedsReview(plugin) @@ -76,7 +77,7 @@ export function installedPluginControls( ) } else if (kind === 'invalid' && plugin.error) { tone = 'destructive' - description = invalidPluginErrorMessage(plugin.error) + description = invalidPluginErrorMessage(plugin.error, !plugin.isDev) } else if (kind === 'error' && plugin.execution === 'data') { // Why: content-only plugins have no code or logs; only their files can fail. tone = 'destructive' @@ -147,6 +148,13 @@ export function installedPluginControls( })} ) + } else if (primary === 'reinstall') { + action = ( + + ) } else if (primary === 'error') { action = (