mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
refactor(cli): group plugin and skill implementation owners (#41549)
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
import type { CommandHandler } from '../../dispatch'
|
||||
import { createPluginFromTemplate, PluginCreateError } from './plugin-create'
|
||||
import {
|
||||
inspectPluginFolder,
|
||||
validatePluginTarget,
|
||||
type PluginInspection,
|
||||
type PluginValidationReport
|
||||
} from './plugin-folder-validation'
|
||||
import { isPluginTemplateName, PLUGIN_TEMPLATE_NAMES } from './plugin-template-names'
|
||||
import { formatPluginReleaseEntry, pluginReleaseEntryForFolder } from './plugin-release-entry'
|
||||
import {
|
||||
printPluginResult,
|
||||
requirePluginPathArg,
|
||||
resolveLocalPluginPath
|
||||
} from '../plugin-cli-output'
|
||||
import { writePluginTypeFiles } from './plugin-type-files'
|
||||
import { findPluginTestFiles, runPluginTests } from './plugin-test-runner'
|
||||
import { checkPluginPanels, formatPluginPanelCheck } from './plugin-panel-check'
|
||||
import { checkPluginEditors, formatPluginEditorCheck } from './plugin-editor-check'
|
||||
import { RuntimeClientError } from '../../runtime/types'
|
||||
import { PLUGIN_INVALID_CODE } from '../../../shared/plugins/approval/plugin-cli-approval'
|
||||
import { formatPluginGrantLines } from '../plugin-grant-lines'
|
||||
|
||||
function optionalString(flags: Map<string, string | boolean>, name: string): string | undefined {
|
||||
const value = flags.get(name)
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||
}
|
||||
|
||||
function formatGrants(report: PluginValidationReport): string[] {
|
||||
const plugin = report.plugin
|
||||
if (!plugin) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
...formatPluginGrantLines(plugin),
|
||||
plugin.needsApproval
|
||||
? ' Loading or enabling it needs --approve <fingerprint>; Orca prints the fingerprint.'
|
||||
: ' Content only: loads without approval.'
|
||||
]
|
||||
}
|
||||
|
||||
function invalidError(report: PluginValidationReport): RuntimeClientError {
|
||||
return new RuntimeClientError(
|
||||
PLUGIN_INVALID_CODE,
|
||||
[
|
||||
`${report.file} is not a valid plugin file:`,
|
||||
...report.errors.map((error) => `- ${error}`)
|
||||
].join('\n'),
|
||||
{
|
||||
...report,
|
||||
nextSteps: ['Fix the listed problems, then run `orca plugin validate` again.']
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function readTarget<T extends PluginValidationReport>(
|
||||
read: (path: string) => Promise<T>,
|
||||
path: string
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await read(path)
|
||||
} catch (error) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function formatInspection(report: PluginInspection): string {
|
||||
return [
|
||||
...formatGrants(report),
|
||||
...(report.contentHash
|
||||
? [
|
||||
`Content hash: ${report.contentHash} (${report.fileCount} files, ${report.totalBytes} bytes)`
|
||||
]
|
||||
: [])
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export const PLUGIN_AUTHORING_HANDLERS: Record<string, CommandHandler> = {
|
||||
'plugin create': async (ctx) => {
|
||||
const dir = resolveLocalPluginPath(ctx, requirePluginPathArg(ctx, 'dir'), 'create')
|
||||
const template = optionalString(ctx.flags, 'template')
|
||||
if (!template || !isPluginTemplateName(template)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--template must be one of: ${PLUGIN_TEMPLATE_NAMES.join(', ')}`,
|
||||
{ templates: [...PLUGIN_TEMPLATE_NAMES] }
|
||||
)
|
||||
}
|
||||
let created
|
||||
try {
|
||||
created = await createPluginFromTemplate({
|
||||
dir,
|
||||
template,
|
||||
publisher: optionalString(ctx.flags, 'publisher'),
|
||||
id: optionalString(ctx.flags, 'id'),
|
||||
name: optionalString(ctx.flags, 'name')
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof PluginCreateError) {
|
||||
throw new RuntimeClientError('invalid_argument', error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const report = await validatePluginTarget(created.path)
|
||||
printPluginResult(
|
||||
ctx,
|
||||
{ ...created, validation: report },
|
||||
[
|
||||
`Created ${created.pluginKey} from the ${template} template in ${created.path}`,
|
||||
...created.files.map((file) => ` ${file}`),
|
||||
...(created.typeFiles.length > 0
|
||||
? [` ${created.typeFiles.join(', ')} (generated typings, ignored by Git)`]
|
||||
: []),
|
||||
...formatGrants(report),
|
||||
'Next: orca plugin dev load <dir>'
|
||||
].join('\n')
|
||||
)
|
||||
},
|
||||
'plugin types': async (ctx) => {
|
||||
const dir = resolveLocalPluginPath(ctx, requirePluginPathArg(ctx, 'dir'), 'types')
|
||||
let files: string[]
|
||||
try {
|
||||
files = await writePluginTypeFiles(dir)
|
||||
} catch (error) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`cannot write typings into ${dir}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
printPluginResult(
|
||||
ctx,
|
||||
{ path: dir, files },
|
||||
[
|
||||
`Wrote Orca plugin typings into ${dir}:`,
|
||||
...files.map((file) => ` ${file}`),
|
||||
'Extend them from tsconfig.json: { "extends": "./.orca/types/tsconfig.json" }'
|
||||
].join('\n')
|
||||
)
|
||||
},
|
||||
'plugin test': async (ctx) => {
|
||||
const dir = resolveLocalPluginPath(ctx, optionalString(ctx.flags, 'dir') ?? '.', 'test')
|
||||
const files = await findPluginTestFiles(dir).catch((error: unknown) => {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`cannot read ${dir}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
})
|
||||
if (files.length === 0) {
|
||||
throw new RuntimeClientError('invalid_argument', `No *.test.ts(x) files under ${dir}.`, {
|
||||
nextSteps: [
|
||||
'Add one next to the panel, e.g. src/panel.test.tsx; see `orca plugin create --template components`.'
|
||||
]
|
||||
})
|
||||
}
|
||||
const result = await runPluginTests({ dir, files, quietStdout: ctx.json })
|
||||
if (result.code !== 0) {
|
||||
throw new RuntimeClientError(
|
||||
'plugin_tests_failed',
|
||||
`Plugin tests failed (${result.runner} exited with ${result.code}).`,
|
||||
{
|
||||
...result,
|
||||
files
|
||||
}
|
||||
)
|
||||
}
|
||||
printPluginResult(
|
||||
ctx,
|
||||
{ ...result, files },
|
||||
`${files.length} test file(s) passed (${result.runner}).`
|
||||
)
|
||||
},
|
||||
'plugin check': async (ctx) => {
|
||||
const dir = resolveLocalPluginPath(ctx, optionalString(ctx.flags, 'dir') ?? '.', 'check')
|
||||
const report = await readTarget(validatePluginTarget, dir)
|
||||
if (report.kind !== 'plugin') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`${report.file} is a catalog; pass the plugin folder.`
|
||||
)
|
||||
}
|
||||
if (!report.valid) {
|
||||
throw invalidError(report)
|
||||
}
|
||||
const [panels, editors] = await Promise.all([
|
||||
checkPluginPanels(report.path),
|
||||
checkPluginEditors(report.path)
|
||||
])
|
||||
const failed = [...panels, ...editors].some((check) => check.issues.length > 0)
|
||||
const text = [
|
||||
`${report.file}: ok`,
|
||||
...panels.flatMap(formatPluginPanelCheck),
|
||||
...editors.flatMap(formatPluginEditorCheck),
|
||||
...(panels.length === 0 && editors.length === 0
|
||||
? ['No components panels or custom editors to check.']
|
||||
: [])
|
||||
].join('\n')
|
||||
if (failed) {
|
||||
throw new RuntimeClientError('plugin_check_failed', text, { ...report, panels, editors })
|
||||
}
|
||||
printPluginResult(ctx, { ...report, panels, editors }, text)
|
||||
},
|
||||
'plugin validate': async (ctx) => {
|
||||
const path = resolveLocalPluginPath(ctx, requirePluginPathArg(ctx, 'path'), 'validate')
|
||||
const report = await readTarget(validatePluginTarget, path)
|
||||
if (!report.valid) {
|
||||
throw invalidError(report)
|
||||
}
|
||||
printPluginResult(
|
||||
ctx,
|
||||
report,
|
||||
[
|
||||
`${report.file}: ok`,
|
||||
...(report.warnings ?? []).map((warning) => `warning: ${warning}`),
|
||||
...formatGrants(report)
|
||||
].join('\n')
|
||||
)
|
||||
},
|
||||
'plugin inspect': async (ctx) => {
|
||||
const path = resolveLocalPluginPath(ctx, requirePluginPathArg(ctx, 'path'), 'inspect')
|
||||
const report = await readTarget(inspectPluginFolder, path)
|
||||
if (!report.valid) {
|
||||
throw invalidError(report)
|
||||
}
|
||||
printPluginResult(ctx, report, formatInspection(report))
|
||||
},
|
||||
'plugin release-entry': async (ctx) => {
|
||||
const path = resolveLocalPluginPath(ctx, requirePluginPathArg(ctx, 'path'), 'release-entry')
|
||||
const report = await readTarget(validatePluginTarget, path)
|
||||
if (report.kind !== 'plugin') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`${report.file} is a catalog; pass the plugin folder.`
|
||||
)
|
||||
}
|
||||
if (!report.valid) {
|
||||
throw invalidError(report)
|
||||
}
|
||||
const result = await pluginReleaseEntryForFolder(report)
|
||||
if (!result.entry) {
|
||||
// Why not plugin_invalid: the files are valid; HEAD or the repository is what is missing.
|
||||
throw new RuntimeClientError('invalid_argument', result.note ?? 'no catalog release entry', {
|
||||
...result,
|
||||
nextSteps: ['Commit the plugin in a Git repository, then run this again.']
|
||||
})
|
||||
}
|
||||
printPluginResult(ctx, result, formatPluginReleaseEntry(result))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
COMMON_FILES,
|
||||
json,
|
||||
manifest,
|
||||
readme,
|
||||
type PluginTemplate,
|
||||
type PluginTemplateInput
|
||||
} from './plugin-template-files'
|
||||
import { PLUGIN_TYPES_DIR } from './plugin-type-files'
|
||||
|
||||
const PANEL_TSX = `import { atom, Badge, Button, Heading, Row, Stack, Text, read, set, update } from 'orca'
|
||||
|
||||
const count = atom(0)
|
||||
|
||||
export default function Panel() {
|
||||
const value = read(count)
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Row justify="between">
|
||||
<Heading>Counter</Heading>
|
||||
<Badge tone={value > 0 ? 'accent' : 'neutral'}>{value}</Badge>
|
||||
</Row>
|
||||
<Row gap="xs">
|
||||
<Button onPress={() => update(count, (n) => n + 1)}>Add one</Button>
|
||||
<Button variant="ghost" onPress={() => set(count, 0)}>
|
||||
Reset
|
||||
</Button>
|
||||
</Row>
|
||||
<Text muted size="xs">
|
||||
Edit src/panel.tsx and save: Orca reloads the panel. No build step.
|
||||
</Text>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
const PANEL_TEST_TSX = `// \`orca plugin test .\` runs this in Node with the same runtime and checks Orca uses.
|
||||
import { assert, mount, test } from 'orca/testing'
|
||||
import * as panel from './panel'
|
||||
|
||||
test('counts presses and resets', async () => {
|
||||
const view = await mount(panel)
|
||||
assert.equal(view.find({ role: 'status' })?.text, '0')
|
||||
|
||||
await view.press({ text: 'Add one' })
|
||||
await view.press({ text: 'Add one' })
|
||||
assert.equal(view.find({ role: 'status' })?.text, '2')
|
||||
|
||||
await view.press({ text: 'Reset' })
|
||||
assert.equal(view.find({ role: 'status' })?.text, '0')
|
||||
assert.deepEqual(view.errors, [])
|
||||
await view.unmount()
|
||||
})
|
||||
`
|
||||
|
||||
/** Build-less TSX panel: Orca transpiles src/panel.tsx when the panel loads. */
|
||||
export function components(input: PluginTemplateInput): PluginTemplate {
|
||||
return {
|
||||
description:
|
||||
'Right-sidebar panel written in TSX with Orca components, no build step (needs --approve to load)',
|
||||
files: {
|
||||
'orca-plugin.json': manifest(input, 'A sidebar panel built from Orca components.', {
|
||||
execution: 'sandboxed-ui',
|
||||
pluginApi: 1,
|
||||
contributes: {
|
||||
panels: [
|
||||
{
|
||||
id: input.id,
|
||||
title: input.name,
|
||||
icon: 'blocks',
|
||||
entry: 'src/panel.tsx',
|
||||
ui: 'components'
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
'src/panel.tsx': PANEL_TSX,
|
||||
'src/panel.test.tsx': PANEL_TEST_TSX,
|
||||
'tsconfig.json': json({ extends: `./${PLUGIN_TYPES_DIR}/tsconfig.json`, include: ['src'] }),
|
||||
'README.md': readme(
|
||||
input,
|
||||
`A right-sidebar panel written in TSX. Orca transpiles \`src/panel.tsx\` when the panel loads, so there is no build step and no build folder: edit, save, and the panel reloads. \`orca plugin types .\` (also run by \`orca plugin dev watch\`) writes the editor typings to \`${PLUGIN_TYPES_DIR}/\`, which \`tsconfig.json\` extends; run \`npx tsc -p .\` to type-check. \`orca plugin test .\` runs \`src/panel.test.tsx\` in Node with \`orca/testing\`, and \`orca plugin check .\` renders the panel once and reports errors with file:line. Import only \`orca\` and relative files.`
|
||||
),
|
||||
...COMMON_FILES
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
COMMON_FILES,
|
||||
json,
|
||||
manifest,
|
||||
readme,
|
||||
type PluginTemplate,
|
||||
type PluginTemplateInput
|
||||
} from './plugin-template-files'
|
||||
|
||||
// Content-only templates: execution "data", so loading them never needs approval.
|
||||
|
||||
export function theme(input: PluginTemplateInput): PluginTemplate {
|
||||
const description = 'A dark app theme.'
|
||||
return {
|
||||
description: 'App color theme (data only, no approval needed)',
|
||||
files: {
|
||||
'orca-plugin.json': manifest(input, description, {
|
||||
execution: 'data',
|
||||
contributes: {
|
||||
themes: [{ id: `${input.id}-dark`, title: input.name, path: 'themes/dark.json' }]
|
||||
}
|
||||
}),
|
||||
'themes/dark.json': json({
|
||||
schemaVersion: 1,
|
||||
base: 'dark',
|
||||
tokens: {
|
||||
'--background': '#1b1430',
|
||||
'--foreground': '#e8e0ff',
|
||||
'--primary': '#b48cff',
|
||||
'--primary-foreground': '#1b1430'
|
||||
},
|
||||
tokenColors: [
|
||||
{ scope: 'comment', settings: { foreground: '#8f84ad', fontStyle: 'italic' } },
|
||||
{ scope: ['keyword', 'storage'], settings: { foreground: '#ff7ad9' } },
|
||||
{ scope: 'string', settings: { foreground: '#a8e6a1' } }
|
||||
]
|
||||
}),
|
||||
'README.md': readme(
|
||||
input,
|
||||
'An Orca app theme. Edit `themes/dark.json`; omitted tokens are derived from `--background` and `--foreground`.'
|
||||
),
|
||||
...COMMON_FILES
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function terminalTheme(input: PluginTemplateInput): PluginTemplate {
|
||||
return {
|
||||
description: 'Terminal ANSI palette (data only, no approval needed)',
|
||||
files: {
|
||||
'orca-plugin.json': manifest(input, 'A dark terminal palette.', {
|
||||
execution: 'data',
|
||||
contributes: {
|
||||
terminalThemes: [{ id: input.id, title: input.name, path: 'terminal/dark.json' }]
|
||||
}
|
||||
}),
|
||||
'terminal/dark.json': json({
|
||||
schemaVersion: 1,
|
||||
mode: 'dark',
|
||||
terminal: {
|
||||
background: '#1b1430',
|
||||
foreground: '#e8e0ff',
|
||||
black: '#2a2142',
|
||||
red: '#ff6b8b',
|
||||
green: '#a8e6a1',
|
||||
yellow: '#ffd479',
|
||||
blue: '#82cfff',
|
||||
magenta: '#ff7ad9',
|
||||
cyan: '#7fe7e0',
|
||||
white: '#e8e0ff'
|
||||
}
|
||||
}),
|
||||
'README.md': readme(input, 'An Orca terminal palette. Edit `terminal/dark.json`.'),
|
||||
...COMMON_FILES
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const FILE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#82cfff" d="M3 1h7l3 3v11H3z"/></svg>\n`
|
||||
const FOLDER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffd479" d="M1 3h5l2 2h7v9H1z"/></svg>\n`
|
||||
const MARKDOWN_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#b48cff" d="M3 1h7l3 3v11H3z"/></svg>\n`
|
||||
|
||||
export function iconTheme(input: PluginTemplateInput): PluginTemplate {
|
||||
return {
|
||||
description: 'File and folder icon theme (data only, no approval needed)',
|
||||
files: {
|
||||
'orca-plugin.json': manifest(input, 'File and folder icons.', {
|
||||
execution: 'data',
|
||||
contributes: {
|
||||
iconThemes: [{ id: input.id, title: input.name, path: 'icons/theme.json' }]
|
||||
}
|
||||
}),
|
||||
'icons/theme.json': json({
|
||||
schemaVersion: 1,
|
||||
icons: {
|
||||
file: 'icons/file.svg',
|
||||
folder: 'icons/folder.svg',
|
||||
'folder-open': 'icons/folder.svg'
|
||||
},
|
||||
fileNames: { 'readme.md': 'icons/markdown.svg' },
|
||||
fileExtensions: { md: 'icons/markdown.svg' }
|
||||
}),
|
||||
'icons/file.svg': FILE_ICON,
|
||||
'icons/folder.svg': FOLDER_ICON,
|
||||
'icons/markdown.svg': MARKDOWN_ICON,
|
||||
'README.md': readme(
|
||||
input,
|
||||
'An Orca icon theme. `icons/theme.json` maps slots, file names, extensions and folder names (`folderNames`, `folderNamesExpanded`) to SVG files (paths from the plugin root).'
|
||||
),
|
||||
...COMMON_FILES
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function syntax(input: PluginTemplateInput): PluginTemplate {
|
||||
const scopeName = `source.${input.id.replace(/[^a-z0-9]/g, '')}`
|
||||
const extension = `.${input.id.replace(/[^a-z0-9]/g, '').slice(0, 12) || 'example'}`
|
||||
return {
|
||||
description: 'TextMate syntax highlighting for a file type (data only, no approval needed)',
|
||||
files: {
|
||||
'orca-plugin.json': manifest(input, `Syntax highlighting for ${extension} files.`, {
|
||||
execution: 'data',
|
||||
contributes: {
|
||||
syntaxes: [
|
||||
{
|
||||
id: input.id,
|
||||
title: input.name,
|
||||
scopeName,
|
||||
path: 'syntaxes/grammar.tmLanguage.json',
|
||||
configuration: 'syntaxes/language-configuration.json',
|
||||
extensions: [extension]
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
'syntaxes/grammar.tmLanguage.json': json({
|
||||
scopeName,
|
||||
patterns: [{ include: '#comments' }, { include: '#strings' }, { include: '#keywords' }],
|
||||
repository: {
|
||||
comments: { patterns: [{ name: 'comment.line.number-sign', match: '#.*$' }] },
|
||||
strings: { patterns: [{ name: 'string.quoted.double', begin: '"', end: '"' }] },
|
||||
keywords: {
|
||||
patterns: [{ name: 'keyword.control', match: '\\b(if|else|while|return)\\b' }]
|
||||
}
|
||||
}
|
||||
}),
|
||||
'syntaxes/language-configuration.json': json({
|
||||
comments: { lineComment: '#' },
|
||||
brackets: [
|
||||
['{', '}'],
|
||||
['(', ')']
|
||||
],
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' }
|
||||
]
|
||||
}),
|
||||
'README.md': readme(
|
||||
input,
|
||||
`Syntax highlighting for \`${extension}\` files. Edit the grammar in \`syntaxes/\`.`
|
||||
),
|
||||
...COMMON_FILES
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function languagePack(input: PluginTemplateInput): PluginTemplate {
|
||||
return {
|
||||
description: 'Interface translation (data only, no approval needed)',
|
||||
files: {
|
||||
'orca-plugin.json': manifest(input, 'Translates Orca into Brazilian Portuguese.', {
|
||||
execution: 'data',
|
||||
contributes: {
|
||||
languagePacks: [
|
||||
{ locale: 'pt-BR', path: 'locales/pt-BR.json', displayName: 'Português (Brasil)' }
|
||||
]
|
||||
}
|
||||
}),
|
||||
'locales/pt-BR.json': json({ common: { cancel: 'Cancelar', retry: 'Tentar novamente' } }),
|
||||
'README.md': readme(
|
||||
input,
|
||||
'An Orca language pack. Keys mirror the English catalog of Orca; add translated strings to `locales/pt-BR.json`, or change the locale in `orca-plugin.json`.'
|
||||
),
|
||||
...COMMON_FILES
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { existsSync, readdirSync, statSync } from 'node:fs'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { parsePluginManifest } from '../../../shared/plugins/manifest/plugin-manifest'
|
||||
import { pluginDisplayNameFromId } from './plugin-display-name'
|
||||
import { PLUGIN_TEMPLATE_NAMES, type PluginTemplateName } from './plugin-template-names'
|
||||
import { pluginTemplateKey } from './plugin-template-files'
|
||||
import { renderPluginTemplate } from './plugin-templates'
|
||||
import { writePluginTypeFiles } from './plugin-type-files'
|
||||
|
||||
export type PluginCreateResult = {
|
||||
path: string
|
||||
template: PluginTemplateName
|
||||
pluginKey: string
|
||||
files: string[]
|
||||
/** Generated, gitignored typings (`.orca/types`), for templates written in TypeScript. */
|
||||
typeFiles: string[]
|
||||
}
|
||||
|
||||
export class PluginCreateError extends Error {}
|
||||
|
||||
/** A manifest id from a folder name: lowercase words joined by `-`. */
|
||||
export function pluginIdFromFolder(folder: string): string {
|
||||
return basename(folder)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
function assertEmptyFolder(dir: string): void {
|
||||
if (!existsSync(dir)) {
|
||||
return
|
||||
}
|
||||
if (!statSync(dir).isDirectory()) {
|
||||
throw new PluginCreateError(`${dir} exists and is not a folder`)
|
||||
}
|
||||
// Why .git allowed: authors often `git init` first; nothing else is overwritten.
|
||||
const entries = readdirSync(dir).filter((entry) => entry !== '.git')
|
||||
if (entries.length > 0) {
|
||||
throw new PluginCreateError(`${dir} is not empty; choose a new or empty folder`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes a template into an empty folder. Never runs Git, installs dependencies or executes code. */
|
||||
export async function createPluginFromTemplate(input: {
|
||||
dir: string
|
||||
template: PluginTemplateName
|
||||
publisher?: string
|
||||
id?: string
|
||||
name?: string
|
||||
}): Promise<PluginCreateResult> {
|
||||
const dir = resolve(input.dir)
|
||||
const id = input.id ?? pluginIdFromFolder(dir)
|
||||
const name = input.name ?? (pluginDisplayNameFromId(id) || id)
|
||||
const templateInput = { publisher: input.publisher, id, name }
|
||||
const pluginKey = pluginTemplateKey(templateInput)
|
||||
const template = renderPluginTemplate(input.template, templateInput)
|
||||
const manifest = parsePluginManifest(JSON.parse(template.files['orca-plugin.json']))
|
||||
if (!manifest.ok) {
|
||||
throw new PluginCreateError(
|
||||
`cannot create ${pluginKey}: ${manifest.error}. Pass --id (and --publisher, if set) using lowercase letters, numbers and dashes.`
|
||||
)
|
||||
}
|
||||
assertEmptyFolder(dir)
|
||||
const files = Object.keys(template.files).sort()
|
||||
for (const file of files) {
|
||||
const target = join(dir, ...file.split('/'))
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await writeFile(target, template.files[file], { flag: 'wx' })
|
||||
}
|
||||
const typeFiles = template.files['tsconfig.json'] ? await writePluginTypeFiles(dir) : []
|
||||
return { path: dir, template: input.template, pluginKey, files, typeFiles }
|
||||
}
|
||||
|
||||
export const PLUGIN_TEMPLATE_LIST = PLUGIN_TEMPLATE_NAMES.map((name) => ({
|
||||
name,
|
||||
description: renderPluginTemplate(name, { id: 'x', name: 'X' }).description
|
||||
}))
|
||||
@@ -0,0 +1,15 @@
|
||||
// Why a short list: only words that read wrong title-cased ("Pr Queue"); a word must match whole.
|
||||
const ACRONYMS =
|
||||
'AI API AWS CD CI CLI CPU CSS CSV DNS GCP GPU HTML HTTP HTTPS ID IO JS JSON K8s LLM MCP OS PDF PR SDK SQL SSH TS TSX UI URI URL VM XML YAML'.split(
|
||||
' '
|
||||
)
|
||||
const SPELLING = new Map(ACRONYMS.map((word) => [word.toLowerCase(), word]))
|
||||
|
||||
/** `pr-queue` → "PR Queue": dash-separated words title-cased, known acronyms kept as written. */
|
||||
export function pluginDisplayNameFromId(id: string): string {
|
||||
return id
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((word) => SPELLING.get(word.toLowerCase()) ?? word[0].toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { checkPluginEditors } from './plugin-editor-check'
|
||||
import { createPluginFromTemplate } from './plugin-create'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
/** The editor template with `files` written over it (paths under the plugin, `/`-separated). */
|
||||
async function editorWith(files: Record<string, string>, entry?: string): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-editor-rules-'))
|
||||
roots.push(root)
|
||||
const dir = join(root, 'notes')
|
||||
await createPluginFromTemplate({ dir, template: 'editor', publisher: 'acme' })
|
||||
for (const [path, text] of Object.entries(files)) {
|
||||
await mkdir(dirname(join(dir, ...path.split('/'))), { recursive: true })
|
||||
await writeFile(join(dir, ...path.split('/')), text)
|
||||
}
|
||||
const manifestPath = join(dir, 'orca-plugin.json')
|
||||
const manifest = await readFile(manifestPath, 'utf8')
|
||||
await writeFile(manifestPath, manifest.replace('editor/main.ts', entry ?? 'editor/index.html'))
|
||||
return root
|
||||
}
|
||||
|
||||
async function check(root: string) {
|
||||
const [result] = await checkPluginEditors(join(root, 'notes'))
|
||||
return result
|
||||
}
|
||||
|
||||
const page = (body: string) => `<!doctype html><html><body>\n${body}\n</body></html>\n`
|
||||
|
||||
describe('orca plugin check: editors load as the frame protocol serves them', () => {
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'refuses a script a symlink takes outside the plugin',
|
||||
async () => {
|
||||
const root = await editorWith({
|
||||
'editor/index.html': page('<script src="./lib.js"></script>')
|
||||
})
|
||||
await writeFile(join(root, 'secret.js'), 'export {}')
|
||||
await symlink(join(root, 'secret.js'), join(root, 'notes', 'editor', 'lib.js'))
|
||||
const result = await check(root)
|
||||
expect(result.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: 'editor_import',
|
||||
message: expect.stringContaining('outside')
|
||||
})
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('reads import maps and skips data blocks, and resolves bare native imports through the map', async () => {
|
||||
const root = await editorWith({
|
||||
'editor/index.html': page(
|
||||
[
|
||||
'<script type="importmap">{ "imports": { "lib": "./vendor/lib.js", "kit/": "./vendor/kit/" } }</script>',
|
||||
'<script type="application/json" id="config">{ "not": "javascript" }</script>',
|
||||
'<script type="module">import { a } from "lib"; import { b } from "kit/b.js"; a(b)</script>'
|
||||
].join('\n')
|
||||
),
|
||||
'editor/vendor/lib.js': 'export const a = (x) => x\n',
|
||||
'editor/vendor/kit/b.js': 'export const b = 1\n'
|
||||
})
|
||||
const result = await check(root)
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.files).toEqual([
|
||||
'editor/index.html',
|
||||
'editor/vendor/kit/b.js',
|
||||
'editor/vendor/lib.js'
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses a bare native import the import map does not name', async () => {
|
||||
const root = await editorWith({
|
||||
'editor/index.html': page('<script type="module">import "left-pad"</script>')
|
||||
})
|
||||
expect((await check(root)).issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: 'editor_import',
|
||||
message: expect.stringContaining('import map')
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('checks JSON imports as JSON and an extensionless entry as a page', async () => {
|
||||
const root = await editorWith(
|
||||
{
|
||||
'editor/page': page('<script type="module" src="./main.ts"></script>'),
|
||||
'editor/main.ts': "import data from './data.json' with { type: 'json' }\nvoid data\n",
|
||||
'editor/data.json': '{ "ok": true, }'
|
||||
},
|
||||
'editor/page'
|
||||
)
|
||||
expect((await check(root)).issues).toEqual([
|
||||
expect.objectContaining({ code: 'editor_syntax', location: 'editor/data.json:1:1' })
|
||||
])
|
||||
})
|
||||
|
||||
it('decodes an escaped script src before looking it up', async () => {
|
||||
const root = await editorWith({
|
||||
'editor/index.html': page('<script src="./my%20script.js"></script>'),
|
||||
'editor/my script.js': 'void 0\n'
|
||||
})
|
||||
const result = await check(root)
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.files).toContain('editor/my script.js')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { checkPluginEditors } from './plugin-editor-check'
|
||||
import { createPluginFromTemplate } from './plugin-create'
|
||||
import { validatePluginTarget } from './plugin-folder-validation'
|
||||
import { checkPluginDevFolder } from '../dev-watch/plugin-dev-watch-check'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
/** The editor template behind an HTML page that loads `editor/main.ts`, which imports `./sync.ts`. */
|
||||
async function editorPlugin(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-editor-check-'))
|
||||
roots.push(root)
|
||||
const dir = join(root, 'notes')
|
||||
await createPluginFromTemplate({ dir, template: 'editor', publisher: 'acme' })
|
||||
await writeFile(
|
||||
join(dir, 'editor', 'index.html'),
|
||||
'<!doctype html>\n<html>\n<body>\n<script type="module" src="./main.ts"></script>\n</body>\n</html>\n'
|
||||
)
|
||||
const manifestPath = join(dir, 'orca-plugin.json')
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
(await readFile(manifestPath, 'utf8')).replace('editor/main.ts', 'editor/index.html')
|
||||
)
|
||||
await writeFile(
|
||||
join(dir, 'editor', 'main.ts'),
|
||||
"import { start } from './sync'\nconst text: string = 'a'\nstart(text)\n"
|
||||
)
|
||||
await writeFile(join(dir, 'editor', 'sync.ts'), 'export function start(text: string): void {}\n')
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('orca plugin check: custom editors', () => {
|
||||
it('fails on a syntax error that validate lets through', async () => {
|
||||
const dir = await editorPlugin()
|
||||
await appendFile(join(dir, 'editor', 'main.ts'), 'this is not valid ts(\n')
|
||||
expect((await validatePluginTarget(dir)).valid).toBe(true)
|
||||
const [check] = await checkPluginEditors(dir)
|
||||
expect(check.issues).toEqual([
|
||||
expect.objectContaining({ code: 'editor_syntax', location: 'editor/main.ts:4:6' })
|
||||
])
|
||||
})
|
||||
|
||||
it('reports missing files and refused imports, and warns on string eval', async () => {
|
||||
const dir = await editorPlugin()
|
||||
await writeFile(
|
||||
join(dir, 'editor', 'main.ts'),
|
||||
[
|
||||
"import './gone'",
|
||||
"import 'left-pad'",
|
||||
'// eval("comments do not count")',
|
||||
"const label = 'new Function in a string does not count'",
|
||||
"eval('1')",
|
||||
"const f = new Function('return 1')",
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
const [check] = await checkPluginEditors(dir)
|
||||
expect(check.issues.map(({ code, location }) => [code, location])).toEqual([
|
||||
['editor_import', 'editor/main.ts:1:9'],
|
||||
['editor_import', 'editor/main.ts:2:9']
|
||||
])
|
||||
// A lexical scan can misread code, so eval is a warning that does not fail the check.
|
||||
expect(check.warnings.map(({ code, location }) => [code, location])).toEqual([
|
||||
['editor_eval', 'editor/main.ts:5:1'],
|
||||
['editor_eval', 'editor/main.ts:6:11']
|
||||
])
|
||||
})
|
||||
|
||||
it('reports a missing script, a broken inline script, and a script over 16 MiB', async () => {
|
||||
const dir = await editorPlugin()
|
||||
const html = join(dir, 'editor', 'index.html')
|
||||
await writeFile(
|
||||
html,
|
||||
[
|
||||
'<body>',
|
||||
'<script src="./absent.js"></script>',
|
||||
'<script type="module" src="./big.js"></script>',
|
||||
'<script>',
|
||||
'const x = (',
|
||||
'</script>',
|
||||
'</body>'
|
||||
].join('\n')
|
||||
)
|
||||
await mkdir(join(dir, 'editor'), { recursive: true })
|
||||
await writeFile(join(dir, 'editor', 'big.js'), 'x'.repeat(16 * 1024 * 1024 + 1))
|
||||
const [check] = await checkPluginEditors(dir)
|
||||
expect(check.issues.map(({ code, location }) => [code, location])).toEqual([
|
||||
['editor_syntax', 'editor/index.html:6:1'],
|
||||
['editor_missing_file', 'editor/index.html:2:1'],
|
||||
['editor_too_large', 'editor/index.html:3:1']
|
||||
])
|
||||
})
|
||||
|
||||
it('caps a transpiled source module at 1 MiB', async () => {
|
||||
const dir = await editorPlugin()
|
||||
await writeFile(join(dir, 'editor', 'sync.ts'), `// ${'x'.repeat(1024 * 1024)}\n`)
|
||||
const [check] = await checkPluginEditors(dir)
|
||||
expect(check.issues).toEqual([
|
||||
expect.objectContaining({ code: 'editor_too_large', location: 'editor/main.ts:1:24' })
|
||||
])
|
||||
})
|
||||
|
||||
it('fails dev watch --check on an editor issue, and passes it with only a warning', async () => {
|
||||
const dir = await editorPlugin()
|
||||
await appendFile(join(dir, 'editor', 'main.ts'), "eval('1')\n")
|
||||
const warned = await checkPluginDevFolder(dir)
|
||||
expect(warned.ok).toBe(true)
|
||||
expect(warned.editors[0]?.warnings).toEqual([expect.objectContaining({ code: 'editor_eval' })])
|
||||
await appendFile(join(dir, 'editor', 'main.ts'), 'this is not valid ts(\n')
|
||||
const failed = await checkPluginDevFolder(dir)
|
||||
expect(failed.ok).toBe(false)
|
||||
expect(failed.editors[0]?.issues).toEqual([expect.objectContaining({ code: 'editor_syntax' })])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import type {
|
||||
PluginEditorCheckIssue,
|
||||
PluginEditorStaticCheck
|
||||
} from '../../../main/plugins/testing/plugin-editor-static-check'
|
||||
import { parsePluginManifest } from '../../../shared/plugins/manifest/plugin-manifest'
|
||||
import { pluginTestingFiles } from './plugin-test-runner'
|
||||
|
||||
export type PluginEditorCheck = {
|
||||
editor: string
|
||||
entry: string
|
||||
/** Plugin-relative files the entry loads, itself included. */
|
||||
files: string[]
|
||||
issues: PluginEditorCheckIssue[]
|
||||
/** `editor_eval`: likely refused by the frame, but found by a scan that can misread code. */
|
||||
warnings: PluginEditorCheckIssue[]
|
||||
}
|
||||
|
||||
type EditorCheckModule = {
|
||||
checkPluginEditorEntry: (dir: string, entry: string) => Promise<PluginEditorStaticCheck>
|
||||
}
|
||||
|
||||
/** Every custom editor's files, transpiled and resolved as the frame loads them; runs no code. */
|
||||
export async function checkPluginEditors(
|
||||
dir: string,
|
||||
appRoot?: string
|
||||
): Promise<PluginEditorCheck[]> {
|
||||
const parsed = parsePluginManifest(
|
||||
JSON.parse(await readFile(join(dir, 'orca-plugin.json'), 'utf8'))
|
||||
)
|
||||
const editors = parsed.ok ? parsed.manifest.contributes.customEditors : []
|
||||
if (editors.length === 0) {
|
||||
return []
|
||||
}
|
||||
// The check ships in the hooks bundle, which carries the frame's transpiler in plain Node.
|
||||
const loaded: Partial<EditorCheckModule> = await import(
|
||||
pathToFileURL(pluginTestingFiles(appRoot).hooks).href
|
||||
)
|
||||
const check = loaded.checkPluginEditorEntry
|
||||
if (!check) {
|
||||
throw new Error('this Orca build has no custom editor check')
|
||||
}
|
||||
const checks: PluginEditorCheck[] = []
|
||||
for (const editor of editors) {
|
||||
checks.push({ editor: editor.id, entry: editor.entry, ...(await check(dir, editor.entry)) })
|
||||
}
|
||||
return checks
|
||||
}
|
||||
|
||||
/** Lines for one editor: how many files it loads, or each issue at its plugin location. */
|
||||
export function formatPluginEditorCheck(check: PluginEditorCheck): string[] {
|
||||
const head = `${check.editor} (${check.entry}): `
|
||||
const line = (issue: PluginEditorCheckIssue) =>
|
||||
`${issue.location}: ${issue.message} [${issue.code}]`
|
||||
const warnings = check.warnings.map((warning) => ` warning: ${line(warning)}`)
|
||||
if (check.issues.length === 0) {
|
||||
return [`${head}ok, ${check.files.length} file(s) load`, ...warnings]
|
||||
}
|
||||
return [`${head}failed`, ...check.issues.map((issue) => ` ${line(issue)}`), ...warnings]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createPluginFromTemplate } from './plugin-create'
|
||||
import { checkPluginEditors } from './plugin-editor-check'
|
||||
import { findPluginTestFiles, runPluginTests } from './plugin-test-runner'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function editorPlugin(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-editor-template-'))
|
||||
roots.push(root)
|
||||
const dir = join(root, 'sketch')
|
||||
await createPluginFromTemplate({ dir, template: 'editor' })
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('orca plugin create --template editor', () => {
|
||||
it(
|
||||
'passes plugin check and plugin test as generated',
|
||||
async () => {
|
||||
const dir = await editorPlugin()
|
||||
const [check] = await checkPluginEditors(dir)
|
||||
expect(check?.issues).toEqual([])
|
||||
expect(check?.files).toEqual(['editor/main.ts', 'editor/sync.ts'])
|
||||
const files = await findPluginTestFiles(dir)
|
||||
expect(files).toEqual(['editor/sync.test.ts'])
|
||||
expect(await runPluginTests({ dir, files, quietStdout: true })).toMatchObject({ code: 0 })
|
||||
},
|
||||
// The test run starts its own Node process and transpiles the plugin.
|
||||
60_000
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { PLUGIN_EDITOR_FRAME_SINCE } from '../../../shared/plugins/manifest/plugin-schema-field-docs'
|
||||
import { PLUGIN_TYPES_DIR } from './plugin-type-files'
|
||||
import {
|
||||
COMMON_FILES,
|
||||
json,
|
||||
manifest,
|
||||
readme,
|
||||
type PluginTemplate,
|
||||
type PluginTemplateInput
|
||||
} from './plugin-template-files'
|
||||
|
||||
// The rich-editor recipe as a starting point: a TypeScript editor on `orca.document.session`, its
|
||||
// sync tested in Node with `mockEditorHost`. Sandboxed frame code, so loading it needs `--approve`.
|
||||
|
||||
const MAIN_TS = `import { start } from './sync'
|
||||
|
||||
// Your editor's view. A library (a canvas, a diagram kit) goes here instead of the textarea:
|
||||
// copy its ESM build into the plugin and import it by a relative path.
|
||||
const text = document.createElement('textarea')
|
||||
text.spellcheck = false
|
||||
text.style.cssText =
|
||||
'box-sizing:border-box;width:100%;height:100vh;margin:0;padding:12px;border:0;resize:none;' +
|
||||
'font:13px ui-monospace,monospace;color:var(--orca-foreground);background:var(--orca-background)'
|
||||
document.body.style.margin = '0'
|
||||
document.body.append(text)
|
||||
|
||||
const doc = await start()
|
||||
text.value = doc.value
|
||||
text.readOnly = doc.readOnly
|
||||
doc.onReplace((value) => {
|
||||
text.value = value
|
||||
})
|
||||
text.addEventListener('input', () => doc.change(text.value))
|
||||
`
|
||||
|
||||
const SYNC_TS = `/** A stand-in merge for plain text: take the side that changed; when both did, keep theirs and
|
||||
* then yours. Replace it with your format's merge (by element id, never by array position). */
|
||||
export function mergeText(base: string, outside: string, mine: string): string {
|
||||
if (mine === base) {
|
||||
return outside
|
||||
}
|
||||
if (outside === base) {
|
||||
return mine
|
||||
}
|
||||
return \`\${outside}\\n\${mine}\`
|
||||
}
|
||||
|
||||
/** The only code that talks to Orca: sends edits, keeps unsaved typing, merges outside writes. */
|
||||
export function start() {
|
||||
const document = orca.document
|
||||
if (!document) {
|
||||
throw new Error('this file runs as a custom editor')
|
||||
}
|
||||
return document.session<string>({ merge: mergeText })
|
||||
}
|
||||
`
|
||||
|
||||
const SYNC_TEST_TS = `import { assert, mockEditorHost, test } from 'orca/testing'
|
||||
import { start } from './sync'
|
||||
|
||||
test('merges an outside write that lands while an edit waits', async () => {
|
||||
const host = mockEditorHost({ text: 'first' })
|
||||
const doc = await start()
|
||||
doc.change('first\\nmine')
|
||||
host.replace('theirs') // an agent writes the file first
|
||||
await host.flush() // as a save: conflict, merge, resend
|
||||
assert.equal(host.text, 'theirs\\nfirst\\nmine')
|
||||
doc.dispose()
|
||||
host.dispose()
|
||||
})
|
||||
`
|
||||
|
||||
export function editor(input: PluginTemplateInput): PluginTemplate {
|
||||
const extension = `.${input.id}`
|
||||
return {
|
||||
description:
|
||||
'Custom editor in TypeScript on the document session, with a Node test (needs --approve to load)',
|
||||
files: {
|
||||
'orca-plugin.json': manifest(
|
||||
input,
|
||||
`Edits ${extension} files.`,
|
||||
{
|
||||
execution: 'sandboxed-ui',
|
||||
pluginApi: 1,
|
||||
contributes: {
|
||||
customEditors: [
|
||||
{
|
||||
id: input.id,
|
||||
title: input.name,
|
||||
extensions: [extension],
|
||||
entry: 'editor/main.ts',
|
||||
frame: 'isolated'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
`>=${PLUGIN_EDITOR_FRAME_SINCE}`
|
||||
),
|
||||
'editor/main.ts': MAIN_TS,
|
||||
'editor/sync.ts': SYNC_TS,
|
||||
'editor/sync.test.ts': SYNC_TEST_TS,
|
||||
'tsconfig.json': json({
|
||||
extends: `./${PLUGIN_TYPES_DIR}/tsconfig.json`,
|
||||
include: ['editor']
|
||||
}),
|
||||
'README.md': readme(
|
||||
input,
|
||||
`A custom editor for \`${extension}\` files, in TypeScript with no build step. \`editor/sync.ts\` keeps the file in step through \`orca.document.session\` (replace its stand-in \`mergeText\` with your format's merge), and \`editor/main.ts\` is the view. \`orca plugin test .\` runs \`editor/sync.test.ts\` in Node with \`mockEditorHost\`; \`orca plugin check .\` reads every file the editor loads; \`orca plugin shot <plugin-key> --editor <file>\` saves a PNG of it. \`"frame": "isolated"\` keeps it to the Orca desktop app. Loading it needs approval: \`orca plugin dev watch .\` prints the exact access and the \`--approve\` fingerprint.`
|
||||
),
|
||||
...COMMON_FILES
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { basename, dirname, join, resolve, sep } from 'node:path'
|
||||
import { hashPluginTree } from '../../../main/plugins/plugin-content-hash'
|
||||
import {
|
||||
validateDeclaredPluginArtifacts,
|
||||
validatePluginInstallContent
|
||||
} from '../../../main/plugins/plugin-artifact-validation'
|
||||
import { PLUGIN_CATALOG_FILENAME } from '../../../shared/plugins/catalog/plugin-catalog'
|
||||
import {
|
||||
pluginApprovalGrants,
|
||||
pluginNeedsCliApproval,
|
||||
type PluginApprovalGrants
|
||||
} from '../../../shared/plugins/approval/plugin-cli-approval'
|
||||
import {
|
||||
strictPluginArtifactErrors,
|
||||
strictPluginArtifactWarnings
|
||||
} from '../../../shared/plugins/authoring/plugin-strict-artifact-checks'
|
||||
import { validatePluginFile } from '../../../shared/plugins/installation/plugin-file-validation'
|
||||
import {
|
||||
localPluginPublisher,
|
||||
withPluginPublisher
|
||||
} from '../../../shared/plugins/installation/plugin-publisher-derivation'
|
||||
import {
|
||||
PLUGIN_MANIFEST_FILENAME,
|
||||
parsePluginManifest,
|
||||
qualifiedPluginKey,
|
||||
type PluginManifest
|
||||
} from '../../../shared/plugins/manifest/plugin-manifest'
|
||||
|
||||
export type PluginFolderSummary = {
|
||||
pluginKey: string
|
||||
name: string
|
||||
version: string
|
||||
/** Whether loading or enabling it needs `--approve`; content-only plugins never do. */
|
||||
needsApproval: boolean
|
||||
grants: PluginApprovalGrants
|
||||
}
|
||||
|
||||
export type PluginValidationReport = {
|
||||
path: string
|
||||
file: string
|
||||
kind: 'plugin' | 'catalog'
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
/** Handled safely at load (e.g. protected language-pack copy); never makes the file invalid. */
|
||||
warnings?: string[]
|
||||
plugin?: PluginFolderSummary
|
||||
}
|
||||
|
||||
function manifestTextReader(root: string) {
|
||||
return (manifestPath: string): string | null => {
|
||||
const file = join(root, ...manifestPath.split('/'))
|
||||
return existsSync(file) && statSync(file).isFile() ? readFileSync(file, 'utf8') : null
|
||||
}
|
||||
}
|
||||
|
||||
// Why contained: a catalog image path must stay inside the catalog repository.
|
||||
function catalogFileReader(root: string) {
|
||||
return (relative: string) => {
|
||||
const target = resolve(root, relative)
|
||||
if (!target.startsWith(`${resolve(root)}${sep}`) || !existsSync(target)) {
|
||||
return null
|
||||
}
|
||||
const bytes = readFileSync(target)
|
||||
return { bytes, sha256: createHash('sha256').update(bytes).digest('hex') }
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTargetFile(target: string): string {
|
||||
if (!existsSync(target)) {
|
||||
throw new Error(`${target} does not exist`)
|
||||
}
|
||||
if (!statSync(target).isDirectory()) {
|
||||
return target
|
||||
}
|
||||
const found = [PLUGIN_MANIFEST_FILENAME, PLUGIN_CATALOG_FILENAME]
|
||||
.map((name) => join(target, name))
|
||||
.find((file) => existsSync(file))
|
||||
if (!found) {
|
||||
throw new Error(`${target} has no ${PLUGIN_MANIFEST_FILENAME} or ${PLUGIN_CATALOG_FILENAME}`)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
async function contentErrors(root: string, manifest: PluginManifest): Promise<string[]> {
|
||||
const errors = strictPluginArtifactErrors(manifest, manifestTextReader(root))
|
||||
for (const check of [validateDeclaredPluginArtifacts, validatePluginInstallContent]) {
|
||||
const result = await check(root, manifest)
|
||||
if (!result.ok) {
|
||||
errors.push(result.error)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
/** The checks `pnpm validate:plugin` runs, plus every file Orca reads at load. Offline; never runs plugin code. */
|
||||
export async function validatePluginTarget(target: string): Promise<PluginValidationReport> {
|
||||
const file = resolveTargetFile(target)
|
||||
const root = dirname(file)
|
||||
const name = basename(file)
|
||||
const kind = name === PLUGIN_CATALOG_FILENAME ? 'catalog' : 'plugin'
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(file, 'utf8'))
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
return { path: root, file, kind, valid: false, errors: [`invalid JSON: ${detail}`] }
|
||||
}
|
||||
const errors = validatePluginFile(name, raw, catalogFileReader(root))
|
||||
const parsed = kind === 'plugin' && errors.length === 0 ? parsePluginManifest(raw) : null
|
||||
if (!parsed?.ok) {
|
||||
return { path: root, file, kind, valid: errors.length === 0, errors }
|
||||
}
|
||||
const { manifest } = parsed
|
||||
errors.push(...(await contentErrors(root, manifest)))
|
||||
const warnings = strictPluginArtifactWarnings(manifest, manifestTextReader(root))
|
||||
return {
|
||||
path: root,
|
||||
file,
|
||||
kind,
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
plugin: {
|
||||
// Why derived: a folder loads under its declared publisher, else `local`, like dev discovery.
|
||||
pluginKey: qualifiedPluginKey(
|
||||
withPluginPublisher(manifest, localPluginPublisher(manifest.publisher))
|
||||
),
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
needsApproval: pluginNeedsCliApproval(manifest),
|
||||
grants: pluginApprovalGrants(manifest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginInspection = PluginValidationReport & {
|
||||
/** What `orca plugin install` of these exact files would record; absent when unhashable. */
|
||||
contentHash?: string
|
||||
fileCount?: number
|
||||
totalBytes?: number
|
||||
}
|
||||
|
||||
export async function inspectPluginFolder(target: string): Promise<PluginInspection> {
|
||||
const report = await validatePluginTarget(target)
|
||||
if (report.kind !== 'plugin') {
|
||||
return report
|
||||
}
|
||||
const hashed = await hashPluginTree(report.path)
|
||||
return hashed.ok
|
||||
? {
|
||||
...report,
|
||||
contentHash: hashed.hash,
|
||||
fileCount: hashed.fileCount,
|
||||
totalBytes: hashed.totalBytes
|
||||
}
|
||||
: { ...report, valid: false, errors: [...report.errors, hashed.error] }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
COMMON_FILES,
|
||||
manifest,
|
||||
readme,
|
||||
type PluginTemplate,
|
||||
type PluginTemplateInput
|
||||
} from './plugin-template-files'
|
||||
|
||||
// Sandboxed-frame templates: they run code, so loading them needs `--approve`.
|
||||
|
||||
const FRAME_STYLE = `body {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font: 13px system-ui, sans-serif;
|
||||
color: var(--orca-foreground);
|
||||
background: var(--orca-background);
|
||||
}`
|
||||
|
||||
export function panel(input: PluginTemplateInput): PluginTemplate {
|
||||
return {
|
||||
description: 'Right-sidebar panel in a sandboxed frame (needs --approve to load)',
|
||||
files: {
|
||||
'orca-plugin.json': manifest(input, 'A sidebar panel.', {
|
||||
execution: 'sandboxed-ui',
|
||||
pluginApi: 1,
|
||||
contributes: {
|
||||
panels: [{ id: input.id, title: input.name, icon: 'puzzle', entry: 'panel/index.html' }]
|
||||
},
|
||||
capabilities: [{ kind: 'workspace:read' }]
|
||||
}),
|
||||
'panel/index.html': `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
${FRAME_STYLE}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${input.name.replace(/[<>&]/g, '')}</h1>
|
||||
<p id="workspace">Loading…</p>
|
||||
<script>
|
||||
const label = document.getElementById('workspace')
|
||||
async function refresh() {
|
||||
if (!orca.host.has('workspace.readContext')) {
|
||||
label.textContent = 'This Orca cannot read workspace context.'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const context = await orca.host.call('workspace.readContext', {})
|
||||
label.textContent = JSON.stringify(context, null, 2)
|
||||
} catch (error) {
|
||||
label.textContent = error.code + ': ' + error.message
|
||||
}
|
||||
}
|
||||
void refresh()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
'README.md': readme(
|
||||
input,
|
||||
'A right-sidebar panel. It runs in a sandboxed frame and calls host methods its `capabilities` allow through `window.orca`. Loading it needs approval: `orca plugin dev watch .` prints the exact access and the `--approve` fingerprint.'
|
||||
),
|
||||
...COMMON_FILES
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mkdtemp, realpath, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createPluginFromTemplate } from './plugin-create'
|
||||
import { findPluginTestFiles, runPluginTests } from './plugin-test-runner'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function hookPlugin(): Promise<string> {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-hook-template-')))
|
||||
roots.push(root)
|
||||
const dir = join(root, 'guard')
|
||||
await createPluginFromTemplate({ dir, template: 'hook' })
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('orca plugin create --template hook', () => {
|
||||
it(
|
||||
"passes its own worker test under `orca plugin test` on Orca's runtime",
|
||||
async () => {
|
||||
const dir = await hookPlugin()
|
||||
const files = await findPluginTestFiles(dir)
|
||||
expect(files).toEqual(['src/worker.test.ts'])
|
||||
const orca = {
|
||||
program: process.execPath,
|
||||
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||
source: 'orca' as const,
|
||||
version: process.version
|
||||
}
|
||||
expect(await runPluginTests({ dir, files, quietStdout: true, node: orca })).toEqual({
|
||||
runner: 'node',
|
||||
code: 0,
|
||||
node: 'orca'
|
||||
})
|
||||
},
|
||||
// Starts its own Node process and transpiles the plugin.
|
||||
60_000
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { PLUGIN_HOOKS_SINCE } from '../../../shared/plugins/manifest/plugin-schema-field-docs'
|
||||
import {
|
||||
COMMON_FILES,
|
||||
json,
|
||||
manifest,
|
||||
readme,
|
||||
type PluginTemplate,
|
||||
type PluginTemplateInput
|
||||
} from './plugin-template-files'
|
||||
import { PLUGIN_TYPES_DIR } from './plugin-type-files'
|
||||
|
||||
const WORKER_TS = `// A guard: Orca asks this worker before it types text into a terminal (terminal.send).
|
||||
// Orca transpiles this file when it loads: no build step.
|
||||
import type { OrcaWorkerApi } from 'orca/worker'
|
||||
|
||||
const BLOCKED = [/\\brm\\s+-rf\\s+\\/(?:\\s|$)/, /\\bmkfs\\b/]
|
||||
|
||||
export default function activate(orca: OrcaWorkerApi): void {
|
||||
orca.hooks.on('terminal.send', (event) => {
|
||||
// Decide first: an awaited call before returning could fail or time out and let it through.
|
||||
if (BLOCKED.some((pattern) => pattern.test(event.text))) {
|
||||
orca.log(\`blocked in \${event.terminalId}: \${event.text}\`)
|
||||
return { deny: 'blocked by the guard plugin' }
|
||||
}
|
||||
// Returning nothing passes the send on unchanged.
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
const WORKER_TEST_TS = `// Runs the hook as Orca would, with this worker as the only plugin in the chain.
|
||||
import { assert, mountWorker, test } from 'orca/testing'
|
||||
import * as main from './worker'
|
||||
|
||||
const send = (text: string) => ({ terminalId: 't1', text, enter: true, inputKind: 'driving' as const })
|
||||
|
||||
test('refuses a dangerous command and passes everything else unchanged', async () => {
|
||||
const worker = await mountWorker(main)
|
||||
assert.deepEqual(await worker.hooks.fire('terminal.send', send('rm -rf /')), {
|
||||
kind: 'deny',
|
||||
reason: 'blocked by the guard plugin'
|
||||
})
|
||||
assert.deepEqual(await worker.hooks.fire('terminal.send', send('ls -la ')), {
|
||||
kind: 'continue',
|
||||
event: send('ls -la ')
|
||||
})
|
||||
await worker.deactivate()
|
||||
})
|
||||
`
|
||||
|
||||
/** A worker-only guard on `terminal.send`, failing closed, with its test. */
|
||||
export function hook(input: PluginTemplateInput): PluginTemplate {
|
||||
return {
|
||||
description:
|
||||
'Worker-only hook that refuses dangerous terminal commands, with a test (needs --approve to load)',
|
||||
files: {
|
||||
'orca-plugin.json': manifest(input, 'Refuses dangerous commands sent to terminals.', {
|
||||
engines: { orca: `>=${PLUGIN_HOOKS_SINCE}` },
|
||||
execution: 'trusted-worker',
|
||||
pluginApi: 1,
|
||||
main: 'src/worker.ts',
|
||||
// Why deny: a guard that crashes or times out must not let the command through.
|
||||
contributes: { hooks: [{ event: 'terminal.send', onError: 'deny' }] }
|
||||
}),
|
||||
'src/worker.ts': WORKER_TS,
|
||||
'src/worker.test.ts': WORKER_TEST_TS,
|
||||
'tsconfig.json': json({ extends: `./${PLUGIN_TYPES_DIR}/tsconfig.json`, include: ['src'] }),
|
||||
'README.md': readme(
|
||||
input,
|
||||
`A guard (\`src/worker.ts\`) on the \`terminal.send\` hook: before Orca, the CLI or a plugin types text into a terminal, the worker can refuse it with \`{ deny }\` or pass it on. \`"onError": "deny"\` in \`orca-plugin.json\` makes a crash or timeout refuse the send too. \`orca plugin test .\` runs \`src/worker.test.ts\` with \`orca/testing\`; with the folder loaded, \`orca plugin dev hook . terminal.send --data '{"terminalId":"t1","text":"rm -rf /","enter":true,"inputKind":"driving"}'\` prints what the chain decides.`
|
||||
),
|
||||
...COMMON_FILES
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Runs inside the plugin's Node process with the test hooks: for each components panel, and each
|
||||
* scenario its `<entry>.check.ts` defines (else one plain run), mount the worker (`main`) and the
|
||||
* panel on one mock host, so the panel's `plugin.request` reaches the worker as in Orca.
|
||||
*/
|
||||
export const PLUGIN_PANEL_CHECK_SCRIPT = `
|
||||
const { mount, mockHost, mountWorker } = await import('orca/testing')
|
||||
const { panels, main, scenario: only, drawnTree, listOnly } = JSON.parse(
|
||||
process.env.ORCA_PLUGIN_CHECK_PANELS
|
||||
)
|
||||
const results = []
|
||||
let stray = []
|
||||
process.on('unhandledRejection', (error) => stray.push(error))
|
||||
const issueOf = (error, prefix = '') => ({
|
||||
kind: 'plugin',
|
||||
message: prefix + String(error?.message ?? error),
|
||||
stack: error?.stack
|
||||
})
|
||||
const refused = (refusals, prefix = '') =>
|
||||
refusals.map(({ method, code, message }) => prefix + 'host refused ' + method + ' (' + code + '): ' + message)
|
||||
for (const { panel, entry, url, fixture } of panels) {
|
||||
let scenarios = { '': {} }
|
||||
const fixtureIssues = []
|
||||
if (fixture) {
|
||||
try {
|
||||
const loaded = await import(fixture.url)
|
||||
const defined = loaded.default ?? loaded.scenarios
|
||||
if (defined === null || typeof defined !== 'object' || Object.keys(defined).length === 0) {
|
||||
throw new Error(fixture.path + ' must export default (or \`scenarios\`) an object of named scenarios')
|
||||
}
|
||||
scenarios = defined
|
||||
} catch (error) {
|
||||
fixtureIssues.push(issueOf(error))
|
||||
}
|
||||
}
|
||||
if (listOnly) {
|
||||
// Names only: nothing is mounted, so nothing runs but the fixture module itself.
|
||||
results.push({ panel, entry, scenarios: fixture ? Object.keys(scenarios) : [], issues: fixtureIssues })
|
||||
continue
|
||||
}
|
||||
if (only !== undefined && !Object.hasOwn(scenarios, only)) {
|
||||
results.push({
|
||||
panel,
|
||||
entry,
|
||||
scenario: only,
|
||||
elements: 0,
|
||||
summary: [],
|
||||
issues: [...fixtureIssues, { kind: 'plugin', message: 'no check scenario named ' + JSON.stringify(only) + (fixture ? ' in ' + fixture.path : ': the panel has no .check file') }],
|
||||
warnings: []
|
||||
})
|
||||
continue
|
||||
}
|
||||
for (const [scenario, options] of Object.entries(scenarios)) {
|
||||
if (only !== undefined && scenario !== only) continue
|
||||
const result = { panel, entry, elements: 0, issues: [...fixtureIssues], warnings: [] }
|
||||
if (scenario) result.scenario = scenario
|
||||
stray = []
|
||||
const { persisted, theme, events, ...hostOptions } = options ?? {}
|
||||
const host = mockHost(hostOptions)
|
||||
let worker = null
|
||||
if (main) {
|
||||
try {
|
||||
worker = await mountWorker(await import(main.url), { host })
|
||||
} catch (error) {
|
||||
result.issues.push(issueOf(error, 'worker (' + main.path + '): '))
|
||||
}
|
||||
}
|
||||
try {
|
||||
const mounted = await mount(await import(url), { host, panelId: panel, persisted, theme })
|
||||
for (const { event, payload } of events ?? []) {
|
||||
await mounted.emit(event, payload)
|
||||
}
|
||||
result.elements = mounted.findAll({}).length
|
||||
result.summary = mounted.summary()
|
||||
result.tree = mounted.tree()
|
||||
if (drawnTree) result.drawnTree = mounted.drawnTree()
|
||||
result.issues.push(...mounted.errors)
|
||||
result.warnings.push(...mounted.warnings)
|
||||
// Refused even when the code caught it: in Orca the call fails the same way.
|
||||
result.warnings.push(...refused(mounted.refusals))
|
||||
await mounted.unmount()
|
||||
} catch (error) {
|
||||
stray.push(error)
|
||||
}
|
||||
try {
|
||||
await worker?.deactivate()
|
||||
} catch (error) {
|
||||
result.issues.push(issueOf(error, 'worker (' + main.path + '): '))
|
||||
}
|
||||
for (const error of stray) {
|
||||
result.issues.push(issueOf(error))
|
||||
}
|
||||
results.push(result)
|
||||
}
|
||||
}
|
||||
process.stdout.write('\\n__ORCA_CHECK__' + JSON.stringify(results))
|
||||
`
|
||||
@@ -0,0 +1,293 @@
|
||||
import { existsSync, realpathSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { z } from 'zod'
|
||||
import type { PluginDeclarativeNode } from '../../../shared/plugins/declarative/plugin-declarative-catalog'
|
||||
import { parsePluginDeclarativeTree } from '../../../shared/plugins/declarative/plugin-declarative-tree'
|
||||
import { parsePluginManifest } from '../../../shared/plugins/manifest/plugin-manifest'
|
||||
import { PLUGIN_PANEL_CHECK_SCRIPT } from './plugin-panel-check-script'
|
||||
import { selectPluginTestNode } from './plugin-test-node'
|
||||
import { pluginTestingFiles } from './plugin-test-runner'
|
||||
|
||||
export type PluginPanelCheckIssue = {
|
||||
kind: 'plugin' | 'refused'
|
||||
message: string
|
||||
/** `file:line:column` inside the plugin, when the error's stack names one. */
|
||||
location?: string
|
||||
}
|
||||
export type PluginPanelCheck = {
|
||||
panel: string
|
||||
entry: string
|
||||
/** The `<entry>.check.ts` scenario this render used, when the panel has one. */
|
||||
scenario?: string
|
||||
/** Elements drawn on the first render; 0 when it never rendered. */
|
||||
elements: number
|
||||
/** What a person notices first, one line each (headings, callouts, empty states, table sizes). */
|
||||
summary: string[]
|
||||
/** The drawn tree as plain data (handlers as `[handler]`); `orca plugin check --json` only. */
|
||||
tree?: unknown
|
||||
/** With `drawnTree`: the validated tree exactly as Orca draws it, handlers as `{ $callback }`. */
|
||||
drawnTree?: PluginDeclarativeNode | null
|
||||
issues: PluginPanelCheckIssue[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
const checkReportSchema = z.array(
|
||||
z.object({
|
||||
panel: z.string(),
|
||||
entry: z.string(),
|
||||
scenario: z.string().optional(),
|
||||
elements: z.number(),
|
||||
summary: z.array(z.string()).default([]),
|
||||
tree: z.unknown().optional(),
|
||||
// Re-validated below; the child only hands back what the kit already validated.
|
||||
drawnTree: z.unknown().optional(),
|
||||
issues: z.array(
|
||||
z.object({
|
||||
kind: z.enum(['plugin', 'refused']),
|
||||
message: z.string(),
|
||||
stack: z.string().optional()
|
||||
})
|
||||
),
|
||||
warnings: z.array(z.string())
|
||||
})
|
||||
)
|
||||
|
||||
/** The first stack frame inside the plugin folder, as `path:line:column`. */
|
||||
export function pluginStackLocation(dir: string, stack: string | undefined): string | undefined {
|
||||
for (const match of stack?.matchAll(
|
||||
/(file:\/\/[^\s)]+|\/[^\s():]+|[A-Za-z]:\\[^\s():]+):(\d+):(\d+)/g
|
||||
) ?? []) {
|
||||
const file = match[1].startsWith('file:') ? fileURLToPath(match[1]) : match[1]
|
||||
const path = relative(dir, file)
|
||||
if (path && !path.startsWith('..') && !path.split(sep).includes('node_modules')) {
|
||||
return `${path.split(sep).join('/')}:${match[2]}:${match[3]}`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// `src/panel.tsx:4:7: Unexpected token`, as Orca's loaders word transpile and import errors.
|
||||
const LOCATED_MESSAGE = /^([^\n:]+\.[a-z]+:\d+:\d+): ([\s\S]*)$/i
|
||||
|
||||
/** One issue per distinct problem, located from its message or else its first plugin frame. */
|
||||
export function pluginPanelCheckIssues(
|
||||
dir: string,
|
||||
reported: readonly { kind: PluginPanelCheckIssue['kind']; message: string; stack?: string }[]
|
||||
): PluginPanelCheckIssue[] {
|
||||
const issues = new Map<string, PluginPanelCheckIssue>()
|
||||
for (const { stack, ...issue } of reported) {
|
||||
const located = LOCATED_MESSAGE.exec(issue.message)
|
||||
const location = located?.[1] ?? pluginStackLocation(dir, stack)
|
||||
const message = located?.[2] ?? issue.message
|
||||
const key = JSON.stringify([issue.kind, location, message])
|
||||
if (!issues.has(key)) {
|
||||
issues.set(key, location ? { ...issue, message, location } : { ...issue, message })
|
||||
}
|
||||
}
|
||||
return [...issues.values()]
|
||||
}
|
||||
|
||||
const FIXTURE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.js', '.mjs']
|
||||
|
||||
/** `src/panel.check.ts` beside `src/panel.tsx`: the scenarios check renders the panel in. */
|
||||
export function pluginCheckFixturePath(dir: string, entry: string): string | null {
|
||||
const stem = entry.replace(/\.[^./]+$/, '')
|
||||
return (
|
||||
FIXTURE_EXTENSIONS.map((extension) => `${stem}.check${extension}`).find((path) =>
|
||||
existsSync(join(dir, ...path.split('/')))
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export type CheckPluginPanelsOptions = {
|
||||
appRoot?: string
|
||||
/** Only this panel id. */
|
||||
panel?: string
|
||||
/** Only this scenario of its `.check` file; an unknown name is reported as an issue. */
|
||||
scenario?: string
|
||||
/** Also return each render's validated tree as Orca draws it (`drawnTree`), for replay. */
|
||||
drawnTree?: boolean
|
||||
}
|
||||
|
||||
type CheckTarget = {
|
||||
panel: string
|
||||
entry: string
|
||||
url: string
|
||||
fixture?: { path: string; url: string }
|
||||
}
|
||||
|
||||
/** The plugin's components panels (and worker) as the check child takes them. */
|
||||
async function checkTargets(
|
||||
dir: string,
|
||||
panelId?: string
|
||||
): Promise<{ panels: CheckTarget[]; main: { path: string; url: string } | null }> {
|
||||
const parsed = parsePluginManifest(
|
||||
JSON.parse(await readFile(join(dir, 'orca-plugin.json'), 'utf8'))
|
||||
)
|
||||
if (!parsed.ok) {
|
||||
return { panels: [], main: null }
|
||||
}
|
||||
const fileUrl = (path: string): string => pathToFileURL(join(dir, ...path.split('/'))).href
|
||||
const panels = parsed.manifest.contributes.panels
|
||||
.filter((panel) => panel.ui === 'components' && (panelId === undefined || panel.id === panelId))
|
||||
.map((panel) => {
|
||||
const fixture = pluginCheckFixturePath(dir, panel.entry)
|
||||
return {
|
||||
panel: panel.id,
|
||||
entry: panel.entry,
|
||||
url: fileUrl(panel.entry),
|
||||
...(fixture ? { fixture: { path: fixture, url: fileUrl(fixture) } } : {})
|
||||
}
|
||||
})
|
||||
const main = parsed.manifest.main
|
||||
? { path: parsed.manifest.main, url: fileUrl(parsed.manifest.main) }
|
||||
: null
|
||||
return { panels, main }
|
||||
}
|
||||
|
||||
/** Runs the check script in the plugin's own Node process, with the test hooks. */
|
||||
type CheckChildInput = {
|
||||
panels: CheckTarget[]
|
||||
main: { path: string; url: string } | null
|
||||
scenario?: string
|
||||
drawnTree?: boolean
|
||||
/** Report each fixture's scenario names without mounting anything. */
|
||||
listOnly?: boolean
|
||||
}
|
||||
|
||||
async function runCheckChild(dir: string, input: CheckChildInput, appRoot?: string) {
|
||||
const testing = pluginTestingFiles(appRoot)
|
||||
const node = await selectPluginTestNode()
|
||||
return runProcess({
|
||||
program: node.program,
|
||||
args: [
|
||||
'--enable-source-maps',
|
||||
'--import',
|
||||
pathToFileURL(testing.register).href,
|
||||
'--input-type=module',
|
||||
'--eval',
|
||||
PLUGIN_PANEL_CHECK_SCRIPT
|
||||
],
|
||||
cwd: dir,
|
||||
env: {
|
||||
...process.env,
|
||||
...node.env,
|
||||
ORCA_PLUGIN_TEST_ROOT: dir,
|
||||
ORCA_PLUGIN_CHECK_PANELS: JSON.stringify(input)
|
||||
},
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The scenario names `<entry>.check.ts` defines for a panel, in order; empty without the file.
|
||||
* Loads the fixture (it may import `orca/testing`), but mounts nothing.
|
||||
*/
|
||||
export async function readPluginCheckScenarioNames(
|
||||
dir: string,
|
||||
entry: string,
|
||||
appRoot?: string
|
||||
): Promise<string[]> {
|
||||
const fixture = pluginCheckFixturePath(dir, entry)
|
||||
if (!fixture) {
|
||||
return []
|
||||
}
|
||||
const url = (path: string): string => pathToFileURL(join(dir, ...path.split('/'))).href
|
||||
const panels = [
|
||||
{ panel: entry, entry, url: url(entry), fixture: { path: fixture, url: url(fixture) } }
|
||||
]
|
||||
const result = await runCheckChild(dir, { panels, main: null, listOnly: true }, appRoot)
|
||||
const marker = result.stdout.lastIndexOf('__ORCA_CHECK__')
|
||||
const listed = scenarioListSchema.safeParse(
|
||||
marker === -1 ? null : JSON.parse(result.stdout.slice(marker + '__ORCA_CHECK__'.length))
|
||||
)
|
||||
if (!listed.success || !listed.data[0]) {
|
||||
throw new Error(
|
||||
`could not read ${fixture}: ${(result.stderr || result.stdout).trim().slice(0, 500)}`
|
||||
)
|
||||
}
|
||||
const [only] = listed.data
|
||||
if (only.issues.length > 0) {
|
||||
throw new Error(only.issues[0]?.message ?? `could not read ${fixture}`)
|
||||
}
|
||||
return only.scenarios
|
||||
}
|
||||
|
||||
const scenarioListSchema = z.array(
|
||||
z.object({
|
||||
scenarios: z.array(z.string()),
|
||||
issues: z.array(z.object({ message: z.string() }))
|
||||
})
|
||||
)
|
||||
|
||||
/** Renders every components panel once per scenario against a stand-in host, in a Node process;
|
||||
* a plugin with a worker (`main`) runs it on the same host, so panel requests reach it. */
|
||||
export async function checkPluginPanels(
|
||||
dir: string,
|
||||
options: CheckPluginPanelsOptions = {}
|
||||
): Promise<PluginPanelCheck[]> {
|
||||
const { panels, main } = await checkTargets(dir, options.panel)
|
||||
if (panels.length === 0) {
|
||||
return []
|
||||
}
|
||||
const result = await runCheckChild(
|
||||
dir,
|
||||
{
|
||||
panels,
|
||||
main,
|
||||
...(options.scenario === undefined ? {} : { scenario: options.scenario }),
|
||||
...(options.drawnTree ? { drawnTree: true } : {})
|
||||
},
|
||||
options.appRoot
|
||||
)
|
||||
const marker = result.stdout.lastIndexOf('__ORCA_CHECK__')
|
||||
if (marker === -1) {
|
||||
// The process died before reporting (a syntax error in a module, say): report what it said.
|
||||
const message = (result.stderr || result.stdout).trim().split('\n').slice(0, 20).join('\n')
|
||||
return panels.map(({ panel, entry }) => ({
|
||||
panel,
|
||||
entry,
|
||||
elements: 0,
|
||||
summary: [],
|
||||
issues: [{ kind: 'plugin', message: message || `exited with code ${result.code}` }],
|
||||
warnings: []
|
||||
}))
|
||||
}
|
||||
const reported = checkReportSchema.parse(
|
||||
JSON.parse(result.stdout.slice(marker + '__ORCA_CHECK__'.length))
|
||||
)
|
||||
// Stacks name real paths (macOS /var is /private/var).
|
||||
const realDir = realpathSync(dir)
|
||||
return reported.map(({ drawnTree, ...check }) => {
|
||||
const drawn = drawnTree === undefined ? undefined : parsePluginDeclarativeTree(drawnTree)
|
||||
return {
|
||||
...check,
|
||||
...(drawn ? { drawnTree: drawn.ok ? drawn.tree : null } : {}),
|
||||
issues: pluginPanelCheckIssues(realDir, check.issues),
|
||||
warnings: [...new Set(check.warnings)]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Lines for one panel: what it drew, or each issue at its plugin location. */
|
||||
export function formatPluginPanelCheck(check: PluginPanelCheck): string[] {
|
||||
const head = `${check.panel} (${check.entry}${check.scenario ? `, scenario "${check.scenario}"` : ''}): `
|
||||
if (check.issues.length === 0) {
|
||||
return [
|
||||
`${head}rendered ${check.elements} element(s)`,
|
||||
...check.summary.map((line) => ` ${line}`),
|
||||
...check.warnings.map((warning) => ` warning: ${warning}`)
|
||||
]
|
||||
}
|
||||
return [
|
||||
`${head}failed`,
|
||||
...check.issues.map(
|
||||
(issue) =>
|
||||
` ${issue.location ?? check.entry}: ${issue.kind === 'refused' ? 'Orca would refuse this render: ' : ''}${issue.message}`
|
||||
),
|
||||
...check.warnings.map((warning) => ` warning: ${warning}`)
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { hashPluginTree } from '../../../main/plugins/plugin-content-hash'
|
||||
import { createPluginFromTemplate } from './plugin-create'
|
||||
import { validatePluginTarget } from './plugin-folder-validation'
|
||||
import { formatPluginReleaseEntry, pluginReleaseEntryForFolder } from './plugin-release-entry'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function tempRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-release-entry-'))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function git(cwd: string, args: string[]): Promise<string> {
|
||||
const result = await runProcess({
|
||||
program: 'git',
|
||||
args: [
|
||||
...['user.name=Orca Test', 'user.email=test@example.com', 'commit.gpgsign=false'].flatMap(
|
||||
(setting) => ['-c', setting]
|
||||
),
|
||||
...args
|
||||
],
|
||||
cwd
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
async function releaseFor(dir: string) {
|
||||
return pluginReleaseEntryForFolder(await validatePluginTarget(dir))
|
||||
}
|
||||
|
||||
describe('orca plugin release-entry', () => {
|
||||
it('prints the HEAD entry and source path for a plugin in a subdirectory', async () => {
|
||||
const repo = await tempRoot()
|
||||
await git(repo, ['init', '--quiet'])
|
||||
const dir = join(repo, 'plugins', 'dusk')
|
||||
await createPluginFromTemplate({ dir, template: 'theme', publisher: 'acme' })
|
||||
await git(repo, ['add', '.'])
|
||||
await git(repo, ['commit', '--quiet', '-m', 'add dusk'])
|
||||
|
||||
const result = await releaseFor(dir)
|
||||
const hashed = await hashPluginTree(dir)
|
||||
expect(hashed.ok).toBe(true)
|
||||
expect(result.sourcePath).toBe('plugins/dusk')
|
||||
expect(result.note).toBeUndefined()
|
||||
expect(result.entry).toMatchObject({
|
||||
version: '0.1.0',
|
||||
commit: await git(repo, ['rev-parse', 'HEAD']),
|
||||
contentHash: hashed.ok ? hashed.hash : '',
|
||||
execution: 'data'
|
||||
})
|
||||
const text = formatPluginReleaseEntry(result)
|
||||
expect(text).toContain('"path" in this plugin\'s "source"): "plugins/dusk"')
|
||||
expect(text).toContain(`"commit": "${result.entry?.commit}"`)
|
||||
})
|
||||
|
||||
it('gives no entry when the commit lacks a file the manifest declares', async () => {
|
||||
const repo = await tempRoot()
|
||||
await git(repo, ['init', '--quiet'])
|
||||
await createPluginFromTemplate({ dir: repo, template: 'theme', publisher: 'acme' })
|
||||
await git(repo, ['add', 'orca-plugin.json'])
|
||||
await git(repo, ['commit', '--quiet', '-m', 'manifest only'])
|
||||
|
||||
const result = await releaseFor(repo)
|
||||
expect(result.entry).toBeUndefined()
|
||||
expect(result.note).toContain('themes/dark.json')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
pluginCatalogReleaseForHead,
|
||||
type PluginCatalogReleaseReport
|
||||
} from '../../../main/plugins/plugin-catalog-release-entry'
|
||||
import type { PluginValidationReport } from './plugin-folder-validation'
|
||||
|
||||
export type PluginReleaseEntryResult = PluginCatalogReleaseReport & {
|
||||
path: string
|
||||
pluginKey?: string
|
||||
}
|
||||
|
||||
/** Release entry for a plugin folder that already passed `validatePluginTarget`. */
|
||||
export async function pluginReleaseEntryForFolder(
|
||||
report: PluginValidationReport
|
||||
): Promise<PluginReleaseEntryResult> {
|
||||
return {
|
||||
path: report.path,
|
||||
...(report.plugin ? { pluginKey: report.plugin.pluginKey } : {}),
|
||||
...(await pluginCatalogReleaseForHead(report.path))
|
||||
}
|
||||
}
|
||||
|
||||
/** Same layout as `pnpm validate:plugin`, so either output can be pasted into a catalog. */
|
||||
export function formatPluginReleaseEntry(result: PluginReleaseEntryResult): string {
|
||||
return [
|
||||
...(result.sourcePath
|
||||
? [
|
||||
`Catalog source path (set "path" in this plugin's "source"): ${JSON.stringify(result.sourcePath)}`,
|
||||
''
|
||||
]
|
||||
: []),
|
||||
...(result.entry
|
||||
? [
|
||||
'Catalog release entry for HEAD (newest first in "releases"):',
|
||||
JSON.stringify(result.entry, null, 2)
|
||||
]
|
||||
: []),
|
||||
...(result.note ? [`note: ${result.note}`] : []),
|
||||
...(result.ownerWarning ? [`warning: ${result.ownerWarning}`] : [])
|
||||
].join('\n')
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/** File helpers shared by the `orca plugin create` templates. */
|
||||
|
||||
import { localPluginPublisher } from '../../../shared/plugins/installation/plugin-publisher-derivation'
|
||||
import { PLUGIN_DERIVED_PUBLISHER_SINCE } from '../../../shared/plugins/manifest/plugin-schema-field-docs'
|
||||
|
||||
/** `publisher` is deprecated: set only for authors who still support Orca that requires it. */
|
||||
export type PluginTemplateInput = { publisher?: string; id: string; name: string }
|
||||
|
||||
export type PluginTemplate = {
|
||||
description: string
|
||||
/** Manifest-relative path (always `/`) to file text. */
|
||||
files: Record<string, string>
|
||||
}
|
||||
|
||||
/** Every template targets the release that reads publisher-less manifests and TSX entries. */
|
||||
const ENGINE = `>=${PLUGIN_DERIVED_PUBLISHER_SINCE}`
|
||||
|
||||
export function pluginTemplateKey(input: PluginTemplateInput): string {
|
||||
return `${localPluginPublisher(input.publisher)}.${input.id}`
|
||||
}
|
||||
|
||||
export function json(value: unknown): string {
|
||||
return `${JSON.stringify(value, null, 2)}\n`
|
||||
}
|
||||
|
||||
/** `engine`: a newer `engines.orca` floor, for templates that use newer fields. */
|
||||
export function manifest(
|
||||
input: PluginTemplateInput,
|
||||
description: string,
|
||||
fields: Record<string, unknown>,
|
||||
engine = ENGINE
|
||||
): string {
|
||||
return json({
|
||||
manifestVersion: 2,
|
||||
id: input.id,
|
||||
...(input.publisher ? { publisher: input.publisher } : {}),
|
||||
name: input.name,
|
||||
version: '0.1.0',
|
||||
description,
|
||||
engines: { orca: engine },
|
||||
...fields
|
||||
})
|
||||
}
|
||||
|
||||
export function readme(input: PluginTemplateInput, body: string): string {
|
||||
return `# ${input.name}
|
||||
|
||||
${body}
|
||||
|
||||
## Develop
|
||||
|
||||
\`\`\`sh
|
||||
orca plugin validate .
|
||||
orca plugin dev watch . # loads this folder into Orca and prints its logs and reloads; Ctrl-C unloads it
|
||||
\`\`\`
|
||||
|
||||
Orca names the publisher after where the plugin is installed from: the GitHub or GitLab owner of
|
||||
its repository, or \`local\` for a folder. See
|
||||
https://github.com/stablyai/orca/blob/main/docs/reference/plugin-authoring.md.
|
||||
`
|
||||
}
|
||||
|
||||
// Why -text: Git must not rewrite line endings, or the content hash differs per checkout.
|
||||
// `.orca/` holds the typings `orca plugin types` writes; they are regenerated, never committed.
|
||||
export const COMMON_FILES = {
|
||||
'.gitattributes': '* -text\n',
|
||||
'.gitignore': 'node_modules/\n.orca/\n'
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Kept apart from the template bodies so command specs load without them.
|
||||
export const PLUGIN_TEMPLATE_NAMES = [
|
||||
'theme',
|
||||
'terminal-theme',
|
||||
'icon-theme',
|
||||
'syntax',
|
||||
'language-pack',
|
||||
'editor',
|
||||
'panel',
|
||||
'components',
|
||||
'worker-panel',
|
||||
'hook'
|
||||
] as const
|
||||
|
||||
export type PluginTemplateName = (typeof PLUGIN_TEMPLATE_NAMES)[number]
|
||||
|
||||
export function isPluginTemplateName(value: string): value is PluginTemplateName {
|
||||
return PLUGIN_TEMPLATE_NAMES.some((name) => name === value)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createPluginFromTemplate } from './plugin-create'
|
||||
import { inspectPluginFolder, validatePluginTarget } from './plugin-folder-validation'
|
||||
import { PLUGIN_TEMPLATE_NAMES } from './plugin-template-names'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function tempRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-create-'))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('orca plugin create templates', () => {
|
||||
it.each(PLUGIN_TEMPLATE_NAMES)('%s scaffolds a plugin that validates as-is', async (template) => {
|
||||
const dir = join(await tempRoot(), `my-${template}`)
|
||||
const created = await createPluginFromTemplate({ dir, template, publisher: 'acme' })
|
||||
expect(created.pluginKey).toBe(`acme.my-${template}`)
|
||||
const report = await inspectPluginFolder(dir)
|
||||
expect(report.errors).toEqual([])
|
||||
expect(report.valid).toBe(true)
|
||||
expect(report.contentHash).toMatch(/^[0-9a-f]{64}$/)
|
||||
const contentOnly = ['theme', 'terminal-theme', 'icon-theme', 'syntax', 'language-pack']
|
||||
expect(report.plugin?.needsApproval).toBe(!contentOnly.includes(template))
|
||||
const readme = await readFile(join(dir, 'README.md'), 'utf8')
|
||||
expect(readme).toContain('orca plugin dev watch .')
|
||||
expect(readme).not.toContain('dev load')
|
||||
})
|
||||
|
||||
it('refuses a non-empty folder and never overwrites', async () => {
|
||||
const dir = await tempRoot()
|
||||
await writeFile(join(dir, 'keep.txt'), 'mine')
|
||||
await expect(createPluginFromTemplate({ dir, template: 'theme' })).rejects.toThrow(
|
||||
'is not empty'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orca plugin validate', () => {
|
||||
it('reports artifact errors Orca would otherwise skip silently', async () => {
|
||||
const dir = join(await tempRoot(), 'broken')
|
||||
await createPluginFromTemplate({ dir, template: 'theme', publisher: 'acme' })
|
||||
await writeFile(
|
||||
join(dir, 'themes', 'dark.json'),
|
||||
JSON.stringify({ base: 'dark', tokens: { '--background': '#000', '--fooground': '#fff' } })
|
||||
)
|
||||
const report = await validatePluginTarget(dir)
|
||||
expect(report.valid).toBe(false)
|
||||
expect(report.errors.join('\n')).toContain('contributes.themes')
|
||||
})
|
||||
|
||||
it('reports a missing panel entry', async () => {
|
||||
const dir = join(await tempRoot(), 'panel')
|
||||
await createPluginFromTemplate({ dir, template: 'panel', publisher: 'acme' })
|
||||
await rm(join(dir, 'panel', 'index.html'))
|
||||
const report = await validatePluginTarget(dir)
|
||||
expect(report.valid).toBe(false)
|
||||
expect(report.errors.join('\n')).toContain('panel/index.html')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Starter plugins for `orca plugin create`. Each one validates as-is and runs no build step. */
|
||||
|
||||
import { iconTheme, languagePack, syntax, terminalTheme, theme } from './plugin-content-templates'
|
||||
import { panel } from './plugin-frame-templates'
|
||||
import { editor } from './plugin-editor-template'
|
||||
import { components } from './plugin-components-template'
|
||||
import { workerPanel } from './plugin-worker-panel-template'
|
||||
import { hook } from './plugin-hook-template'
|
||||
import type { PluginTemplate, PluginTemplateInput } from './plugin-template-files'
|
||||
import type { PluginTemplateName } from './plugin-template-names'
|
||||
|
||||
const TEMPLATES: Record<PluginTemplateName, (input: PluginTemplateInput) => PluginTemplate> = {
|
||||
theme,
|
||||
'terminal-theme': terminalTheme,
|
||||
'icon-theme': iconTheme,
|
||||
syntax,
|
||||
'language-pack': languagePack,
|
||||
editor,
|
||||
panel,
|
||||
components,
|
||||
'worker-panel': workerPanel,
|
||||
hook
|
||||
}
|
||||
|
||||
export function renderPluginTemplate(
|
||||
name: PluginTemplateName,
|
||||
input: PluginTemplateInput
|
||||
): PluginTemplate {
|
||||
return TEMPLATES[name](input)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec'
|
||||
import { selectPluginTestNode } from './plugin-test-node'
|
||||
|
||||
const ORCA = { execPath: '/Applications/Orca.app/Contents/MacOS/Orca', version: 'v22.9.0' }
|
||||
|
||||
function answering(stdout: string | Error, code = 0) {
|
||||
const specs: ProcessSpec[] = []
|
||||
const run = async (spec: ProcessSpec): Promise<ProcessResult> => {
|
||||
specs.push(spec)
|
||||
if (stdout instanceof Error) {
|
||||
throw stdout
|
||||
}
|
||||
return { code, signal: null, stdout, stderr: '', timedOut: false }
|
||||
}
|
||||
return { run, specs }
|
||||
}
|
||||
|
||||
describe('selectPluginTestNode', () => {
|
||||
it('uses a new enough system node by the absolute path it reports, probed outside the plugin', async () => {
|
||||
const probe = answering('v20.6.0 /usr/local/bin/node\n')
|
||||
const node = await selectPluginTestNode({ ...ORCA, electron: '38.0.0', run: probe.run })
|
||||
expect(node).toEqual({
|
||||
program: '/usr/local/bin/node',
|
||||
env: {},
|
||||
source: 'system',
|
||||
version: 'v20.6.0'
|
||||
})
|
||||
expect(probe.specs[0]?.program).toBe('node')
|
||||
expect(probe.specs[0]?.cwd).toMatch(/orca-plugin-node-/)
|
||||
})
|
||||
|
||||
it('keeps a Windows path with spaces whole', async () => {
|
||||
const probe = answering('v22.1.0 C:\\Program Files\\nodejs\\node.exe\r\n')
|
||||
const node = await selectPluginTestNode({ ...ORCA, electron: '38.0.0', run: probe.run })
|
||||
expect(node.program).toBe('C:\\Program Files\\nodejs\\node.exe')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['too old for module.register', answering('v20.5.1 /usr/bin/node')],
|
||||
['not on PATH', answering(new Error('spawn node ENOENT'))],
|
||||
['failing', answering('', 1)],
|
||||
['answering garbage', answering('nope')]
|
||||
])('falls back to Orca as Node when the system node is %s', async (_reason, probe) => {
|
||||
const node = await selectPluginTestNode({ ...ORCA, electron: '38.0.0', run: probe.run })
|
||||
expect(node).toEqual({
|
||||
program: ORCA.execPath,
|
||||
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||
source: 'orca',
|
||||
version: ORCA.version
|
||||
})
|
||||
})
|
||||
|
||||
it('needs no Electron switch when the CLI itself runs on plain Node', async () => {
|
||||
const probe = answering(new Error('spawn node ENOENT'))
|
||||
const node = await selectPluginTestNode({ ...ORCA, electron: undefined, run: probe.run })
|
||||
expect(node.env).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec'
|
||||
|
||||
/** `module.register` (the `--import` hooks) arrived in Node 20.6; `node:test` is older. */
|
||||
const MIN_NODE = [20, 6] as const
|
||||
|
||||
export type PluginTestNode = {
|
||||
/** Absolute path of the executable to run. */
|
||||
program: string
|
||||
/** Extra environment: Orca's own runtime needs ELECTRON_RUN_AS_NODE to act as Node. */
|
||||
env: Record<string, string>
|
||||
source: 'system' | 'orca'
|
||||
version: string
|
||||
}
|
||||
|
||||
type Dependencies = {
|
||||
run: (spec: ProcessSpec) => Promise<ProcessResult>
|
||||
/** The runtime this CLI runs on: Electron as Node in a packaged app, or Node in development. */
|
||||
execPath: string
|
||||
version: string
|
||||
electron: string | undefined
|
||||
}
|
||||
|
||||
const defaults = (): Dependencies => ({
|
||||
run: runProcess,
|
||||
execPath: process.execPath,
|
||||
version: process.version,
|
||||
electron: process.versions.electron
|
||||
})
|
||||
|
||||
function supports(version: string): boolean {
|
||||
const [major = 0, minor = 0] = version.replace(/^v/, '').split('.').map(Number)
|
||||
return major > MIN_NODE[0] || (major === MIN_NODE[0] && minor >= MIN_NODE[1])
|
||||
}
|
||||
|
||||
/**
|
||||
* The Node to run plugin tests with: the author's `node` when it is new enough, else Orca's own
|
||||
* runtime, so tests run on a machine with no Node installed. The probe asks `node` for its own
|
||||
* absolute path from an empty folder: on Windows a bare name resolves in the working directory
|
||||
* first, and the plugin folder may hold a planted `node.exe`.
|
||||
*/
|
||||
export async function selectPluginTestNode(
|
||||
deps: Dependencies = defaults()
|
||||
): Promise<PluginTestNode> {
|
||||
const empty = await mkdtemp(join(tmpdir(), 'orca-plugin-node-'))
|
||||
const probe = await deps
|
||||
.run({
|
||||
program: 'node',
|
||||
args: ['-p', 'process.version + " " + process.execPath'],
|
||||
cwd: empty,
|
||||
timeoutMs: 10_000,
|
||||
maxOutputBytes: 4096
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => rm(empty, { recursive: true, force: true }))
|
||||
const answer = probe?.code === 0 ? probe.stdout.trim() : ''
|
||||
const space = answer.indexOf(' ')
|
||||
const version = space > 0 ? answer.slice(0, space) : ''
|
||||
const program = space > 0 ? answer.slice(space + 1) : ''
|
||||
if (version && program && supports(version)) {
|
||||
return { program, env: {}, source: 'system', version }
|
||||
}
|
||||
return {
|
||||
program: deps.execPath,
|
||||
env: deps.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {},
|
||||
source: 'orca',
|
||||
version: deps.version
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createPluginFromTemplate } from './plugin-create'
|
||||
import { parsePluginDeclarativeTree } from '../../../shared/plugins/declarative/plugin-declarative-tree'
|
||||
import {
|
||||
checkPluginPanels,
|
||||
formatPluginPanelCheck,
|
||||
readPluginCheckScenarioNames,
|
||||
pluginPanelCheckIssues,
|
||||
pluginStackLocation
|
||||
} from './plugin-panel-check'
|
||||
import { findPluginTestFiles, PLUGIN_TEST_FILE, runPluginTests } from './plugin-test-runner'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function componentsPlugin(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-test-'))
|
||||
roots.push(root)
|
||||
const dir = join(root, 'todo-panel')
|
||||
await createPluginFromTemplate({ dir, template: 'components' })
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('orca plugin test', () => {
|
||||
it(
|
||||
"runs the components template's own test with node:test on Orca's runtime and passes",
|
||||
async () => {
|
||||
const dir = await componentsPlugin()
|
||||
const files = await findPluginTestFiles(dir)
|
||||
expect(files).toEqual(['src/panel.test.tsx'])
|
||||
// Orca's own runtime, as on a machine with no system Node.
|
||||
const orca = {
|
||||
program: process.execPath,
|
||||
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||
source: 'orca' as const,
|
||||
version: process.version
|
||||
}
|
||||
expect(await runPluginTests({ dir, files, quietStdout: true, node: orca })).toEqual({
|
||||
runner: 'node',
|
||||
code: 0,
|
||||
node: 'orca'
|
||||
})
|
||||
},
|
||||
// Each run starts its own Node (or vitest) process and transpiles the plugin.
|
||||
60_000
|
||||
)
|
||||
|
||||
it(
|
||||
'fails with a non-zero exit when a test fails, and skips generated folders',
|
||||
async () => {
|
||||
const dir = await componentsPlugin()
|
||||
await writeFile(
|
||||
join(dir, 'src', 'broken.test.ts'),
|
||||
"import { test } from 'node:test'\nimport assert from 'node:assert'\ntest('x', () => assert.equal(1, 2))\n"
|
||||
)
|
||||
await mkdir(join(dir, 'node_modules', 'dep'), { recursive: true })
|
||||
await writeFile(join(dir, 'node_modules', 'dep', 'a.test.ts'), '')
|
||||
const files = await findPluginTestFiles(dir)
|
||||
expect(files).toEqual(['src/broken.test.ts', 'src/panel.test.tsx'])
|
||||
const result = await runPluginTests({ dir, files, quietStdout: true })
|
||||
expect(result.code).not.toBe(0)
|
||||
},
|
||||
// Each run starts its own Node (or vitest) process and transpiles the plugin.
|
||||
60_000
|
||||
)
|
||||
|
||||
it(
|
||||
"uses the plugin's own vitest when it has one",
|
||||
async () => {
|
||||
const dir = await componentsPlugin()
|
||||
await mkdir(join(dir, 'node_modules'), { recursive: true })
|
||||
await symlink(resolve('node_modules', 'vitest'), join(dir, 'node_modules', 'vitest'), 'dir')
|
||||
await writeFile(
|
||||
join(dir, 'src', 'panel.test.tsx'),
|
||||
[
|
||||
"import { expect, test } from 'vitest'",
|
||||
"import { mount } from 'orca/testing'",
|
||||
"import * as panel from './panel'",
|
||||
"test('adds one', async () => {",
|
||||
' const view = await mount(panel)',
|
||||
" await view.press({ text: 'Add one' })",
|
||||
" expect(view.find({ role: 'status' })?.text).toBe('1')",
|
||||
' expect(view.errors).toEqual([])',
|
||||
'})',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
const files = await findPluginTestFiles(dir)
|
||||
expect(await runPluginTests({ dir, files, quietStdout: true })).toMatchObject({
|
||||
runner: 'vitest',
|
||||
code: 0
|
||||
})
|
||||
},
|
||||
// Each run starts its own Node (or vitest) process and transpiles the plugin.
|
||||
120_000
|
||||
)
|
||||
})
|
||||
|
||||
const EXAMPLES = resolve(__dirname, '../../../../examples/plugins')
|
||||
// Every example with its own tests (examples/plugins/*/src/*.test.tsx).
|
||||
const TESTED_EXAMPLES = readdirSync(EXAMPLES).filter((name) => {
|
||||
const src = join(EXAMPLES, name, 'src')
|
||||
return existsSync(src) && readdirSync(src).some((file) => PLUGIN_TEST_FILE.test(file))
|
||||
})
|
||||
|
||||
describe('orca plugin test with a worker', () => {
|
||||
it(
|
||||
"runs a .ts worker's own tests through mountWorker, resolving its imports as Orca does",
|
||||
async () => {
|
||||
const dir = await componentsPlugin()
|
||||
const manifestPath = join(dir, 'orca-plugin.json')
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
JSON.stringify({
|
||||
...manifest,
|
||||
execution: 'trusted-worker',
|
||||
main: 'src/worker.ts',
|
||||
capabilities: [{ kind: 'agent:read' }, { kind: 'notifications:show' }],
|
||||
contributes: { ...manifest.contributes, events: [{ on: 'agent.status.changed' }] }
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(dir, 'src', 'labels.ts'),
|
||||
"import { basename } from 'node:path'\nexport const label = (key: string): string => basename(key)\n"
|
||||
)
|
||||
await writeFile(
|
||||
join(dir, 'src', 'worker.ts'),
|
||||
[
|
||||
"import type { OrcaWorkerApi } from 'orca/worker'",
|
||||
"import { label } from './labels'",
|
||||
'export default function activate(orca: OrcaWorkerApi): void {',
|
||||
" orca.events.on('agent.status.changed', async (event) => {",
|
||||
" if (event.state === 'blocked') {",
|
||||
" await orca.host.call('notifications.show', { title: label(event.paneKey) })",
|
||||
' }',
|
||||
' })',
|
||||
'}',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
await writeFile(
|
||||
join(dir, 'src', 'worker.test.ts'),
|
||||
[
|
||||
"import { assert, mountWorker, test } from 'orca/testing'",
|
||||
"import * as main from './worker'",
|
||||
"test('notifies when an agent is blocked', async () => {",
|
||||
' const worker = await mountWorker(main)',
|
||||
" await worker.emit('agent.status.changed', { workspaceId: null, paneKey: 'a/b', state: 'blocked', receivedAt: 1 })",
|
||||
" assert.deepEqual(worker.host.callsOf('notifications.show'), [{ title: 'b' }])",
|
||||
' assert.deepEqual(worker.refusals, [])',
|
||||
' await worker.deactivate()',
|
||||
'})',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
const files = (await findPluginTestFiles(dir)).filter((file) => file.includes('worker'))
|
||||
expect(await runPluginTests({ dir, files, quietStdout: true })).toMatchObject({ code: 0 })
|
||||
},
|
||||
// Starts its own Node process and transpiles the plugin.
|
||||
60_000
|
||||
)
|
||||
})
|
||||
|
||||
describe('the example plugins', () => {
|
||||
it.each(TESTED_EXAMPLES)(
|
||||
'%s passes its own tests and renders without problems',
|
||||
async (example) => {
|
||||
const dir = join(EXAMPLES, example)
|
||||
const files = await findPluginTestFiles(dir)
|
||||
expect(files.length).toBeGreaterThan(0)
|
||||
expect(await runPluginTests({ dir, files, quietStdout: true })).toMatchObject({
|
||||
runner: 'node',
|
||||
code: 0
|
||||
})
|
||||
const checks = await checkPluginPanels(dir)
|
||||
expect(checks.map(({ issues, warnings }) => ({ issues, warnings }))).toEqual(
|
||||
checks.map(() => ({ issues: [], warnings: [] }))
|
||||
)
|
||||
},
|
||||
// Each run starts its own Node process and transpiles the plugin.
|
||||
60_000
|
||||
)
|
||||
})
|
||||
|
||||
describe('orca plugin check', () => {
|
||||
it(
|
||||
"runs the plugin's worker so panel requests reach it, and renders each fixture scenario",
|
||||
async () => {
|
||||
const dir = await componentsPlugin()
|
||||
const manifestPath = join(dir, 'orca-plugin.json')
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
JSON.stringify({
|
||||
...manifest,
|
||||
execution: 'trusted-worker',
|
||||
main: 'src/worker.ts',
|
||||
capabilities: [{ kind: 'agent:read' }]
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(dir, 'src', 'worker.ts'),
|
||||
[
|
||||
"import type { OrcaWorkerApi } from 'orca/worker'",
|
||||
'export default function activate(orca: OrcaWorkerApi): void {',
|
||||
" orca.panels.handle('stats', () => ({ changes: 7 }))",
|
||||
'}',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
await writeFile(
|
||||
join(dir, 'src', 'panel.tsx'),
|
||||
[
|
||||
"import { atom, host, List, ListItem, plugin, read, set, Text } from 'orca'",
|
||||
"const changes = atom<number | null>('changes', null)",
|
||||
"const names = atom<string[]>('names', [])",
|
||||
'export function setup(): void {',
|
||||
" void plugin.request('stats').then((stats) => set(changes, (stats as { changes: number }).changes))",
|
||||
" void host.call('agents.list').then(({ agents }) => set(names, agents.map((agent) => agent.title ?? '?')))",
|
||||
'}',
|
||||
'export default function Panel() {',
|
||||
' return (',
|
||||
' <List label="Agents">',
|
||||
' {read(names).map((name) => <ListItem key={name} title={name} />)}',
|
||||
' <ListItem key="#changes" title={`changes ${read(changes)}`} />',
|
||||
' </List>',
|
||||
' )',
|
||||
'}',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
await writeFile(
|
||||
join(dir, 'src', 'panel.check.ts'),
|
||||
[
|
||||
"import { defineCheck } from 'orca/testing'",
|
||||
'export default defineCheck({',
|
||||
' empty: {},',
|
||||
' busy: {',
|
||||
" results: { 'agents.list': { agents: [",
|
||||
" { terminalId: 't1', title: 'Claude', agent: 'claude', state: 'working', stateStartedAt: 1 },",
|
||||
" { terminalId: 't2', title: 'Codex', agent: 'codex', state: 'done', stateStartedAt: 1 }",
|
||||
' ] } }',
|
||||
' }',
|
||||
'})',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
const checks = await checkPluginPanels(dir)
|
||||
expect(
|
||||
checks.map(({ scenario, elements, issues, warnings }) => ({
|
||||
scenario,
|
||||
elements,
|
||||
issues,
|
||||
warnings
|
||||
}))
|
||||
).toEqual([
|
||||
{ scenario: 'empty', elements: 1, issues: [], warnings: [] },
|
||||
{ scenario: 'busy', elements: 3, issues: [], warnings: [] }
|
||||
])
|
||||
// What each scenario drew, at a glance, and the full tree for --json.
|
||||
expect(checks.map((check) => check.summary)).toEqual([
|
||||
['List "Agents": 1 item(s)'],
|
||||
['List "Agents": 3 item(s)']
|
||||
])
|
||||
expect(checks[1]?.tree).toMatchObject({ type: 'List', props: { label: 'Agents' } })
|
||||
// One scenario, with the tree Orca would draw: it validates and keeps live handler ids.
|
||||
expect(await readPluginCheckScenarioNames(dir, 'src/panel.tsx')).toEqual(['empty', 'busy'])
|
||||
const [busy] = await checkPluginPanels(dir, { scenario: 'busy', drawnTree: true })
|
||||
expect(busy).toMatchObject({ scenario: 'busy', issues: [] })
|
||||
expect(busy?.drawnTree).toMatchObject({ type: 'List', props: { label: 'Agents' } })
|
||||
expect(parsePluginDeclarativeTree(busy?.drawnTree).ok).toBe(true)
|
||||
const [missing] = await checkPluginPanels(dir, { scenario: 'nope' })
|
||||
expect(missing?.issues.map((issue) => issue.message)).toEqual([
|
||||
'no check scenario named "nope" in src/panel.check.ts'
|
||||
])
|
||||
expect(formatPluginPanelCheck(checks[1]!)).toEqual([
|
||||
'todo-panel (src/panel.tsx, scenario "busy"): rendered 3 element(s)',
|
||||
' List "Agents": 3 item(s)'
|
||||
])
|
||||
|
||||
await writeFile(join(dir, 'src', 'worker.ts'), 'export function activate(): void {}\n')
|
||||
await writeFile(join(dir, 'src', 'panel.check.ts'), 'export default {}\n')
|
||||
const [broken] = await checkPluginPanels(dir)
|
||||
expect(broken.issues.map((issue) => issue.message)).toEqual([
|
||||
expect.stringContaining('must export default'),
|
||||
expect.stringMatching(/^worker \(src\/worker\.ts\): .*exports activate by name/),
|
||||
'no panel handler named stats'
|
||||
])
|
||||
},
|
||||
// Each run starts its own Node process and transpiles the plugin.
|
||||
60_000
|
||||
)
|
||||
|
||||
it(
|
||||
'renders the template panel cleanly',
|
||||
async () => {
|
||||
const dir = await componentsPlugin()
|
||||
expect(await checkPluginPanels(dir)).toEqual([
|
||||
{
|
||||
panel: 'todo-panel',
|
||||
entry: 'src/panel.tsx',
|
||||
elements: expect.any(Number),
|
||||
summary: ['Heading "Counter"'],
|
||||
tree: expect.objectContaining({ type: 'Stack' }),
|
||||
issues: [],
|
||||
warnings: []
|
||||
}
|
||||
])
|
||||
},
|
||||
// Each run starts its own Node (or vitest) process and transpiles the plugin.
|
||||
60_000
|
||||
)
|
||||
|
||||
it(
|
||||
'reports a throw with its file:line, a refused layout, and dropped props',
|
||||
async () => {
|
||||
const dir = await componentsPlugin()
|
||||
await writeFile(
|
||||
join(dir, 'src', 'panel.tsx'),
|
||||
[
|
||||
"import { List, Stack, Text } from 'orca'",
|
||||
'',
|
||||
'export default function Panel() {',
|
||||
" if (Math.random() < 2) throw new Error('kaboom')",
|
||||
' return <Stack />',
|
||||
'}',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
const [thrown] = await checkPluginPanels(dir)
|
||||
expect(thrown.issues).toEqual([
|
||||
{
|
||||
kind: 'plugin',
|
||||
message: 'kaboom',
|
||||
location: expect.stringMatching(/^src\/panel\.tsx:4:\d+$/)
|
||||
}
|
||||
])
|
||||
await writeFile(
|
||||
join(dir, 'src', 'panel.tsx'),
|
||||
"import { List, Text } from 'orca'\nexport default () => <List><Text>x</Text></List>\n"
|
||||
)
|
||||
const [refused] = await checkPluginPanels(dir)
|
||||
expect(refused.issues).toEqual([
|
||||
{ kind: 'refused', message: expect.stringContaining('ListItem') }
|
||||
])
|
||||
await writeFile(
|
||||
join(dir, 'src', 'panel.tsx'),
|
||||
'import { Text } from \'orca\'\nexport default () => <Text color="red">x</Text>\n'
|
||||
)
|
||||
const [dropped] = await checkPluginPanels(dir)
|
||||
expect(dropped).toMatchObject({
|
||||
issues: [],
|
||||
warnings: ['Text: ignored unknown prop "color"']
|
||||
})
|
||||
await writeFile(join(dir, 'src', 'panel.tsx'), "import { Text } from 'orca'\nconst = 1\n")
|
||||
const [broken] = await checkPluginPanels(dir)
|
||||
expect(broken.issues).toEqual([
|
||||
{ kind: 'plugin', message: 'Unexpected token', location: 'src/panel.tsx:2:7' }
|
||||
])
|
||||
},
|
||||
// Each run starts its own Node (or vitest) process and transpiles the plugin.
|
||||
60_000
|
||||
)
|
||||
|
||||
it('locates transpile errors from their message and lists a repeated error once', () => {
|
||||
const stack = 'Error: kaboom\n at Panel (file:///p/src/panel.tsx:4:41)'
|
||||
expect(
|
||||
pluginPanelCheckIssues('/p', [
|
||||
{ kind: 'plugin', message: 'kaboom', stack },
|
||||
{ kind: 'plugin', message: 'kaboom', stack },
|
||||
{ kind: 'plugin', message: 'src/lib/a.ts:3:9: Unexpected token', stack: 'SyntaxError' },
|
||||
{ kind: 'refused', message: 'kaboom' }
|
||||
])
|
||||
).toEqual([
|
||||
{ kind: 'plugin', message: 'kaboom', location: 'src/panel.tsx:4:41' },
|
||||
{ kind: 'plugin', message: 'Unexpected token', location: 'src/lib/a.ts:3:9' },
|
||||
{ kind: 'refused', message: 'kaboom' }
|
||||
])
|
||||
})
|
||||
|
||||
it(
|
||||
'warns about host calls Orca would refuse, and reports a repeated throw once',
|
||||
async () => {
|
||||
const dir = await componentsPlugin()
|
||||
await writeFile(
|
||||
join(dir, 'src', 'panel.tsx'),
|
||||
[
|
||||
"import { atom, host, read, set, Text } from 'orca'",
|
||||
'const tick = atom(0)',
|
||||
'export function setup() {',
|
||||
" void host.call('workspace.readContext').catch(() => undefined)",
|
||||
' void Promise.resolve().then(() => set(tick, 1))',
|
||||
'}',
|
||||
'export default function Panel() {',
|
||||
" if (read(tick) >= 0) throw new Error('again')",
|
||||
' return <Text>x</Text>',
|
||||
'}',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
const [check] = await checkPluginPanels(dir)
|
||||
expect(check.issues).toEqual([
|
||||
{ kind: 'plugin', message: 'again', location: expect.stringMatching(/^src\/panel\.tsx:8:/) }
|
||||
])
|
||||
expect(check.warnings).toEqual([
|
||||
'host refused workspace.readContext (capability_denied): workspace.readContext needs the workspace:read capability'
|
||||
])
|
||||
},
|
||||
// Each run starts its own Node process and transpiles the plugin.
|
||||
60_000
|
||||
)
|
||||
|
||||
it('finds the first plugin frame in a stack', () => {
|
||||
const dir = '/p'
|
||||
expect(
|
||||
pluginStackLocation(
|
||||
dir,
|
||||
'Error: x\n at f (file:///p/node_modules/a.js:1:2)\n at Panel (file:///p/src/panel.tsx:9:11)'
|
||||
)
|
||||
).toBe('src/panel.tsx:9:11')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readdir, writeFile } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { selectPluginTestNode, type PluginTestNode } from './plugin-test-node'
|
||||
|
||||
/** Plugin test files `orca plugin test` runs. */
|
||||
export const PLUGIN_TEST_FILE = /\.test\.(?:tsx?|mts|jsx?|mjs)$/i
|
||||
// Not plugin sources: dependencies, Orca's own generated files, VCS and build output.
|
||||
const SKIPPED_DIRS = new Set(['node_modules', '.orca', '.git', 'dist', 'out', 'coverage'])
|
||||
|
||||
const defaultAppRoot = (): string => join(__dirname, '..', '..', '..', '..')
|
||||
|
||||
/** The files Orca ships for testing components panels, from the app this CLI belongs to. */
|
||||
export function pluginTestingFiles(appRoot = defaultAppRoot()) {
|
||||
const dir = join(appRoot, 'resources', 'plugins', 'declarative')
|
||||
return {
|
||||
runtime: join(dir, 'orca-runtime.mjs'),
|
||||
testing: join(dir, 'orca-testing.mjs'),
|
||||
hooks: join(dir, 'orca-test-hooks.mjs'),
|
||||
register: join(dir, 'orca-test-register.mjs')
|
||||
}
|
||||
}
|
||||
|
||||
/** Test files under `dir`, plugin-relative with `/`, sorted. */
|
||||
export async function findPluginTestFiles(dir: string): Promise<string[]> {
|
||||
const found: string[] = []
|
||||
const walk = async (folder: string): Promise<void> => {
|
||||
for (const entry of await readdir(folder, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIPPED_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
|
||||
await walk(join(folder, entry.name))
|
||||
}
|
||||
} else if (entry.isFile() && PLUGIN_TEST_FILE.test(entry.name)) {
|
||||
found.push(relative(dir, join(folder, entry.name)).split(sep).join('/'))
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(dir)
|
||||
return found.sort()
|
||||
}
|
||||
|
||||
export type PluginTestRunner = 'node' | 'vitest'
|
||||
|
||||
/** The plugin's own vitest when it has one installed, else Node's built-in runner. */
|
||||
export function pluginTestRunner(dir: string): { runner: PluginTestRunner; entry?: string } {
|
||||
const vitest = join(dir, 'node_modules', 'vitest', 'vitest.mjs')
|
||||
return existsSync(vitest) ? { runner: 'vitest', entry: vitest } : { runner: 'node' }
|
||||
}
|
||||
|
||||
/** A Vite config that resolves `orca` like the guest's import map and transpiles like Orca. */
|
||||
export function pluginVitestConfig(files: ReturnType<typeof pluginTestingFiles>): string {
|
||||
const url = (path: string): string => JSON.stringify(pathToFileURL(path).href)
|
||||
const path = (value: string): string => JSON.stringify(value)
|
||||
return `// Written by \`orca plugin test\`. Do not edit.
|
||||
import { transformPluginSourceForTest } from ${url(files.hooks)}
|
||||
|
||||
const SOURCE = /\\.(?:tsx?|mts|jsx)$/
|
||||
export default {
|
||||
esbuild: false,
|
||||
plugins: [
|
||||
{
|
||||
name: 'orca-plugin-sources',
|
||||
enforce: 'pre',
|
||||
transform(code, id) {
|
||||
const file = id.split('?')[0]
|
||||
if (!SOURCE.test(file) || file.includes('/node_modules/')) return null
|
||||
return { code: transformPluginSourceForTest(file, code), map: null }
|
||||
}
|
||||
}
|
||||
],
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: /^orca$/, replacement: ${path(files.runtime)} },
|
||||
{ find: /^orca\\/jsx-(?:dev-)?runtime$/, replacement: ${path(files.runtime)} },
|
||||
{ find: /^orca\\/testing$/, replacement: ${path(files.testing)} }
|
||||
]
|
||||
},
|
||||
test: {
|
||||
include: ['**/*.test.{ts,tsx,mts,js,jsx,mjs}'],
|
||||
exclude: ['**/node_modules/**', '**/.orca/**'],
|
||||
// One runtime instance for the panel and the kit: never let vite-node externalize them.
|
||||
server: { deps: { inline: [/resources[\\\\/]plugins[\\\\/]declarative/] } }
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the plugin's tests and resolves with the runner's exit code. Output streams through; with
|
||||
* `quietStdout` (the CLI's --json mode) the runner's stdout goes to stderr instead.
|
||||
*/
|
||||
export async function runPluginTests(options: {
|
||||
dir: string
|
||||
files: readonly string[]
|
||||
appRoot?: string
|
||||
quietStdout?: boolean
|
||||
node?: PluginTestNode
|
||||
}): Promise<{ runner: PluginTestRunner; code: number; node: PluginTestNode['source'] }> {
|
||||
const testing = pluginTestingFiles(options.appRoot)
|
||||
const selected = pluginTestRunner(options.dir)
|
||||
const node = options.node ?? (await selectPluginTestNode())
|
||||
const env = { ...process.env, ...node.env, ORCA_PLUGIN_TEST_ROOT: options.dir }
|
||||
let args: string[]
|
||||
if (selected.runner === 'vitest' && selected.entry) {
|
||||
const configDir = join(options.dir, '.orca', 'test')
|
||||
await mkdir(configDir, { recursive: true })
|
||||
// `.orca/` is Orca's generated folder; it keeps itself out of Git.
|
||||
if (!existsSync(join(options.dir, '.orca', '.gitignore'))) {
|
||||
await writeFile(join(options.dir, '.orca', '.gitignore'), '*\n')
|
||||
}
|
||||
const config = join(configDir, 'vitest.config.mjs')
|
||||
await writeFile(config, pluginVitestConfig(testing))
|
||||
args = [selected.entry, 'run', '--config', config, '--root', options.dir, ...options.files]
|
||||
} else {
|
||||
args = [
|
||||
'--enable-source-maps',
|
||||
'--import',
|
||||
pathToFileURL(testing.register).href,
|
||||
'--test',
|
||||
...options.files
|
||||
]
|
||||
}
|
||||
const result = await runProcess({
|
||||
program: node.program,
|
||||
args,
|
||||
cwd: options.dir,
|
||||
env,
|
||||
timeoutMs: null,
|
||||
stdio: ['ignore', options.quietStdout ? 2 : 'inherit', 'inherit']
|
||||
})
|
||||
return { runner: selected.runner, code: result.code ?? 1, node: node.source }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { mkdtemp, realpath, rm, symlink } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createPluginFromTemplate } from './plugin-create'
|
||||
|
||||
const TSC = resolve(__dirname, '../../../../node_modules/typescript/bin/tsc')
|
||||
const cleanup: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('orca plugin types under a symlinked folder', () => {
|
||||
// macOS /tmp is a symlink to /private/tmp; tsc 7 loaded the typings via both paths (TS2300).
|
||||
it('typechecks a scaffolded components plugin reached through a symlink', async () => {
|
||||
const real = await mkdtemp(join(await realpath(tmpdir()), 'orca-plugin-types-real-'))
|
||||
const link = `${real}-link`
|
||||
cleanup.push(link, real)
|
||||
await symlink(real, link, 'dir')
|
||||
const pluginDir = join(link, 'panel')
|
||||
await createPluginFromTemplate({ dir: pluginDir, template: 'components' })
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
args: [TSC, '-p', pluginDir],
|
||||
cwd: pluginDir,
|
||||
env: { ...process.env, PWD: pluginDir },
|
||||
timeoutMs: 60_000,
|
||||
maxOutputBytes: 256 * 1024
|
||||
})
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.code, `${result.stdout}${result.stderr}`).toBe(0)
|
||||
}, 90_000)
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { isPluginSourceModulePath } from '../../../shared/plugins/frames/plugin-frame-source-modules'
|
||||
import { parsePluginManifest } from '../../../shared/plugins/manifest/plugin-manifest'
|
||||
|
||||
/** Plugin-relative folder `orca plugin types` owns; dev hashing and loading skip it. */
|
||||
export const PLUGIN_TYPES_DIR = '.orca/types'
|
||||
|
||||
/** Host, frame (`window.orca`), worker (`orca/worker`) and test kit (`orca/testing`) typings,
|
||||
* under resources/plugins/types. */
|
||||
const PLUGIN_SURFACE_TYPINGS = [
|
||||
{ dir: 'orca-host', file: 'orca-host.d.ts' },
|
||||
{ dir: 'orca-frame', file: 'orca-frame.d.ts' },
|
||||
{ dir: 'orca-worker', file: 'orca-worker.d.ts' },
|
||||
{ dir: 'orca-services', file: 'orca-services.d.ts' },
|
||||
{ dir: 'orca-testing', file: 'orca-testing.d.ts' }
|
||||
] as const
|
||||
|
||||
// Why one tsconfig the plugin extends: the typings and JSX settings then track the Orca that wrote
|
||||
// them, and a plugin's own `include` cannot drop them (typeRoots resolve against this file).
|
||||
const TYPES_TSCONFIG = {
|
||||
compilerOptions: {
|
||||
strict: true,
|
||||
noEmit: true,
|
||||
target: 'ES2022',
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'Bundler',
|
||||
allowImportingTsExtensions: true,
|
||||
isolatedModules: true,
|
||||
jsx: 'react-jsx',
|
||||
jsxImportSource: 'orca',
|
||||
lib: ['ES2022', 'DOM'],
|
||||
// tsc 7 reaches `orca` via the symlinked path and the typeRoots via the realpath, so a plugin
|
||||
// under a symlink (macOS /tmp) loads the typings twice (TS2300 duplicate identifiers).
|
||||
preserveSymlinks: true,
|
||||
typeRoots: ['.'],
|
||||
types: ['orca', ...PLUGIN_SURFACE_TYPINGS.map(({ dir }) => dir)]
|
||||
}
|
||||
}
|
||||
|
||||
/** The `orca` module's typings, split by size; written as one `orca/index.d.ts`. */
|
||||
export const ORCA_MODULE_TYPINGS = [
|
||||
'orca.d.ts',
|
||||
'orca-components.d.ts',
|
||||
'orca-components-layout.d.ts',
|
||||
'orca-components-data.d.ts'
|
||||
] as const
|
||||
|
||||
const GENERATED_NOTE =
|
||||
'// Written by `orca plugin types`; regenerated by `orca plugin dev load`. Do not edit.\n'
|
||||
|
||||
/** Packaged apps ship typings as extraResources at <Resources>/plugins; dev: resources/plugins. */
|
||||
function defaultTypingsRoot(): string {
|
||||
const appRoot = join(__dirname, '..', '..', '..', '..')
|
||||
return basename(appRoot) === 'app.asar.unpacked'
|
||||
? join(dirname(appRoot), 'plugins')
|
||||
: join(appRoot, 'resources', 'plugins')
|
||||
}
|
||||
|
||||
async function readOrcaPluginTypings(typingsRoot: string): Promise<string> {
|
||||
const parts = await Promise.all(
|
||||
ORCA_MODULE_TYPINGS.map((file) => readFile(join(typingsRoot, 'declarative', file), 'utf8'))
|
||||
)
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
/** Every typings file `orca plugin types` writes, keyed by its `.orca/types` folder. */
|
||||
export async function readOrcaAuthorTypings(): Promise<Record<string, string>> {
|
||||
const typingsRoot = defaultTypingsRoot()
|
||||
const typings: Record<string, string> = { orca: await readOrcaPluginTypings(typingsRoot) }
|
||||
for (const { dir, file } of PLUGIN_SURFACE_TYPINGS) {
|
||||
typings[dir] = await readFile(join(typingsRoot, 'types', file), 'utf8')
|
||||
}
|
||||
return typings
|
||||
}
|
||||
|
||||
/** Writes `.orca/types/<folder>/index.d.ts` per typings file, a `tsconfig.json` listing them, and
|
||||
* a `.gitignore` that ignores `.orca/`. */
|
||||
export async function writePluginTypeFiles(pluginDir: string): Promise<string[]> {
|
||||
const typings = await readOrcaAuthorTypings()
|
||||
const files: Record<string, string> = { '.orca/.gitignore': '*\n' }
|
||||
for (const [dir, text] of Object.entries(typings)) {
|
||||
await mkdir(join(pluginDir, '.orca', 'types', dir), { recursive: true })
|
||||
files[`${PLUGIN_TYPES_DIR}/${dir}/index.d.ts`] = GENERATED_NOTE + text
|
||||
}
|
||||
files[`${PLUGIN_TYPES_DIR}/tsconfig.json`] = `${JSON.stringify(TYPES_TSCONFIG, null, 2)}\n`
|
||||
for (const [file, content] of Object.entries(files)) {
|
||||
await writeFile(join(pluginDir, ...file.split('/')), content)
|
||||
}
|
||||
return Object.keys(files)
|
||||
}
|
||||
|
||||
/** A folder gets typings on dev load when it has them, its tsconfig wants them, or it has a
|
||||
* TS/TSX entry or a components panel. */
|
||||
export async function pluginFolderWantsTypes(pluginDir: string): Promise<boolean> {
|
||||
if (existsSync(join(pluginDir, '.orca', 'types'))) {
|
||||
return true
|
||||
}
|
||||
const tsconfig = await readFile(join(pluginDir, 'tsconfig.json'), 'utf8').catch(() => '')
|
||||
if (tsconfig.includes(PLUGIN_TYPES_DIR)) {
|
||||
return true
|
||||
}
|
||||
const raw = await readFile(join(pluginDir, 'orca-plugin.json'), 'utf8').catch(() => null)
|
||||
let parsed: unknown = null
|
||||
try {
|
||||
parsed = raw === null ? null : JSON.parse(raw)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const manifest = parsePluginManifest(parsed)
|
||||
if (!manifest.ok) {
|
||||
return false
|
||||
}
|
||||
const { panels, customEditors } = manifest.manifest.contributes
|
||||
return (
|
||||
panels.some((panel) => panel.ui === 'components') ||
|
||||
[...panels, ...customEditors].some((item) => isPluginSourceModulePath(item.entry))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { mkdtemp, realpath, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createPluginFromTemplate } from './plugin-create'
|
||||
import { findPluginTestFiles, runPluginTests } from './plugin-test-runner'
|
||||
|
||||
const TSC = resolve(__dirname, '../../../../node_modules/typescript/bin/tsc')
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function workerPanelPlugin(): Promise<string> {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-worker-panel-')))
|
||||
roots.push(root)
|
||||
const dir = join(root, 'counter')
|
||||
await createPluginFromTemplate({ dir, template: 'worker-panel' })
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('orca plugin create --template worker-panel', () => {
|
||||
it(
|
||||
"passes its own panel and worker tests under `orca plugin test` on Orca's runtime",
|
||||
async () => {
|
||||
const dir = await workerPanelPlugin()
|
||||
const files = await findPluginTestFiles(dir)
|
||||
expect(files).toEqual(['src/panel.test.tsx', 'src/worker.test.ts'])
|
||||
const orca = {
|
||||
program: process.execPath,
|
||||
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||
source: 'orca' as const,
|
||||
version: process.version
|
||||
}
|
||||
expect(await runPluginTests({ dir, files, quietStdout: true, node: orca })).toEqual({
|
||||
runner: 'node',
|
||||
code: 0,
|
||||
node: 'orca'
|
||||
})
|
||||
},
|
||||
// Starts its own Node process and transpiles the plugin.
|
||||
60_000
|
||||
)
|
||||
|
||||
it(
|
||||
'type-checks against the typings it writes',
|
||||
async () => {
|
||||
const dir = await workerPanelPlugin()
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
args: [TSC, '-p', dir],
|
||||
cwd: dir,
|
||||
env: { ...process.env, PWD: dir },
|
||||
timeoutMs: 60_000,
|
||||
maxOutputBytes: 256 * 1024
|
||||
})
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.code, `${result.stdout}${result.stderr}`).toBe(0)
|
||||
},
|
||||
// Runs real tsc over the scaffold and Orca's typings, which takes a while on CI.
|
||||
90_000
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
COMMON_FILES,
|
||||
json,
|
||||
manifest,
|
||||
readme,
|
||||
type PluginTemplate,
|
||||
type PluginTemplateInput
|
||||
} from './plugin-template-files'
|
||||
import { PLUGIN_TYPES_DIR } from './plugin-type-files'
|
||||
|
||||
const PROTOCOL_TS = `// The panel <-> worker contract, declared once: src/panel.tsx and src/worker.ts both read it.
|
||||
export {}
|
||||
|
||||
declare module 'orca' {
|
||||
interface PluginRequests {
|
||||
/** The total the worker keeps in storage. */
|
||||
'counter.get': { output: { total: number } }
|
||||
/** Adds \`by\` to the stored total and answers the new total. */
|
||||
'counter.add': { input: { by: number }; output: { total: number } }
|
||||
}
|
||||
interface PluginPanelEvents {
|
||||
/** Posted to every open panel after the total changes. */
|
||||
'counter.changed': { total: number }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const PANEL_CHECK_TS = `// \`orca plugin check\` renders the panel once per scenario, each with the worker on a fresh
|
||||
// host. Seed what the worker reads so check shows more than the first state.
|
||||
import { defineCheck } from 'orca/testing'
|
||||
|
||||
export default defineCheck({
|
||||
fresh: {},
|
||||
counted: { storage: { total: 41 } }
|
||||
})
|
||||
`
|
||||
|
||||
const WORKER_TS = `// The plugin's own worker. Orca starts it on the panel's first plugin.request and transpiles
|
||||
// this file when it loads: no build step. A save restarts it and drops its in-memory state, so
|
||||
// the total lives in orca.storage (the storage:own capability).
|
||||
import type { OrcaWorkerApi } from 'orca/worker'
|
||||
|
||||
const TOTAL = 'total'
|
||||
|
||||
async function storedTotal(orca: OrcaWorkerApi): Promise<number> {
|
||||
const value = await orca.storage.get(TOTAL)
|
||||
return typeof value === 'number' ? value : 0
|
||||
}
|
||||
|
||||
export default function activate(orca: OrcaWorkerApi): void {
|
||||
orca.panels.handle('counter.get', async () => ({ total: await storedTotal(orca) }))
|
||||
|
||||
orca.panels.handle('counter.add', async ({ by }) => {
|
||||
const total = (await storedTotal(orca)) + by
|
||||
await orca.storage.set(TOTAL, total)
|
||||
// Every open panel hears it, so a second panel stays in step.
|
||||
orca.panels.post('counter.changed', { total })
|
||||
return { total }
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
const PANEL_TSX = `// No build step: Orca strips the types and compiles the JSX when the panel loads.
|
||||
import {
|
||||
atom,
|
||||
Badge,
|
||||
Button,
|
||||
Heading,
|
||||
plugin,
|
||||
read,
|
||||
Row,
|
||||
set,
|
||||
Stack,
|
||||
Text,
|
||||
update,
|
||||
type PanelContext
|
||||
} from 'orca'
|
||||
|
||||
/** What the worker last reported; null until it answers. */
|
||||
const total = atom<number | null>(null)
|
||||
/** A keyed atom: \`persist: 'plugin'\` keeps it across panel runs. */
|
||||
const step = atom('step', 1, { persist: 'plugin' })
|
||||
|
||||
async function add(): Promise<void> {
|
||||
// Typed by src/protocol.ts; starts the worker if it is not running.
|
||||
const answer = await plugin.request('counter.add', { by: read(step) })
|
||||
set(total, answer.total)
|
||||
}
|
||||
|
||||
/** Runs once before the first render; what it pushes to \`subscriptions\` ends with the panel. */
|
||||
export function setup(ctx: PanelContext): void {
|
||||
ctx.subscriptions.push(plugin.on('counter.changed', (payload) => set(total, payload.total)))
|
||||
void plugin.request('counter.get').then((answer) => set(total, answer.total))
|
||||
}
|
||||
|
||||
export default function Panel() {
|
||||
const value = read(total)
|
||||
const by = read(step)
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Row justify="between">
|
||||
<Heading>Counter</Heading>
|
||||
<Badge tone="accent">{value === null ? '-' : value}</Badge>
|
||||
</Row>
|
||||
<Row gap="xs">
|
||||
<Button onPress={add}>Add {by}</Button>
|
||||
<Button variant="ghost" onPress={() => update(step, (n) => (n === 1 ? 10 : 1))}>
|
||||
Step {by === 1 ? 10 : 1}
|
||||
</Button>
|
||||
</Row>
|
||||
<Text muted size="xs">
|
||||
src/worker.ts keeps the total in storage. Edit either file and save: Orca reloads it.
|
||||
</Text>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
const PANEL_TEST_TSX = `// \`orca plugin test .\` runs this in Node with the same runtime and checks Orca uses.
|
||||
import { assert, mockHost, mount, test } from 'orca/testing'
|
||||
import * as panel from './panel'
|
||||
|
||||
test('shows the worker total and adds the step', async () => {
|
||||
let total = 4
|
||||
// Stands in for src/worker.ts: what each plugin.request answers.
|
||||
const host = mockHost({
|
||||
requests: {
|
||||
'counter.get': () => ({ total }),
|
||||
'counter.add': ({ by }) => ({ total: (total += by) })
|
||||
}
|
||||
})
|
||||
const view = await mount(panel, { host })
|
||||
assert.equal(view.find({ role: 'status' })?.text, '4')
|
||||
|
||||
await view.press({ text: 'Add 1' })
|
||||
assert.equal(view.find({ role: 'status' })?.text, '5')
|
||||
|
||||
await view.press({ text: 'Step 10' })
|
||||
await view.press({ text: 'Add 10' })
|
||||
assert.equal(view.find({ role: 'status' })?.text, '15')
|
||||
|
||||
// Another open panel added: the worker posts the new total.
|
||||
await view.post('counter.changed', { total: 20 })
|
||||
assert.equal(view.find({ role: 'status' })?.text, '20')
|
||||
assert.deepEqual(view.errors, [])
|
||||
assert.deepEqual(view.refusals, [])
|
||||
await view.unmount()
|
||||
})
|
||||
`
|
||||
|
||||
const WORKER_TEST_TS = `// Runs src/worker.ts's activate(orca) with the orca object Orca passes, over a mock host.
|
||||
import { assert, mockHost, mountWorker, test } from 'orca/testing'
|
||||
import * as main from './worker'
|
||||
|
||||
test('adds to the stored total and tells open panels', async () => {
|
||||
const host = mockHost({ storage: { total: 2 } })
|
||||
const worker = await mountWorker(main, { host })
|
||||
assert.deepEqual(await worker.request('counter.get'), { total: 2 })
|
||||
assert.deepEqual(await worker.request('counter.add', { by: 3 }), { total: 5 })
|
||||
assert.deepEqual(worker.posts, [{ event: 'counter.changed', payload: { total: 5 } }])
|
||||
assert.deepEqual(host.data().storage, { total: 5 })
|
||||
assert.deepEqual(worker.refusals, [])
|
||||
await worker.deactivate()
|
||||
})
|
||||
`
|
||||
|
||||
/** A components panel and its own build-less TS worker, typed by one shared contract. */
|
||||
export function workerPanel(input: PluginTemplateInput): PluginTemplate {
|
||||
return {
|
||||
description:
|
||||
'Components panel backed by its own TypeScript worker: requests, posts and storage, no build step (needs --approve to load)',
|
||||
files: {
|
||||
'orca-plugin.json': manifest(input, 'A sidebar panel and the worker that answers it.', {
|
||||
execution: 'trusted-worker',
|
||||
pluginApi: 1,
|
||||
main: 'src/worker.ts',
|
||||
contributes: {
|
||||
panels: [
|
||||
{
|
||||
id: input.id,
|
||||
title: input.name,
|
||||
icon: 'blocks',
|
||||
entry: 'src/panel.tsx',
|
||||
ui: 'components'
|
||||
}
|
||||
]
|
||||
},
|
||||
capabilities: [{ kind: 'storage:own' }]
|
||||
}),
|
||||
'src/protocol.ts': PROTOCOL_TS,
|
||||
'src/worker.ts': WORKER_TS,
|
||||
'src/panel.tsx': PANEL_TSX,
|
||||
'src/panel.test.tsx': PANEL_TEST_TSX,
|
||||
'src/panel.check.ts': PANEL_CHECK_TS,
|
||||
'src/worker.test.ts': WORKER_TEST_TS,
|
||||
'tsconfig.json': json({ extends: `./${PLUGIN_TYPES_DIR}/tsconfig.json`, include: ['src'] }),
|
||||
'README.md': readme(
|
||||
input,
|
||||
`A right-sidebar panel (\`src/panel.tsx\`) and the plugin's own worker (\`src/worker.ts\`). The panel asks with \`plugin.request\`, the worker answers with \`orca.panels.handle\`, keeps the total in \`orca.storage\` and tells every open panel with \`orca.panels.post\`; \`src/protocol.ts\` types both sides. Orca transpiles both files when they load, so there is no build step: save and the panel or worker reloads (a worker reload drops its in-memory state, so keep what must last in storage). \`orca plugin test .\` runs \`src/panel.test.tsx\` and \`src/worker.test.ts\` in Node with \`orca/testing\`, and \`orca plugin check .\` renders the panel with the worker in each \`src/panel.check.ts\` scenario; \`npx tsc -p .\` type-checks against the typings \`orca plugin types .\` writes to \`${PLUGIN_TYPES_DIR}/\`. With the folder loaded, \`orca plugin dev request . counter.add --data '{"by":1}'\` calls the worker as the panel does.`
|
||||
),
|
||||
...COMMON_FILES
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import '../../runtime/types'
|
||||
import { createTerminalConfirm } from './plugin-dev-watch-approval'
|
||||
|
||||
import './plugin-dev-watch-loop'
|
||||
|
||||
function terminal(isTTY = true) {
|
||||
const input = Object.assign(new PassThrough(), { isTTY })
|
||||
const output = Object.assign(new PassThrough(), { isTTY })
|
||||
let printed = ''
|
||||
output.on('data', (chunk: Buffer) => {
|
||||
printed += chunk.toString()
|
||||
})
|
||||
return { input, output, printed: () => printed }
|
||||
}
|
||||
|
||||
describe('pluginDevWatchConfirmFor', () => {
|
||||
it.each([
|
||||
['end of input', (input: PassThrough) => input.end()],
|
||||
['Ctrl-C', (input: PassThrough) => input.write('\u0003')]
|
||||
])('reads %s as no', async (_name, stop) => {
|
||||
const streams = terminal()
|
||||
const answer = createTerminalConfirm(streams)('Approve and load? [y/N] ')
|
||||
stop(streams.input)
|
||||
await expect(answer).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createInterface } from 'node:readline'
|
||||
import { z } from 'zod'
|
||||
import { formatPluginGrantLines } from '../plugin-grant-lines'
|
||||
import { RuntimeClientError } from '../../runtime/types'
|
||||
import type { DevLoadResult } from '../plugin-runtime'
|
||||
|
||||
/** Asks a yes/no question; false on anything but yes, end of input or Ctrl-C. */
|
||||
export type PluginDevWatchConfirm = (question: string) => Promise<boolean>
|
||||
|
||||
const firstLoadApprovalSchema = z.object({
|
||||
pluginKey: z.string(),
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
approvalFingerprint: z.string(),
|
||||
why: z.string().optional(),
|
||||
grants: z.object({
|
||||
execution: z.string(),
|
||||
executionDescription: z.string(),
|
||||
capabilities: z.array(z.object({ description: z.string() })),
|
||||
contributions: z.array(z.object({ kind: z.string(), name: z.string() }))
|
||||
})
|
||||
})
|
||||
|
||||
type FirstLoadApproval = z.infer<typeof firstLoadApprovalSchema>
|
||||
|
||||
function firstLoadApprovalOf(error: unknown): FirstLoadApproval | null {
|
||||
// Why not `approval_stale`: it answers a fingerprint passed with --approve, which stays explicit.
|
||||
if (!(error instanceof RuntimeClientError) || error.code !== 'approval_required') {
|
||||
return null
|
||||
}
|
||||
const parsed = firstLoadApprovalSchema.safeParse(error.data)
|
||||
return parsed.success ? parsed.data : null
|
||||
}
|
||||
|
||||
export function pluginDevWatchApprovalQuestion(approval: FirstLoadApproval): string {
|
||||
return [
|
||||
`${approval.name} needs your approval before Orca runs it. Approving grants:`,
|
||||
...formatPluginGrantLines(approval),
|
||||
...(approval.why ? [approval.why] : []),
|
||||
'Approve and load? [y/N] '
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** The first dev load; with `confirm` (a person at a terminal) an approval is asked, never assumed.
|
||||
* A declined or unanswered ask fails with approval_required (exit 3) and only the re-run command. */
|
||||
export async function devLoadWithApproval(
|
||||
devLoad: (approvalFingerprint?: string) => Promise<DevLoadResult>,
|
||||
confirm: PluginDevWatchConfirm | undefined
|
||||
): Promise<DevLoadResult> {
|
||||
try {
|
||||
return await devLoad()
|
||||
} catch (error) {
|
||||
const approval = confirm ? firstLoadApprovalOf(error) : null
|
||||
if (!confirm || !approval) {
|
||||
throw error
|
||||
}
|
||||
if (!(await confirm(pluginDevWatchApprovalQuestion(approval)))) {
|
||||
throw declinedApprovalError(error, approval)
|
||||
}
|
||||
return devLoad(approval.approvalFingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
/** Why trimmed: the person just read the grants; repeating them buries the re-run command. */
|
||||
function declinedApprovalError(error: unknown, approval: FirstLoadApproval): RuntimeClientError {
|
||||
const data = error instanceof RuntimeClientError ? error.data : undefined
|
||||
const command =
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'command' in data &&
|
||||
typeof data.command === 'string'
|
||||
? data.command
|
||||
: null
|
||||
return new RuntimeClientError(
|
||||
'approval_required',
|
||||
command ? `Not approved. To approve later, run: ${command}` : 'Not approved.',
|
||||
{
|
||||
pluginKey: approval.pluginKey,
|
||||
approvalFingerprint: approval.approvalFingerprint,
|
||||
command,
|
||||
nextSteps: []
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
type PromptStreams = {
|
||||
input: NodeJS.ReadableStream & { isTTY?: boolean }
|
||||
output: NodeJS.WritableStream & { isTTY?: boolean }
|
||||
}
|
||||
|
||||
/** A prompt only for a person at a terminal; agents, pipes and `--json` keep the exit-3 error. */
|
||||
export function pluginDevWatchConfirmFor(
|
||||
json: boolean,
|
||||
streams: PromptStreams = { input: process.stdin, output: process.stdout }
|
||||
): PluginDevWatchConfirm | undefined {
|
||||
return !json && streams.input.isTTY && streams.output.isTTY
|
||||
? createTerminalConfirm(streams)
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** readline works the same in Windows cmd, PowerShell and Windows Terminal as on POSIX ttys. */
|
||||
export function createTerminalConfirm(streams: PromptStreams): PluginDevWatchConfirm {
|
||||
return (question) =>
|
||||
new Promise((resolve) => {
|
||||
const rl = createInterface({ input: streams.input, output: streams.output })
|
||||
let answered = false
|
||||
const finish = (yes: boolean): void => {
|
||||
if (answered) {
|
||||
return
|
||||
}
|
||||
answered = true
|
||||
rl.close()
|
||||
resolve(yes)
|
||||
}
|
||||
// Why: with no SIGINT listener readline only pauses on Ctrl-C, leaving the prompt hanging.
|
||||
rl.on('SIGINT', () => {
|
||||
streams.output.write('\n')
|
||||
finish(false)
|
||||
})
|
||||
rl.on('close', () => {
|
||||
if (!answered) {
|
||||
streams.output.write('\n')
|
||||
}
|
||||
finish(false)
|
||||
})
|
||||
rl.question(question, (answer) => finish(/^y(es)?$/i.test(answer.trim())))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PluginHostListEntry } from '../../../shared/plugins/plugin-list-entry'
|
||||
import type { PluginDevWatchCheckResult } from './plugin-dev-watch-check'
|
||||
import { formatPluginDevWatchEvent, type PluginDevWatchEvent } from './plugin-dev-watch-events'
|
||||
import { runPluginDevWatch, type PluginDevWatchHost } from './plugin-dev-watch-loop'
|
||||
|
||||
const KEY = 'acme.todos'
|
||||
|
||||
function entry(
|
||||
devChangeCount: number,
|
||||
status: PluginHostListEntry['status'] = 'idle'
|
||||
): PluginHostListEntry {
|
||||
return {
|
||||
pluginKey: KEY,
|
||||
consentFingerprint: 'sha256-x',
|
||||
name: 'Todos',
|
||||
version: '1.0.0',
|
||||
publisher: 'acme',
|
||||
status,
|
||||
needsReconsent: false,
|
||||
isDev: true,
|
||||
devChangeCount,
|
||||
official: false,
|
||||
bundled: false,
|
||||
capabilities: [],
|
||||
panels: [],
|
||||
commands: [],
|
||||
hasWorker: false,
|
||||
restarts: 0
|
||||
}
|
||||
}
|
||||
|
||||
const passed: PluginDevWatchCheckResult = {
|
||||
ok: true,
|
||||
invalid: [],
|
||||
panels: [
|
||||
{
|
||||
panel: 'todos',
|
||||
entry: 'src/panel.tsx',
|
||||
elements: 12,
|
||||
summary: ['Heading "Tasks"'],
|
||||
issues: [],
|
||||
warnings: []
|
||||
}
|
||||
],
|
||||
editors: []
|
||||
}
|
||||
|
||||
/** Polls: 1 baseline, 2 status (unchanged), 3 logs, 4 status (one save), then Ctrl-C. */
|
||||
async function watch(
|
||||
check: PluginDevWatchHost['check'],
|
||||
lists = [[entry(0)], [entry(1)]]
|
||||
): Promise<PluginDevWatchEvent[]> {
|
||||
const events: PluginDevWatchEvent[] = []
|
||||
let interrupt = () => {}
|
||||
let poll = 0
|
||||
const host: PluginDevWatchHost = {
|
||||
refreshTypes: async () => [],
|
||||
devLoad: async () => ({
|
||||
path: '/work/todos',
|
||||
pluginKey: KEY,
|
||||
added: true,
|
||||
approval: 'approve-flag',
|
||||
plugin: entry(0)
|
||||
}),
|
||||
list: async () => (lists.length > 1 ? lists.shift() : lists[0]) ?? [entry(1)],
|
||||
logs: async () => ({ pluginKey: KEY, lines: [] }),
|
||||
approvalNeeded: async () => null,
|
||||
devUnload: async () => {},
|
||||
check
|
||||
}
|
||||
await runPluginDevWatch({
|
||||
host,
|
||||
emit: (event) => events.push(event),
|
||||
wait: async () => {
|
||||
poll += 1
|
||||
if (poll === 6) {
|
||||
interrupt()
|
||||
}
|
||||
},
|
||||
onInterrupt: (listener) => {
|
||||
interrupt = listener
|
||||
return () => {}
|
||||
},
|
||||
exitNow: () => {},
|
||||
isConnectionLost: () => false
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
describe('plugin dev watch --check', () => {
|
||||
it('fails a check that renders while Orca does not run the plugin', async () => {
|
||||
const pending = entry(1, 'pending')
|
||||
const events = await watch(vi.fn().mockResolvedValue(passed), [[entry(0)], [pending]])
|
||||
const checks = events.filter((event) => event.type === 'check')
|
||||
expect(checks.map((event) => [event.ok, event.checkOk, event.pluginStatus])).toEqual([
|
||||
[true, true, 'idle'],
|
||||
[false, true, 'pending']
|
||||
])
|
||||
expect(formatPluginDevWatchEvent(checks[1]).split('\n')[0]).toBe(
|
||||
'✗ check passed, but in Orca acme.todos is pending approval'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { validatePluginTarget } from '../authoring/plugin-folder-validation'
|
||||
import { checkPluginPanels, type PluginPanelCheck } from '../authoring/plugin-panel-check'
|
||||
import { checkPluginEditors, type PluginEditorCheck } from '../authoring/plugin-editor-check'
|
||||
|
||||
/** `orca plugin check` for one folder, as data: validation errors, else each panel's render. */
|
||||
export type PluginDevWatchCheckResult = {
|
||||
ok: boolean
|
||||
/** Manifest problems; panels are not rendered while there are any. */
|
||||
invalid: string[]
|
||||
panels: PluginPanelCheck[]
|
||||
/** Each custom editor's static check. */
|
||||
editors: PluginEditorCheck[]
|
||||
}
|
||||
|
||||
export async function checkPluginDevFolder(dir: string): Promise<PluginDevWatchCheckResult> {
|
||||
const report = await validatePluginTarget(dir)
|
||||
if (!report.valid) {
|
||||
return { ok: false, invalid: report.errors, panels: [], editors: [] }
|
||||
}
|
||||
const [rendered, editors] = await Promise.all([
|
||||
checkPluginPanels(report.path),
|
||||
checkPluginEditors(report.path)
|
||||
])
|
||||
// Why no tree: a watch prints one line per save; the summary says what drew.
|
||||
const panels = rendered.map(({ tree: _tree, ...panel }) => panel)
|
||||
const ok = [...panels, ...editors].every((check) => check.issues.length === 0)
|
||||
return { ok, invalid: [], panels, editors }
|
||||
}
|
||||
|
||||
/** Runs checks one at a time: a request while one runs queues a single rerun of the newest code. */
|
||||
export function createPluginDevWatchCheckQueue(run: () => Promise<void>): {
|
||||
request(): void
|
||||
idle(): Promise<void>
|
||||
} {
|
||||
let running: Promise<void> | null = null
|
||||
let again = false
|
||||
const start = (): void => {
|
||||
running = run().finally(() => {
|
||||
running = null
|
||||
if (again) {
|
||||
again = false
|
||||
start()
|
||||
}
|
||||
})
|
||||
}
|
||||
return {
|
||||
request: () => {
|
||||
if (running) {
|
||||
again = true
|
||||
return
|
||||
}
|
||||
start()
|
||||
},
|
||||
idle: async () => {
|
||||
while (running) {
|
||||
await running
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { PluginHostStatus } from '../../../shared/plugins/plugin-list-entry'
|
||||
import { formatPluginPanelCheck } from '../authoring/plugin-panel-check'
|
||||
import type { PluginDevWatchCheckResult } from './plugin-dev-watch-check'
|
||||
import { formatPluginEditorCheck } from '../authoring/plugin-editor-check'
|
||||
import { PLUGIN_DEV_WATCH_SHOOTABLE, type PluginDevWatchShot } from './plugin-dev-watch-shot'
|
||||
|
||||
/** `missing`: the host lists no plugin for the folder (for example, its id changed). */
|
||||
export type PluginDevWatchStatus = PluginHostStatus | 'missing'
|
||||
|
||||
export type PluginDevWatchApproval = { message: string; fingerprint: string; command: string }
|
||||
|
||||
/** One `--json` line each; `type` is the discriminant agents switch on. */
|
||||
export type PluginDevWatchEvent =
|
||||
| {
|
||||
type: 'loaded'
|
||||
pluginKey: string
|
||||
path: string
|
||||
added: boolean
|
||||
status: PluginDevWatchStatus
|
||||
error?: string
|
||||
typeFiles: string[]
|
||||
}
|
||||
| {
|
||||
type: 'log'
|
||||
pluginKey: string
|
||||
ts: number
|
||||
level: string
|
||||
line: string
|
||||
/** A panel line that arrived this many times together (absent: once). */
|
||||
count?: number
|
||||
/** `count` more copies of a panel line printed earlier in this reload. */
|
||||
repeat?: true
|
||||
}
|
||||
| {
|
||||
type: 'reloaded'
|
||||
pluginKey: string
|
||||
/** File changes the host saw since the last reload. */
|
||||
changes: number
|
||||
status: PluginDevWatchStatus
|
||||
error?: string
|
||||
}
|
||||
| {
|
||||
type: 'status'
|
||||
pluginKey: string
|
||||
status: PluginDevWatchStatus
|
||||
previousStatus: PluginDevWatchStatus
|
||||
error?: string
|
||||
}
|
||||
| ({ type: 'approval'; pluginKey: string } & PluginDevWatchApproval)
|
||||
/** `--check`: `orca plugin check` after the load and after each reload. `ok` also needs Orca to
|
||||
* run the plugin (`pluginStatus` running, idle or restarting); `checkOk` is the check alone. */
|
||||
| ({
|
||||
type: 'check'
|
||||
pluginKey: string
|
||||
checkOk: boolean
|
||||
pluginStatus: PluginDevWatchStatus
|
||||
pluginError?: string
|
||||
} & PluginDevWatchCheckResult)
|
||||
/** `--shot`: a PNG of one panel after the load and after each reload that left it running. */
|
||||
| ({ type: 'shot'; pluginKey: string } & PluginDevWatchShot)
|
||||
/** `--shot`: the person switched away from the panel; once per time away, never switched back. */
|
||||
| { type: 'shot_skipped'; pluginKey: string; panelId: string; reason: string }
|
||||
| { type: 'warning'; message: string }
|
||||
| { type: 'disconnected'; message: string }
|
||||
| { type: 'unloaded'; pluginKey: string; path: string; removed: boolean }
|
||||
|
||||
function withError(status: PluginDevWatchStatus, error: string | undefined): string {
|
||||
return error ? `${status}: ${error}` : status
|
||||
}
|
||||
|
||||
function clockTime(ts: number): string {
|
||||
const date = new Date(ts)
|
||||
const pad = (value: number, width = 2) => String(value).padStart(width, '0')
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`
|
||||
}
|
||||
|
||||
function pluginStatusPhrase(status: PluginDevWatchStatus, error: string | undefined): string {
|
||||
const phrase =
|
||||
status === 'pending' ? 'pending approval' : status === 'missing' ? 'not loaded' : status
|
||||
return error ? `${phrase}: ${error}` : phrase
|
||||
}
|
||||
|
||||
function checkHeadline(event: Extract<PluginDevWatchEvent, { type: 'check' }>): string {
|
||||
if (event.ok) {
|
||||
return `✓ check passed for ${event.pluginKey}`
|
||||
}
|
||||
if (!event.checkOk) {
|
||||
return `✗ check failed for ${event.pluginKey}`
|
||||
}
|
||||
return `✗ check passed, but in Orca ${event.pluginKey} is ${pluginStatusPhrase(event.pluginStatus, event.pluginError)}`
|
||||
}
|
||||
|
||||
export function formatPluginDevWatchEvent(event: PluginDevWatchEvent): string {
|
||||
switch (event.type) {
|
||||
case 'loaded':
|
||||
return [
|
||||
`${event.added ? 'Loaded' : 'Watching'} ${event.pluginKey} from ${event.path} (${withError(event.status, event.error)}).`,
|
||||
...event.typeFiles.map((file) => `Refreshed ${file}`),
|
||||
'Watching for changes and logs; Ctrl-C to stop.'
|
||||
].join('\n')
|
||||
case 'log': {
|
||||
const times = event.repeat
|
||||
? ` (×${event.count ?? 1} more)`
|
||||
: event.count && event.count > 1
|
||||
? ` ×${event.count}`
|
||||
: ''
|
||||
return `${clockTime(event.ts)} ${event.level.padEnd(5)} ${event.line}${times}`
|
||||
}
|
||||
case 'reloaded': {
|
||||
const changes = event.changes === 1 ? '1 file changed' : `${event.changes} files changed`
|
||||
return `↻ reloaded ${event.pluginKey} (${changes}): ${withError(event.status, event.error)}`
|
||||
}
|
||||
case 'status':
|
||||
return `${event.pluginKey} is now ${withError(event.status, event.error)} (was ${event.previousStatus})`
|
||||
case 'approval':
|
||||
return `${event.pluginKey} needs approval: ${event.message}\n To approve, run: ${event.command}`
|
||||
case 'check':
|
||||
return [
|
||||
checkHeadline(event),
|
||||
...(!event.ok && !event.checkOk && !PLUGIN_DEV_WATCH_SHOOTABLE.has(event.pluginStatus)
|
||||
? [` in Orca: ${pluginStatusPhrase(event.pluginStatus, event.pluginError)}`]
|
||||
: []),
|
||||
...event.invalid.map((error) => ` invalid: ${error}`),
|
||||
...event.panels.flatMap(formatPluginPanelCheck).map((line) => ` ${line}`),
|
||||
...event.editors.flatMap(formatPluginEditorCheck).map((line) => ` ${line}`)
|
||||
].join('\n')
|
||||
case 'shot':
|
||||
return [
|
||||
`shot ${event.path} (${event.width}x${event.height})${event.settled ? '' : ', not finished drawing'}`,
|
||||
...(event.pendingRequests
|
||||
? [
|
||||
` note: ${event.pendingRequests} host call(s) were still unanswered; the panel may show its loading state`
|
||||
]
|
||||
: []),
|
||||
...event.errors.map((error) => ` error: ${error}`)
|
||||
].join('\n')
|
||||
case 'shot_skipped':
|
||||
return `shot skipped: ${event.reason}, so ${event.panelId} is not on screen; open it again to resume`
|
||||
case 'warning':
|
||||
return `warning: ${event.message}`
|
||||
case 'disconnected':
|
||||
return `Lost the connection to Orca: ${event.message}`
|
||||
case 'unloaded':
|
||||
return event.removed
|
||||
? `Unloaded ${event.pluginKey} (${event.path}).`
|
||||
: `Left ${event.pluginKey} loaded: it was loaded before this session.`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PluginHostListEntry } from '../../../shared/plugins/plugin-list-entry'
|
||||
import type { LogsResult } from '../plugin-runtime'
|
||||
import type { PluginDevWatchEvent } from './plugin-dev-watch-events'
|
||||
import { formatPluginDevWatchEvent } from './plugin-dev-watch-events'
|
||||
import { runPluginDevWatch, type PluginDevWatchHost } from './plugin-dev-watch-loop'
|
||||
|
||||
const KEY = 'acme.notes'
|
||||
const PATH = '/work/notes'
|
||||
|
||||
function entry(overrides: Partial<PluginHostListEntry> = {}): PluginHostListEntry {
|
||||
return {
|
||||
pluginKey: KEY,
|
||||
consentFingerprint: 'sha256-x',
|
||||
name: 'Notes',
|
||||
version: '1.0.0',
|
||||
publisher: 'acme',
|
||||
status: 'running',
|
||||
needsReconsent: false,
|
||||
isDev: true,
|
||||
devChangeCount: 0,
|
||||
official: false,
|
||||
bundled: false,
|
||||
capabilities: [],
|
||||
panels: [],
|
||||
commands: [],
|
||||
hasWorker: true,
|
||||
restarts: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
class LostError extends Error {}
|
||||
|
||||
/** A scripted host: each poll (one wait) advances to the next step. */
|
||||
function harness(options: {
|
||||
added?: boolean
|
||||
logs?: LogsResult[]
|
||||
lists?: PluginHostListEntry[][]
|
||||
failAt?: { poll: number; error: Error }
|
||||
stopAfterPolls?: number
|
||||
approvalNeeded?: PluginDevWatchHost['approvalNeeded']
|
||||
devUnload?: PluginDevWatchHost['devUnload']
|
||||
}) {
|
||||
const events: PluginDevWatchEvent[] = []
|
||||
let interrupt: () => void = () => {}
|
||||
let poll = 0
|
||||
const logs = options.logs ?? []
|
||||
const lists = options.lists ?? []
|
||||
const logCalls: (string | undefined)[] = []
|
||||
const host: PluginDevWatchHost = {
|
||||
refreshTypes: vi.fn(async () => ['.orca/types/orca/index.d.ts']),
|
||||
devLoad: vi.fn(async () => ({
|
||||
path: PATH,
|
||||
pluginKey: KEY,
|
||||
added: options.added ?? true,
|
||||
approval: 'approve-flag' as const,
|
||||
plugin: entry()
|
||||
})),
|
||||
list: vi.fn(async () => {
|
||||
if (options.failAt?.poll === poll) {
|
||||
throw options.failAt.error
|
||||
}
|
||||
return lists.shift() ?? [entry()]
|
||||
}),
|
||||
logs: vi.fn(async (_key: string, after: string | undefined) => {
|
||||
logCalls.push(after)
|
||||
if (options.failAt?.poll === poll) {
|
||||
throw options.failAt.error
|
||||
}
|
||||
return logs.shift() ?? { pluginKey: KEY, lines: [] }
|
||||
}),
|
||||
approvalNeeded: options.approvalNeeded ?? vi.fn(async () => null),
|
||||
devUnload: options.devUnload ?? vi.fn(async () => {})
|
||||
}
|
||||
const unsubscribe = vi.fn()
|
||||
const exitNow = vi.fn()
|
||||
const run = runPluginDevWatch({
|
||||
host,
|
||||
emit: (event) => events.push(event),
|
||||
wait: async () => {
|
||||
poll += 1
|
||||
if (poll === (options.stopAfterPolls ?? 4)) {
|
||||
interrupt()
|
||||
}
|
||||
},
|
||||
onInterrupt: (listener) => {
|
||||
interrupt = listener
|
||||
return unsubscribe
|
||||
},
|
||||
exitNow,
|
||||
isConnectionLost: (error) => error instanceof LostError
|
||||
})
|
||||
return { run, events, host, logCalls, unsubscribe, exitNow, interrupt: () => interrupt() }
|
||||
}
|
||||
|
||||
const line = (ts: number, text: string, level = 'info') => ({ ts, level, line: text })
|
||||
|
||||
describe('runPluginDevWatch', () => {
|
||||
it('loads, skips old lines, follows the host cursor, and unloads on Ctrl-C', async () => {
|
||||
const { run, events, host, logCalls, unsubscribe } = harness({
|
||||
logs: [
|
||||
{ pluginKey: KEY, lines: [line(1, 'before the session')], cursor: 'n:1' },
|
||||
{ pluginKey: KEY, lines: [line(2, '[frame] panel.tsx:3 boom', 'error')], cursor: 'n:2' },
|
||||
{ pluginKey: KEY, lines: [], cursor: 'n:2' }
|
||||
]
|
||||
})
|
||||
await expect(run).resolves.toBe(0)
|
||||
expect(logCalls.slice(0, 3)).toEqual([undefined, 'n:1', 'n:2'])
|
||||
expect(events.map((event) => event.type)).toEqual(['loaded', 'log', 'unloaded'])
|
||||
expect(events[1]).toMatchObject({ line: '[frame] panel.tsx:3 boom', level: 'error' })
|
||||
expect(events.at(-1)).toEqual({ type: 'unloaded', pluginKey: KEY, path: PATH, removed: true })
|
||||
expect(host.devUnload).toHaveBeenCalledTimes(1)
|
||||
expect(unsubscribe).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('de-duplicates whole-ring replies from hosts without the cursor', async () => {
|
||||
const { run, events } = harness({
|
||||
logs: [
|
||||
{ pluginKey: KEY, lines: [line(5, 'a')] },
|
||||
{ pluginKey: KEY, lines: [line(5, 'a'), line(5, 'b')] },
|
||||
{ pluginKey: KEY, lines: [line(5, 'a'), line(5, 'b'), line(6, 'c')] }
|
||||
]
|
||||
})
|
||||
await run
|
||||
expect(events.filter((event) => event.type === 'log').map((event) => event.line)).toEqual([
|
||||
'b',
|
||||
'c'
|
||||
])
|
||||
})
|
||||
|
||||
it('reports a reload with the new status, and a later status change', async () => {
|
||||
const { run, events } = harness({
|
||||
stopAfterPolls: 6,
|
||||
lists: [
|
||||
[entry({ devChangeCount: 2, status: 'errored', error: 'SyntaxError in main.ts:4' })],
|
||||
[entry({ devChangeCount: 2, status: 'running' })]
|
||||
]
|
||||
})
|
||||
await run
|
||||
const reload = events.find((event) => event.type === 'reloaded')
|
||||
expect(reload).toEqual({
|
||||
type: 'reloaded',
|
||||
pluginKey: KEY,
|
||||
changes: 2,
|
||||
status: 'errored',
|
||||
error: 'SyntaxError in main.ts:4'
|
||||
})
|
||||
expect(reload && formatPluginDevWatchEvent(reload)).toBe(
|
||||
'↻ reloaded acme.notes (2 files changed): errored: SyntaxError in main.ts:4'
|
||||
)
|
||||
expect(events.find((event) => event.type === 'status')).toMatchObject({
|
||||
status: 'running',
|
||||
previousStatus: 'errored'
|
||||
})
|
||||
})
|
||||
|
||||
it('prints a reload before the lines its new code logged in the same poll', async () => {
|
||||
const { run, events } = harness({
|
||||
logs: [
|
||||
{ pluginKey: KEY, lines: [], cursor: 'n:0' },
|
||||
{ pluginKey: KEY, lines: [line(2, 'new code started')], cursor: 'n:1' }
|
||||
],
|
||||
lists: [[entry({ devChangeCount: 1 })]]
|
||||
})
|
||||
await run
|
||||
expect(events.map((event) => event.type)).toEqual(['loaded', 'reloaded', 'log', 'unloaded'])
|
||||
})
|
||||
|
||||
it('follows a manifest that stopped parsing to the one new invalid dev entry', async () => {
|
||||
const stray = entry({
|
||||
pluginKey: 'invalid-development-plugin-2',
|
||||
status: 'invalid',
|
||||
error: 'invalid manifest: id',
|
||||
devChangeCount: 1
|
||||
})
|
||||
const { run, events } = harness({
|
||||
lists: [[entry(), entry({ pluginKey: 'acme.other', isDev: false })], [stray]]
|
||||
})
|
||||
await run
|
||||
expect(events.find((event) => event.type === 'reloaded')).toMatchObject({
|
||||
status: 'invalid',
|
||||
error: 'invalid manifest: id'
|
||||
})
|
||||
})
|
||||
|
||||
it('exits 1 without unloading when the host connection drops', async () => {
|
||||
const { run, events, host } = harness({
|
||||
failAt: { poll: 1, error: new LostError('connect ECONNREFUSED') }
|
||||
})
|
||||
await expect(run).resolves.toBe(1)
|
||||
expect(events.at(-1)).toEqual({ type: 'disconnected', message: 'connect ECONNREFUSED' })
|
||||
expect(host.devUnload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a host-side poll failure once and keeps watching', async () => {
|
||||
const { run, events } = harness({
|
||||
stopAfterPolls: 6,
|
||||
failAt: { poll: 2, error: new Error('Plugin service is not available') }
|
||||
})
|
||||
await expect(run).resolves.toBe(0)
|
||||
expect(events.filter((event) => event.type === 'warning')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { DevLoadResult, LogsResult } from '../plugin-runtime'
|
||||
import type { PluginHostListEntry } from '../../../shared/plugins/plugin-list-entry'
|
||||
import type {
|
||||
PluginDevWatchApproval,
|
||||
PluginDevWatchEvent,
|
||||
PluginDevWatchStatus
|
||||
} from './plugin-dev-watch-events'
|
||||
import { PluginLogFollower } from './plugin-log-follower'
|
||||
import { devLoadWithApproval, type PluginDevWatchConfirm } from './plugin-dev-watch-approval'
|
||||
import { PluginFrameLogDedupe, type PluginDevWatchLogLine } from './plugin-frame-log-dedupe'
|
||||
import {
|
||||
createPluginDevWatchCheckQueue,
|
||||
type PluginDevWatchCheckResult
|
||||
} from './plugin-dev-watch-check'
|
||||
import {
|
||||
createPluginDevWatchShooter,
|
||||
PLUGIN_DEV_WATCH_SHOOTABLE,
|
||||
type PluginDevWatchShotHost
|
||||
} from './plugin-dev-watch-shot'
|
||||
|
||||
/** What the loop asks of the Orca host; the handler binds it to RPC for one folder. */
|
||||
export type PluginDevWatchHost = {
|
||||
refreshTypes(): Promise<string[]>
|
||||
/** `approvalFingerprint` overrides `--approve` once the person approved at the prompt. */
|
||||
devLoad(approvalFingerprint?: string): Promise<DevLoadResult>
|
||||
list(): Promise<PluginHostListEntry[]>
|
||||
logs(pluginKey: string, after: string | undefined): Promise<LogsResult>
|
||||
/** Re-checks the folder's approval; null when it needs none. */
|
||||
approvalNeeded(): Promise<PluginDevWatchApproval | null>
|
||||
devUnload(): Promise<void>
|
||||
/** `--check` only: validates the folder and renders its components panels once. */
|
||||
check?: () => Promise<PluginDevWatchCheckResult>
|
||||
/** `--shot` only: a PNG of one panel after each good reload, while it stays on screen. */
|
||||
shot?: PluginDevWatchShotHost
|
||||
}
|
||||
|
||||
export type PluginDevWatchDeps = {
|
||||
host: PluginDevWatchHost
|
||||
emit(event: PluginDevWatchEvent): void
|
||||
/** Resolves after `ms`, or as soon as `signal` aborts. */
|
||||
wait(ms: number, signal: AbortSignal): Promise<void>
|
||||
/** Subscribes to Ctrl-C (and the platform's other stop signals); returns the unsubscribe. */
|
||||
onInterrupt(listener: () => void): () => void
|
||||
exitNow(code: number): void
|
||||
isConnectionLost(error: unknown): boolean
|
||||
/** Only for a person at a terminal without `--json`: asks to approve a first load that needs it. */
|
||||
confirm?: PluginDevWatchConfirm
|
||||
}
|
||||
|
||||
// Why these rates: logs feel live at 500 ms, and the plugin list (a projection of every plugin) is
|
||||
// heavier, so it is read once a second.
|
||||
export const PLUGIN_DEV_WATCH_POLL_MS = 500
|
||||
const LIST_EVERY_POLLS = 2
|
||||
const INTERRUPTED_EXIT_CODE = 130
|
||||
|
||||
type Snapshot = {
|
||||
status: PluginDevWatchStatus
|
||||
error?: string
|
||||
needsApproval: boolean
|
||||
devChangeCount?: number
|
||||
}
|
||||
|
||||
function snapshotOf(entry: PluginHostListEntry | null | undefined): Snapshot {
|
||||
if (!entry) {
|
||||
return { status: 'missing', needsApproval: false }
|
||||
}
|
||||
return {
|
||||
status: entry.status,
|
||||
...(entry.error ? { error: entry.error } : {}),
|
||||
needsApproval: entry.status === 'pending' || entry.needsReconsent,
|
||||
...(entry.devChangeCount === undefined ? {} : { devChangeCount: entry.devChangeCount })
|
||||
}
|
||||
}
|
||||
|
||||
/** Our entry, or the one new invalid dev entry: a manifest that no longer parses loses its key. */
|
||||
function findEntry(
|
||||
list: readonly PluginHostListEntry[],
|
||||
pluginKey: string,
|
||||
otherKeys: ReadonlySet<string>
|
||||
): PluginHostListEntry | undefined {
|
||||
const own = list.find((entry) => entry.pluginKey === pluginKey)
|
||||
if (own) {
|
||||
return own
|
||||
}
|
||||
const strays = list.filter(
|
||||
(entry) => entry.isDev && entry.status === 'invalid' && !otherKeys.has(entry.pluginKey)
|
||||
)
|
||||
return strays.length === 1 ? strays[0] : undefined
|
||||
}
|
||||
|
||||
/** A check that renders still fails while Orca itself does not run the plugin. */
|
||||
function checkEvent(
|
||||
pluginKey: string,
|
||||
result: PluginDevWatchCheckResult,
|
||||
snapshot: Snapshot
|
||||
): PluginDevWatchEvent {
|
||||
return {
|
||||
type: 'check',
|
||||
pluginKey,
|
||||
...result,
|
||||
ok: result.ok && PLUGIN_DEV_WATCH_SHOOTABLE.has(snapshot.status),
|
||||
checkOk: result.ok,
|
||||
pluginStatus: snapshot.status,
|
||||
...(snapshot.error ? { pluginError: snapshot.error } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/** `--shot`: picks the panel once; an unknown `--shot-panel` unloads what this run loaded and fails. */
|
||||
async function startShooter(
|
||||
host: PluginDevWatchHost,
|
||||
loaded: DevLoadResult,
|
||||
emit: (event: PluginDevWatchEvent) => void
|
||||
): Promise<(() => Promise<void>) | null> {
|
||||
if (!host.shot) {
|
||||
return null
|
||||
}
|
||||
let panelId: string | null
|
||||
try {
|
||||
panelId = host.shot.panelOf(loaded.plugin)
|
||||
} catch (error) {
|
||||
if (loaded.added) {
|
||||
await host.devUnload().catch(() => undefined)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (panelId === null) {
|
||||
emit({ type: 'warning', message: `${loaded.pluginKey} has no panel for --shot to capture` })
|
||||
return null
|
||||
}
|
||||
return createPluginDevWatchShooter(host.shot, loaded.pluginKey, panelId, emit)
|
||||
}
|
||||
|
||||
/** Loads the folder, then streams its logs and reload status until interrupted. Returns the exit
|
||||
* code. A failed first load throws (approval_required exits 3 like `orca plugin dev load`) unless
|
||||
* the person approves it at the `confirm` prompt. */
|
||||
export async function runPluginDevWatch(deps: PluginDevWatchDeps): Promise<number> {
|
||||
const { host, emit } = deps
|
||||
const typeFiles = await host.refreshTypes()
|
||||
const loaded = await devLoadWithApproval((fingerprint) => host.devLoad(fingerprint), deps.confirm)
|
||||
const { pluginKey } = loaded
|
||||
let snapshot = snapshotOf(loaded.plugin)
|
||||
emit({
|
||||
type: 'loaded',
|
||||
pluginKey,
|
||||
path: loaded.path,
|
||||
added: loaded.added,
|
||||
status: snapshot.status,
|
||||
...(snapshot.error ? { error: snapshot.error } : {}),
|
||||
typeFiles
|
||||
})
|
||||
|
||||
const stop = new AbortController()
|
||||
let interrupts = 0
|
||||
const unsubscribe = deps.onInterrupt(() => {
|
||||
interrupts += 1
|
||||
if (interrupts > 1) {
|
||||
deps.exitNow(INTERRUPTED_EXIT_CODE)
|
||||
return
|
||||
}
|
||||
stop.abort()
|
||||
})
|
||||
const runCheck = host.check
|
||||
const shoot = await startShooter(host, loaded, emit)
|
||||
// Check, then shot: one at a time, and saves during a run coalesce into one more.
|
||||
const afterLoad =
|
||||
runCheck || shoot
|
||||
? createPluginDevWatchCheckQueue(async () => {
|
||||
if (runCheck) {
|
||||
try {
|
||||
emit(checkEvent(pluginKey, await runCheck(), snapshot))
|
||||
} catch (error) {
|
||||
emit({ type: 'warning', message: `check could not run: ${errorMessage(error)}` })
|
||||
}
|
||||
}
|
||||
if (shoot && PLUGIN_DEV_WATCH_SHOOTABLE.has(snapshot.status)) {
|
||||
await shoot()
|
||||
}
|
||||
})
|
||||
: null
|
||||
afterLoad?.request()
|
||||
const follower = new PluginLogFollower()
|
||||
const dedupe = new PluginFrameLogDedupe()
|
||||
const emitLogs = (lines: readonly PluginDevWatchLogLine[]): void => {
|
||||
for (const line of lines) {
|
||||
emit({ type: 'log', pluginKey, ...line })
|
||||
}
|
||||
}
|
||||
let otherKeys: ReadonlySet<string> | null = null
|
||||
let lastWarning: string | null = null
|
||||
|
||||
const pollLogs = async (): Promise<void> => {
|
||||
emitLogs(dedupe.take(follower.take(await host.logs(pluginKey, follower.cursor))))
|
||||
}
|
||||
|
||||
const pollStatus = async (): Promise<void> => {
|
||||
const list = await host.list()
|
||||
otherKeys ??= new Set(list.map((entry) => entry.pluginKey).filter((key) => key !== pluginKey))
|
||||
const next = snapshotOf(findEntry(list, pluginKey, otherKeys))
|
||||
const changes =
|
||||
next.devChangeCount !== undefined && snapshot.devChangeCount !== undefined
|
||||
? next.devChangeCount - snapshot.devChangeCount
|
||||
: 0
|
||||
const error = next.error ? { error: next.error } : {}
|
||||
if (changes > 0) {
|
||||
emitLogs(dedupe.flush())
|
||||
emit({ type: 'reloaded', pluginKey, changes, status: next.status, ...error })
|
||||
} else if (next.status !== snapshot.status || next.error !== snapshot.error) {
|
||||
emit({
|
||||
type: 'status',
|
||||
pluginKey,
|
||||
status: next.status,
|
||||
previousStatus: snapshot.status,
|
||||
...error
|
||||
})
|
||||
}
|
||||
const newlyNeedsApproval = next.needsApproval && !snapshot.needsApproval
|
||||
snapshot = next
|
||||
if (changes > 0) {
|
||||
afterLoad?.request()
|
||||
}
|
||||
if (newlyNeedsApproval) {
|
||||
const approval = await host.approvalNeeded()
|
||||
if (approval) {
|
||||
emit({ type: 'approval', pluginKey, ...approval })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let polls = -1
|
||||
while (!stop.signal.aborted) {
|
||||
try {
|
||||
if (polls === -1) {
|
||||
// Baseline: only lines written from now on are streamed.
|
||||
follower.take(await host.logs(pluginKey, undefined))
|
||||
} else {
|
||||
// Status first, so a reload's `reloaded` line precedes the lines its new code logs.
|
||||
if (polls % LIST_EVERY_POLLS === 0) {
|
||||
await pollStatus()
|
||||
}
|
||||
await pollLogs()
|
||||
}
|
||||
lastWarning = null
|
||||
} catch (error) {
|
||||
if (deps.isConnectionLost(error)) {
|
||||
emit({ type: 'disconnected', message: errorMessage(error) })
|
||||
return 1
|
||||
}
|
||||
// Why once: a host-side failure repeats every poll until something changes.
|
||||
const message = errorMessage(error)
|
||||
if (message !== lastWarning) {
|
||||
emit({ type: 'warning', message })
|
||||
lastWarning = message
|
||||
}
|
||||
}
|
||||
polls += 1
|
||||
await deps.wait(PLUGIN_DEV_WATCH_POLL_MS, stop.signal)
|
||||
}
|
||||
// A check or shot that is still running reports before the unload line.
|
||||
await afterLoad?.idle()
|
||||
emitLogs(dedupe.flush())
|
||||
if (!loaded.added) {
|
||||
emit({ type: 'unloaded', pluginKey, path: loaded.path, removed: false })
|
||||
return 0
|
||||
}
|
||||
try {
|
||||
await host.devUnload()
|
||||
} catch (error) {
|
||||
emit({
|
||||
type: deps.isConnectionLost(error) ? 'disconnected' : 'warning',
|
||||
message: `could not unload ${loaded.path}: ${errorMessage(error)}`
|
||||
})
|
||||
return 1
|
||||
}
|
||||
emit({ type: 'unloaded', pluginKey, path: loaded.path, removed: true })
|
||||
return 0
|
||||
} finally {
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Ctrl-C everywhere; Ctrl-Break on Windows; a closed terminal or `kill` on POSIX. */
|
||||
function stopSignals(platform: NodeJS.Platform): NodeJS.Signals[] {
|
||||
return platform === 'win32' ? ['SIGINT', 'SIGBREAK'] : ['SIGINT', 'SIGTERM', 'SIGHUP']
|
||||
}
|
||||
|
||||
type SignalTarget = {
|
||||
platform: NodeJS.Platform
|
||||
on(signal: NodeJS.Signals, listener: () => void): unknown
|
||||
off(signal: NodeJS.Signals, listener: () => void): unknown
|
||||
}
|
||||
|
||||
export function onPluginDevWatchInterrupt(
|
||||
listener: () => void,
|
||||
target: SignalTarget = process
|
||||
): () => void {
|
||||
const signals = stopSignals(target.platform)
|
||||
for (const signal of signals) {
|
||||
target.on(signal, listener)
|
||||
}
|
||||
return () => {
|
||||
for (const signal of signals) {
|
||||
target.off(signal, listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function waitUnlessAborted(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const done = (): void => {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener('abort', done)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(done, ms)
|
||||
signal.addEventListener('abort', done, { once: true })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
PluginHostListEntry,
|
||||
PluginHostStatus
|
||||
} from '../../../shared/plugins/plugin-list-entry'
|
||||
import { formatPluginDevWatchEvent, type PluginDevWatchEvent } from './plugin-dev-watch-events'
|
||||
import { runPluginDevWatch, type PluginDevWatchHost } from './plugin-dev-watch-loop'
|
||||
import {
|
||||
pluginDevWatchShotPanel,
|
||||
type PluginDevWatchShot,
|
||||
type PluginDevWatchShotHost
|
||||
} from './plugin-dev-watch-shot'
|
||||
|
||||
const KEY = 'acme.todos'
|
||||
|
||||
function entry(devChangeCount: number, status: PluginHostStatus = 'idle'): PluginHostListEntry {
|
||||
return {
|
||||
pluginKey: KEY,
|
||||
consentFingerprint: 'sha256-x',
|
||||
name: 'Todos',
|
||||
version: '1.0.0',
|
||||
publisher: 'acme',
|
||||
status,
|
||||
needsReconsent: false,
|
||||
isDev: true,
|
||||
devChangeCount,
|
||||
official: false,
|
||||
bundled: false,
|
||||
capabilities: [],
|
||||
panels: [
|
||||
{ id: 'todos', title: 'Todos', tabKey: `plugin:${KEY}/todos` },
|
||||
{ id: 'stats', title: 'Stats', tabKey: `plugin:${KEY}/stats` }
|
||||
],
|
||||
commands: [],
|
||||
hasWorker: false,
|
||||
restarts: 0
|
||||
}
|
||||
}
|
||||
|
||||
const SHOT: PluginDevWatchShot = {
|
||||
panelId: 'todos',
|
||||
path: '/work/shots/acme.todos-todos.png',
|
||||
width: 360,
|
||||
height: 700,
|
||||
settled: true,
|
||||
errors: []
|
||||
}
|
||||
|
||||
type Take = PluginDevWatchShotHost['take']
|
||||
|
||||
function shotHost(take: Take, requested?: string) {
|
||||
const open = vi.fn<PluginDevWatchShotHost['open']>(async () => {})
|
||||
const host: PluginDevWatchShotHost = {
|
||||
panelOf: (loaded) => pluginDevWatchShotPanel(loaded, requested),
|
||||
open,
|
||||
take: vi.fn(take)
|
||||
}
|
||||
return { host, open, take: host.take }
|
||||
}
|
||||
|
||||
/** The load, then one status poll per entry in `saves` (each one save later), then Ctrl-C. */
|
||||
async function watch(
|
||||
host: Partial<Pick<PluginDevWatchHost, 'check' | 'shot' | 'devUnload'>>,
|
||||
saves: PluginHostListEntry[] = [entry(1)]
|
||||
): Promise<PluginDevWatchEvent[]> {
|
||||
const events: PluginDevWatchEvent[] = []
|
||||
const lists = [[entry(0)], ...saves.map((save) => [save])]
|
||||
let interrupt = () => {}
|
||||
let poll = 0
|
||||
await runPluginDevWatch({
|
||||
host: {
|
||||
refreshTypes: async () => [],
|
||||
devLoad: async () => ({
|
||||
path: '/work/todos',
|
||||
pluginKey: KEY,
|
||||
added: true,
|
||||
approval: 'approve-flag',
|
||||
plugin: entry(0)
|
||||
}),
|
||||
list: async () => lists.shift() ?? [saves.at(-1) ?? entry(0)],
|
||||
logs: async () => ({ pluginKey: KEY, lines: [] }),
|
||||
approvalNeeded: async () => null,
|
||||
devUnload: async () => {},
|
||||
...host
|
||||
},
|
||||
emit: (event) => events.push(event),
|
||||
wait: async () => {
|
||||
poll += 1
|
||||
if (poll === 2 * (saves.length + 1)) {
|
||||
interrupt()
|
||||
}
|
||||
},
|
||||
onInterrupt: (listener) => {
|
||||
interrupt = listener
|
||||
return () => {}
|
||||
},
|
||||
exitNow: () => {},
|
||||
isConnectionLost: () => false
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
const ofType = <T extends PluginDevWatchEvent['type']>(events: PluginDevWatchEvent[], type: T) =>
|
||||
events.filter((event): event is Extract<PluginDevWatchEvent, { type: T }> => event.type === type)
|
||||
|
||||
describe('plugin dev watch --shot', () => {
|
||||
it('shoots --shot-panel and fails at start, unloading, on an unknown id', async () => {
|
||||
const { host, open } = shotHost(async () => ({ ...SHOT, panelId: 'stats' }), 'stats')
|
||||
await watch({ shot: host })
|
||||
expect(open).toHaveBeenCalledWith(KEY, 'stats')
|
||||
|
||||
const devUnload = vi.fn(async () => {})
|
||||
await expect(
|
||||
watch({ shot: shotHost(async () => SHOT, 'nope').host, devUnload })
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: '--shot-panel: acme.todos has no panel nope; its panels: todos, stats.'
|
||||
})
|
||||
expect(devUnload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips while away, says so once per time away, and resumes when back', async () => {
|
||||
const outcomes: Awaited<ReturnType<Take>>[] = [
|
||||
SHOT,
|
||||
{ notShown: 'the right sidebar shows another tab' },
|
||||
{ notShown: 'the right sidebar shows another tab' },
|
||||
SHOT,
|
||||
{ notShown: 'the right sidebar is closed' }
|
||||
]
|
||||
const { host, open } = shotHost(async () => outcomes.shift() ?? SHOT)
|
||||
const events = await watch({ shot: host }, [entry(1), entry(2), entry(3), entry(4)])
|
||||
expect(open).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
events
|
||||
.filter((event) => event.type === 'shot' || event.type === 'shot_skipped')
|
||||
.map((event) => event.type)
|
||||
).toEqual(['shot', 'shot_skipped', 'shot', 'shot_skipped'])
|
||||
const skipped = ofType(events, 'shot_skipped')
|
||||
expect(skipped[0]).toEqual({
|
||||
type: 'shot_skipped',
|
||||
pluginKey: KEY,
|
||||
panelId: 'todos',
|
||||
reason: 'the right sidebar shows another tab'
|
||||
})
|
||||
expect(formatPluginDevWatchEvent(skipped[1]!)).toBe(
|
||||
'shot skipped: the right sidebar is closed, so todos is not on screen; open it again to resume'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { PluginHostListEntry } from '../../../shared/plugins/plugin-list-entry'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from '../../runtime/types'
|
||||
import type { PluginDevWatchEvent } from './plugin-dev-watch-events'
|
||||
|
||||
/** One `--shot` PNG written after a load or reload. */
|
||||
export type PluginDevWatchShot = {
|
||||
panelId: string
|
||||
path: string
|
||||
width: number
|
||||
height: number
|
||||
/** False when the panel had not finished drawing by the host's timeout. */
|
||||
settled: boolean
|
||||
errors: string[]
|
||||
/** Host calls still unanswered at the capture; absent on hosts that predate it. */
|
||||
pendingRequests?: number
|
||||
}
|
||||
|
||||
/** What `--shot` asks of the Orca host; the handler binds it to RPC. */
|
||||
export type PluginDevWatchShotHost = {
|
||||
/** `--shot-panel`, else the plugin's first panel; null when it has none. Throws on an unknown id. */
|
||||
panelOf(entry: PluginHostListEntry | null): string | null
|
||||
/** Shows the panel in the right sidebar; called once per session. */
|
||||
open(pluginKey: string, panelId: string): Promise<void>
|
||||
/** Captures it only while it is the open right-sidebar tab. */
|
||||
take(pluginKey: string, panelId: string): Promise<PluginDevWatchShot | { notShown: string }>
|
||||
}
|
||||
|
||||
/** Statuses whose panels Orca draws; an errored or invalid reload has nothing new to show. */
|
||||
export const PLUGIN_DEV_WATCH_SHOOTABLE = new Set(['running', 'idle', 'restarting'])
|
||||
|
||||
/** A host that cannot capture now never will this session: too old, or no desktop window. */
|
||||
export function isLastingShotFailure(error: unknown): boolean {
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
return error.response.error.message.includes('only available in the Orca desktop app')
|
||||
}
|
||||
return error instanceof RuntimeClientError && error.code === 'runtime_unsupported'
|
||||
}
|
||||
|
||||
/** `--shot-panel` must name one of the plugin's panels; checked once, at start. */
|
||||
export function pluginDevWatchShotPanel(
|
||||
entry: PluginHostListEntry | null,
|
||||
requested: string | undefined
|
||||
): string | null {
|
||||
const ids = entry?.panels.map((panel) => panel.id) ?? []
|
||||
if (requested === undefined) {
|
||||
return ids[0] ?? null
|
||||
}
|
||||
if (entry && !ids.includes(requested)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
ids.length === 0
|
||||
? `--shot-panel: ${entry.pluginKey} has no panels.`
|
||||
: `--shot-panel: ${entry.pluginKey} has no panel ${requested}; its panels: ${ids.join(', ')}.`
|
||||
)
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the panel once, then captures it after each good reload while it stays on screen. Once
|
||||
* the person switches away it never switches back: it skips, saying so once per time away.
|
||||
*/
|
||||
export function createPluginDevWatchShooter(
|
||||
host: PluginDevWatchShotHost,
|
||||
pluginKey: string,
|
||||
panelId: string,
|
||||
emit: (event: PluginDevWatchEvent) => void
|
||||
): () => Promise<void> {
|
||||
let opened = false
|
||||
let away = false
|
||||
let off = false
|
||||
const warn = (what: string, error: unknown): void => {
|
||||
off = isLastingShotFailure(error)
|
||||
emit({
|
||||
type: 'warning',
|
||||
message: `${what}${off ? ', so --shot is off' : ''}: ${errorMessage(error)}`
|
||||
})
|
||||
}
|
||||
return async () => {
|
||||
if (off) {
|
||||
return
|
||||
}
|
||||
if (!opened) {
|
||||
opened = true
|
||||
try {
|
||||
await host.open(pluginKey, panelId)
|
||||
} catch (error) {
|
||||
warn(`could not show ${panelId} for --shot`, error)
|
||||
if (off) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const outcome = await host.take(pluginKey, panelId)
|
||||
if ('notShown' in outcome) {
|
||||
if (!away) {
|
||||
away = true
|
||||
emit({ type: 'shot_skipped', pluginKey, panelId, reason: outcome.notShown })
|
||||
}
|
||||
return
|
||||
}
|
||||
away = false
|
||||
emit({ type: 'shot', pluginKey, ...outcome })
|
||||
} catch (error) {
|
||||
warn('shot could not run', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { COMMAND_SPECS } from '../../specs/index'
|
||||
import { findCommandSpec, normalizeCommandPositionals, parseArgs } from '../../args'
|
||||
import { specPaths } from '../../command-spec'
|
||||
import type { HandlerContext } from '../../dispatch'
|
||||
import { reportCliError } from '../../cli-error'
|
||||
import type { RuntimeClient } from '../../runtime/client'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from '../../runtime/types'
|
||||
import { bindPluginDevWatchHost, PluginHostUnreachableError } from './plugin-dev-watch'
|
||||
|
||||
function context(call: ReturnType<typeof vi.fn>, rawArgs: string[] = []): HandlerContext {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the watch host only calls client.call and reads isRemote.
|
||||
const client = { call, isRemote: true } as unknown as RuntimeClient
|
||||
return { flags: new Map(), client, cwd: '/work', json: false, rawArgs }
|
||||
}
|
||||
|
||||
function rpcFailure(code: string, data?: unknown): RuntimeRpcFailureError {
|
||||
return new RuntimeRpcFailureError({
|
||||
id: 'req',
|
||||
ok: false,
|
||||
error: { code, message: `${code} message`, data },
|
||||
_meta: { runtimeId: 'runtime_local' }
|
||||
})
|
||||
}
|
||||
|
||||
function parse(argv: string[]) {
|
||||
const paths = COMMAND_SPECS.flatMap((spec) => specPaths(spec))
|
||||
return normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv, paths, COMMAND_SPECS))
|
||||
}
|
||||
|
||||
describe('orca plugin dev watch', () => {
|
||||
it('suggests an approval command that dev reload accepts, without watch-only flags', async () => {
|
||||
const rawArgs = [
|
||||
'plugin',
|
||||
'dev',
|
||||
'watch',
|
||||
'--check',
|
||||
'/work/notes',
|
||||
'--shot',
|
||||
'./shots',
|
||||
'--shot-panel=stats',
|
||||
'--approve',
|
||||
'ap1-old',
|
||||
'--json',
|
||||
'--environment',
|
||||
'box'
|
||||
]
|
||||
const call = vi
|
||||
.fn()
|
||||
.mockRejectedValue(rpcFailure('approval_required', { approvalFingerprint: 'ap1-new' }))
|
||||
const approval = await bindPluginDevWatchHost(
|
||||
context(call, rawArgs),
|
||||
'/work/notes'
|
||||
).approvalNeeded()
|
||||
const argv = approval?.command.split(' ').slice(1) ?? []
|
||||
const parsed = parse(argv)
|
||||
expect(parsed.commandPath).toEqual(['plugin', 'dev', 'reload'])
|
||||
const spec = findCommandSpec(COMMAND_SPECS, parsed.commandPath)
|
||||
expect([...parsed.flags.keys()].filter((flag) => !spec?.allowedFlags.includes(flag))).toEqual(
|
||||
[]
|
||||
)
|
||||
expect(Object.fromEntries(parsed.flags)).toEqual({
|
||||
dir: '/work/notes',
|
||||
approve: 'ap1-new',
|
||||
json: true,
|
||||
environment: 'box'
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a transport failure as a lost host but a host error reply as a reply', async () => {
|
||||
const call = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new RuntimeClientError('runtime_unavailable', 'Orca is not running'))
|
||||
.mockRejectedValueOnce(rpcFailure('internal_error'))
|
||||
const host = bindPluginDevWatchHost(context(call), '/work/notes')
|
||||
await expect(host.list()).rejects.toBeInstanceOf(PluginHostUnreachableError)
|
||||
const reply = await host.list().catch((error: unknown) => error)
|
||||
expect(reply).not.toBeInstanceOf(PluginHostUnreachableError)
|
||||
expect(reply).toMatchObject({ code: 'internal_error' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin dev watch --json errors', () => {
|
||||
it('prints a failed first load as one JSON line, unlike other commands', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
try {
|
||||
const error = new RuntimeClientError('approval_required', 'acme.notes needs approval')
|
||||
reportCliError(error, true, { commandPath: ['plugin', 'dev', 'watch'] })
|
||||
reportCliError(error, true, { commandPath: ['plugin', 'dev', 'load'] })
|
||||
const [watchLine, loadOutput] = log.mock.calls.map(([text]) => String(text))
|
||||
expect(watchLine).not.toContain('\n')
|
||||
expect(JSON.parse(watchLine)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'approval_required' }
|
||||
})
|
||||
expect(loadOutput).toContain('\n')
|
||||
} finally {
|
||||
log.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin dev watch --check', () => {
|
||||
it('needs a local folder, since it renders the plugin on this machine', () => {
|
||||
const remote = context(vi.fn())
|
||||
remote.flags.set('check', true)
|
||||
expect(() => bindPluginDevWatchHost(remote, '/work/notes')).toThrow(/needs a local folder/)
|
||||
expect(bindPluginDevWatchHost(context(vi.fn()), '/work/notes').check).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin dev watch --shot', () => {
|
||||
it('opens once by RPC, then shoots only while shown, also from a remote host', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'orca-watch-shot-'))
|
||||
try {
|
||||
const png = Buffer.from('png').toString('base64')
|
||||
const replies = [
|
||||
{
|
||||
pluginKey: 'acme.notes',
|
||||
panelId: 'a',
|
||||
png,
|
||||
width: 300,
|
||||
height: 500,
|
||||
colorScheme: 'dark',
|
||||
themeApplied: false,
|
||||
settled: true,
|
||||
errors: [],
|
||||
pendingRequests: 1
|
||||
},
|
||||
{
|
||||
pluginKey: 'acme.notes',
|
||||
panelId: 'a',
|
||||
title: 'A',
|
||||
notShown: 'the right sidebar is closed'
|
||||
}
|
||||
]
|
||||
const call = vi.fn(async (method: string, _params?: unknown) => ({
|
||||
result: method === 'plugins.open' ? { kind: 'panel' } : replies.shift()
|
||||
}))
|
||||
const ctx = context(call)
|
||||
ctx.flags.set('shot', dir)
|
||||
const shot = bindPluginDevWatchHost(ctx, '/work/notes').shot
|
||||
await shot?.open('acme.notes', 'a')
|
||||
await expect(shot?.take('acme.notes', 'a')).resolves.toMatchObject({
|
||||
path: join(dir, 'acme.notes-a.png'),
|
||||
pendingRequests: 1
|
||||
})
|
||||
await expect(shot?.take('acme.notes', 'a')).resolves.toEqual({
|
||||
notShown: 'the right sidebar is closed'
|
||||
})
|
||||
expect(await readFile(join(dir, 'acme.notes-a.png'), 'utf8')).toBe('png')
|
||||
expect(call.mock.calls.map(([method, params]) => [method, params])).toEqual([
|
||||
['plugins.open', { pluginKey: 'acme.notes', panelId: 'a' }],
|
||||
['plugins.shot', { pluginKey: 'acme.notes', panelId: 'a', ifShown: true }],
|
||||
['plugins.shot', { pluginKey: 'acme.notes', panelId: 'a', ifShown: true }]
|
||||
])
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { CommandHandler, HandlerContext } from '../../dispatch'
|
||||
import { requirePluginPathArg } from '../plugin-cli-output'
|
||||
import { formatPluginDevWatchEvent, type PluginDevWatchApproval } from './plugin-dev-watch-events'
|
||||
import { runPluginDevWatch, type PluginDevWatchHost } from './plugin-dev-watch-loop'
|
||||
import { onPluginDevWatchInterrupt, waitUnlessAborted } from './plugin-dev-watch-process'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from '../../runtime/types'
|
||||
import { writeStdoutLine } from '../../stdout-line'
|
||||
import type { PluginHostListEntry } from '../../../shared/plugins/plugin-list-entry'
|
||||
import { CLI_BOOLEAN_FLAGS } from '../../../shared/cli-argument-boundary'
|
||||
import { checkPluginDevFolder } from './plugin-dev-watch-check'
|
||||
import { pluginDevWatchConfirmFor } from './plugin-dev-watch-approval'
|
||||
import { pluginDevWatchShotPanel, type PluginDevWatchShotHost } from './plugin-dev-watch-shot'
|
||||
import {
|
||||
defaultPluginShotPath,
|
||||
resolveLocalShotPath,
|
||||
takePluginShotIfShown
|
||||
} from '../view/plugin-view'
|
||||
import {
|
||||
callPlugins,
|
||||
refreshDevTypes,
|
||||
resolveHostPluginPath,
|
||||
translatePluginRpcError,
|
||||
type DevLoadResult,
|
||||
type LogsResult
|
||||
} from '../plugin-runtime'
|
||||
|
||||
/** The host did not answer: Orca quit, or the network to a remote server dropped. */
|
||||
export class PluginHostUnreachableError extends Error {}
|
||||
|
||||
// Why: `dev reload` rejects watch-only flags; the old `--approve` is replaced by the new one.
|
||||
const WATCH_ONLY_FLAGS = new Set(['check', 'shot', 'shot-panel', 'approve'])
|
||||
|
||||
/** `orca … plugin dev watch <dir>` → `… plugin dev reload <dir>`, so approving keeps this session. */
|
||||
export function devReloadArgs(rawArgs: readonly string[]): string[] {
|
||||
const watch = rawArgs.findIndex((arg, index) => arg === 'watch' && rawArgs[index - 1] === 'dev')
|
||||
const kept: string[] = []
|
||||
for (let index = 0; index < rawArgs.length; index += 1) {
|
||||
const arg = rawArgs[index]
|
||||
const name = arg.startsWith('--') ? arg.slice(2).split('=')[0] : null
|
||||
if (name === null || !WATCH_ONLY_FLAGS.has(name)) {
|
||||
kept.push(index === watch ? 'reload' : arg)
|
||||
continue
|
||||
}
|
||||
// Same value rule as parseArgs: a non-boolean flag takes the next token unless it is a flag.
|
||||
const next = rawArgs[index + 1]
|
||||
if (
|
||||
!arg.includes('=') &&
|
||||
!CLI_BOOLEAN_FLAGS.has(name) &&
|
||||
next !== undefined &&
|
||||
!next.startsWith('--')
|
||||
) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
function approvalOf(error: unknown): PluginDevWatchApproval | null {
|
||||
if (!(error instanceof RuntimeClientError) || typeof error.data !== 'object' || !error.data) {
|
||||
return null
|
||||
}
|
||||
const data = error.data
|
||||
const fingerprint =
|
||||
'approvalFingerprint' in data && typeof data.approvalFingerprint === 'string'
|
||||
? data.approvalFingerprint
|
||||
: null
|
||||
const command = 'command' in data && typeof data.command === 'string' ? data.command : null
|
||||
return fingerprint && command ? { message: error.message, fingerprint, command } : null
|
||||
}
|
||||
|
||||
/** Why classify before translating: any reply, even an error, proves the host is still there. */
|
||||
async function pollHost<T>(ctx: HandlerContext, method: string, params?: unknown): Promise<T> {
|
||||
try {
|
||||
return (await ctx.client.call<T>(method, params)).result
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
throw translatePluginRpcError(ctx, method, error)
|
||||
}
|
||||
throw new PluginHostUnreachableError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
function bindShotHost(
|
||||
ctx: HandlerContext,
|
||||
shotDir: string,
|
||||
shotPanel: string | boolean | undefined
|
||||
): PluginDevWatchShotHost {
|
||||
return {
|
||||
panelOf: (entry) =>
|
||||
pluginDevWatchShotPanel(entry, typeof shotPanel === 'string' ? shotPanel : undefined),
|
||||
open: async (pluginKey, panelId) => {
|
||||
await callPlugins(ctx, 'plugins.open', { pluginKey, panelId })
|
||||
},
|
||||
take: async (pluginKey, panelId) => {
|
||||
const shot = await takePluginShotIfShown(ctx, {
|
||||
pluginKey,
|
||||
panelId,
|
||||
outFor: (id) => defaultPluginShotPath(shotDir, pluginKey, id)
|
||||
})
|
||||
if ('notShown' in shot) {
|
||||
return { notShown: shot.notShown }
|
||||
}
|
||||
const { path: file, width, height, settled, errors, pendingRequests } = shot
|
||||
return {
|
||||
panelId,
|
||||
path: file,
|
||||
width,
|
||||
height,
|
||||
settled,
|
||||
errors,
|
||||
...(pendingRequests === undefined ? {} : { pendingRequests })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function bindPluginDevWatchHost(ctx: HandlerContext, path: string): PluginDevWatchHost {
|
||||
const approve = ctx.flags.get('approve')
|
||||
const check = ctx.flags.get('check') === true
|
||||
// Why local only: the check renders the folder's code on this machine, not on the Orca host.
|
||||
if (check && ctx.client.isRemote) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--check renders the plugin on this machine, so it needs a local folder; drop --environment or --check.'
|
||||
)
|
||||
}
|
||||
const shotFlag = ctx.flags.get('shot')
|
||||
const shotDir = typeof shotFlag === 'string' ? resolveLocalShotPath(ctx, shotFlag) : null
|
||||
const shotPanel = ctx.flags.get('shot-panel')
|
||||
if (shotPanel !== undefined && shotDir === null) {
|
||||
throw new RuntimeClientError('invalid_argument', '--shot-panel needs --shot <out-dir>.')
|
||||
}
|
||||
return {
|
||||
...(check ? { check: () => checkPluginDevFolder(path) } : {}),
|
||||
...(shotDir ? { shot: bindShotHost(ctx, shotDir, shotPanel) } : {}),
|
||||
refreshTypes: () => refreshDevTypes(ctx, path),
|
||||
devLoad: (approvalFingerprint) =>
|
||||
callPlugins<DevLoadResult>(ctx, 'plugins.devLoad', {
|
||||
path,
|
||||
approvalFingerprint:
|
||||
approvalFingerprint ?? (typeof approve === 'string' ? approve : undefined)
|
||||
}),
|
||||
list: () => pollHost<PluginHostListEntry[]>(ctx, 'plugins.list'),
|
||||
logs: (pluginKey, after) =>
|
||||
pollHost<LogsResult>(ctx, 'plugins.getLogs', { pluginKey, ...(after ? { after } : {}) }),
|
||||
approvalNeeded: async () => {
|
||||
const reloadCtx = { ...ctx, rawArgs: devReloadArgs(ctx.rawArgs ?? []) }
|
||||
try {
|
||||
await pollHost(reloadCtx, 'plugins.devLoad', { path })
|
||||
return null
|
||||
} catch (error) {
|
||||
const approval = approvalOf(error)
|
||||
if (approval) {
|
||||
return approval
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
devUnload: async () => {
|
||||
await pollHost(ctx, 'plugins.devUnload', { path })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const PLUGIN_DEV_WATCH_HANDLERS: Record<string, CommandHandler> = {
|
||||
'plugin dev watch': async (ctx) => {
|
||||
const path = resolveHostPluginPath(ctx, requirePluginPathArg(ctx, 'dir'))
|
||||
process.exitCode = await runPluginDevWatch({
|
||||
host: bindPluginDevWatchHost(ctx, path),
|
||||
emit: (event) =>
|
||||
writeStdoutLine(ctx.json ? JSON.stringify(event) : formatPluginDevWatchEvent(event)),
|
||||
wait: waitUnlessAborted,
|
||||
onInterrupt: onPluginDevWatchInterrupt,
|
||||
exitNow: (code) => process.exit(code),
|
||||
isConnectionLost: (error) => error instanceof PluginHostUnreachableError,
|
||||
confirm: pluginDevWatchConfirmFor(ctx.json)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PluginFrameLogDedupe } from './plugin-frame-log-dedupe'
|
||||
|
||||
const line = (ts: number, text: string, level = 'warn') => ({ ts, level, line: text })
|
||||
const KEY_WARNING = '[frame] Each child in a list should have a unique "key"'
|
||||
|
||||
describe('PluginFrameLogDedupe', () => {
|
||||
it('prints a repeated panel line once per reload with its count, then the later copies', () => {
|
||||
const dedupe = new PluginFrameLogDedupe()
|
||||
expect(
|
||||
dedupe.take([line(1, KEY_WARNING), line(2, 'worker tick', 'info'), line(3, KEY_WARNING)])
|
||||
).toEqual([{ ...line(1, KEY_WARNING), count: 2 }, line(2, 'worker tick', 'info')])
|
||||
// Worker lines are never folded; the panel line already printed this reload is only counted.
|
||||
expect(
|
||||
dedupe.take([line(4, KEY_WARNING), line(5, 'worker tick', 'info'), line(6, KEY_WARNING)])
|
||||
).toEqual([line(5, 'worker tick', 'info')])
|
||||
expect(dedupe.flush()).toEqual([{ ...line(6, KEY_WARNING), count: 2, repeat: true }])
|
||||
// A new reload prints it again.
|
||||
expect(dedupe.take([line(7, KEY_WARNING)])).toEqual([line(7, KEY_WARNING)])
|
||||
expect(dedupe.flush()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { PluginLogLine } from './plugin-log-follower'
|
||||
|
||||
/** A log line as dev watch prints it: `count` identical lines, or `repeat`s of one printed earlier. */
|
||||
export type PluginDevWatchLogLine = PluginLogLine & { count?: number; repeat?: true }
|
||||
|
||||
const FRAME_PREFIX = '[frame] '
|
||||
|
||||
const keyOf = (line: PluginLogLine): string => `${line.level}\u0000${line.line}`
|
||||
|
||||
/**
|
||||
* Panel console lines repeat per item and per render (a warning inside `items.map`), so within one
|
||||
* reload each `[frame]` line prints once with how many arrived together; later copies are counted
|
||||
* and reported once when the plugin reloads or the watch ends. Worker lines pass through as is.
|
||||
*/
|
||||
export class PluginFrameLogDedupe {
|
||||
private readonly repeats = new Map<string, { more: number; last: PluginLogLine }>()
|
||||
|
||||
take(lines: readonly PluginLogLine[]): PluginDevWatchLogLine[] {
|
||||
const batch = new Map<string, number>()
|
||||
for (const line of lines) {
|
||||
if (line.line.startsWith(FRAME_PREFIX)) {
|
||||
batch.set(keyOf(line), (batch.get(keyOf(line)) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
const out: PluginDevWatchLogLine[] = []
|
||||
const printed = new Map<string, PluginLogLine>()
|
||||
for (const line of lines) {
|
||||
const key = keyOf(line)
|
||||
const count = batch.get(key)
|
||||
if (count === undefined) {
|
||||
out.push(line)
|
||||
continue
|
||||
}
|
||||
const seen = this.repeats.get(key)
|
||||
if (seen) {
|
||||
seen.more += 1
|
||||
seen.last = line
|
||||
continue
|
||||
}
|
||||
if (!printed.has(key)) {
|
||||
printed.set(key, line)
|
||||
out.push(count > 1 ? { ...line, count } : line)
|
||||
}
|
||||
}
|
||||
// After the loop, so this batch's own copies count in `count`, not as repeats.
|
||||
for (const [key, line] of printed) {
|
||||
this.repeats.set(key, { more: 0, last: line })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** A reload (or the end of the watch): what repeated since it printed, then a clean slate. */
|
||||
flush(): PluginDevWatchLogLine[] {
|
||||
const out: PluginDevWatchLogLine[] = []
|
||||
for (const { more, last } of this.repeats.values()) {
|
||||
if (more > 0) {
|
||||
out.push({ ...last, count: more, repeat: true })
|
||||
}
|
||||
}
|
||||
this.repeats.clear()
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { LogsResult } from '../plugin-runtime'
|
||||
|
||||
export type PluginLogLine = LogsResult['lines'][number]
|
||||
|
||||
/**
|
||||
* Turns repeated `plugins.getLogs` replies into only the lines not yet seen. Hosts with the
|
||||
* `after` cursor send just those; older hosts send their whole ring, which is ordered by time,
|
||||
* so lines past the last timestamp seen (and past those already seen at it) are new.
|
||||
*/
|
||||
export class PluginLogFollower {
|
||||
private hostCursor: string | undefined
|
||||
private lastTs = Number.NEGATIVE_INFINITY
|
||||
private seenAtLastTs = 0
|
||||
|
||||
/** Pass as `after`; undefined until a host that supports it replied. */
|
||||
get cursor(): string | undefined {
|
||||
return this.hostCursor
|
||||
}
|
||||
|
||||
take(reply: LogsResult): PluginLogLine[] {
|
||||
if (typeof reply.cursor === 'string') {
|
||||
this.hostCursor = reply.cursor
|
||||
return reply.lines
|
||||
}
|
||||
const fresh: PluginLogLine[] = []
|
||||
let skipAtLastTs = this.seenAtLastTs
|
||||
for (const line of reply.lines) {
|
||||
if (line.ts < this.lastTs) {
|
||||
continue
|
||||
}
|
||||
if (line.ts === this.lastTs && skipAtLastTs > 0) {
|
||||
skipAtLastTs -= 1
|
||||
continue
|
||||
}
|
||||
fresh.push(line)
|
||||
}
|
||||
for (const line of fresh) {
|
||||
this.seenAtLastTs = line.ts === this.lastTs ? this.seenAtLastTs + 1 : 1
|
||||
this.lastTs = line.ts
|
||||
}
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { HandlerContext } from '../dispatch'
|
||||
import { quoteCliCommandArgument } from '../shell-command-quote'
|
||||
|
||||
/** The caller's exact command with `--approve <fingerprint>`, so nothing else changes on re-run. */
|
||||
export function approvalRerunCommand(rawArgs: readonly string[], fingerprint: string): string {
|
||||
const kept: string[] = []
|
||||
for (let index = 0; index < rawArgs.length; index += 1) {
|
||||
const arg = rawArgs[index]
|
||||
if (arg === '--approve') {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (arg.startsWith('--approve=')) {
|
||||
continue
|
||||
}
|
||||
kept.push(arg)
|
||||
}
|
||||
return ['orca', ...kept, '--approve', fingerprint].map(quoteCliCommandArgument).join(' ')
|
||||
}
|
||||
|
||||
export function approvalFingerprintOf(data: unknown): string | null {
|
||||
return typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'approvalFingerprint' in data &&
|
||||
typeof data.approvalFingerprint === 'string'
|
||||
? data.approvalFingerprint
|
||||
: null
|
||||
}
|
||||
|
||||
export function nextStepsOf(data: unknown): string[] {
|
||||
return typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'nextSteps' in data &&
|
||||
Array.isArray(data.nextSteps)
|
||||
? data.nextSteps.filter((step): step is string => typeof step === 'string')
|
||||
: []
|
||||
}
|
||||
|
||||
export type PendingApproval = { pluginKey: string; approvalFingerprint: string }
|
||||
|
||||
export function pendingApprovalsOf(data: unknown): PendingApproval[] {
|
||||
if (typeof data !== 'object' || data === null || !('approvals' in data)) {
|
||||
return []
|
||||
}
|
||||
const { approvals } = data
|
||||
return Array.isArray(approvals)
|
||||
? approvals.filter(
|
||||
(entry): entry is PendingApproval =>
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
typeof entry.pluginKey === 'string' &&
|
||||
typeof entry.approvalFingerprint === 'string'
|
||||
)
|
||||
: []
|
||||
}
|
||||
|
||||
/** `update --all` re-runs per plugin: the same command with the key in place of --all. */
|
||||
export function perPluginApprovalCommands(
|
||||
ctx: HandlerContext,
|
||||
approvals: PendingApproval[]
|
||||
): string[] {
|
||||
return approvals.map((approval) => {
|
||||
const args = (ctx.rawArgs ?? []).map((arg) => (arg === '--all' ? approval.pluginKey : arg))
|
||||
return `To approve ${approval.pluginKey}, run: ${approvalRerunCommand(args, approval.approvalFingerprint)}`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { isAbsolute, resolve } from 'node:path'
|
||||
import type { HandlerContext } from '../dispatch'
|
||||
import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection'
|
||||
import { RuntimeClientError } from '../runtime/types'
|
||||
import { writeStdoutLine } from '../stdout-line'
|
||||
|
||||
/** One `{ ok, result }` object on stdout for --json; plain text otherwise. */
|
||||
export function printPluginResult(ctx: HandlerContext, result: unknown, text: string): void {
|
||||
writeStdoutLine(ctx.json ? JSON.stringify({ ok: true, result }, null, 2) : text)
|
||||
}
|
||||
|
||||
export function requirePluginPathArg(ctx: HandlerContext, flag: string): string {
|
||||
const value = ctx.flags.get(flag)
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new RuntimeClientError('invalid_argument', `Missing <${flag}>.`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoring commands read and write the files of the machine that runs them. Why refuse a bridged
|
||||
* shell: there the CLI runs on the Orca host, so the caller's paths would name the wrong machine.
|
||||
*/
|
||||
export function resolveLocalPluginPath(
|
||||
ctx: HandlerContext,
|
||||
value: string,
|
||||
command: string
|
||||
): string {
|
||||
rejectRemoteSelectionFlags(ctx.flags, `orca plugin ${command}; it works on local files`)
|
||||
if (process.env.ORCA_CLI_CWD) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_environment',
|
||||
`orca plugin ${command} works on the files of the machine that runs it, but this shell forwards orca to the Orca host. Write and check the plugin files on this machine directly, or run the command on the Orca host.`
|
||||
)
|
||||
}
|
||||
return isAbsolute(value) ? value : resolve(ctx.cwd, value)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { CommandHandler, HandlerContext } from '../dispatch'
|
||||
import { printPluginResult, requirePluginPathArg } from './plugin-cli-output'
|
||||
import { RuntimeClientError } from '../runtime/types'
|
||||
import { callPlugins, devTargetParams, stringFlag } from './plugin-runtime'
|
||||
|
||||
export type PluginDevEmitReply = {
|
||||
pluginKey: string
|
||||
event: string
|
||||
delivered: { worker: boolean; panels: number }
|
||||
}
|
||||
|
||||
export type PluginDevHookReply = {
|
||||
pluginKey: string
|
||||
event: string
|
||||
decision: { kind: 'continue'; event: unknown } | { kind: 'deny'; reason: string }
|
||||
}
|
||||
|
||||
/** `--data` as JSON; shared by `dev emit` and `dev request`. */
|
||||
export function parseDataFlag(ctx: HandlerContext): unknown {
|
||||
const raw = stringFlag(ctx, 'data')
|
||||
if (raw === undefined) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch (error) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--data is not JSON: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const PLUGIN_DEV_EMIT_HANDLERS: Record<string, CommandHandler> = {
|
||||
'plugin dev hook': async (ctx) => {
|
||||
const target = devTargetParams(ctx, requirePluginPathArg(ctx, 'target'))
|
||||
const event = requirePluginPathArg(ctx, 'event')
|
||||
const result = await callPlugins<PluginDevHookReply>(ctx, 'plugins.devHook', {
|
||||
...target,
|
||||
event,
|
||||
payload: parseDataFlag(ctx)
|
||||
})
|
||||
const decision = result.decision
|
||||
printPluginResult(
|
||||
ctx,
|
||||
result,
|
||||
decision.kind === 'deny'
|
||||
? `${result.event}: denied by ${result.pluginKey}: ${decision.reason}`
|
||||
: `${result.event}: continue with ${JSON.stringify(decision.event)}`
|
||||
)
|
||||
},
|
||||
'plugin dev emit': async (ctx) => {
|
||||
const target = devTargetParams(ctx, requirePluginPathArg(ctx, 'target'))
|
||||
const event = requirePluginPathArg(ctx, 'event')
|
||||
const result = await callPlugins<PluginDevEmitReply>(ctx, 'plugins.devEmit', {
|
||||
...target,
|
||||
event,
|
||||
payload: parseDataFlag(ctx)
|
||||
})
|
||||
printPluginResult(
|
||||
ctx,
|
||||
result,
|
||||
`Sent ${result.event} to ${result.pluginKey}'s worker; handler errors show in \`orca plugin logs ${result.pluginKey}\`.`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { getOptionalPositiveIntegerFlag } from '../flags'
|
||||
import { printPluginResult, requirePluginPathArg } from './plugin-cli-output'
|
||||
import { parseDataFlag } from './plugin-dev-emit'
|
||||
import { callPlugins, devTargetParams, stringFlag } from './plugin-runtime'
|
||||
|
||||
export type PluginDevRequestReply = { pluginKey: string; name: string; result: unknown }
|
||||
|
||||
const DEFAULT_HANDLER_TIMEOUT_MS = 30_000
|
||||
// Why: the host may start the worker first, so the CLI waits past the handler's own deadline.
|
||||
const WORKER_START_GRACE_MS = 30_000
|
||||
|
||||
export const PLUGIN_DEV_REQUEST_HANDLERS: Record<string, CommandHandler> = {
|
||||
'plugin dev request': async (ctx) => {
|
||||
const target = devTargetParams(ctx, requirePluginPathArg(ctx, 'target'))
|
||||
const name = requirePluginPathArg(ctx, 'name')
|
||||
const input = parseDataFlag(ctx)
|
||||
const timeoutMs = getOptionalPositiveIntegerFlag(ctx.flags, 'timeout-ms')
|
||||
const result = await callPlugins<PluginDevRequestReply>(
|
||||
ctx,
|
||||
'plugins.devRequest',
|
||||
{ ...target, name, input, panel: stringFlag(ctx, 'panel'), timeoutMs },
|
||||
{ timeoutMs: (timeoutMs ?? DEFAULT_HANDLER_TIMEOUT_MS) + WORKER_START_GRACE_MS }
|
||||
)
|
||||
printPluginResult(ctx, result, JSON.stringify(result.result ?? null, null, 2))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** The displayed part of `PluginApprovalGrants`; also what a host's `approval_required` data carries. */
|
||||
export type PluginGrantSummary = {
|
||||
execution: string
|
||||
executionDescription: string
|
||||
capabilities: { description: string }[]
|
||||
contributions: { kind: string; name: string }[]
|
||||
}
|
||||
|
||||
type GrantSubject = { pluginKey: string; version: string; grants: PluginGrantSummary }
|
||||
|
||||
/** What a plugin would be granted, as `orca plugin inspect` and the dev watch prompt print it. */
|
||||
export function formatPluginGrantLines(subject: GrantSubject): string[] {
|
||||
const { grants } = subject
|
||||
return [
|
||||
`${subject.pluginKey} ${subject.version} (${grants.execution})`,
|
||||
` ${grants.executionDescription}`,
|
||||
...grants.capabilities.map((capability) => ` - ${capability.description}`),
|
||||
...grants.contributions.map((item) => ` adds ${item.kind}: ${item.name}`)
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { HandlerGroup } from '../handler-group-manifest'
|
||||
|
||||
// Authoring runs offline; runtime commands reach the Orca host. Split so authoring never loads the RPC graph.
|
||||
export const PLUGIN_HANDLER_GROUPS: readonly HandlerGroup[] = [
|
||||
{
|
||||
name: 'plugin-authoring',
|
||||
keys: [
|
||||
'plugin create',
|
||||
'plugin types',
|
||||
'plugin validate',
|
||||
'plugin inspect',
|
||||
'plugin release-entry',
|
||||
'plugin test',
|
||||
'plugin check'
|
||||
],
|
||||
load: async () => (await import('./authoring/plugin-authoring.js')).PLUGIN_AUTHORING_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'plugin-runtime',
|
||||
keys: [
|
||||
'plugin list',
|
||||
'plugin logs',
|
||||
'plugin enable',
|
||||
'plugin disable',
|
||||
'plugin system on',
|
||||
'plugin system off',
|
||||
'plugin restart',
|
||||
'plugin dev load',
|
||||
'plugin dev reload',
|
||||
'plugin dev inspect',
|
||||
'plugin dev unload'
|
||||
],
|
||||
load: async () => (await import('./plugin-runtime.js')).PLUGIN_RUNTIME_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'plugin-dev-watch',
|
||||
keys: ['plugin dev watch'],
|
||||
load: async () => (await import('./dev-watch/plugin-dev-watch.js')).PLUGIN_DEV_WATCH_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'plugin-dev-emit',
|
||||
keys: ['plugin dev emit', 'plugin dev hook'],
|
||||
load: async () => (await import('./plugin-dev-emit.js')).PLUGIN_DEV_EMIT_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'plugin-dev-request',
|
||||
keys: ['plugin dev request'],
|
||||
load: async () => (await import('./plugin-dev-request.js')).PLUGIN_DEV_REQUEST_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'plugin-view',
|
||||
keys: ['plugin open', 'plugin shot'],
|
||||
load: async () => (await import('./view/plugin-view.js')).PLUGIN_VIEW_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'plugin-install',
|
||||
keys: [
|
||||
'plugin install',
|
||||
'plugin update',
|
||||
'plugin uninstall',
|
||||
'plugin catalog list',
|
||||
'plugin catalog add',
|
||||
'plugin catalog rm'
|
||||
],
|
||||
load: async () => (await import('./plugin-install.js')).PLUGIN_INSTALL_HANDLERS
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,210 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import type { CommandHandler, HandlerContext } from '../dispatch'
|
||||
import { printPluginResult, requirePluginPathArg } from './plugin-cli-output'
|
||||
import { RuntimeClientError } from '../runtime/types'
|
||||
import { isQualifiedPluginKey } from '../../shared/plugins/frames/plugin-tab-key'
|
||||
import { PLUGIN_MANIFEST_FILENAME } from '../../shared/plugins/manifest/plugin-manifest'
|
||||
import type { PluginHostListEntry } from '../../shared/plugins/plugin-list-entry'
|
||||
import {
|
||||
pluginInstallSourceLines,
|
||||
type PluginInstallSourceInfo
|
||||
} from '../../shared/plugins/installation/plugin-install-source-info'
|
||||
import {
|
||||
callPlugins,
|
||||
formatPluginRow,
|
||||
requirePluginKey,
|
||||
resolveHostPluginPath,
|
||||
stringFlag
|
||||
} from './plugin-runtime'
|
||||
|
||||
// Why long: a first install may resolve, download and verify a repository twice over a slow link.
|
||||
const INSTALL_TIMEOUT = { timeoutMs: 10 * 60_000 }
|
||||
|
||||
type InstallSpec =
|
||||
| { kind: 'catalog'; pluginKey: string; catalog?: string }
|
||||
| { kind: 'git'; input: string }
|
||||
| { kind: 'local-path'; path: string }
|
||||
|
||||
type InstallResult = {
|
||||
pluginKey: string
|
||||
version: string
|
||||
previousVersion: string | null
|
||||
approval: string
|
||||
source: PluginInstallSourceInfo
|
||||
plugin?: PluginHostListEntry | null
|
||||
}
|
||||
|
||||
type UpdateResult =
|
||||
| ({ status: 'updated' } & InstallResult)
|
||||
| { status: 'up-to-date' | 'pinned'; pluginKey: string; version: string; reason: string }
|
||||
|
||||
type CatalogEntry = {
|
||||
id: string
|
||||
url: string
|
||||
ref: string
|
||||
name: string | null
|
||||
official: boolean
|
||||
stale: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const SHORTHAND_WITH_REF = /^([A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+)@([^@\s]+)$/
|
||||
|
||||
function looksLikePath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith('.') ||
|
||||
isAbsolute(value) ||
|
||||
/^[A-Za-z]:[\\/]/.test(value) ||
|
||||
value.startsWith('\\\\')
|
||||
)
|
||||
}
|
||||
|
||||
/** Sorts what the caller typed into a catalog id, a Git source or a folder on the Orca host. */
|
||||
export function classifyPluginInstallSource(ctx: HandlerContext, value: string): InstallSpec {
|
||||
const ref = stringFlag(ctx, 'ref')
|
||||
const catalog = stringFlag(ctx, 'catalog')
|
||||
const localFolder =
|
||||
!ctx.client.isRemote && existsSync(join(resolve(ctx.cwd, value), PLUGIN_MANIFEST_FILENAME))
|
||||
if (looksLikePath(value) || localFolder) {
|
||||
return { kind: 'local-path', path: resolveHostPluginPath(ctx, value) }
|
||||
}
|
||||
if (!value.includes('/') && !value.includes(':') && isQualifiedPluginKey(value)) {
|
||||
return { kind: 'catalog', pluginKey: value, ...(catalog ? { catalog } : {}) }
|
||||
}
|
||||
if (catalog) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--catalog only applies to a catalog id such as acme.dusk.'
|
||||
)
|
||||
}
|
||||
// Why `@` only for owner/repo: in an SSH URL `@` already separates the user from the host.
|
||||
const shorthand = SHORTHAND_WITH_REF.exec(value)
|
||||
const input = shorthand ? `${shorthand[1]}#${shorthand[2]}` : value
|
||||
if (ref && input.includes('#')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Pass the version once: either @ref/#ref or --ref.'
|
||||
)
|
||||
}
|
||||
return { kind: 'git', input: ref ? `${input}#${ref}` : input }
|
||||
}
|
||||
|
||||
function approvalText(approval: string): string {
|
||||
switch (approval) {
|
||||
case 'content-only':
|
||||
return 'content only, no approval needed'
|
||||
case 'approve-flag':
|
||||
return 'approved with --approve'
|
||||
case 'kept':
|
||||
return 'it asks for nothing new, so it kept its approval'
|
||||
case 'not-approved':
|
||||
return 'it was not enabled before, so it still is not'
|
||||
default:
|
||||
return 'already approved'
|
||||
}
|
||||
}
|
||||
|
||||
function formatInstall(result: InstallResult, verb: string): string {
|
||||
const from =
|
||||
result.previousVersion && result.previousVersion !== result.version
|
||||
? ` (was ${result.previousVersion})`
|
||||
: ''
|
||||
return [
|
||||
`${verb} ${result.pluginKey} ${result.version}${from}: ${approvalText(result.approval)}.`,
|
||||
...pluginInstallSourceLines(result.source),
|
||||
result.plugin ? formatPluginRow(result.plugin) : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function formatUpdate(result: UpdateResult): string {
|
||||
return result.status === 'updated'
|
||||
? formatInstall(result, 'Updated')
|
||||
: `${result.pluginKey} ${result.version} is ${result.status}: ${result.reason}`
|
||||
}
|
||||
|
||||
function formatCatalog(entry: CatalogEntry): string {
|
||||
const tags = [entry.official ? 'official' : null, entry.stale ? 'stale' : null]
|
||||
return `${entry.id} ${entry.name ?? '(not fetched)'} ${entry.url} (${entry.ref})${tags.some(Boolean) ? ` ${tags.filter(Boolean).join(', ')}` : ''}${entry.error ? `\n ${entry.error}` : ''}`
|
||||
}
|
||||
|
||||
export const PLUGIN_INSTALL_HANDLERS: Record<string, CommandHandler> = {
|
||||
'plugin install': async (ctx) => {
|
||||
const source = classifyPluginInstallSource(ctx, requirePluginPathArg(ctx, 'source').trim())
|
||||
const result = await callPlugins<InstallResult>(
|
||||
ctx,
|
||||
'plugins.install',
|
||||
{ source, approvalFingerprint: stringFlag(ctx, 'approve') },
|
||||
INSTALL_TIMEOUT
|
||||
)
|
||||
printPluginResult(ctx, result, formatInstall(result, 'Installed'))
|
||||
},
|
||||
'plugin update': async (ctx) => {
|
||||
const all = ctx.flags.get('all') === true
|
||||
if (all === (stringFlag(ctx, 'plugin') !== undefined)) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Pass a plugin key or --all, not both.')
|
||||
}
|
||||
if (all) {
|
||||
const result = await callPlugins<{ updates: UpdateResult[] }>(
|
||||
ctx,
|
||||
'plugins.update',
|
||||
{ all: true },
|
||||
INSTALL_TIMEOUT
|
||||
)
|
||||
printPluginResult(
|
||||
ctx,
|
||||
result,
|
||||
result.updates.length === 0
|
||||
? 'No installed plugins.'
|
||||
: result.updates.map(formatUpdate).join('\n')
|
||||
)
|
||||
return
|
||||
}
|
||||
const result = await callPlugins<UpdateResult>(
|
||||
ctx,
|
||||
'plugins.update',
|
||||
{ pluginKey: requirePluginKey(ctx), approvalFingerprint: stringFlag(ctx, 'approve') },
|
||||
INSTALL_TIMEOUT
|
||||
)
|
||||
printPluginResult(ctx, result, formatUpdate(result))
|
||||
},
|
||||
'plugin uninstall': async (ctx) => {
|
||||
const result = await callPlugins<{ pluginKey: string }>(ctx, 'plugins.uninstall', {
|
||||
pluginKey: requirePluginKey(ctx)
|
||||
})
|
||||
printPluginResult(ctx, result, `Uninstalled ${result.pluginKey}.`)
|
||||
},
|
||||
'plugin catalog list': async (ctx) => {
|
||||
const catalogs = await callPlugins<CatalogEntry[]>(ctx, 'plugins.catalogList')
|
||||
printPluginResult(
|
||||
ctx,
|
||||
{ catalogs },
|
||||
catalogs.length === 0 ? 'No catalogs.' : catalogs.map(formatCatalog).join('\n')
|
||||
)
|
||||
},
|
||||
'plugin catalog add': async (ctx) => {
|
||||
const result = await callPlugins<{ added: boolean; catalog: CatalogEntry }>(
|
||||
ctx,
|
||||
'plugins.catalogAdd',
|
||||
{
|
||||
input: requirePluginPathArg(ctx, 'source').trim(),
|
||||
ref: stringFlag(ctx, 'ref'),
|
||||
approvalFingerprint: stringFlag(ctx, 'approve')
|
||||
},
|
||||
INSTALL_TIMEOUT
|
||||
)
|
||||
printPluginResult(
|
||||
ctx,
|
||||
result,
|
||||
`${result.added ? 'Added' : 'Refreshed'} catalog ${formatCatalog(result.catalog)}`
|
||||
)
|
||||
},
|
||||
'plugin catalog rm': async (ctx) => {
|
||||
const result = await callPlugins<{ removed: CatalogEntry }>(ctx, 'plugins.catalogRemove', {
|
||||
catalog: requirePluginPathArg(ctx, 'catalog').trim()
|
||||
})
|
||||
printPluginResult(ctx, result, `Removed catalog ${formatCatalog(result.removed)}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { isAbsolute, resolve } from 'node:path'
|
||||
import type { CommandHandler, HandlerContext } from '../dispatch'
|
||||
import { printPluginResult, requirePluginPathArg } from './plugin-cli-output'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from '../runtime/types'
|
||||
import {
|
||||
approvalFingerprintOf,
|
||||
approvalRerunCommand,
|
||||
nextStepsOf,
|
||||
pendingApprovalsOf,
|
||||
perPluginApprovalCommands
|
||||
} from './plugin-approval-rerun'
|
||||
import { isQualifiedPluginKey } from '../../shared/plugins/frames/plugin-tab-key'
|
||||
import type { PluginHostListEntry } from '../../shared/plugins/plugin-list-entry'
|
||||
|
||||
export type DevLoadResult = {
|
||||
path: string
|
||||
pluginKey: string
|
||||
added: boolean
|
||||
approval: 'content-only' | 'approve-flag' | 'already-approved'
|
||||
plugin: PluginHostListEntry | null
|
||||
}
|
||||
export type LogsResult = {
|
||||
pluginKey: string
|
||||
lines: { ts: number; level: string; line: string }[]
|
||||
/** Absent on hosts older than the `after` cursor. */
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
const APPROVAL_CODES = new Set(['approval_required', 'approval_stale'])
|
||||
|
||||
export function stringFlag(ctx: HandlerContext, name: string): string | undefined {
|
||||
const value = ctx.flags.get(name)
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||
}
|
||||
|
||||
export function translatePluginRpcError(
|
||||
ctx: HandlerContext,
|
||||
method: string,
|
||||
error: unknown
|
||||
): unknown {
|
||||
if (!(error instanceof RuntimeRpcFailureError)) {
|
||||
return error
|
||||
}
|
||||
const { code, message, data } = error.response.error
|
||||
if (code === 'method_not_found') {
|
||||
return new RuntimeClientError(
|
||||
'runtime_unsupported',
|
||||
`The Orca host this command reached is too old for \`${method}\`.`,
|
||||
{ nextSteps: ['Update Orca on that host, then run the command again.'] }
|
||||
)
|
||||
}
|
||||
const fingerprint = approvalFingerprintOf(data)
|
||||
const pending = pendingApprovalsOf(data)
|
||||
if (APPROVAL_CODES.has(code) && fingerprint === null && pending.length > 0) {
|
||||
const nextSteps = nextStepsOf(data).filter((step) => !step.startsWith('To approve '))
|
||||
return new RuntimeClientError(code, message, {
|
||||
...(typeof data === 'object' ? data : {}),
|
||||
nextSteps: [...nextSteps, ...perPluginApprovalCommands(ctx, pending)]
|
||||
})
|
||||
}
|
||||
if (!APPROVAL_CODES.has(code) || fingerprint === null || typeof data !== 'object') {
|
||||
return error
|
||||
}
|
||||
const command = approvalRerunCommand(ctx.rawArgs ?? [], fingerprint)
|
||||
const nextSteps = nextStepsOf(data).map((step) =>
|
||||
step.startsWith('To approve, run the same command again') ? `To approve, run: ${command}` : step
|
||||
)
|
||||
return new RuntimeClientError(code, message, { ...data, command, nextSteps })
|
||||
}
|
||||
|
||||
export async function callPlugins<T>(
|
||||
ctx: HandlerContext,
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: { timeoutMs?: number }
|
||||
): Promise<T> {
|
||||
try {
|
||||
const reply = options
|
||||
? await ctx.client.call<T>(method, params, options)
|
||||
: await ctx.client.call<T>(method, params)
|
||||
return reply.result
|
||||
} catch (error) {
|
||||
throw translatePluginRpcError(ctx, method, error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Development folders are paths on the Orca host, which may not be this machine. */
|
||||
export function resolveHostPluginPath(ctx: HandlerContext, value: string): string {
|
||||
if (process.env.ORCA_CLI_CWD) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_environment',
|
||||
'This shell forwards orca to an Orca host on another machine, which cannot read this folder. Run the command on the machine that runs Orca, or pass --environment for an Orca server that has the folder.'
|
||||
)
|
||||
}
|
||||
if (isAbsolute(value)) {
|
||||
return value
|
||||
}
|
||||
if (ctx.client.isRemote) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Pass an absolute path on the Orca server; a relative path names a folder on this machine.'
|
||||
)
|
||||
}
|
||||
return resolve(ctx.cwd, value)
|
||||
}
|
||||
|
||||
/** A `<dir|plugin-key>` argument as host params; a folder wins, since one may be named like a key. */
|
||||
export function devTargetParams(
|
||||
ctx: HandlerContext,
|
||||
target: string
|
||||
): { pluginKey: string } | { path: string } {
|
||||
const localFolder = !ctx.client.isRemote && existsSync(resolve(ctx.cwd, target))
|
||||
return !localFolder && !isAbsolute(target) && isQualifiedPluginKey(target)
|
||||
? { pluginKey: target }
|
||||
: { path: resolveHostPluginPath(ctx, target) }
|
||||
}
|
||||
|
||||
export function formatPluginRow(plugin: PluginHostListEntry): string {
|
||||
const tags = [plugin.execution, plugin.isDev ? 'dev' : null, plugin.official ? 'official' : null]
|
||||
return `${plugin.pluginKey} ${plugin.version} ${plugin.status} ${tags.filter(Boolean).join(', ')}${plugin.error ? `\n ${plugin.error}` : ''}`
|
||||
}
|
||||
|
||||
function formatDevLoad(result: DevLoadResult): string {
|
||||
const how =
|
||||
result.approval === 'approve-flag'
|
||||
? 'approved with --approve'
|
||||
: result.approval === 'content-only'
|
||||
? 'content only, no approval needed'
|
||||
: 'already approved'
|
||||
return [
|
||||
`${result.added ? 'Loaded' : 'Reloaded'} ${result.pluginKey} from ${result.path} (${how}).`,
|
||||
result.plugin ? formatPluginRow(result.plugin) : '',
|
||||
'Orca reloads it (and restarts its worker) when its files change; `orca plugin logs` shows what it printed.'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
async function devLoad(ctx: HandlerContext, path: string): Promise<void> {
|
||||
const result = await callPlugins<DevLoadResult>(ctx, 'plugins.devLoad', {
|
||||
path,
|
||||
approvalFingerprint: stringFlag(ctx, 'approve')
|
||||
})
|
||||
const typeFiles = await refreshDevTypes(ctx, path)
|
||||
printPluginResult(
|
||||
ctx,
|
||||
{ ...result, typeFiles },
|
||||
[formatDevLoad(result), ...typeFiles.map((file) => `Refreshed ${file}`)].join('\n')
|
||||
)
|
||||
}
|
||||
|
||||
/** Why best effort and local only: typings help the editor; a remote host's folder is not here. */
|
||||
export async function refreshDevTypes(ctx: HandlerContext, path: string): Promise<string[]> {
|
||||
if (ctx.client.isRemote) {
|
||||
return []
|
||||
}
|
||||
// Lazy: the manifest schema it parses with is heavy for every other runtime command.
|
||||
const types = await import('./authoring/plugin-type-files.js')
|
||||
if (!(await types.pluginFolderWantsTypes(path))) {
|
||||
return []
|
||||
}
|
||||
return types
|
||||
.writePluginTypeFiles(path)
|
||||
.then((files) => files.filter((file) => file.endsWith('.d.ts')))
|
||||
.catch(() => [])
|
||||
}
|
||||
|
||||
export function requirePluginKey(ctx: HandlerContext): string {
|
||||
const key = stringFlag(ctx, 'plugin')
|
||||
if (!key || !isQualifiedPluginKey(key)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Pass a plugin key such as acme.dusk; `orca plugin list` shows them.'
|
||||
)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
async function setEnabled(ctx: HandlerContext, enabled: boolean): Promise<void> {
|
||||
const pluginKey = requirePluginKey(ctx)
|
||||
const list = await callPlugins<PluginHostListEntry[]>(ctx, 'plugins.setEnabled', {
|
||||
pluginKey,
|
||||
enabled,
|
||||
...(enabled ? { approvalFingerprint: stringFlag(ctx, 'approve') } : {})
|
||||
})
|
||||
const plugin = list.find((entry) => entry.pluginKey === pluginKey) ?? null
|
||||
printPluginResult(
|
||||
ctx,
|
||||
{ pluginKey, enabled, plugin },
|
||||
plugin ? formatPluginRow(plugin) : `${pluginKey} ${enabled ? 'enabled' : 'disabled'}`
|
||||
)
|
||||
}
|
||||
|
||||
async function setSystemEnabled(ctx: HandlerContext, enabled: boolean): Promise<void> {
|
||||
const result = await callPlugins<{ enabled: boolean; plugins: PluginHostListEntry[] }>(
|
||||
ctx,
|
||||
'plugins.setSystemEnabled',
|
||||
{ enabled }
|
||||
)
|
||||
const rows = result.plugins.map(formatPluginRow)
|
||||
printPluginResult(
|
||||
ctx,
|
||||
result,
|
||||
enabled
|
||||
? ['Plugins are on. Only plugins approved earlier run.', ...rows].join('\n')
|
||||
: 'Plugins are off. No plugin runs; installs and approvals are kept. Automations plugins created are paused.'
|
||||
)
|
||||
}
|
||||
|
||||
export const PLUGIN_RUNTIME_HANDLERS: Record<string, CommandHandler> = {
|
||||
'plugin list': async (ctx) => {
|
||||
const plugins = await callPlugins<PluginHostListEntry[]>(ctx, 'plugins.list')
|
||||
printPluginResult(
|
||||
ctx,
|
||||
{ plugins },
|
||||
plugins.length === 0 ? 'No plugins.' : plugins.map(formatPluginRow).join('\n')
|
||||
)
|
||||
},
|
||||
'plugin logs': async (ctx) => {
|
||||
const result = await callPlugins<LogsResult>(ctx, 'plugins.getLogs', {
|
||||
pluginKey: requirePluginKey(ctx)
|
||||
})
|
||||
printPluginResult(
|
||||
ctx,
|
||||
result,
|
||||
result.lines.length === 0
|
||||
? `No log lines from ${result.pluginKey}.`
|
||||
: result.lines
|
||||
.map((line) => `${new Date(line.ts).toISOString()} ${line.level} ${line.line}`)
|
||||
.join('\n')
|
||||
)
|
||||
},
|
||||
'plugin restart': async (ctx) => {
|
||||
const result = await callPlugins<{ pluginKey: string; plugin: PluginHostListEntry | null }>(
|
||||
ctx,
|
||||
'plugins.restart',
|
||||
{ pluginKey: requirePluginKey(ctx) }
|
||||
)
|
||||
printPluginResult(
|
||||
ctx,
|
||||
result,
|
||||
`Stopped ${result.pluginKey}'s worker; its next command or event starts the current code.`
|
||||
)
|
||||
},
|
||||
'plugin enable': (ctx) => setEnabled(ctx, true),
|
||||
'plugin disable': (ctx) => setEnabled(ctx, false),
|
||||
'plugin system on': (ctx) => setSystemEnabled(ctx, true),
|
||||
'plugin system off': (ctx) => setSystemEnabled(ctx, false),
|
||||
'plugin dev load': (ctx) =>
|
||||
devLoad(ctx, resolveHostPluginPath(ctx, requirePluginPathArg(ctx, 'dir'))),
|
||||
'plugin dev reload': async (ctx) => {
|
||||
const dir = stringFlag(ctx, 'dir')
|
||||
if (dir) {
|
||||
await devLoad(ctx, resolveHostPluginPath(ctx, dir))
|
||||
return
|
||||
}
|
||||
const plugins = await callPlugins<PluginHostListEntry[]>(ctx, 'plugins.refresh')
|
||||
printPluginResult(ctx, { plugins }, `Reloaded ${plugins.length} plugin(s).`)
|
||||
},
|
||||
'plugin dev inspect': async (ctx) => {
|
||||
const result = await callPlugins<{ pluginKey: string; opened: number }>(
|
||||
ctx,
|
||||
'plugins.devInspect',
|
||||
{ pluginKey: requirePluginKey(ctx) }
|
||||
)
|
||||
printPluginResult(
|
||||
ctx,
|
||||
result,
|
||||
result.opened === 0
|
||||
? `${result.pluginKey} has no open panel or editor; open one in Orca, then run this again.`
|
||||
: `Opened DevTools for ${result.opened} ${result.pluginKey} frame(s).`
|
||||
)
|
||||
},
|
||||
'plugin dev unload': async (ctx) => {
|
||||
const params = devTargetParams(ctx, requirePluginPathArg(ctx, 'target'))
|
||||
const result = await callPlugins<{ path: string; pluginKey: string | null }>(
|
||||
ctx,
|
||||
'plugins.devUnload',
|
||||
params
|
||||
)
|
||||
printPluginResult(ctx, result, `Unloaded ${result.pluginKey ?? 'the folder'} (${result.path}).`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { COMMAND_SPECS } from '../../specs/index'
|
||||
import { normalizeCommandPositionals, parseArgs } from '../../args'
|
||||
import { specPaths } from '../../command-spec'
|
||||
import type { HandlerContext } from '../../dispatch'
|
||||
import type { RuntimeClient } from '../../runtime/client'
|
||||
import { PLUGIN_VIEW_HANDLERS } from './plugin-view'
|
||||
|
||||
function context(argv: string[], call: ReturnType<typeof vi.fn>, cwd: string): HandlerContext {
|
||||
const paths = COMMAND_SPECS.flatMap((spec) => specPaths(spec))
|
||||
const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv, paths, COMMAND_SPECS))
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers only call client.call and read isRemote.
|
||||
const client = { call, isRemote: false } as unknown as RuntimeClient
|
||||
return { flags: parsed.flags, client, cwd, json: true, rawArgs: argv }
|
||||
}
|
||||
|
||||
let cwd: string
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = await mkdtemp(join(tmpdir(), 'orca-chrome-shot-'))
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const shot = (argv: string[], call: ReturnType<typeof vi.fn>) =>
|
||||
PLUGIN_VIEW_HANDLERS['plugin shot']!(context(['plugin', 'shot', ...argv], call, cwd))
|
||||
|
||||
describe('orca plugin shot --status-bar / --window', () => {
|
||||
it('refuses both regions at once, and flags that belong to other shots', async () => {
|
||||
const call = vi.fn()
|
||||
await expect(shot(['acme.todos', '--status-bar', '--window'], call)).rejects.toThrow(
|
||||
/--status-bar or --window, not both/
|
||||
)
|
||||
await expect(shot(['acme.todos', '--window', '--panel', 'todos'], call)).rejects.toThrow(
|
||||
/--panel is not for a --window shot/
|
||||
)
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { HandlerContext } from '../../dispatch'
|
||||
import { printPluginResult } from '../plugin-cli-output'
|
||||
import { RuntimeClientError } from '../../runtime/types'
|
||||
import { callPlugins } from '../plugin-runtime'
|
||||
import { editorShotPluginKey } from './plugin-shot-editor'
|
||||
|
||||
type ChromeRegion = 'status-bar' | 'window'
|
||||
|
||||
/** What `plugins.shotChrome` answers; `png` is base64. */
|
||||
type PluginChromeShotReply = {
|
||||
pluginKey: string
|
||||
region: ChromeRegion
|
||||
png: string
|
||||
width: number
|
||||
height: number
|
||||
colorScheme: 'light' | 'dark'
|
||||
themeApplied: boolean
|
||||
themeReason?: string
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export type PluginChromeShotSaved = Omit<PluginChromeShotReply, 'png' | 'colorScheme'> & {
|
||||
path: string
|
||||
theme: 'light' | 'dark'
|
||||
}
|
||||
|
||||
// Above the host's 10s for the renderer to repaint and measure.
|
||||
const CHROME_SHOT_RPC_TIMEOUT_MS = 20_000
|
||||
|
||||
/** `--status-bar` or `--window`, or null for a panel or editor shot. */
|
||||
export function chromeShotRegion(ctx: HandlerContext): ChromeRegion | null {
|
||||
const statusBar = ctx.flags.has('status-bar')
|
||||
const window = ctx.flags.has('window')
|
||||
if (statusBar && window) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Pass --status-bar or --window, not both.')
|
||||
}
|
||||
if (!statusBar && !window) {
|
||||
return null
|
||||
}
|
||||
const other = ['panel', 'editor', 'scenario', 'act', 'width', 'height', 'in-place'].find((flag) =>
|
||||
ctx.flags.has(flag)
|
||||
)
|
||||
if (other) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--${other} is not for a ${statusBar ? '--status-bar' : '--window'} shot; it takes --theme and --out.`
|
||||
)
|
||||
}
|
||||
return statusBar ? 'status-bar' : 'window'
|
||||
}
|
||||
|
||||
export function formatPluginChromeShot(shot: PluginChromeShotSaved, requested?: string): string {
|
||||
return [
|
||||
`Saved ${shot.path} (${shot.width}x${shot.height}, ${shot.theme} theme, Orca's ${shot.region === 'window' ? 'window' : 'status bar'})`,
|
||||
...(requested && !shot.themeApplied
|
||||
? [` note: ${requested} was not applied (${shot.themeReason ?? 'Orca could not paint it'}).`]
|
||||
: []),
|
||||
...shot.errors.map((error) => ` error: ${error}`)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Captures Orca's status bar or whole window on the host and writes the PNG here. */
|
||||
export async function runPluginChromeShot(
|
||||
ctx: HandlerContext,
|
||||
request: {
|
||||
region: ChromeRegion
|
||||
colorScheme: 'light' | 'dark' | undefined
|
||||
out: string | null
|
||||
shotsDir: string
|
||||
}
|
||||
): Promise<void> {
|
||||
const pluginKey = await editorShotPluginKey(ctx)
|
||||
const reply = await callPlugins<PluginChromeShotReply>(
|
||||
ctx,
|
||||
'plugins.shotChrome',
|
||||
{
|
||||
pluginKey,
|
||||
region: request.region,
|
||||
...(request.colorScheme ? { colorScheme: request.colorScheme } : {})
|
||||
},
|
||||
{ timeoutMs: CHROME_SHOT_RPC_TIMEOUT_MS }
|
||||
)
|
||||
const { png, colorScheme, ...rest } = reply
|
||||
const path = request.out ?? join(request.shotsDir, `${reply.pluginKey}-${reply.region}.png`)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, Buffer.from(png, 'base64'))
|
||||
const saved: PluginChromeShotSaved = { ...rest, path, theme: colorScheme }
|
||||
printPluginResult(ctx, saved, formatPluginChromeShot(saved, request.colorScheme))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defaultPluginShotsDir, enclosingPluginFolder } from './plugin-shot-default-dir'
|
||||
|
||||
const ROOT = join('/', 'work', 'repo')
|
||||
const PLUGIN = join(ROOT, 'plugins', 'todos')
|
||||
|
||||
function filesystem(...paths: string[]): (path: string) => boolean {
|
||||
const present = new Set(paths)
|
||||
return (path) => present.has(path)
|
||||
}
|
||||
|
||||
describe('plugin shot default folder', () => {
|
||||
it('finds the plugin folder from inside it, up to the repository root', () => {
|
||||
const exists = filesystem(join(PLUGIN, 'orca-plugin.json'), join(ROOT, '.git'))
|
||||
expect(enclosingPluginFolder(join(PLUGIN, 'src', 'panels'), exists)).toBe(PLUGIN)
|
||||
expect(defaultPluginShotsDir(PLUGIN, exists)).toBe(join(PLUGIN, '.orca', 'shots'))
|
||||
})
|
||||
|
||||
it('keeps <cwd>/.orca/shots elsewhere, and never looks above the repository root', () => {
|
||||
const exists = filesystem(join('/', 'work', 'orca-plugin.json'), join(ROOT, '.git'))
|
||||
expect(enclosingPluginFolder(join(ROOT, 'docs'), exists)).toBeNull()
|
||||
expect(defaultPluginShotsDir(join(ROOT, 'docs'), exists)).toBe(
|
||||
join(ROOT, 'docs', '.orca', 'shots')
|
||||
)
|
||||
expect(enclosingPluginFolder(join('/', 'tmp'), filesystem())).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
/**
|
||||
* The plugin folder `cwd` is in: the nearest directory holding `orca-plugin.json`, looking up no
|
||||
* further than the repository root (the first directory with `.git`).
|
||||
*/
|
||||
export function enclosingPluginFolder(
|
||||
cwd: string,
|
||||
exists: (path: string) => boolean = existsSync
|
||||
): string | null {
|
||||
for (let dir = cwd; ; dir = dirname(dir)) {
|
||||
if (exists(join(dir, 'orca-plugin.json'))) {
|
||||
return dir
|
||||
}
|
||||
if (exists(join(dir, '.git')) || dirname(dir) === dir) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Where shots go without --out: beside the plugin, as `dev watch --shot` writes them. */
|
||||
export function defaultPluginShotsDir(
|
||||
cwd: string,
|
||||
exists: (path: string) => boolean = existsSync
|
||||
): string {
|
||||
return join(enclosingPluginFolder(cwd, exists) ?? cwd, '.orca', 'shots')
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { COMMAND_SPECS } from '../../specs/index'
|
||||
import { normalizeCommandPositionals, parseArgs } from '../../args'
|
||||
import { specPaths } from '../../command-spec'
|
||||
import type { HandlerContext } from '../../dispatch'
|
||||
import type { RuntimeClient } from '../../runtime/client'
|
||||
import { PLUGIN_VIEW_HANDLERS } from './plugin-view'
|
||||
|
||||
// A 1x1 PNG.
|
||||
const PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
|
||||
|
||||
function context(argv: string[], call: ReturnType<typeof vi.fn>, cwd: string): HandlerContext {
|
||||
const paths = COMMAND_SPECS.flatMap((spec) => specPaths(spec))
|
||||
const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv, paths, COMMAND_SPECS))
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers only call client.call and read isRemote.
|
||||
const client = { call, isRemote: false } as unknown as RuntimeClient
|
||||
return { flags: parsed.flags, client, cwd, json: true, rawArgs: argv }
|
||||
}
|
||||
|
||||
const reply = (overrides: Record<string, unknown> = {}) => ({
|
||||
result: {
|
||||
pluginKey: 'local.sketch',
|
||||
editorId: 'sketch',
|
||||
title: 'Sketch',
|
||||
filePath: '/w/a.sketch',
|
||||
png: PNG_BASE64,
|
||||
width: 800,
|
||||
height: 600,
|
||||
colorScheme: 'light',
|
||||
themeApplied: true,
|
||||
settled: true,
|
||||
subscribed: true,
|
||||
errors: [],
|
||||
...overrides
|
||||
}
|
||||
})
|
||||
|
||||
let cwd: string
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = await mkdtemp(join(tmpdir(), 'orca-editor-shot-'))
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
process.exitCode = undefined
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const shot = (argv: string[], call: ReturnType<typeof vi.fn>) =>
|
||||
PLUGIN_VIEW_HANDLERS['plugin shot']!(context(['plugin', 'shot', ...argv], call, cwd))
|
||||
|
||||
describe('orca plugin shot --editor', () => {
|
||||
it('takes a plugin folder, sends size and acts, and writes the PNG under .orca/shots', async () => {
|
||||
await mkdir(join(cwd, 'sketch'))
|
||||
await writeFile(
|
||||
join(cwd, 'sketch', 'orca-plugin.json'),
|
||||
JSON.stringify({
|
||||
manifestVersion: 2,
|
||||
execution: 'sandboxed-ui',
|
||||
id: 'sketch',
|
||||
name: 'Sketch',
|
||||
version: '1.0.0',
|
||||
engines: { orca: '>=1.4.246' },
|
||||
contributes: {
|
||||
customEditors: [
|
||||
{ id: 'sketch', title: 'Sketch', entry: 'editor.html', extensions: ['.sketch'] }
|
||||
]
|
||||
}
|
||||
})
|
||||
)
|
||||
const call = vi.fn().mockResolvedValue(reply({ acted: 2 }))
|
||||
await shot(
|
||||
[
|
||||
'./sketch',
|
||||
'--editor',
|
||||
'a.sketch',
|
||||
'--theme',
|
||||
'light',
|
||||
'--width',
|
||||
'800',
|
||||
'--height',
|
||||
'600',
|
||||
'--act',
|
||||
'[{"click":[10,20]},{"key":"r"}]'
|
||||
],
|
||||
call
|
||||
)
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'plugins.shotEditor',
|
||||
{
|
||||
pluginKey: 'local.sketch',
|
||||
filePath: join(cwd, 'a.sketch'),
|
||||
colorScheme: 'light',
|
||||
width: 800,
|
||||
height: 600,
|
||||
acts: [{ click: [10, 20] }, { key: 'r' }]
|
||||
},
|
||||
expect.anything()
|
||||
)
|
||||
const png = await readFile(join(cwd, '.orca', 'shots', 'local.sketch-sketch.png'))
|
||||
expect(png.toString('base64')).toBe(PNG_BASE64)
|
||||
expect(process.exitCode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('passes --in-place, and notes whether acts ran on a copy', async () => {
|
||||
const call = vi.fn().mockResolvedValue(reply({ acted: 1 }))
|
||||
await shot(
|
||||
[
|
||||
'local.sketch',
|
||||
'--editor',
|
||||
'/w/a.sketch',
|
||||
'--act',
|
||||
'{"key":"r"}',
|
||||
'--out',
|
||||
'x.png',
|
||||
'--in-place'
|
||||
],
|
||||
call
|
||||
)
|
||||
expect(call.mock.calls[0]?.[1]).toMatchObject({ inPlace: true, acts: [{ key: 'r' }] })
|
||||
await expect(shot(['local.sketch', '--in-place'], vi.fn())).rejects.toThrow(/--editor/)
|
||||
})
|
||||
|
||||
it('exits 1 when the editor never asked for its document or logged errors', async () => {
|
||||
await shot(
|
||||
['local.sketch', '--editor', 'a.sketch', '--out', 'x.png'],
|
||||
vi.fn().mockResolvedValue(reply({ subscribed: false, errors: ['never asked'] }))
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('checks its flags before reaching the host', async () => {
|
||||
const call = vi.fn()
|
||||
const run = (argv: string[]) => shot(['local.sketch', '--editor', 'a.sketch', ...argv], call)
|
||||
await expect(run(['--act', '[{"click":[-1,0]}]'])).rejects.toThrow(/--act: action 1/)
|
||||
await expect(run(['--height', '50'])).rejects.toThrow(/--height takes/)
|
||||
await expect(run(['--panel', 'p'])).rejects.toThrow(/--panel or --editor/)
|
||||
await expect(shot(['local.sketch', '--height', '600'], call)).rejects.toThrow(/--editor/)
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import type { HandlerContext } from '../../dispatch'
|
||||
import { getOptionalPositiveIntegerFlag } from '../../flags'
|
||||
import { printPluginResult } from '../plugin-cli-output'
|
||||
import { RuntimeClientError } from '../../runtime/types'
|
||||
import { isQualifiedPluginKey } from '../../../shared/plugins/frames/plugin-tab-key'
|
||||
import {
|
||||
parsePluginEditorShotActs,
|
||||
type PluginEditorShotAct
|
||||
} from '../../../shared/plugins/view/plugin-editor-shot-acts'
|
||||
import {
|
||||
PLUGIN_SHOT_MAX_HEIGHT,
|
||||
PLUGIN_SHOT_MAX_WIDTH,
|
||||
PLUGIN_SHOT_MIN_HEIGHT,
|
||||
PLUGIN_SHOT_MIN_WIDTH
|
||||
} from '../../../shared/rpc-contract/plugins-params'
|
||||
import { callPlugins, resolveHostPluginPath, stringFlag } from '../plugin-runtime'
|
||||
import { readPluginFolderKey } from './plugin-shot-scenario'
|
||||
|
||||
/** What `plugins.shotEditor` answers; `png` is base64. */
|
||||
type PluginEditorShotReply = {
|
||||
pluginKey: string
|
||||
editorId: string
|
||||
title: string
|
||||
filePath: string
|
||||
png: string
|
||||
width: number
|
||||
height: number
|
||||
colorScheme: 'light' | 'dark'
|
||||
themeApplied: boolean
|
||||
themeReason?: string
|
||||
settled: boolean
|
||||
subscribed: boolean
|
||||
errors: string[]
|
||||
acted?: number
|
||||
copy?: true
|
||||
}
|
||||
|
||||
export type PluginEditorShotSaved = Omit<PluginEditorShotReply, 'png' | 'colorScheme'> & {
|
||||
path: string
|
||||
theme: 'light' | 'dark'
|
||||
}
|
||||
|
||||
// Why above the host's 25s: it waits for the editor to load, then for each act to paint.
|
||||
const EDITOR_SHOT_RPC_TIMEOUT_MS = 40_000
|
||||
const EDITOR_SHOT_RPC_ACT_TIMEOUT_MS = 2_000
|
||||
|
||||
/** Pixel flags for an editor shot; each takes the host's limits. */
|
||||
export function parseEditorShotSize(ctx: HandlerContext): { width?: number; height?: number } {
|
||||
const width = getOptionalPositiveIntegerFlag(ctx.flags, 'width')
|
||||
const height = getOptionalPositiveIntegerFlag(ctx.flags, 'height')
|
||||
if (width !== undefined && (width < PLUGIN_SHOT_MIN_WIDTH || width > PLUGIN_SHOT_MAX_WIDTH)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--width takes ${PLUGIN_SHOT_MIN_WIDTH} to ${PLUGIN_SHOT_MAX_WIDTH} pixels.`
|
||||
)
|
||||
}
|
||||
if (
|
||||
height !== undefined &&
|
||||
(height < PLUGIN_SHOT_MIN_HEIGHT || height > PLUGIN_SHOT_MAX_HEIGHT)
|
||||
) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--height takes ${PLUGIN_SHOT_MIN_HEIGHT} to ${PLUGIN_SHOT_MAX_HEIGHT} pixels.`
|
||||
)
|
||||
}
|
||||
return { ...(width === undefined ? {} : { width }), ...(height === undefined ? {} : { height }) }
|
||||
}
|
||||
|
||||
export function parseEditorShotActs(ctx: HandlerContext): PluginEditorShotAct[] | undefined {
|
||||
const json = stringFlag(ctx, 'act')
|
||||
if (json === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = parsePluginEditorShotActs(json)
|
||||
if (!parsed.ok) {
|
||||
throw new RuntimeClientError('invalid_argument', `--act: ${parsed.error}.`)
|
||||
}
|
||||
return parsed.acts
|
||||
}
|
||||
|
||||
/** `<dir|plugin-key>`: a local plugin folder names the key it loads under. */
|
||||
export async function editorShotPluginKey(ctx: HandlerContext): Promise<string> {
|
||||
const target = stringFlag(ctx, 'plugin')
|
||||
if (target === undefined) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Pass the plugin folder or its key.')
|
||||
}
|
||||
const asPath = isAbsolute(target) ? target : resolve(ctx.cwd, target)
|
||||
if (!ctx.client.isRemote && existsSync(asPath)) {
|
||||
return readPluginFolderKey(asPath)
|
||||
}
|
||||
if (!isQualifiedPluginKey(target)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Pass a plugin folder, or a plugin key such as acme.sketch; `orca plugin list` shows them.'
|
||||
)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
export function formatPluginEditorShot(shot: PluginEditorShotSaved, requested?: string): string {
|
||||
return [
|
||||
`Saved ${shot.path} (${shot.width}x${shot.height}, ${shot.theme} theme, ${shot.title} on ${shot.filePath})`,
|
||||
...(requested && !shot.themeApplied
|
||||
? [` note: ${requested} was not applied (${shot.themeReason ?? 'Orca could not paint it'}).`]
|
||||
: []),
|
||||
...(shot.acted
|
||||
? [
|
||||
` acted: ${shot.acted} input action(s) sent to the editor${shot.copy ? ', on a temporary copy of the file' : ', on the file itself'}`
|
||||
]
|
||||
: []),
|
||||
...shot.errors.map((error) => ` error: ${error}`)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Opens the file in the plugin's editor on the host, captures it and writes the PNG here. */
|
||||
export async function runPluginEditorShot(
|
||||
ctx: HandlerContext,
|
||||
request: {
|
||||
editor: string
|
||||
colorScheme?: 'light' | 'dark'
|
||||
out: string | null
|
||||
shotsDir: string
|
||||
}
|
||||
): Promise<void> {
|
||||
const pluginKey = await editorShotPluginKey(ctx)
|
||||
const acts = parseEditorShotActs(ctx)
|
||||
const reply = await callPlugins<PluginEditorShotReply>(
|
||||
ctx,
|
||||
'plugins.shotEditor',
|
||||
{
|
||||
pluginKey,
|
||||
filePath: resolveHostPluginPath(ctx, request.editor),
|
||||
...(request.colorScheme ? { colorScheme: request.colorScheme } : {}),
|
||||
...parseEditorShotSize(ctx),
|
||||
...(acts ? { acts } : {}),
|
||||
...(ctx.flags.has('in-place') ? { inPlace: true } : {})
|
||||
},
|
||||
{ timeoutMs: EDITOR_SHOT_RPC_TIMEOUT_MS + (acts?.length ?? 0) * EDITOR_SHOT_RPC_ACT_TIMEOUT_MS }
|
||||
)
|
||||
const { png, colorScheme, ...rest } = reply
|
||||
const path = request.out ?? join(request.shotsDir, `${reply.pluginKey}-${reply.editorId}.png`)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, Buffer.from(png, 'base64'))
|
||||
const saved: PluginEditorShotSaved = { ...rest, path, theme: colorScheme }
|
||||
printPluginResult(ctx, saved, formatPluginEditorShot(saved, request.colorScheme))
|
||||
// The PNG is saved either way; a broken editor still fails the command.
|
||||
if (!reply.subscribed || reply.errors.length > 0) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { COMMAND_SPECS } from '../../specs/index'
|
||||
import { normalizeCommandPositionals, parseArgs } from '../../args'
|
||||
import { specPaths } from '../../command-spec'
|
||||
import type { HandlerContext } from '../../dispatch'
|
||||
import type { RuntimeClient } from '../../runtime/client'
|
||||
import { RuntimeRpcFailureError } from '../../runtime/types'
|
||||
import { createPluginFromTemplate } from '../authoring/plugin-create'
|
||||
import { PLUGIN_VIEW_HANDLERS, type PluginShotReply } from './plugin-view'
|
||||
|
||||
const check = vi.hoisted(() => ({
|
||||
readPluginCheckScenarioNames: vi.fn<(dir: string, entry: string) => Promise<string[]>>(),
|
||||
checkPluginPanels: vi.fn(),
|
||||
formatPluginPanelCheck: vi.fn(() => [' src/panel.tsx: boom'])
|
||||
}))
|
||||
vi.mock('../authoring/plugin-panel-check', () => check)
|
||||
|
||||
const PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
|
||||
const TREE = { type: 'Text', props: {}, children: ['3 counted'] }
|
||||
|
||||
function context(
|
||||
argv: string[],
|
||||
call: ReturnType<typeof vi.fn>,
|
||||
options: { cwd: string; remote?: boolean }
|
||||
): HandlerContext {
|
||||
const paths = COMMAND_SPECS.flatMap((spec) => specPaths(spec))
|
||||
const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv, paths, COMMAND_SPECS))
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers only call client.call and read isRemote.
|
||||
const client = { call, isRemote: options.remote === true } as unknown as RuntimeClient
|
||||
return { flags: parsed.flags, client, cwd: options.cwd, json: true, rawArgs: argv }
|
||||
}
|
||||
|
||||
function replayReply(overrides: Partial<PluginShotReply & { replay: true }> = {}) {
|
||||
return {
|
||||
pluginKey: 'x',
|
||||
panelId: 'counter',
|
||||
title: 'Counter',
|
||||
ui: 'components',
|
||||
png: PNG_BASE64,
|
||||
width: 320,
|
||||
height: 120,
|
||||
colorScheme: 'dark',
|
||||
themeApplied: true,
|
||||
settled: true,
|
||||
errors: [],
|
||||
replay: true,
|
||||
scenario: 'counted',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
let root: string
|
||||
let dir: string
|
||||
let pluginKey: string
|
||||
let panelId: string
|
||||
let stdout: string[]
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-shot-scenario-')))
|
||||
dir = join(root, 'counter')
|
||||
await createPluginFromTemplate({ dir, template: 'worker-panel' })
|
||||
const manifest = JSON.parse(await readFile(join(dir, 'orca-plugin.json'), 'utf8'))
|
||||
pluginKey = `${manifest.publisher ?? 'local'}.${manifest.id}`
|
||||
panelId = manifest.contributes.panels[0].id
|
||||
check.readPluginCheckScenarioNames.mockResolvedValue(['fresh', 'counted'])
|
||||
check.checkPluginPanels.mockResolvedValue([
|
||||
{ panel: panelId, entry: 'src/panel.tsx', scenario: 'counted', drawnTree: TREE, issues: [] }
|
||||
])
|
||||
stdout = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
stdout.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
check.readPluginCheckScenarioNames.mockReset()
|
||||
check.checkPluginPanels.mockReset()
|
||||
process.exitCode = undefined
|
||||
delete process.env.ORCA_CLI_CWD
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const run = (argv: string[], call: ReturnType<typeof vi.fn>, remote = false) =>
|
||||
PLUGIN_VIEW_HANDLERS['plugin shot'](context(argv, call, { cwd: root, remote }))
|
||||
|
||||
const printed = () => JSON.parse(stdout.join('')).result
|
||||
|
||||
describe('orca plugin shot --scenario', () => {
|
||||
it('renders the scenario here and sends only its tree to plugins.shotReplay', async () => {
|
||||
const call = vi.fn(async () => ({ result: replayReply() }))
|
||||
await run(['plugin', 'shot', dir, '--scenario', 'counted', '--theme', 'dark'], call)
|
||||
expect(check.checkPluginPanels).toHaveBeenCalledWith(dir, {
|
||||
panel: panelId,
|
||||
scenario: 'counted',
|
||||
drawnTree: true
|
||||
})
|
||||
expect(call.mock.calls).toEqual([
|
||||
[
|
||||
'plugins.shotReplay',
|
||||
{
|
||||
pluginKey,
|
||||
panelId,
|
||||
title: expect.any(String),
|
||||
scenario: 'counted',
|
||||
tree: TREE,
|
||||
colorScheme: 'dark'
|
||||
},
|
||||
{ timeoutMs: expect.any(Number) }
|
||||
]
|
||||
])
|
||||
const result = printed()
|
||||
expect(result).toMatchObject({ replay: true, scenario: 'counted', errors: [] })
|
||||
expect(result.notes[0]).toMatch(/host-owned control values start from the tree's props/)
|
||||
expect(result.path).toBe(join(dir, '.orca', 'shots', `${pluginKey}-${panelId}.counted.png`))
|
||||
expect((await readFile(result.path)).toString('base64')).toBe(PNG_BASE64)
|
||||
})
|
||||
|
||||
it('refuses another host and a bridged shell, since it runs the code here', async () => {
|
||||
await expect(
|
||||
run(['plugin', 'shot', dir, '--scenario', 'counted'], vi.fn(), true)
|
||||
).rejects.toMatchObject({ code: 'invalid_environment' })
|
||||
process.env.ORCA_CLI_CWD = '/elsewhere'
|
||||
await expect(
|
||||
run(['plugin', 'shot', dir, '--scenario', 'counted'], vi.fn())
|
||||
).rejects.toMatchObject({ code: 'invalid_environment' })
|
||||
expect(check.readPluginCheckScenarioNames).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('takes a dev plugin key inside its folder and refuses an installed one', async () => {
|
||||
const list = (isDev: boolean) =>
|
||||
vi.fn(async (method: string) => ({
|
||||
result: method === 'plugins.list' ? [{ pluginKey, isDev }] : replayReply()
|
||||
}))
|
||||
await expect(
|
||||
PLUGIN_VIEW_HANDLERS['plugin shot'](
|
||||
context(['plugin', 'shot', pluginKey, '--scenario', 'counted'], list(false), { cwd: dir })
|
||||
)
|
||||
).rejects.toThrow(/is installed, not a development folder/)
|
||||
expect(check.readPluginCheckScenarioNames).not.toHaveBeenCalled()
|
||||
const dev = list(true)
|
||||
await PLUGIN_VIEW_HANDLERS['plugin shot'](
|
||||
context(['plugin', 'shot', pluginKey, '--scenario', 'counted'], dev, { cwd: dir })
|
||||
)
|
||||
expect(dev.mock.calls.map(([method]) => method)).toEqual(['plugins.list', 'plugins.shotReplay'])
|
||||
// A key from outside its folder: Orca does not share dev folder paths.
|
||||
await expect(
|
||||
run(['plugin', 'shot', pluginKey, '--scenario', 'counted'], list(true))
|
||||
).rejects.toThrow(/Run this inside/)
|
||||
})
|
||||
|
||||
it('reports an older host as runtime_unsupported, never falling back to a live shot', async () => {
|
||||
const tooOld = vi.fn(async (_method: string) => {
|
||||
throw new RuntimeRpcFailureError({
|
||||
id: '1',
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method: plugins.shotReplay' },
|
||||
_meta: { runtimeId: 'r' }
|
||||
})
|
||||
})
|
||||
await expect(
|
||||
run(['plugin', 'shot', dir, '--scenario', 'counted'], tooOld)
|
||||
).rejects.toMatchObject({ code: 'runtime_unsupported' })
|
||||
expect(tooOld.mock.calls.map(([method]) => method)).toEqual(['plugins.shotReplay'])
|
||||
// A host that answered without drawing the replay is not trusted either.
|
||||
const ignored = vi.fn(async () => ({ result: { ...replayReply(), replay: undefined } }))
|
||||
await expect(
|
||||
run(['plugin', 'shot', dir, '--scenario', 'counted'], ignored)
|
||||
).rejects.toMatchObject({ code: 'runtime_unsupported' })
|
||||
})
|
||||
|
||||
it('fails when the scenario drew no tree, without reaching the host', async () => {
|
||||
check.checkPluginPanels.mockResolvedValue([
|
||||
{
|
||||
panel: panelId,
|
||||
entry: 'src/panel.tsx',
|
||||
drawnTree: null,
|
||||
issues: [{ kind: 'plugin', message: 'boom' }]
|
||||
}
|
||||
])
|
||||
const call = vi.fn()
|
||||
await expect(run(['plugin', 'shot', dir, '--scenario', 'counted'], call)).rejects.toMatchObject(
|
||||
{
|
||||
code: 'plugin_check_failed'
|
||||
}
|
||||
)
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import type { HandlerContext } from '../../dispatch'
|
||||
import { RuntimeClientError } from '../../runtime/types'
|
||||
import type { PluginHostListEntry } from '../../../shared/plugins/plugin-list-entry'
|
||||
import { isQualifiedPluginKey } from '../../../shared/plugins/frames/plugin-tab-key'
|
||||
import {
|
||||
parsePluginManifest,
|
||||
qualifiedPluginKey,
|
||||
type PluginManifest
|
||||
} from '../../../shared/plugins/manifest/plugin-manifest'
|
||||
import {
|
||||
localPluginPublisher,
|
||||
withPluginPublisher
|
||||
} from '../../../shared/plugins/installation/plugin-publisher-derivation'
|
||||
import { callPlugins } from '../plugin-runtime'
|
||||
import { enclosingPluginFolder } from './plugin-shot-default-dir'
|
||||
import type { PluginShotReply, PluginShotSaved } from './plugin-view'
|
||||
|
||||
/** What `plugins.shotReplay` answers: a shot of the drawn scenario tree. */
|
||||
type PluginShotReplayReply = PluginShotReply & { replay?: true; scenario?: string }
|
||||
|
||||
export type PluginShotScenarioRequest = {
|
||||
target: string
|
||||
scenario: string
|
||||
panelId?: string
|
||||
colorScheme?: 'light' | 'dark'
|
||||
width?: number
|
||||
out?: string
|
||||
}
|
||||
|
||||
// Why above the host's: it waits up to 10s for the preview to draw and answer, then captures.
|
||||
const REPLAY_RPC_TIMEOUT_MS = 30_000
|
||||
|
||||
export const PLUGIN_SHOT_REPLAY_NOTE =
|
||||
"a still frame of what the scenario rendered, drawn apart from the plugin: host-owned control values start from the tree's props and images show their placeholder"
|
||||
|
||||
function refuse(code: string, message: string, nextSteps: string[] = []): RuntimeClientError {
|
||||
return new RuntimeClientError(code, message, nextSteps.length > 0 ? { nextSteps } : undefined)
|
||||
}
|
||||
|
||||
/** Why local only: the scenario runs the plugin's code here, then Orca on this machine draws it. */
|
||||
function requireLocal(ctx: HandlerContext): void {
|
||||
if (process.env.ORCA_CLI_CWD || ctx.client.isRemote) {
|
||||
throw refuse(
|
||||
'invalid_environment',
|
||||
"shot --scenario runs the plugin's code on this machine and shows it in the Orca on this machine, so it does not reach another host. Run it where Orca and the plugin folder are, without --environment."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function readManifest(dir: string): Promise<PluginManifest> {
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = JSON.parse(await readFile(join(dir, 'orca-plugin.json'), 'utf8'))
|
||||
} catch (error) {
|
||||
throw refuse(
|
||||
'invalid_argument',
|
||||
`${dir} has no readable orca-plugin.json: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
const parsed = parsePluginManifest(raw)
|
||||
if (!parsed.ok) {
|
||||
throw refuse('plugin_invalid', `${dir}: ${parsed.error}`, [
|
||||
`\`orca plugin validate ${dir}\` lists every problem.`
|
||||
])
|
||||
}
|
||||
return parsed.manifest
|
||||
}
|
||||
|
||||
// Why derived: a folder loads under its declared publisher, else `local`, like dev discovery.
|
||||
const folderKey = (manifest: PluginManifest): string =>
|
||||
qualifiedPluginKey(withPluginPublisher(manifest, localPluginPublisher(manifest.publisher)))
|
||||
|
||||
/** The key a development folder loads under. */
|
||||
export async function readPluginFolderKey(dir: string): Promise<string> {
|
||||
return folderKey(await readManifest(dir))
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin folder to render: the argument when it is a folder, else for a key the dev folder
|
||||
* this command runs in. The host does not publish dev folder paths, so it cannot name one.
|
||||
*/
|
||||
async function resolveFolder(
|
||||
ctx: HandlerContext,
|
||||
target: string
|
||||
): Promise<{ dir: string; manifest: PluginManifest; pluginKey: string }> {
|
||||
const asPath = isAbsolute(target) ? target : resolve(ctx.cwd, target)
|
||||
if (existsSync(asPath) || !isQualifiedPluginKey(target)) {
|
||||
const manifest = await readManifest(asPath)
|
||||
return { dir: asPath, manifest, pluginKey: folderKey(manifest) }
|
||||
}
|
||||
const plugins = await callPlugins<PluginHostListEntry[]>(ctx, 'plugins.list')
|
||||
const entry = plugins.find((plugin) => plugin.pluginKey === target)
|
||||
if (!entry) {
|
||||
throw refuse('plugin_not_found', `Orca has no plugin ${target}.`, [
|
||||
'Pass the plugin folder instead: `orca plugin shot <dir> --scenario <name>`.'
|
||||
])
|
||||
}
|
||||
if (!entry.isDev) {
|
||||
throw refuse(
|
||||
'invalid_argument',
|
||||
`${target} is installed, not a development folder; --scenario renders a plugin's own source folder.`,
|
||||
['Pass the plugin folder: `orca plugin shot <dir> --scenario <name>`.']
|
||||
)
|
||||
}
|
||||
const enclosing = enclosingPluginFolder(ctx.cwd)
|
||||
const manifest = enclosing ? await readManifest(enclosing).catch(() => null) : null
|
||||
if (!enclosing || !manifest || folderKey(manifest) !== target) {
|
||||
throw refuse(
|
||||
'invalid_argument',
|
||||
`Run this inside ${target}'s folder, or pass the folder; Orca does not share where a development folder is.`,
|
||||
[`orca plugin shot <dir> --scenario <name>`]
|
||||
)
|
||||
}
|
||||
return { dir: enclosing, manifest, pluginKey: target }
|
||||
}
|
||||
|
||||
function scenarioPanel(
|
||||
manifest: PluginManifest,
|
||||
pluginKey: string,
|
||||
panelId: string | undefined
|
||||
): { id: string; title: string; entry: string } {
|
||||
const panels = manifest.contributes.panels
|
||||
const panel =
|
||||
panelId === undefined
|
||||
? panels.find((each) => each.ui === 'components')
|
||||
: panels.find((each) => each.id === panelId)
|
||||
if (!panel) {
|
||||
throw refuse(
|
||||
'invalid_argument',
|
||||
panelId === undefined
|
||||
? `${pluginKey} has no components panel; --scenario renders components panels only.`
|
||||
: `${pluginKey} has no panel ${panelId}; its panels: ${panels.map((each) => each.id).join(', ') || 'none'}.`
|
||||
)
|
||||
}
|
||||
if (panel.ui !== 'components') {
|
||||
throw refuse(
|
||||
'invalid_argument',
|
||||
`${pluginKey}/${panel.id} is an html panel; --scenario renders components panels only.`
|
||||
)
|
||||
}
|
||||
return { id: panel.id, title: panel.title, entry: panel.entry }
|
||||
}
|
||||
|
||||
/** A scenario name as a file-name part: anything but letters, digits, `.`, `_` and `-` becomes `-`. */
|
||||
export function pluginScenarioFileStem(scenario: string): string {
|
||||
return scenario.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '') || 'scenario'
|
||||
}
|
||||
|
||||
/**
|
||||
* `orca plugin shot --scenario`: renders the panel in the named `.check.ts` scenario on this
|
||||
* machine (as `orca plugin check` does, against a stand-in host and the plugin's worker mounted
|
||||
* here), then has Orca draw that tree apart from the plugin and capture it.
|
||||
*/
|
||||
export async function takePluginScenarioShot(
|
||||
ctx: HandlerContext,
|
||||
request: PluginShotScenarioRequest
|
||||
): Promise<PluginShotSaved & { replay: true; scenario: string; notes: string[] }> {
|
||||
requireLocal(ctx)
|
||||
const { dir, manifest, pluginKey } = await resolveFolder(ctx, request.target)
|
||||
const panel = scenarioPanel(manifest, pluginKey, request.panelId)
|
||||
// Lazy: the check pulls in the declarative tree validator and the Node selection.
|
||||
const check = await import('../authoring/plugin-panel-check.js')
|
||||
const names = await check.readPluginCheckScenarioNames(dir, panel.entry).catch((error) => {
|
||||
throw refuse('invalid_argument', error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
if (!names.includes(request.scenario)) {
|
||||
throw refuse(
|
||||
'invalid_argument',
|
||||
names.length === 0
|
||||
? `${panel.entry} has no scenarios: add ${panel.entry.replace(/\.[^./]+$/, '')}.check.ts beside it.`
|
||||
: `${pluginKey}/${panel.id} has no scenario "${request.scenario}"; its scenarios: ${names.join(', ')}.`
|
||||
)
|
||||
}
|
||||
const [rendered] = await check.checkPluginPanels(dir, {
|
||||
panel: panel.id,
|
||||
scenario: request.scenario,
|
||||
drawnTree: true
|
||||
})
|
||||
const issues = (rendered?.issues ?? []).map(
|
||||
(issue) => `${issue.location ?? panel.entry}: ${issue.message}`
|
||||
)
|
||||
if (!rendered?.drawnTree) {
|
||||
throw refuse(
|
||||
'plugin_check_failed',
|
||||
[
|
||||
`${pluginKey}/${panel.id} drew nothing to shoot in scenario "${request.scenario}".`,
|
||||
...(rendered ? check.formatPluginPanelCheck(rendered) : [])
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
const reply = await callPlugins<PluginShotReplayReply>(
|
||||
ctx,
|
||||
'plugins.shotReplay',
|
||||
{
|
||||
pluginKey,
|
||||
panelId: panel.id,
|
||||
title: panel.title,
|
||||
scenario: request.scenario,
|
||||
tree: rendered.drawnTree,
|
||||
...(request.colorScheme === undefined ? {} : { colorScheme: request.colorScheme }),
|
||||
...(request.width === undefined ? {} : { width: request.width })
|
||||
},
|
||||
{ timeoutMs: REPLAY_RPC_TIMEOUT_MS }
|
||||
)
|
||||
if (reply.replay !== true) {
|
||||
throw refuse(
|
||||
'runtime_unsupported',
|
||||
'This Orca does not draw shot --scenario previews; update Orca, then run it again.'
|
||||
)
|
||||
}
|
||||
const path =
|
||||
request.out ??
|
||||
join(
|
||||
dir,
|
||||
'.orca',
|
||||
'shots',
|
||||
`${pluginKey}-${panel.id}.${pluginScenarioFileStem(request.scenario)}.png`
|
||||
)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, Buffer.from(reply.png, 'base64'))
|
||||
return {
|
||||
path,
|
||||
pluginKey,
|
||||
panelId: panel.id,
|
||||
width: reply.width,
|
||||
height: reply.height,
|
||||
theme: reply.colorScheme,
|
||||
themeApplied: reply.themeApplied,
|
||||
...(reply.themeReason ? { themeReason: reply.themeReason } : {}),
|
||||
settled: reply.settled,
|
||||
errors: [...issues, ...reply.errors],
|
||||
replay: true,
|
||||
scenario: request.scenario,
|
||||
notes: [PLUGIN_SHOT_REPLAY_NOTE]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { COMMAND_SPECS } from '../../specs/index'
|
||||
import { normalizeCommandPositionals, parseArgs } from '../../args'
|
||||
import { specPaths } from '../../command-spec'
|
||||
import type { HandlerContext } from '../../dispatch'
|
||||
import type { RuntimeClient } from '../../runtime/client'
|
||||
import type { PluginShotActResult } from '../../../shared/plugins/view/plugin-shot-acts'
|
||||
import { PLUGIN_VIEW_HANDLERS, type PluginShotReply } from './plugin-view'
|
||||
|
||||
// A 1x1 PNG.
|
||||
const PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
|
||||
|
||||
function parse(argv: string[]) {
|
||||
const paths = COMMAND_SPECS.flatMap((spec) => specPaths(spec))
|
||||
return normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv, paths, COMMAND_SPECS))
|
||||
}
|
||||
|
||||
function context(
|
||||
argv: string[],
|
||||
call: ReturnType<typeof vi.fn>,
|
||||
options: { cwd: string; remote?: boolean }
|
||||
): HandlerContext {
|
||||
const parsed = parse(argv)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers only call client.call and read isRemote.
|
||||
const client = { call, isRemote: options.remote === true } as unknown as RuntimeClient
|
||||
return { flags: parsed.flags, client, cwd: options.cwd, json: true, rawArgs: argv }
|
||||
}
|
||||
|
||||
function shotReply(overrides: Partial<PluginShotReply> = {}): { result: PluginShotReply } {
|
||||
return {
|
||||
result: {
|
||||
pluginKey: 'acme.todos',
|
||||
panelId: 'todos',
|
||||
title: 'Todos',
|
||||
ui: 'components',
|
||||
png: PNG_BASE64,
|
||||
width: 360,
|
||||
height: 700,
|
||||
colorScheme: 'dark',
|
||||
themeApplied: true,
|
||||
settled: true,
|
||||
errors: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cwd: string
|
||||
let stdout: string[]
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = await mkdtemp(join(tmpdir(), 'orca-plugin-shot-'))
|
||||
stdout = []
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
stdout.push(String(chunk))
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
process.exitCode = undefined
|
||||
delete process.env.ORCA_CLI_CWD
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('orca plugin open', () => {
|
||||
it('sends --editor as a host path, absolute on a remote host', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: { pluginKey: 'acme.notes', kind: 'editor', editorId: 'e', title: 'N', filePath: 'x' }
|
||||
})
|
||||
await PLUGIN_VIEW_HANDLERS['plugin open'](
|
||||
context(['plugin', 'open', 'acme.notes', '--editor', 'docs/a.notes'], call, { cwd })
|
||||
)
|
||||
expect(call.mock.calls[0]?.[1]).toEqual({
|
||||
pluginKey: 'acme.notes',
|
||||
filePath: join(cwd, 'docs/a.notes')
|
||||
})
|
||||
await expect(
|
||||
PLUGIN_VIEW_HANDLERS['plugin open'](
|
||||
context(['plugin', 'open', 'acme.notes', '--editor', 'docs/a.notes'], call, {
|
||||
cwd,
|
||||
remote: true
|
||||
})
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'invalid_argument' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('orca plugin shot', () => {
|
||||
it('writes the PNG to .orca/shots under cwd by default', async () => {
|
||||
const call = vi.fn().mockResolvedValue(shotReply())
|
||||
await PLUGIN_VIEW_HANDLERS['plugin shot'](
|
||||
context(['plugin', 'shot', 'acme.todos', '--theme', 'dark', '--width', '360'], call, { cwd })
|
||||
)
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'plugins.shot',
|
||||
{ pluginKey: 'acme.todos', colorScheme: 'dark', width: 360 },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
const path = join(cwd, '.orca', 'shots', 'acme.todos-todos.png')
|
||||
expect((await readFile(path)).toString('base64')).toBe(PNG_BASE64)
|
||||
expect(JSON.parse(stdout.join('')).result).toEqual({
|
||||
path,
|
||||
pluginKey: 'acme.todos',
|
||||
panelId: 'todos',
|
||||
width: 360,
|
||||
height: 700,
|
||||
theme: 'dark',
|
||||
themeApplied: true,
|
||||
settled: true,
|
||||
errors: []
|
||||
})
|
||||
})
|
||||
|
||||
it('writes to --out, also for a remote host', async () => {
|
||||
const call = vi.fn().mockResolvedValue(shotReply())
|
||||
await PLUGIN_VIEW_HANDLERS['plugin shot'](
|
||||
context(['plugin', 'shot', 'acme.todos', '--out', 'a/b.png'], call, { cwd, remote: true })
|
||||
)
|
||||
expect((await readFile(join(cwd, 'a', 'b.png'))).byteLength).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('refuses a bridged shell, whose cwd is on another machine', async () => {
|
||||
process.env.ORCA_CLI_CWD = '/remote/home'
|
||||
await expect(
|
||||
PLUGIN_VIEW_HANDLERS['plugin shot'](
|
||||
context(['plugin', 'shot', 'acme.todos'], vi.fn(), { cwd })
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'invalid_environment' })
|
||||
})
|
||||
|
||||
it('writes beside the plugin when run inside its folder', async () => {
|
||||
const plugin = join(cwd, 'todos')
|
||||
await mkdir(join(plugin, 'src'), { recursive: true })
|
||||
await writeFile(join(plugin, 'orca-plugin.json'), '{}')
|
||||
await PLUGIN_VIEW_HANDLERS['plugin shot'](
|
||||
context(['plugin', 'shot', 'acme.todos'], vi.fn().mockResolvedValue(shotReply()), {
|
||||
cwd: join(plugin, 'src')
|
||||
})
|
||||
)
|
||||
expect(JSON.parse(stdout.join('')).result.path).toBe(
|
||||
join(plugin, '.orca', 'shots', 'acme.todos-todos.png')
|
||||
)
|
||||
})
|
||||
|
||||
it('sends --act, reports each act, and exits 1 when one failed', async () => {
|
||||
const actions: PluginShotActResult[] = [
|
||||
{ ok: true },
|
||||
{ ok: false, error: 'press: the Button is disabled' }
|
||||
]
|
||||
const call = vi.fn().mockResolvedValue(shotReply({ actions }))
|
||||
const acts = '[{"type":{"label":"New task"},"text":"Ship"},{"press":{"text":"Add"}}]'
|
||||
await PLUGIN_VIEW_HANDLERS['plugin shot'](
|
||||
context(['plugin', 'shot', 'acme.todos', '--act', acts], call, { cwd })
|
||||
)
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'plugins.shot',
|
||||
{ pluginKey: 'acme.todos', acts: JSON.parse(acts) },
|
||||
{ timeoutMs: 40_000 }
|
||||
)
|
||||
expect(JSON.parse(stdout.join('')).result.actions).toEqual(actions)
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('checks --act before reaching the host, and refuses a host that ignored it', async () => {
|
||||
const call = vi.fn().mockResolvedValue(shotReply())
|
||||
await expect(
|
||||
PLUGIN_VIEW_HANDLERS['plugin shot'](
|
||||
context(['plugin', 'shot', 'acme.todos', '--act', '[{"click":{}}]'], call, { cwd })
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: expect.stringMatching(/^--act: /)
|
||||
})
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
await expect(
|
||||
PLUGIN_VIEW_HANDLERS['plugin shot'](
|
||||
context(['plugin', 'shot', 'acme.todos', '--act', '{"press":{"text":"Add"}}'], call, {
|
||||
cwd
|
||||
})
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'runtime_unsupported' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,321 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import type { CommandHandler, HandlerContext } from '../../dispatch'
|
||||
import { getOptionalPositiveIntegerFlag } from '../../flags'
|
||||
import { printPluginResult } from '../plugin-cli-output'
|
||||
import { RuntimeClientError } from '../../runtime/types'
|
||||
import {
|
||||
PLUGIN_SHOT_MAX_WIDTH,
|
||||
PLUGIN_SHOT_MIN_WIDTH
|
||||
} from '../../../shared/rpc-contract/plugins-params'
|
||||
import {
|
||||
parsePluginShotActs,
|
||||
type PluginShotAct,
|
||||
type PluginShotActResult
|
||||
} from '../../../shared/plugins/view/plugin-shot-acts'
|
||||
import { callPlugins, requirePluginKey, resolveHostPluginPath, stringFlag } from '../plugin-runtime'
|
||||
import { defaultPluginShotsDir } from './plugin-shot-default-dir'
|
||||
import { takePluginScenarioShot } from './plugin-shot-scenario'
|
||||
import { runPluginEditorShot } from './plugin-shot-editor'
|
||||
import { chromeShotRegion, runPluginChromeShot } from './plugin-shot-chrome'
|
||||
|
||||
/** What `plugins.shot` answers; `png` is base64. */
|
||||
export type PluginShotReply = {
|
||||
pluginKey: string
|
||||
panelId: string
|
||||
title: string
|
||||
ui: 'components' | 'html'
|
||||
png: string
|
||||
width: number
|
||||
height: number
|
||||
colorScheme: 'light' | 'dark'
|
||||
themeApplied: boolean
|
||||
/** Absent on hosts that predate it. */
|
||||
themeReason?: string
|
||||
settled: boolean
|
||||
errors: string[]
|
||||
/** Host calls still unanswered at the capture (components panels); absent on older hosts. */
|
||||
pendingRequests?: number
|
||||
/** Present when `acts` were sent; absent from hosts that predate --act. */
|
||||
actions?: PluginShotActResult[]
|
||||
/** With acts: the panel's atoms were put back after the capture; absent on older hosts. */
|
||||
restored?: boolean
|
||||
}
|
||||
|
||||
/** `ifShown` and the panel was not on screen: the host opened, switched and captured nothing. */
|
||||
export type PluginShotNotShown = { pluginKey: string; panelId: string; notShown: string }
|
||||
|
||||
export type PluginShotSaved = {
|
||||
path: string
|
||||
pluginKey: string
|
||||
panelId: string
|
||||
width: number
|
||||
height: number
|
||||
theme: 'light' | 'dark'
|
||||
themeApplied: boolean
|
||||
themeReason?: string
|
||||
settled: boolean
|
||||
errors: string[]
|
||||
pendingRequests?: number
|
||||
actions?: PluginShotActResult[]
|
||||
restored?: boolean
|
||||
/** `--scenario`: a CLI-rendered scenario tree, drawn apart from the plugin. */
|
||||
replay?: true
|
||||
scenario?: string
|
||||
notes?: string[]
|
||||
}
|
||||
|
||||
type PluginOpenReply =
|
||||
| { pluginKey: string; kind: 'panel'; panelId: string; title: string }
|
||||
| { pluginKey: string; kind: 'editor'; editorId: string; title: string; filePath: string }
|
||||
|
||||
// Why above the host's 20s: the host waits up to 15s for the panel to draw, then captures.
|
||||
const SHOT_RPC_TIMEOUT_MS = 30_000
|
||||
// Above the host's 4s per act.
|
||||
const SHOT_RPC_ACT_TIMEOUT_MS = 5_000
|
||||
|
||||
/** `.orca/` ignores itself in Git, so shots next to a plugin never get committed. */
|
||||
export function defaultPluginShotPath(dir: string, pluginKey: string, panelId: string): string {
|
||||
return join(dir, `${pluginKey}-${panelId}.png`)
|
||||
}
|
||||
|
||||
/** The PNG lands on the machine that runs the CLI, also with --environment. */
|
||||
export function resolveLocalShotPath(ctx: HandlerContext, value: string): string {
|
||||
if (process.env.ORCA_CLI_CWD) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_environment',
|
||||
'orca plugin shot writes the PNG on the machine that runs it, but this shell forwards orca to the Orca host. Run it on the machine that should keep the image.'
|
||||
)
|
||||
}
|
||||
return isAbsolute(value) ? value : resolve(ctx.cwd, value)
|
||||
}
|
||||
|
||||
function parseWidth(ctx: HandlerContext): number | undefined {
|
||||
const width = getOptionalPositiveIntegerFlag(ctx.flags, 'width')
|
||||
if (width !== undefined && (width < PLUGIN_SHOT_MIN_WIDTH || width > PLUGIN_SHOT_MAX_WIDTH)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--width takes ${PLUGIN_SHOT_MIN_WIDTH} to ${PLUGIN_SHOT_MAX_WIDTH} pixels.`
|
||||
)
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
function parseTheme(ctx: HandlerContext): 'light' | 'dark' | undefined {
|
||||
const theme = stringFlag(ctx, 'theme')
|
||||
if (theme === undefined || theme === 'light' || theme === 'dark') {
|
||||
return theme
|
||||
}
|
||||
throw new RuntimeClientError('invalid_argument', '--theme takes light or dark.')
|
||||
}
|
||||
|
||||
type PluginShotRequest = {
|
||||
pluginKey: string
|
||||
panelId?: string
|
||||
colorScheme?: 'light' | 'dark'
|
||||
width?: number
|
||||
acts?: PluginShotAct[]
|
||||
outFor: (panelId: string) => string
|
||||
}
|
||||
|
||||
async function shoot(
|
||||
ctx: HandlerContext,
|
||||
request: PluginShotRequest & { ifShown?: true }
|
||||
): Promise<PluginShotSaved | PluginShotNotShown> {
|
||||
const { outFor, ...params } = request
|
||||
const reply = await callPlugins<PluginShotReply | PluginShotNotShown>(
|
||||
ctx,
|
||||
'plugins.shot',
|
||||
params,
|
||||
{ timeoutMs: SHOT_RPC_TIMEOUT_MS + (params.acts?.length ?? 0) * SHOT_RPC_ACT_TIMEOUT_MS }
|
||||
)
|
||||
if ('notShown' in reply) {
|
||||
return { pluginKey: reply.pluginKey, panelId: reply.panelId, notShown: reply.notShown }
|
||||
}
|
||||
if (params.acts && !reply.actions) {
|
||||
throw new RuntimeClientError(
|
||||
'runtime_unsupported',
|
||||
'This Orca does not run shot --act (it captured without acting); update Orca, then run it again.'
|
||||
)
|
||||
}
|
||||
const path = outFor(reply.panelId)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, Buffer.from(reply.png, 'base64'))
|
||||
return {
|
||||
path,
|
||||
pluginKey: reply.pluginKey,
|
||||
panelId: reply.panelId,
|
||||
width: reply.width,
|
||||
height: reply.height,
|
||||
theme: reply.colorScheme,
|
||||
themeApplied: reply.themeApplied,
|
||||
...(reply.themeReason ? { themeReason: reply.themeReason } : {}),
|
||||
settled: reply.settled,
|
||||
errors: reply.errors,
|
||||
...(reply.pendingRequests === undefined ? {} : { pendingRequests: reply.pendingRequests }),
|
||||
...(reply.actions ? { actions: reply.actions } : {}),
|
||||
...(reply.restored === undefined ? {} : { restored: reply.restored })
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the panel, captures it on the host and writes it to `outFor(panelId)` here. */
|
||||
export async function takePluginShot(
|
||||
ctx: HandlerContext,
|
||||
request: PluginShotRequest
|
||||
): Promise<PluginShotSaved> {
|
||||
const shot = await shoot(ctx, request)
|
||||
if ('notShown' in shot) {
|
||||
throw new RuntimeClientError('runtime_error', `The panel is not shown: ${shot.notShown}.`)
|
||||
}
|
||||
return shot
|
||||
}
|
||||
|
||||
/** Captures only while the panel is the open right-sidebar tab; never opens or switches it. */
|
||||
export function takePluginShotIfShown(
|
||||
ctx: HandlerContext,
|
||||
request: PluginShotRequest
|
||||
): Promise<PluginShotSaved | PluginShotNotShown> {
|
||||
return shoot(ctx, { ...request, ifShown: true })
|
||||
}
|
||||
|
||||
export function formatPluginShot(shot: PluginShotSaved, requestedTheme?: string): string {
|
||||
return [
|
||||
`Saved ${shot.path} (${shot.width}x${shot.height}, ${shot.theme} theme${shot.scenario === undefined ? '' : `, scenario "${shot.scenario}"`})`,
|
||||
...(shot.notes ?? []).map((note) => ` note: ${note}.`),
|
||||
...(requestedTheme && !shot.themeApplied
|
||||
? [
|
||||
` note: ${requestedTheme} was not applied (${shot.themeReason ?? 'Orca could not paint it'}); it shows ${shot.theme}.`
|
||||
]
|
||||
: []),
|
||||
...(shot.settled
|
||||
? []
|
||||
: [' note: the panel had not finished drawing; this is what it showed.']),
|
||||
...(shot.pendingRequests
|
||||
? [
|
||||
` note: ${shot.pendingRequests} host call(s) (plugin.request included) were unanswered after 3s; the panel may still show its loading state.`
|
||||
]
|
||||
: []),
|
||||
...(shot.actions ?? []).map((action, index) =>
|
||||
action.ok ? ` act ${index + 1}: ok` : ` act ${index + 1} failed: ${action.error}`
|
||||
),
|
||||
...(shot.restored === false
|
||||
? [' note: the panel reloaded during --act, so its acted state was not put back.']
|
||||
: []),
|
||||
...shot.errors.map((error) => ` error: ${error}`)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function parseActs(ctx: HandlerContext): PluginShotAct[] | undefined {
|
||||
const json = stringFlag(ctx, 'act')
|
||||
if (json === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = parsePluginShotActs(json)
|
||||
if (!parsed.ok) {
|
||||
throw new RuntimeClientError('invalid_argument', `--act: ${parsed.error}.`)
|
||||
}
|
||||
return parsed.acts
|
||||
}
|
||||
|
||||
async function scenarioShot(ctx: HandlerContext, scenario: string): Promise<void> {
|
||||
if (stringFlag(ctx, 'act') !== undefined) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--act drives the live panel and --scenario draws a still frame apart from it; pass one.'
|
||||
)
|
||||
}
|
||||
const target = stringFlag(ctx, 'plugin')
|
||||
if (target === undefined) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Pass the plugin folder or its key.')
|
||||
}
|
||||
const colorScheme = parseTheme(ctx)
|
||||
const out = stringFlag(ctx, 'out')
|
||||
const panelId = stringFlag(ctx, 'panel')
|
||||
const width = parseWidth(ctx)
|
||||
const shot = await takePluginScenarioShot(ctx, {
|
||||
target,
|
||||
scenario,
|
||||
...(panelId === undefined ? {} : { panelId }),
|
||||
...(colorScheme === undefined ? {} : { colorScheme }),
|
||||
...(width === undefined ? {} : { width }),
|
||||
...(out === undefined ? {} : { out: resolveLocalShotPath(ctx, out) })
|
||||
})
|
||||
printPluginResult(ctx, shot, formatPluginShot(shot, colorScheme))
|
||||
}
|
||||
|
||||
export const PLUGIN_VIEW_HANDLERS: Record<string, CommandHandler> = {
|
||||
'plugin open': async (ctx) => {
|
||||
const pluginKey = requirePluginKey(ctx)
|
||||
const panelId = stringFlag(ctx, 'panel')
|
||||
const editor = stringFlag(ctx, 'editor')
|
||||
if (panelId !== undefined && editor !== undefined) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Pass --panel or --editor, not both.')
|
||||
}
|
||||
const result = await callPlugins<PluginOpenReply>(ctx, 'plugins.open', {
|
||||
pluginKey,
|
||||
...(panelId === undefined ? {} : { panelId }),
|
||||
...(editor === undefined ? {} : { filePath: resolveHostPluginPath(ctx, editor) })
|
||||
})
|
||||
printPluginResult(
|
||||
ctx,
|
||||
result,
|
||||
result.kind === 'panel'
|
||||
? `Showing ${result.title} (${result.pluginKey}/${result.panelId}) in Orca's right sidebar.`
|
||||
: `Opened ${result.filePath} in ${result.title} (${result.pluginKey}/${result.editorId}).`
|
||||
)
|
||||
},
|
||||
'plugin shot': async (ctx) => {
|
||||
const region = chromeShotRegion(ctx)
|
||||
const scenario = stringFlag(ctx, 'scenario')
|
||||
if (scenario !== undefined && stringFlag(ctx, 'editor') !== undefined) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Pass --scenario or --editor, not both.')
|
||||
}
|
||||
if (scenario !== undefined) {
|
||||
await scenarioShot(ctx, scenario)
|
||||
return
|
||||
}
|
||||
const colorScheme = parseTheme(ctx)
|
||||
const out = stringFlag(ctx, 'out')
|
||||
const outPath = out === undefined ? null : resolveLocalShotPath(ctx, out)
|
||||
const shotsDir = resolveLocalShotPath(ctx, defaultPluginShotsDir(ctx.cwd))
|
||||
if (region) {
|
||||
return runPluginChromeShot(ctx, { region, colorScheme, out: outPath, shotsDir })
|
||||
}
|
||||
const editor = stringFlag(ctx, 'editor')
|
||||
if (editor !== undefined) {
|
||||
if (stringFlag(ctx, 'panel') !== undefined) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Pass --panel or --editor, not both.')
|
||||
}
|
||||
await runPluginEditorShot(ctx, {
|
||||
editor,
|
||||
...(colorScheme === undefined ? {} : { colorScheme }),
|
||||
out: outPath,
|
||||
shotsDir
|
||||
})
|
||||
return
|
||||
}
|
||||
if (stringFlag(ctx, 'height') !== undefined || ctx.flags.has('in-place')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--height and --in-place are for an editor shot (--editor).'
|
||||
)
|
||||
}
|
||||
const pluginKey = requirePluginKey(ctx)
|
||||
const panelId = stringFlag(ctx, 'panel')
|
||||
const width = parseWidth(ctx)
|
||||
const acts = parseActs(ctx)
|
||||
const shot = await takePluginShot(ctx, {
|
||||
pluginKey,
|
||||
...(panelId === undefined ? {} : { panelId }),
|
||||
...(colorScheme === undefined ? {} : { colorScheme }),
|
||||
...(width === undefined ? {} : { width }),
|
||||
...(acts ? { acts } : {}),
|
||||
outFor: (shotPanelId) => outPath ?? defaultPluginShotPath(shotsDir, pluginKey, shotPanelId)
|
||||
})
|
||||
printPluginResult(ctx, shot, formatPluginShot(shot, colorScheme))
|
||||
// The PNG is saved either way; a failed act still fails the command.
|
||||
if (shot.actions?.some((action) => !action.ok)) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user