Files
orca/src/shared/plugins/plugin-marketplace.ts
T
Neil 5018241866 feat(plugins): app and terminal themes in one Theme list, painted safely (#30719)
* plugins: app and terminal theme contributions

Manifests can contribute themes (public CSS tokens over a light or dark
base) and terminal themes (an ANSI palette), each at most 16 KiB. App
themes must set --background and --foreground; omitted tokens are derived
from them (surfaces copy the background, muted text, borders and accents
mix the foreground into it), and values must be colors (no keywords,
lengths, calc, var or url). Both load through the shared artifact-pack
registry with the plugin's display name and a content revision, reach the
renderer over IPC and are re-validated there. Catalog releases can declare
app-theme and terminal-theme, and marketplaces stop hiding theme listings.
docs/reference/plugin-themes.md documents the format.

* plugins: resolve a selected plugin terminal palette on every host

Selecting a plugin terminal theme saves a copy of its palette in settings
(terminalPluginPalettes). The shared lookup reads it, so main, headless
serve and relays answer terminal color queries and native chat matches the
terminal without the plugin loaded; a malformed snapshot falls back to the
default theme.

* plugins: one Theme list with plugin themes, painted safely

Appearance keeps the System/Dark/Light control until a plugin offers a
theme; then Theme becomes one list with plugin themes after a divider,
labelled with the publisher when names collide. Choosing one stores its
base in theme and remembers the earlier built-in choice, which comes back
while the plugin is off or after it is uninstalled; a plugin update that
changes the base re-syncs theme. It also offers the plugin's terminal
colors in one click.

The renderer paints only values CSS parses as colors, checks every
text/surface pair, ring and border against the effective set (plugin
values over this mode's built-ins) and repeats until nothing changes, so
dropping one pair cannot expose another. The applied theme is cached with
its id and revision, painted before first render, and dropped as soon as
settings show it no longer applies. Main bounds the new settings on every
write and load, and uninstalling a plugin clears its theme choices and
palette copies. Terminal themes appear under Plugins in the picker with the
plugin's name and are passed to lookups instead of read from a store.

* plugins: type the theme settings normalizer input; let the store own its normalization

* fix(plugins): check text contrast on the editor surface and clear terminal overrides when adopting a theme's terminal colors

* fix(plugins): reject theme colors the canvas probe silently ignores
2026-10-28 15:13:10 -07:00

262 lines
8.9 KiB
TypeScript

import { z } from 'zod'
import { isAllowedPluginGitUrl } from './plugin-install-lockfile'
import { isQualifiedPluginKey } from './plugin-manifest'
import { pluginCatalogReleaseSchema } from './plugin-catalog-release'
export const PLUGIN_MARKETPLACE_FILENAME = 'orca-marketplace.json'
// Why a second file: v1 entries are strict, so shipped clients reject any new field.
// Release-bearing entries live here and the v1 file stays frozen for those clients.
export const PLUGIN_MARKETPLACE_V2_FILENAME = 'orca-marketplace.v2.json'
export const PLUGIN_MARKETPLACE_ENTRY_LIMIT = 2_048
export const PLUGIN_MARKETPLACE_CATEGORY_LIMIT = 16
export const OFFICIAL_PLUGIN_PUBLISHER = 'stablyai'
export const OFFICIAL_PLUGIN_ID_PREFIX = 'orca-'
export const OFFICIAL_MARKETPLACE_OWNER = 'stablyai'
export const OFFICIAL_MARKETPLACE_REPOSITORY = 'orca-plugins'
// Why: icon-theme and skill contributions are not supported, so a plugin declaring one fails
// to install. Shared catalogs this build does not control still list such packs; hide them so
// users never reach a dead install.
export const UNSUPPORTED_MARKETPLACE_CATEGORIES: readonly string[] = [
'icons',
'icon-themes',
'skills'
]
export function isMarketplaceListingSupported(categories: readonly string[]): boolean {
return !categories.some((category) => UNSUPPORTED_MARKETPLACE_CATEGORIES.includes(category))
}
const marketplaceOwnerSchema = z
.string()
.min(1)
.max(128)
.regex(/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/, 'invalid marketplace owner')
const marketplaceCategorySchema = z
.string()
.min(1)
.max(64)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'categories must be lowercase slugs')
export const pluginMarketplaceGitSourceSchema = z.strictObject({
kind: z.literal('git'),
url: z
.string()
.trim()
.min(1)
.max(32 * 1024)
.refine(isAllowedPluginGitUrl, 'git URL must use HTTPS or SSH'),
// Require a named ref before resolving the exact commit; unspecified remote defaults are not reproducible listings.
ref: z.string().trim().min(1).max(4_096)
})
const marketplaceEntryFields = {
/** Canonical `<publisher>.<id>` identity expected in the source manifest. */
id: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'),
source: pluginMarketplaceGitSourceSchema,
description: z.string().min(1).max(4_096).optional(),
categories: z.array(marketplaceCategorySchema).max(PLUGIN_MARKETPLACE_CATEGORY_LIMIT).default([])
}
function rejectDuplicateCategories(
entry: { categories: string[] },
context: z.RefinementCtx
): void {
const seen = new Set<string>()
for (const [index, category] of entry.categories.entries()) {
if (seen.has(category)) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['categories', index],
message: `duplicate category: ${category}`
})
}
seen.add(category)
}
}
function rejectDuplicatePluginIds(
marketplace: { plugins: { id: string }[] },
context: z.RefinementCtx
): void {
const seen = new Set<string>()
for (const [index, plugin] of marketplace.plugins.entries()) {
if (seen.has(plugin.id)) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['plugins', index, 'id'],
message: `duplicate plugin id: ${plugin.id}`
})
}
seen.add(plugin.id)
}
}
export const pluginMarketplaceEntrySchema = z
.strictObject(marketplaceEntryFields)
.superRefine(rejectDuplicateCategories)
/** v2 entries strip unknown keys so later optional fields reach older clients safely. */
const pluginMarketplaceV2EntrySchema = z
.object({ ...marketplaceEntryFields, release: pluginCatalogReleaseSchema })
.superRefine(rejectDuplicateCategories)
/** Normalized entry held in memory and in the snapshot cache, from either catalog file. */
const pluginMarketplaceListingEntrySchema = z
.strictObject({ ...marketplaceEntryFields, release: pluginCatalogReleaseSchema.optional() })
.superRefine(rejectDuplicateCategories)
const marketplaceHeaderFields = {
name: z.string().min(1).max(256),
owner: marketplaceOwnerSchema
}
export const pluginMarketplaceSchema = z
.strictObject({
...marketplaceHeaderFields,
plugins: z.array(pluginMarketplaceEntrySchema).max(PLUGIN_MARKETPLACE_ENTRY_LIMIT)
})
.superRefine(rejectDuplicatePluginIds)
export const pluginMarketplaceListingSchema = z
.strictObject({
...marketplaceHeaderFields,
plugins: z.array(pluginMarketplaceListingEntrySchema).max(PLUGIN_MARKETPLACE_ENTRY_LIMIT)
})
.superRefine(rejectDuplicatePluginIds)
/** Why entries parse one by one: an entry this client cannot install (a newer contribution
* type) is skipped instead of hiding every other plugin in the catalog. Duplicate ids are
* still checked across every raw entry, matching the v1 rule. */
export const pluginMarketplaceV2Schema = z
.object({
...marketplaceHeaderFields,
plugins: z.array(z.unknown()).max(PLUGIN_MARKETPLACE_ENTRY_LIMIT)
})
.superRefine((catalog, context) =>
rejectDuplicatePluginIds({ plugins: catalog.plugins.map(rawPluginIdentity) }, context)
)
.transform((catalog) => ({
...catalog,
plugins: catalog.plugins.flatMap((entry) => {
const parsed = pluginMarketplaceV2EntrySchema.safeParse(entry)
return parsed.success ? [parsed.data] : []
})
}))
function rawPluginIdentity(entry: unknown, index: number): { id: string } {
const id = typeof entry === 'object' && entry !== null && 'id' in entry ? entry.id : undefined
// Why the NUL placeholder: entries without a string id can never collide with each other.
return { id: typeof id === 'string' ? id : `\0${index}` }
}
/** Host-derived trust metadata. Marketplace JSON cannot self-award either bit. */
export const pluginMarketplaceTrustMetadataSchema = z
.strictObject({
official: z.boolean(),
bundled: z.boolean()
})
.refine((metadata) => !metadata.bundled || metadata.official, {
message: 'bundled plugins must be official',
path: ['bundled']
})
export type PluginMarketplace = z.infer<typeof pluginMarketplaceListingSchema>
export type PluginMarketplaceEntry = z.infer<typeof pluginMarketplaceListingEntrySchema>
export type PluginMarketplaceGitSource = z.infer<typeof pluginMarketplaceGitSourceSchema>
export type PluginMarketplaceTrustMetadata = z.infer<typeof pluginMarketplaceTrustMetadataSchema>
export const OFFICIAL_MARKETPLACE_GIT_SOURCE: PluginMarketplaceGitSource = {
kind: 'git',
url: 'https://github.com/stablyai/orca-plugins.git',
ref: 'main'
}
export function splitQualifiedPluginKey(pluginKey: string): {
publisher: string
id: string
} | null {
if (!isQualifiedPluginKey(pluginKey)) {
return null
}
const separator = pluginKey.indexOf('.')
return {
publisher: pluginKey.slice(0, separator),
id: pluginKey.slice(separator + 1)
}
}
export function isReservedPluginIdentity(pluginKey: string): boolean {
const identity = splitQualifiedPluginKey(pluginKey)
return (
identity !== null &&
(identity.publisher === OFFICIAL_PLUGIN_PUBLISHER ||
identity.id.startsWith(OFFICIAL_PLUGIN_ID_PREFIX))
)
}
export function isOfficialPluginIdentity(pluginKey: string): boolean {
const identity = splitQualifiedPluginKey(pluginKey)
return (
identity !== null &&
identity.publisher === OFFICIAL_PLUGIN_PUBLISHER &&
identity.id.startsWith(OFFICIAL_PLUGIN_ID_PREFIX)
)
}
type GitRepositoryIdentity = {
host: string
owner: string
repository: string
}
/** Extracts the host and first owner segment from the HTTPS/SSH Git forms the
* installer accepts. It is intentionally only a provenance parser. */
export function parseGitRepositoryIdentity(url: string): GitRepositoryIdentity | null {
const trimmed = url.trim()
const scp = /^[^\s@/:]+@([^\s:]+):(.+)$/.exec(trimmed)
if (scp) {
return repositoryIdentity(scp[1]!, scp[2]!)
}
try {
const parsed = new URL(trimmed)
if (parsed.protocol !== 'https:' && parsed.protocol !== 'ssh:') {
return null
}
return repositoryIdentity(parsed.hostname, parsed.pathname)
} catch {
return null
}
}
function repositoryIdentity(host: string, repositoryPath: string): GitRepositoryIdentity | null {
const segments = repositoryPath
.replace(/^\/+|\/+$/g, '')
.split('/')
.filter(Boolean)
if (segments.length < 2) {
return null
}
const repository = segments.at(-1)!.replace(/\.git$/i, '')
if (!repository) {
return null
}
return { host: host.toLowerCase(), owner: segments[0]!, repository }
}
export function isOfficialOrganizationGitSource(url: string): boolean {
const source = parseGitRepositoryIdentity(url)
return source?.host === 'github.com' && source.owner.toLowerCase() === OFFICIAL_PLUGIN_PUBLISHER
}
export function isOfficialMarketplaceGitSource(url: string): boolean {
const source = parseGitRepositoryIdentity(url)
return (
source?.host === 'github.com' &&
source.owner.toLowerCase() === OFFICIAL_MARKETPLACE_OWNER &&
source.repository.toLowerCase() === OFFICIAL_MARKETPLACE_REPOSITORY
)
}