@standardagents/code-plugin-sdk 1.0.0-alpha.0 → 1.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -1
- package/bin/standard-plugin.mjs +149 -0
- package/package.json +3 -2
- package/src/collection.mjs +0 -0
- package/src/index.d.ts +154 -12
- package/src/index.mjs +7 -2
- package/src/internal.d.ts +2 -1
- package/src/manifest.mjs +15 -2
- package/src/protocol.mjs +7 -3
- package/src/publish.mjs +62 -0
- package/src/runtime.mjs +17 -4
- package/src/testing.d.ts +3 -1
- package/src/testing.mjs +7 -0
- package/src/view.mjs +246 -0
package/README.md
CHANGED
|
@@ -54,6 +54,89 @@ Plugins retain Node access to files, networking and subprocesses under the user'
|
|
|
54
54
|
Worker threads provide JavaScript fault isolation.
|
|
55
55
|
Memory exhaustion outside V8 limits and native crashes can affect the runner process.
|
|
56
56
|
|
|
57
|
+
## Collections
|
|
58
|
+
|
|
59
|
+
A plugin installs from a Git repository or an npm package. One source holds
|
|
60
|
+
one plugin or a collection of plugins.
|
|
61
|
+
|
|
62
|
+
A collection lists its plugins in `standard-plugins.json` at the root of the
|
|
63
|
+
repository or package:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"schema": 1,
|
|
68
|
+
"plugins": [
|
|
69
|
+
{ "id": "builds-monitor", "path": "builds-monitor" },
|
|
70
|
+
{ "id": "pong", "path": "games/pong" }
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`schema` is `1`. `plugins` holds 1 to 256 entries. Each `id` follows the
|
|
76
|
+
plugin id pattern of the manifest and equals the `standardPlugin.id` in that
|
|
77
|
+
entry's `package.json`. Each `path` is a relative POSIX path of at most 512
|
|
78
|
+
characters with no leading slash, no backslash, and no empty, `.`, or `..`
|
|
79
|
+
segment. Ids and paths are unique within one collection. An installer
|
|
80
|
+
selects one entry with `--plugin <id>`.
|
|
81
|
+
|
|
82
|
+
A root without `standard-plugins.json` is a single plugin when its
|
|
83
|
+
`package.json` carries a valid `standardPlugin` manifest. Discovery treats it
|
|
84
|
+
as one entry at path `""`.
|
|
85
|
+
|
|
86
|
+
`validateCollection(value)` checks a parsed collection file and returns a
|
|
87
|
+
frozen `PluginCollection`. `resolveCollection({ collection, packageJson })`
|
|
88
|
+
applies the single-plugin fallback. `COLLECTION_FILE` names the file.
|
|
89
|
+
|
|
90
|
+
## Dependencies
|
|
91
|
+
|
|
92
|
+
A plugin with `dependencies` or `optionalDependencies` ships a lockfile
|
|
93
|
+
beside its `package.json`, so every machine installs the same dependency
|
|
94
|
+
tree. The installer refuses a plugin with dependencies and no lockfile.
|
|
95
|
+
|
|
96
|
+
For an npm package the lockfile is `npm-shrinkwrap.json`. npm never
|
|
97
|
+
publishes `package-lock.json`; `npm shrinkwrap` converts an existing
|
|
98
|
+
`package-lock.json` into `npm-shrinkwrap.json`, and npm includes that file
|
|
99
|
+
when it publishes the package. For a Git repository either
|
|
100
|
+
`package-lock.json` or `npm-shrinkwrap.json` satisfies the rule.
|
|
101
|
+
|
|
102
|
+
The SDK belongs under `peerDependencies`, with a `devDependencies` copy for
|
|
103
|
+
the plugin's own tests. Inside Standard Code the bundled runtime supplies it.
|
|
104
|
+
|
|
105
|
+
`lockfileRequirement({ packageJson, sourceKind })` returns whether the rule
|
|
106
|
+
applies and which lockfile names satisfy it for `"npm"` or `"git"`.
|
|
107
|
+
`checkPackageForPublish({ packageJson, files })` reports problems as
|
|
108
|
+
`{ code, message }` objects: a missing lockfile, the SDK under
|
|
109
|
+
`dependencies`, a missing peer declaration, a missing or invalid manifest, an
|
|
110
|
+
entry outside the package files, or an id that differs from the collection
|
|
111
|
+
entry. Both helpers are pure; they read no files.
|
|
112
|
+
|
|
113
|
+
In a collection, each plugin directory holds its own `package.json` and its
|
|
114
|
+
own lockfile.
|
|
115
|
+
|
|
116
|
+
## Publishing a plugin
|
|
117
|
+
|
|
118
|
+
The package installs a `standard-plugin` command for authoring.
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
npx standard-plugin check
|
|
122
|
+
npx standard-plugin pack
|
|
123
|
+
npm publish
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`check [dir] [--source npm|git]` reads `package.json`, the optional
|
|
127
|
+
`standard-plugins.json`, every collection entry's `package.json`, and the
|
|
128
|
+
lockfiles. It prints each problem as `<path>: <code>: <message>` and exits
|
|
129
|
+
with status 1 when it finds one. `--source git` applies the Git lockfile
|
|
130
|
+
rule; the default is `npm`.
|
|
131
|
+
|
|
132
|
+
`pack [dir]` runs `check`. When a plugin has dependencies and no
|
|
133
|
+
`npm-shrinkwrap.json`, it runs `npm shrinkwrap`, which converts
|
|
134
|
+
`package-lock.json`, and asks you to commit the new file. It then runs
|
|
135
|
+
`npm pack --dry-run` and prints the files that npm will publish.
|
|
136
|
+
|
|
137
|
+
The command is authoring tooling. The SDK entry never imports it, and a
|
|
138
|
+
plugin never depends on it at run time.
|
|
139
|
+
|
|
57
140
|
## Testing
|
|
58
141
|
|
|
59
142
|
`@standardagents/code-plugin-sdk/testing` exports `createHarness`.
|
|
@@ -66,7 +149,8 @@ The harness starts no subprocesses.
|
|
|
66
149
|
## Supported interface
|
|
67
150
|
|
|
68
151
|
The public interface is the package's default export, its `testing` export,
|
|
69
|
-
|
|
152
|
+
the declarations in `src/index.d.ts` and `src/testing.d.ts`, and the
|
|
153
|
+
`standard-plugin` command's `check` and `pack` behavior.
|
|
70
154
|
|
|
71
155
|
The harness records the frames that the SDK runtime exchanges with its host,
|
|
72
156
|
and `harness.receive` accepts such a frame. That wire protocol between the
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Authoring tool for plugin packages. The SDK entry never imports this file.
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { join, resolve } from 'node:path'
|
|
6
|
+
import { COLLECTION_FILE, PluginError, checkPackageForPublish, validateCollection } from '../src/index.mjs'
|
|
7
|
+
|
|
8
|
+
const USAGE = `usage: standard-plugin check [dir] [--source npm|git]
|
|
9
|
+
standard-plugin pack [dir]
|
|
10
|
+
|
|
11
|
+
check validates package.json, ${COLLECTION_FILE}, each collection entry, and lockfiles
|
|
12
|
+
pack runs check, creates npm-shrinkwrap.json when dependencies need one, then runs npm pack --dry-run`
|
|
13
|
+
const IGNORED_DIRECTORIES = new Set(['node_modules', '.git'])
|
|
14
|
+
const FILE_LIMIT = 50000
|
|
15
|
+
|
|
16
|
+
function readJson(path) {
|
|
17
|
+
let text
|
|
18
|
+
try { text = readFileSync(path, 'utf8') } catch (error) {
|
|
19
|
+
if (error.code === 'ENOENT') return undefined
|
|
20
|
+
throw error
|
|
21
|
+
}
|
|
22
|
+
return JSON.parse(text)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Relative POSIX paths of regular files under root, without following links. */
|
|
26
|
+
function listFiles(root) {
|
|
27
|
+
const files = []
|
|
28
|
+
const pending = ['']
|
|
29
|
+
while (pending.length) {
|
|
30
|
+
const directory = pending.pop()
|
|
31
|
+
for (const entry of readdirSync(join(root, directory), { withFileTypes: true })) {
|
|
32
|
+
const path = directory ? `${directory}/${entry.name}` : entry.name
|
|
33
|
+
if (entry.isDirectory()) { if (!IGNORED_DIRECTORIES.has(entry.name)) pending.push(path) }
|
|
34
|
+
else if (entry.isFile()) files.push(path)
|
|
35
|
+
if (files.length > FILE_LIMIT) throw new Error(`More than ${FILE_LIMIT} files under ${root}`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return files
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function tryJson(root, name, problems, location) {
|
|
42
|
+
try { return readJson(join(root, name)) } catch (error) {
|
|
43
|
+
problems.push({ location, code: 'invalid_json', message: `${name}: ${error.message}` })
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function collect(problems, location, list) {
|
|
49
|
+
for (const problem of list) problems.push({ location, ...problem })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Checks one source directory. Returns problems with the directory each one belongs to. */
|
|
53
|
+
function checkDirectory(root, { sourceKind = 'npm' } = {}) {
|
|
54
|
+
const problems = []
|
|
55
|
+
const files = listFiles(root)
|
|
56
|
+
const packageJson = tryJson(root, 'package.json', problems, '.')
|
|
57
|
+
const collectionInput = tryJson(root, COLLECTION_FILE, problems, '.')
|
|
58
|
+
if (collectionInput === undefined) {
|
|
59
|
+
if (packageJson === undefined) problems.push({ location: '.', code: 'package_missing', message: `package.json or ${COLLECTION_FILE} is required` })
|
|
60
|
+
else collect(problems, '.', checkPackageForPublish({ packageJson, files, sourceKind }))
|
|
61
|
+
return problems
|
|
62
|
+
}
|
|
63
|
+
let collection
|
|
64
|
+
try { collection = validateCollection(collectionInput) } catch (error) {
|
|
65
|
+
if (!(error instanceof PluginError)) throw error
|
|
66
|
+
problems.push({ location: '.', code: error.code, message: `${COLLECTION_FILE}: ${error.message}` })
|
|
67
|
+
return problems
|
|
68
|
+
}
|
|
69
|
+
if (packageJson !== undefined) collect(problems, '.', checkPackageForPublish({ packageJson, files, sourceKind, requireManifest: false }))
|
|
70
|
+
else if (sourceKind === 'npm') problems.push({ location: '.', code: 'package_missing', message: 'an npm package needs a root package.json' })
|
|
71
|
+
for (const entry of collection.plugins) {
|
|
72
|
+
const entryPackage = tryJson(join(root, entry.path), 'package.json', problems, entry.path)
|
|
73
|
+
if (entryPackage === undefined) {
|
|
74
|
+
if (!problems.some(problem => problem.location === entry.path)) {
|
|
75
|
+
problems.push({ location: entry.path, code: 'package_missing', message: `collection entry ${entry.id} has no package.json` })
|
|
76
|
+
}
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
const prefix = `${entry.path}/`
|
|
80
|
+
const entryFiles = files.filter(file => file.startsWith(prefix)).map(file => file.slice(prefix.length))
|
|
81
|
+
collect(problems, entry.path, checkPackageForPublish({ packageJson: entryPackage, files: entryFiles, sourceKind, expectedId: entry.id }))
|
|
82
|
+
}
|
|
83
|
+
return problems
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function report(problems) {
|
|
87
|
+
for (const problem of problems) console.log(`${problem.location}: ${problem.code}: ${problem.message}`)
|
|
88
|
+
console.log(problems.length ? `${problems.length} problem${problems.length === 1 ? '' : 's'}` : 'ok')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function npm(args, cwd) {
|
|
92
|
+
const result = spawnSync('npm', args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' })
|
|
93
|
+
if (result.error) throw result.error
|
|
94
|
+
return result.status ?? 1
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parse(argv) {
|
|
98
|
+
const [command, ...rest] = argv
|
|
99
|
+
const options = { directory: '.', sourceKind: 'npm' }
|
|
100
|
+
for (let index = 0; index < rest.length; index++) {
|
|
101
|
+
const argument = rest[index]
|
|
102
|
+
if (argument === '--source') {
|
|
103
|
+
options.sourceKind = rest[++index]
|
|
104
|
+
if (!['npm', 'git'].includes(options.sourceKind)) throw new Error('--source takes npm or git')
|
|
105
|
+
} else if (argument.startsWith('-')) throw new Error(`unknown option ${argument}`)
|
|
106
|
+
else options.directory = argument
|
|
107
|
+
}
|
|
108
|
+
return { command, ...options, directory: resolve(options.directory) }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function check(options) {
|
|
112
|
+
const problems = checkDirectory(options.directory, options)
|
|
113
|
+
report(problems)
|
|
114
|
+
return problems.length ? 1 : 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pack(options) {
|
|
118
|
+
if (options.sourceKind !== 'npm') throw new Error('pack checks an npm package; --source git does not apply')
|
|
119
|
+
let problems = checkDirectory(options.directory, options)
|
|
120
|
+
const missing = problems.filter(problem => problem.code === 'lockfile_missing')
|
|
121
|
+
for (const problem of missing) {
|
|
122
|
+
const directory = resolve(options.directory, problem.location)
|
|
123
|
+
if (!existsSync(join(directory, 'package-lock.json'))) {
|
|
124
|
+
console.log(`${problem.location}: run npm install first so npm shrinkwrap has a package-lock.json to convert`)
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
console.log(`${problem.location}: creating npm-shrinkwrap.json from package-lock.json`)
|
|
128
|
+
const status = npm(['shrinkwrap'], directory)
|
|
129
|
+
if (status !== 0) return status
|
|
130
|
+
console.log(`${problem.location}: commit npm-shrinkwrap.json; npm publishes it with the package`)
|
|
131
|
+
}
|
|
132
|
+
if (missing.length) problems = checkDirectory(options.directory, options)
|
|
133
|
+
report(problems)
|
|
134
|
+
if (problems.length) return 1
|
|
135
|
+
return npm(['pack', '--dry-run'], options.directory)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function main(argv) {
|
|
139
|
+
let options
|
|
140
|
+
try { options = parse(argv) } catch (error) { console.error(error.message); console.error(USAGE); return 2 }
|
|
141
|
+
try {
|
|
142
|
+
if (options.command === 'check') return check(options)
|
|
143
|
+
if (options.command === 'pack') return pack(options)
|
|
144
|
+
} catch (error) { console.error(`standard-plugin: ${error.message}`); return 2 }
|
|
145
|
+
console.error(USAGE)
|
|
146
|
+
return 2
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
process.exitCode = main(process.argv.slice(2))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@standardagents/code-plugin-sdk",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Standard Code plugin authoring SDK",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
".": { "types": "./src/index.d.ts", "import": "./src/index.mjs" },
|
|
16
16
|
"./testing": { "types": "./src/testing.d.ts", "import": "./src/testing.mjs" }
|
|
17
17
|
},
|
|
18
|
-
"
|
|
18
|
+
"bin": { "standard-plugin": "./bin/standard-plugin.mjs" },
|
|
19
|
+
"files": ["src", "bin", "README.md", "LICENSE"],
|
|
19
20
|
"scripts": { "test": "node --test test/*.test.mjs" }
|
|
20
21
|
}
|
|
Binary file
|
package/src/index.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
2
2
|
export type Capability = 'surfaces' | 'events' | 'hooks' | 'panes' | 'projects' |
|
|
3
3
|
'notifications' | 'url' | 'fetch' | 'secrets' | 'webhook';
|
|
4
|
-
|
|
4
|
+
/** `card` draws a boxed card in the sidebar under the `plugins` anchor. */
|
|
5
|
+
export type SurfaceKind = 'section' | 'card' | 'slot' | 'badge' | 'panel' | 'overlay' |
|
|
5
6
|
'menu' | 'command' | 'key' | 'link';
|
|
7
|
+
/** How a panel opens. Absent means `popover`. */
|
|
8
|
+
export type Presentation = 'popover' | 'column' | 'pane';
|
|
6
9
|
export type MenuPosition = 'top' | 'after-open' | 'before-danger' | 'bottom';
|
|
7
10
|
export type Anchor = 'plugins' | 'machine.before' | 'machine.after' |
|
|
8
11
|
'project.before' | 'project.after' | 'pane.header' | 'pane.footer' |
|
|
@@ -19,6 +22,10 @@ export interface ContributionDeclaration {
|
|
|
19
22
|
chord?: string;
|
|
20
23
|
pattern?: string;
|
|
21
24
|
actionId?: string;
|
|
25
|
+
/** How a panel opens (panels), or how the panel named by `opens` opens (cards and commands). */
|
|
26
|
+
presentation?: Presentation;
|
|
27
|
+
/** The id of a declared panel that a card or command opens when activated. */
|
|
28
|
+
opens?: string;
|
|
22
29
|
}
|
|
23
30
|
/** The package.json standardPlugin field is read before any plugin code runs. */
|
|
24
31
|
export interface PluginManifest {
|
|
@@ -72,9 +79,11 @@ export interface NativeRow {
|
|
|
72
79
|
spark?: number[];
|
|
73
80
|
divider?: boolean;
|
|
74
81
|
}
|
|
75
|
-
export type
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
export type RowsContent = { kind: 'rows'; rows: NativeRow[] };
|
|
83
|
+
export type TextContent = { kind: 'text'; lines: TextSpan[][] };
|
|
84
|
+
export type BadgeContent = { kind: 'badge'; spans: TextSpan[]; actionId?: string };
|
|
85
|
+
export type NativeContent = RowsContent | TextContent | BadgeContent;
|
|
86
|
+
/** `columns` is also the intrinsic width of a card, column, or plugin pane that shows the canvas. */
|
|
78
87
|
export interface CanvasSpec {
|
|
79
88
|
columns: number;
|
|
80
89
|
rows: number;
|
|
@@ -82,7 +91,57 @@ export interface CanvasSpec {
|
|
|
82
91
|
shade?: number;
|
|
83
92
|
captureInput?: boolean;
|
|
84
93
|
}
|
|
85
|
-
export type
|
|
94
|
+
export type CanvasContent = { kind: 'canvas'; canvas: CanvasSpec };
|
|
95
|
+
|
|
96
|
+
/** Semantic tones. The host maps each tone to the viewer's theme. */
|
|
97
|
+
export type Tone = 'ok' | 'info' | 'warn' | 'error' | 'muted' | 'accent' | 'pending' | 'bright';
|
|
98
|
+
export type TextWeight = 'normal' | 'bold' | 'dim';
|
|
99
|
+
export type Align = 'start' | 'center' | 'end';
|
|
100
|
+
/** The host sends `actionId` and `value` to the plugin; `opens` names a panel the host opens after the plugin accepts. */
|
|
101
|
+
export interface ViewAction { actionId: string; value?: string; opens?: string }
|
|
102
|
+
export interface ViewSpan { text: string; tone?: Tone; weight?: TextWeight; mono?: boolean }
|
|
103
|
+
export interface StackNode { type: 'stack'; gap?: number; children?: ViewNode[] }
|
|
104
|
+
export interface RowNode { type: 'row'; children?: ViewNode[]; align?: Align }
|
|
105
|
+
export interface DividerNode { type: 'divider'; label?: string }
|
|
106
|
+
/** Either `text` or `spans`; a node with both draws `text` first. */
|
|
107
|
+
export interface TextNode { type: 'text'; text?: string; spans?: ViewSpan[]; tone?: Tone; weight?: TextWeight; mono?: boolean }
|
|
108
|
+
export interface BadgeNode { type: 'badge'; label: string; tone?: Tone }
|
|
109
|
+
export interface DotNode { type: 'dot'; tone?: Tone }
|
|
110
|
+
export interface Meter { value: number; max: number; tone?: Tone; label?: string }
|
|
111
|
+
export interface ProgressNode extends Meter { type: 'progress' }
|
|
112
|
+
export interface SegmentsNode { type: 'segments'; items: Meter[] }
|
|
113
|
+
/** A boxed group inside a panel or section view. A card contribution's view cannot contain one. */
|
|
114
|
+
export interface CardNode { type: 'card'; title?: string; tone?: Tone; children?: ViewNode[] }
|
|
115
|
+
export interface StatNode { type: 'stat'; label: string; value: string; tone?: Tone; hint?: string }
|
|
116
|
+
/** `copy` marks a value the viewer can copy from the focused row. */
|
|
117
|
+
export interface KvItem { label: string; value: string; mono?: boolean; copy?: boolean }
|
|
118
|
+
export interface KvNode { type: 'kv'; items: KvItem[] }
|
|
119
|
+
export interface TabItem { id: string; label: string; sublabel?: string; tone?: Tone; count?: number; tag?: string }
|
|
120
|
+
/**
|
|
121
|
+
* With `filters` naming a table in the same view, the host shows only rows whose `tags` contain the
|
|
122
|
+
* chosen item's `tag`; an item without `tag` shows every row. `action` also notifies the plugin, and
|
|
123
|
+
* its value defaults to the chosen item id.
|
|
124
|
+
*/
|
|
125
|
+
export interface TabsNode { type: 'tabs'; id: string; items: TabItem[]; filters?: string; action?: ViewAction }
|
|
126
|
+
export interface SelectOption { id: string; label: string; group?: string; count?: number; tag?: string }
|
|
127
|
+
export interface SelectNode { type: 'select'; id: string; label: string; options: SelectOption[]; filters?: string; action?: ViewAction }
|
|
128
|
+
/** Lower `priority` values stay visible longest when the host drops columns to fit. */
|
|
129
|
+
export interface TableColumn { id: string; label?: string; width?: 'fill' | number; maxWidth?: number; align?: Align; priority?: number }
|
|
130
|
+
/** A missing cell draws empty. `note` is a secondary line; `tags` feed host-side filters. */
|
|
131
|
+
export interface TableRow { id: string; cells?: Record<string, ViewNode>; tone?: Tone; note?: ViewSpan[]; tags?: string[]; action?: ViewAction }
|
|
132
|
+
export interface TableNode { type: 'table'; id: string; columns: TableColumn[]; rows?: TableRow[] }
|
|
133
|
+
export interface LogNode { type: 'log'; lines: string[] }
|
|
134
|
+
export interface ButtonNode { type: 'button'; label: string; action: ViewAction }
|
|
135
|
+
export type ViewNode = StackNode | RowNode | DividerNode | TextNode | BadgeNode | DotNode | ProgressNode |
|
|
136
|
+
SegmentsNode | CardNode | StatNode | KvNode | TabsNode | SelectNode | TableNode | LogNode | ButtonNode;
|
|
137
|
+
/** A host-rendered view tree. Cards, panels, and sections accept it. */
|
|
138
|
+
export interface ViewContent { kind: 'view'; root: ViewNode }
|
|
139
|
+
|
|
140
|
+
/** Slots and overlays accept rows, text, or canvas. */
|
|
141
|
+
export type DrawnContent = RowsContent | TextContent | CanvasContent;
|
|
142
|
+
/** Sections, cards, and panels also accept a view. */
|
|
143
|
+
export type PanelContent = DrawnContent | ViewContent;
|
|
144
|
+
export type SurfaceContent = NativeContent | CanvasContent | ViewContent;
|
|
86
145
|
export interface RequestOptions { signal?: AbortSignal; timeoutMs?: number }
|
|
87
146
|
export interface Disposable { dispose(): void }
|
|
88
147
|
export type Cleanup = () => void | Promise<void>;
|
|
@@ -106,6 +165,7 @@ export interface PaneCreate {
|
|
|
106
165
|
contributionId?: string;
|
|
107
166
|
}
|
|
108
167
|
export interface PaneResult { pane: EntityRef; operationId: string }
|
|
168
|
+
/** A view action delivers its string `value`; other selections may carry any JSON value. */
|
|
109
169
|
export interface Selection { entity?: EntityRef; actionId: string; value?: Json }
|
|
110
170
|
export interface LinkSelection extends Selection { url: string }
|
|
111
171
|
export type ActionHandler = (event: Selection, context: HandlerContext) => Json | void | Promise<Json | void>;
|
|
@@ -186,8 +246,9 @@ export interface OperationMap {
|
|
|
186
246
|
export type OperationName = keyof OperationMap;
|
|
187
247
|
export type Operation = { [K in OperationName]: { op: K; args: OperationMap[K]['input'] } }[OperationName];
|
|
188
248
|
export type Request = <K extends OperationName>(op: K, args: OperationMap[K]['input'], options?: RequestOptions) => Promise<OperationMap[K]['output']>;
|
|
189
|
-
export interface Publisher extends Disposable {
|
|
190
|
-
|
|
249
|
+
export interface Publisher<C extends SurfaceContent = SurfaceContent> extends Disposable {
|
|
250
|
+
/** Throws a PluginError when the content breaks a protocol bound or does not suit the contribution kind. */
|
|
251
|
+
replace(content: C): void;
|
|
191
252
|
clear(): void;
|
|
192
253
|
}
|
|
193
254
|
export interface Canvas extends Publisher {
|
|
@@ -206,11 +267,13 @@ export interface PluginContext {
|
|
|
206
267
|
readonly producer: Readonly<Producer>;
|
|
207
268
|
readonly signal: AbortSignal;
|
|
208
269
|
request: Request;
|
|
209
|
-
section(id: string, entity?: EntityRef): Publisher
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
270
|
+
section(id: string, entity?: EntityRef): Publisher<PanelContent>;
|
|
271
|
+
card(id: string, entity?: EntityRef): Publisher<PanelContent>;
|
|
272
|
+
slot(id: string, entity: EntityRef): Publisher<DrawnContent>;
|
|
273
|
+
badge(id: string, entity: EntityRef): Publisher<BadgeContent>;
|
|
274
|
+
panel(id: string, entity?: EntityRef): Publisher<PanelContent>;
|
|
275
|
+
overlay(id: string, entity?: EntityRef): Publisher<DrawnContent>;
|
|
276
|
+
/** Publishes plugin-drawn content to any section, card, slot, panel, or overlay declaration. */
|
|
214
277
|
canvas(id: string, spec: CanvasSpec, entity?: EntityRef): Canvas;
|
|
215
278
|
/** IDs match static manifest contributions. Options override declaration defaults. */
|
|
216
279
|
menu(id: string, options: MenuOptions, handler: ActionHandler): Subscription;
|
|
@@ -262,3 +325,82 @@ export interface PluginDefinition {
|
|
|
262
325
|
export function definePlugin(definition: PluginDefinition): Readonly<PluginDefinition>;
|
|
263
326
|
export function validateManifest(value: unknown): Readonly<PluginManifest>;
|
|
264
327
|
export class PluginError extends Error { code: string; constructor(code: string, message: string) }
|
|
328
|
+
export const PRESENTATIONS: readonly Presentation[];
|
|
329
|
+
export const TONES: readonly Tone[];
|
|
330
|
+
export const VIEW_LIMITS: Readonly<{ depth: 8; nodes: 4096; tableRows: 512; tableColumns: 12; cardLines: 6; actionValueBytes: 512 }>;
|
|
331
|
+
/**
|
|
332
|
+
* Throws a PluginError when a view tree breaks a protocol bound or a daemon rule. Unknown node types pass.
|
|
333
|
+
* `kind: 'card'` adds the card rules; `manifest` requires each action `opens` to name one of its panels.
|
|
334
|
+
*/
|
|
335
|
+
export function validateView(root: unknown, options?: { kind?: SurfaceKind; manifest?: Pick<PluginManifest, 'contributions'> }): void;
|
|
336
|
+
type Options<T> = Omit<T, 'type' | 'children'>;
|
|
337
|
+
/** Optional builders. Each returns the plain protocol JSON for one node; hand-written JSON is equivalent. */
|
|
338
|
+
export const ui: {
|
|
339
|
+
view(root: ViewNode): ViewContent;
|
|
340
|
+
action(actionId: string, options?: { value?: string; opens?: string }): ViewAction;
|
|
341
|
+
span(text: string, options?: Omit<ViewSpan, 'text'>): ViewSpan;
|
|
342
|
+
stack(children: ViewNode[], options?: Options<StackNode>): StackNode;
|
|
343
|
+
row(children: ViewNode[], options?: Options<RowNode>): RowNode;
|
|
344
|
+
divider(label?: string): DividerNode;
|
|
345
|
+
/** A string sets `text`; an array of spans sets `spans`. */
|
|
346
|
+
text(content: string | ViewSpan[], options?: Omit<TextNode, 'type' | 'text' | 'spans'>): TextNode;
|
|
347
|
+
badge(label: string, tone?: Tone): BadgeNode;
|
|
348
|
+
dot(tone?: Tone): DotNode;
|
|
349
|
+
progress(value: number, max: number, options?: Omit<Meter, 'value' | 'max'>): ProgressNode;
|
|
350
|
+
segments(items: Meter[]): SegmentsNode;
|
|
351
|
+
card(children: ViewNode[], options?: Options<CardNode>): CardNode;
|
|
352
|
+
stat(label: string, value: string, options?: Omit<StatNode, 'type' | 'label' | 'value'>): StatNode;
|
|
353
|
+
kv(items: KvItem[]): KvNode;
|
|
354
|
+
tabs(id: string, items: TabItem[], options?: Omit<TabsNode, 'type' | 'id' | 'items'>): TabsNode;
|
|
355
|
+
select(id: string, label: string, options: SelectOption[], extra?: Omit<SelectNode, 'type' | 'id' | 'label' | 'options'>): SelectNode;
|
|
356
|
+
table(id: string, columns: TableColumn[], rows?: TableRow[]): TableNode;
|
|
357
|
+
log(lines: string[]): LogNode;
|
|
358
|
+
/** `action` is an action object or an action id. */
|
|
359
|
+
button(label: string, action: ViewAction | string): ButtonNode;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/** One plugin inside a collection. The path is relative to the source root. */
|
|
363
|
+
export interface PluginCollectionEntry {
|
|
364
|
+
id: string;
|
|
365
|
+
/** Relative POSIX path; "" names the source root for a single-plugin source. */
|
|
366
|
+
path: string;
|
|
367
|
+
}
|
|
368
|
+
/** The standard-plugins.json file at the root of a repository or npm package. */
|
|
369
|
+
export interface PluginCollection {
|
|
370
|
+
schema: 1;
|
|
371
|
+
plugins: PluginCollectionEntry[];
|
|
372
|
+
}
|
|
373
|
+
/** File name of the collection manifest: standard-plugins.json. */
|
|
374
|
+
export const COLLECTION_FILE: 'standard-plugins.json';
|
|
375
|
+
export function validateCollection(value: unknown): Readonly<PluginCollection>;
|
|
376
|
+
/** A collection file wins; without one, a valid package manifest makes the root one plugin at path "". */
|
|
377
|
+
export function resolveCollection(source: { collection?: unknown; packageJson?: unknown }): Readonly<PluginCollection>;
|
|
378
|
+
|
|
379
|
+
export type SourceKind = 'git' | 'npm';
|
|
380
|
+
export type LockfileName = 'package-lock.json' | 'npm-shrinkwrap.json';
|
|
381
|
+
export interface LockfileRequirement {
|
|
382
|
+
/** True when the package declares dependencies or optionalDependencies. */
|
|
383
|
+
required: boolean;
|
|
384
|
+
dependencies: readonly string[];
|
|
385
|
+
/** Lockfile names that satisfy the rule for this source kind. */
|
|
386
|
+
lockfiles: readonly LockfileName[];
|
|
387
|
+
}
|
|
388
|
+
/** Package name of this SDK, which a plugin lists under peerDependencies. */
|
|
389
|
+
export const SDK_PACKAGE: '@standardagents/code-plugin-sdk';
|
|
390
|
+
export function lockfileRequirement(input: { packageJson: unknown; sourceKind: SourceKind }): LockfileRequirement;
|
|
391
|
+
export interface PublishProblem {
|
|
392
|
+
code: 'invalid_package' | 'package_private' | 'lockfile_missing' | 'sdk_dependency' | 'sdk_peer_missing' |
|
|
393
|
+
'manifest_missing' | 'invalid_manifest' | 'invalid_payload' | 'payload_too_large' | 'incompatible_version' |
|
|
394
|
+
'entry_missing' | 'id_mismatch';
|
|
395
|
+
message: string;
|
|
396
|
+
}
|
|
397
|
+
/** Pure checks over a package.json and the relative paths of the files that ship with it. */
|
|
398
|
+
export function checkPackageForPublish(input: {
|
|
399
|
+
packageJson: unknown;
|
|
400
|
+
files: readonly string[];
|
|
401
|
+
sourceKind?: SourceKind;
|
|
402
|
+
/** False for a collection root whose package.json carries no plugin manifest. */
|
|
403
|
+
requireManifest?: boolean;
|
|
404
|
+
/** The collection entry id the manifest must match. */
|
|
405
|
+
expectedId?: string;
|
|
406
|
+
}): readonly PublishProblem[];
|
package/src/index.mjs
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { ensure, identifier, validateManifest, PluginError } from './manifest.mjs'
|
|
1
|
+
import { PRESENTATIONS, ensure, identifier, validateManifest, PluginError } from './manifest.mjs'
|
|
2
|
+
import { COLLECTION_FILE, resolveCollection, validateCollection } from './collection.mjs'
|
|
3
|
+
import { SDK_PACKAGE, checkPackageForPublish, lockfileRequirement } from './publish.mjs'
|
|
2
4
|
|
|
3
|
-
export { validateManifest, PluginError }
|
|
5
|
+
export { PRESENTATIONS, validateManifest, PluginError }
|
|
6
|
+
export { COLLECTION_FILE, resolveCollection, validateCollection }
|
|
7
|
+
export { SDK_PACKAGE, checkPackageForPublish, lockfileRequirement }
|
|
8
|
+
export { TONES, VIEW_LIMITS, ui, validateView } from './view.mjs'
|
|
4
9
|
|
|
5
10
|
export function definePlugin(definition) {
|
|
6
11
|
ensure(definition && identifier(definition.id) && typeof definition.activate === 'function',
|
package/src/internal.d.ts
CHANGED
|
@@ -30,7 +30,8 @@ export interface Runtime {
|
|
|
30
30
|
}
|
|
31
31
|
export function createRuntime(options: { manifest: PluginManifest; producer: Producer; send(frame: Envelope): void; clock?: Clock; instanceId?: string; onError?(error: Error): void }): Runtime;
|
|
32
32
|
export function authorize(operation: Operation, capabilities: string[]): void;
|
|
33
|
-
export function
|
|
33
|
+
export function frameLimit(frame: unknown, inbound: boolean): number;
|
|
34
|
+
export function validateEnvelope(frame: unknown, options?: { inbound?: boolean }): Envelope;
|
|
34
35
|
export class RpcPeer {
|
|
35
36
|
constructor(options: { producer: Producer; send(frame: Envelope): void; clock?: Clock; idPrefix?: string; onRequest?(operation: Operation | HostOperation, options: { signal: AbortSignal; timeoutMs: number }): Promise<Json> | Json; onSurface?(frame: Envelope): void; onError?(error: Error): void });
|
|
36
37
|
request(operation: Operation | HostOperation, options?: RequestOptions): Promise<Json>;
|
package/src/manifest.mjs
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
export const CAPABILITIES = Object.freeze(['surfaces', 'events', 'hooks', 'panes', 'projects',
|
|
2
2
|
'notifications', 'url', 'fetch', 'secrets', 'webhook'])
|
|
3
|
-
export const SURFACE_KINDS = Object.freeze(['section', 'slot', 'badge', 'panel', 'overlay',
|
|
3
|
+
export const SURFACE_KINDS = Object.freeze(['section', 'card', 'slot', 'badge', 'panel', 'overlay',
|
|
4
4
|
'menu', 'command', 'key', 'link'])
|
|
5
|
+
export const PRESENTATIONS = Object.freeze(['popover', 'column', 'pane'])
|
|
5
6
|
export const ANCHORS = Object.freeze(['plugins', 'machine.before', 'machine.after',
|
|
6
7
|
'project.before', 'project.after', 'pane.header', 'pane.footer',
|
|
7
8
|
'account', 'machine', 'project', 'pane', 'section'])
|
|
9
|
+
// responseFrameBytes admits a host response carrying a 16 MiB fetch body after
|
|
10
|
+
// JSON escaping (PLUGIN_FETCH_RESULT_MAX_BYTES in standardd).
|
|
8
11
|
export const LIMITS = Object.freeze({ manifestBytes: 65536, frameBytes: 262144,
|
|
12
|
+
responseFrameBytes: 64 * 1024 * 1024,
|
|
9
13
|
pendingRequests: 128, subscriptions: 256, schedules: 128, contributions: 256,
|
|
10
14
|
queuedBytes: 4 * 1024 * 1024, hookTimeoutMs: 60000, requestTimeoutMs: 30000,
|
|
11
15
|
canvasColumns: 512, canvasRows: 256 })
|
|
@@ -72,13 +76,22 @@ export function validateManifest(value) {
|
|
|
72
76
|
SURFACE_KINDS.includes(declaration.kind) && ANCHORS.includes(declaration.anchor), 'invalid_manifest', 'Invalid contribution declaration')
|
|
73
77
|
ids.add(declaration.id)
|
|
74
78
|
for (const [key, choices] of Object.entries({ merge: ['by-machine', 'by-identity'], width: ['full', 'half'],
|
|
75
|
-
position: ['top', 'after-open', 'before-danger', 'bottom'] })) {
|
|
79
|
+
position: ['top', 'after-open', 'before-danger', 'bottom'], presentation: PRESENTATIONS })) {
|
|
76
80
|
ensure(declaration[key] === undefined || choices.includes(declaration[key]), 'invalid_manifest', `Invalid contribution ${key}`)
|
|
77
81
|
}
|
|
78
82
|
for (const key of ['title', 'group', 'chord', 'pattern', 'actionId']) {
|
|
79
83
|
ensure(declaration[key] === undefined || (typeof declaration[key] === 'string' && declaration[key].length <= 512),
|
|
80
84
|
'invalid_manifest', `Invalid contribution ${key}`)
|
|
81
85
|
}
|
|
86
|
+
ensure(declaration.opens === undefined || identifier(declaration.opens), 'invalid_manifest', 'Invalid contribution opens')
|
|
87
|
+
ensure(declaration.presentation === undefined || ['panel', 'card', 'command'].includes(declaration.kind),
|
|
88
|
+
'invalid_manifest', 'Only panels, cards, and commands declare a presentation')
|
|
89
|
+
ensure(declaration.kind !== 'card' || declaration.anchor === 'plugins', 'invalid_manifest', 'Cards use the plugins anchor')
|
|
90
|
+
}
|
|
91
|
+
for (const declaration of manifest.contributions) {
|
|
92
|
+
ensure(declaration.opens === undefined ||
|
|
93
|
+
manifest.contributions.some(item => item.id === declaration.opens && item.kind === 'panel'),
|
|
94
|
+
'invalid_manifest', 'Contribution opens must name a declared panel')
|
|
82
95
|
}
|
|
83
96
|
ensure(!manifest.contributions.length || manifest.capabilities.includes('surfaces'), 'invalid_manifest', 'Contributions require surfaces capability')
|
|
84
97
|
ensure(manifest.configSchema === undefined || object(manifest.configSchema), 'invalid_manifest', 'Configuration schema must be an object')
|
package/src/protocol.mjs
CHANGED
|
@@ -40,8 +40,12 @@ export function validateProducer(producer) {
|
|
|
40
40
|
export function sameProducer(a, b) {
|
|
41
41
|
return a.pluginId === b.pluginId && a.machineId === b.machineId && a.epoch === b.epoch
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
/** Only an inbound response may exceed the frame limit, so a fetch body can reach the plugin. */
|
|
44
|
+
export function frameLimit(frame, inbound) {
|
|
45
|
+
return inbound && object(frame) && frame.kind === 'response' ? LIMITS.responseFrameBytes : LIMITS.frameBytes
|
|
46
|
+
}
|
|
47
|
+
export function validateEnvelope(frame, { inbound = false } = {}) {
|
|
48
|
+
jsonBytes(frame, frameLimit(frame, inbound))
|
|
45
49
|
ensure(object(frame) && frame.version === RUNNER_PROTOCOL_VERSION, 'incompatible_version', 'Daemon and runner protocol versions differ')
|
|
46
50
|
validateProducer(frame.producer)
|
|
47
51
|
ensure(['request', 'response', 'cancel', 'surface'].includes(frame.kind), 'invalid_payload', 'Unknown runner frame kind')
|
|
@@ -115,7 +119,7 @@ export class RpcPeer {
|
|
|
115
119
|
}
|
|
116
120
|
receive(frame) {
|
|
117
121
|
if (this.closed) return
|
|
118
|
-
validateEnvelope(frame)
|
|
122
|
+
validateEnvelope(frame, { inbound: true })
|
|
119
123
|
ensure(sameProducer(frame.producer, this.producer), 'stale_producer', 'Frame belongs to another plugin instance')
|
|
120
124
|
if (frame.kind === 'surface') { this.onSurface?.(frame); return }
|
|
121
125
|
if (frame.kind === 'response') {
|
package/src/publish.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { PluginError, ensure, object, validateManifest } from './manifest.mjs'
|
|
2
|
+
|
|
3
|
+
export const SDK_PACKAGE = '@standardagents/code-plugin-sdk'
|
|
4
|
+
export const SOURCE_KINDS = Object.freeze(['git', 'npm'])
|
|
5
|
+
const LOCKFILES = Object.freeze({ git: Object.freeze(['package-lock.json', 'npm-shrinkwrap.json']), npm: Object.freeze(['npm-shrinkwrap.json']) })
|
|
6
|
+
|
|
7
|
+
function names(section) { return object(section) ? Object.keys(section) : [] }
|
|
8
|
+
|
|
9
|
+
/** Runtime dependencies are the ones npm installs for a consumer of the package. */
|
|
10
|
+
export function runtimeDependencies(packageJson) {
|
|
11
|
+
ensure(object(packageJson), 'invalid_package', 'package.json must contain an object')
|
|
12
|
+
return [...new Set([...names(packageJson.dependencies), ...names(packageJson.optionalDependencies)])]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A plugin with runtime dependencies ships a lockfile. npm publishes
|
|
17
|
+
* npm-shrinkwrap.json and drops package-lock.json, so an npm source needs the
|
|
18
|
+
* shrinkwrap; a Git source may keep either file.
|
|
19
|
+
*/
|
|
20
|
+
export function lockfileRequirement({ packageJson, sourceKind }) {
|
|
21
|
+
ensure(SOURCE_KINDS.includes(sourceKind), 'invalid_source', 'Source kind must be git or npm')
|
|
22
|
+
const dependencies = runtimeDependencies(packageJson)
|
|
23
|
+
return Object.freeze({ required: dependencies.length > 0, dependencies: Object.freeze(dependencies), lockfiles: LOCKFILES[sourceKind] })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalize(path) { return path.replace(/^\.\//, '') }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pure publish checks for one plugin directory. `files` lists the relative
|
|
30
|
+
* POSIX paths that ship with the package. Returns a list of problems; an empty
|
|
31
|
+
* list means the package passes.
|
|
32
|
+
*/
|
|
33
|
+
export function checkPackageForPublish({ packageJson, files, sourceKind = 'npm', requireManifest = true, expectedId } = {}) {
|
|
34
|
+
ensure(Array.isArray(files) && files.every(file => typeof file === 'string'), 'invalid_source', 'files must list relative paths')
|
|
35
|
+
const problems = []
|
|
36
|
+
const problem = (code, message) => problems.push(Object.freeze({ code, message }))
|
|
37
|
+
if (!object(packageJson)) return Object.freeze([Object.freeze({ code: 'invalid_package', message: 'package.json must contain an object' })])
|
|
38
|
+
const present = new Set(files.map(normalize))
|
|
39
|
+
if (packageJson.private === true && sourceKind === 'npm') problem('package_private', 'package.json marks the package private, so npm refuses to publish it')
|
|
40
|
+
const requirement = lockfileRequirement({ packageJson, sourceKind })
|
|
41
|
+
if (requirement.required && !requirement.lockfiles.some(name => present.has(name))) {
|
|
42
|
+
problem('lockfile_missing', `Dependencies (${requirement.dependencies.join(', ')}) need ${requirement.lockfiles.join(' or ')} beside package.json`)
|
|
43
|
+
}
|
|
44
|
+
for (const section of ['dependencies', 'optionalDependencies']) {
|
|
45
|
+
if (names(packageJson[section]).includes(SDK_PACKAGE)) problem('sdk_dependency', `${SDK_PACKAGE} belongs under peerDependencies, not ${section}`)
|
|
46
|
+
}
|
|
47
|
+
let manifest = null
|
|
48
|
+
if (packageJson.standardPlugin === undefined) {
|
|
49
|
+
if (requireManifest) problem('manifest_missing', 'package.json has no standardPlugin manifest')
|
|
50
|
+
} else {
|
|
51
|
+
try { manifest = validateManifest(packageJson.standardPlugin) } catch (error) {
|
|
52
|
+
if (!(error instanceof PluginError)) throw error
|
|
53
|
+
problem(error.code, error.message)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (manifest) {
|
|
57
|
+
if (!names(packageJson.peerDependencies).includes(SDK_PACKAGE)) problem('sdk_peer_missing', `${SDK_PACKAGE} must be listed under peerDependencies`)
|
|
58
|
+
if (!present.has(normalize(manifest.entry))) problem('entry_missing', `Entry ${manifest.entry} is not among the package files`)
|
|
59
|
+
if (expectedId !== undefined && manifest.id !== expectedId) problem('id_mismatch', `Manifest id ${manifest.id} differs from collection entry ${expectedId}`)
|
|
60
|
+
}
|
|
61
|
+
return Object.freeze(problems)
|
|
62
|
+
}
|
package/src/runtime.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LIMITS, PluginError, ensure, jsonBytes, validateManifest, ANCHORS, identifier, object } from './manifest.mjs'
|
|
2
2
|
import { RpcPeer, authorize, realClock } from './protocol.mjs'
|
|
3
|
+
import { validateView } from './view.mjs'
|
|
3
4
|
|
|
4
5
|
const always = Object.freeze({ kind: 'always' })
|
|
5
6
|
const menuPositions = ['top', 'after-open', 'before-danger', 'bottom']
|
|
@@ -40,9 +41,21 @@ function validateCondition(condition) {
|
|
|
40
41
|
(condition.kind === 'always' || identifier(condition.contributionId)), 'invalid_payload', 'Invalid schedule condition')
|
|
41
42
|
return structuredClone(condition)
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
+
const CONTENT_KINDS = ['rows', 'text', 'badge', 'canvas', 'view']
|
|
45
|
+
/** Mirrors PluginSurfaceKind::accepts: canvas suits every drawn kind, view suits cards, panels and sections. */
|
|
46
|
+
export function acceptsContent(kind, content) {
|
|
47
|
+
if (['menu', 'command', 'key', 'link'].includes(kind)) return false
|
|
48
|
+
if (kind === 'badge') return content.kind === 'badge'
|
|
49
|
+
if (content.kind === 'badge') return false
|
|
50
|
+
return content.kind !== 'view' || ['section', 'card', 'panel'].includes(kind)
|
|
51
|
+
}
|
|
52
|
+
function validateContent(content, declaration, manifest) {
|
|
53
|
+
const { kind } = declaration
|
|
54
|
+
ensure(object(content) && CONTENT_KINDS.includes(content.kind), 'invalid_payload', 'Invalid surface content')
|
|
55
|
+
ensure(acceptsContent(kind, content), 'invalid_payload', `A ${kind} contribution cannot show ${content.kind} content`)
|
|
56
|
+
// The view walk bounds depth before serialization visits the tree.
|
|
57
|
+
if (content.kind === 'view') validateView(content.root, { kind: declaration.kind, manifest })
|
|
44
58
|
jsonBytes(content)
|
|
45
|
-
ensure(content && ['rows', 'text', 'badge', 'canvas'].includes(content.kind), 'invalid_payload', 'Invalid surface content')
|
|
46
59
|
if (content.kind === 'canvas') {
|
|
47
60
|
const { columns, rows, shade = 0 } = content.canvas ?? {}
|
|
48
61
|
ensure(Number.isSafeInteger(columns) && columns > 0 && columns <= LIMITS.canvasColumns &&
|
|
@@ -102,7 +115,7 @@ export function createRuntime({ manifest: input, producer, send, clock = realClo
|
|
|
102
115
|
const replace = content => {
|
|
103
116
|
active()
|
|
104
117
|
ensure(!disposed, 'disposed', 'Contribution publisher is disposed')
|
|
105
|
-
if (content !== null) validateContent(content)
|
|
118
|
+
if (content !== null) validateContent(content, declaration, manifest)
|
|
106
119
|
peer.transmit(peer.frame('surface', { key, sequence: String(++sequence), content }))
|
|
107
120
|
}
|
|
108
121
|
const publisher = { replace, clear: () => replace(null), dispose() {
|
|
@@ -292,7 +305,7 @@ export function createRuntime({ manifest: input, producer, send, clock = realClo
|
|
|
292
305
|
const method = op => (args, options) => request(op, args, options)
|
|
293
306
|
const context = Object.freeze({
|
|
294
307
|
manifest, producer: peer.producer, signal: lifetime.signal, request,
|
|
295
|
-
...Object.fromEntries(['section', 'slot', 'badge', 'panel', 'overlay'].map(kind => [kind,
|
|
308
|
+
...Object.fromEntries(['section', 'card', 'slot', 'badge', 'panel', 'overlay'].map(kind => [kind,
|
|
296
309
|
(id, entity) => publish(kind, id, entity).publisher])),
|
|
297
310
|
canvas(id, spec, entity) {
|
|
298
311
|
const { publisher, key } = publish(null, id, entity)
|
package/src/testing.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Json, ManifestInput, OperationMap, PluginContext, PluginDefinition, Condition, RequestOptions } from './index.js';
|
|
1
|
+
import type { Json, ManifestInput, OperationMap, PluginContext, PluginDefinition, Condition, RequestOptions, EntityRef, SurfaceContent } from './index.js';
|
|
2
2
|
import type { Envelope } from './internal.js';
|
|
3
3
|
export interface Harness {
|
|
4
4
|
context: PluginContext;
|
|
@@ -11,6 +11,8 @@ export interface Harness {
|
|
|
11
11
|
emit(kind: string, name: string, event: Json, options?: RequestOptions): Promise<Json[]>;
|
|
12
12
|
visibility(conditions: Condition[]): Promise<Json>;
|
|
13
13
|
receive(frame: Envelope): void;
|
|
14
|
+
/** The current content of a published contribution, or undefined when it is clear. */
|
|
15
|
+
surface(id: string, entity?: EntityRef): SurfaceContent | undefined;
|
|
14
16
|
drainTrace(): unknown[];
|
|
15
17
|
readonly resources: { timers: number; subscriptions: number; surfaces: number; disposed: boolean };
|
|
16
18
|
dispose(): Promise<void>;
|
package/src/testing.mjs
CHANGED
|
@@ -88,6 +88,13 @@ export function createHarness({ manifest: input, machineId = 'test-machine', epo
|
|
|
88
88
|
},
|
|
89
89
|
visibility: conditions => host.request({ op: 'runtime.visibility', args: { conditions } }),
|
|
90
90
|
receive: frame => runtime.receive(frame),
|
|
91
|
+
/** The current content of a published contribution, or undefined when it is clear. */
|
|
92
|
+
surface(id, entity) {
|
|
93
|
+
const declaration = manifest.contributions.find(item => item.id === id)
|
|
94
|
+
if (!declaration) return undefined
|
|
95
|
+
const key = { contributionId: id, anchor: declaration.anchor, ...(entity ? { entity } : {}) }
|
|
96
|
+
return structuredClone(surfaces.get(JSON.stringify(key))?.content)
|
|
97
|
+
},
|
|
91
98
|
drainTrace() { return trace.splice(0) },
|
|
92
99
|
get resources() { return { timers: timers.size, subscriptions: subscriptions.size, surfaces: surfaces.size, disposed } },
|
|
93
100
|
async dispose() {
|
package/src/view.mjs
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { LIMITS, ensure, identifier, jsonBytes, object } from './manifest.mjs'
|
|
2
|
+
|
|
3
|
+
// Mirrors crates/standard-protocol/src/plugin_view.rs. The host applies the
|
|
4
|
+
// same bounds; checking here reports a mistake at the replace() call.
|
|
5
|
+
export const VIEW_LIMITS = Object.freeze({ depth: 8, nodes: 4096, tableRows: 512, tableColumns: 12,
|
|
6
|
+
cardLines: 6, actionValueBytes: 512 })
|
|
7
|
+
export const TONES = Object.freeze(['ok', 'info', 'warn', 'error', 'muted', 'accent', 'pending', 'bright'])
|
|
8
|
+
const WEIGHTS = ['normal', 'bold', 'dim']
|
|
9
|
+
const ALIGNS = ['start', 'center', 'end']
|
|
10
|
+
// C0, DEL, C1, and the bidirectional formatting characters that can reorder terminal output.
|
|
11
|
+
const FORBIDDEN = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/
|
|
12
|
+
|
|
13
|
+
const invalid = message => ensure(false, 'invalid_payload', message)
|
|
14
|
+
function text(value, field) {
|
|
15
|
+
if (typeof value !== 'string') invalid(`Plugin view ${field} must be a string`)
|
|
16
|
+
ensure(!FORBIDDEN.test(value), 'invalid_payload', 'Plugin view text contains a control or bidirectional character')
|
|
17
|
+
}
|
|
18
|
+
function optionalText(value, field) { if (value !== undefined) text(value, field) }
|
|
19
|
+
function choice(value, choices, field) {
|
|
20
|
+
ensure(value === undefined || choices.includes(value), 'invalid_payload', `Invalid plugin view ${field}`)
|
|
21
|
+
}
|
|
22
|
+
function tone(value) { choice(value, TONES, 'tone') }
|
|
23
|
+
function integer(value, max, field) {
|
|
24
|
+
ensure(value === undefined || (Number.isInteger(value) && value >= 0 && value <= max), 'invalid_payload', `Invalid plugin view ${field}`)
|
|
25
|
+
}
|
|
26
|
+
function flag(value, field) { ensure(value === undefined || typeof value === 'boolean', 'invalid_payload', `Invalid plugin view ${field}`) }
|
|
27
|
+
function number(value) {
|
|
28
|
+
ensure(typeof value === 'number' && Number.isFinite(value), 'invalid_payload', 'Plugin view number is not finite')
|
|
29
|
+
}
|
|
30
|
+
function list(value, field, optional = false) {
|
|
31
|
+
if (optional && value === undefined) return []
|
|
32
|
+
ensure(Array.isArray(value), 'invalid_payload', `Plugin view ${field} must be an array`)
|
|
33
|
+
return value
|
|
34
|
+
}
|
|
35
|
+
function entries(value, field, optional, check) {
|
|
36
|
+
for (const item of list(value, field, optional)) {
|
|
37
|
+
ensure(object(item), 'invalid_payload', `Plugin view ${field} must contain objects`)
|
|
38
|
+
check(item)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function spans(value, optional) {
|
|
42
|
+
entries(value, 'spans', optional, span => {
|
|
43
|
+
text(span.text, 'span text')
|
|
44
|
+
tone(span.tone)
|
|
45
|
+
choice(span.weight, WEIGHTS, 'weight')
|
|
46
|
+
flag(span.mono, 'mono')
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
function action(value, optional = true) {
|
|
50
|
+
if (optional && value === undefined) return
|
|
51
|
+
ensure(object(value), 'invalid_payload', 'Plugin view action must be an object')
|
|
52
|
+
text(value.actionId, 'action id')
|
|
53
|
+
optionalText(value.value, 'action value')
|
|
54
|
+
optionalText(value.opens, 'action opens')
|
|
55
|
+
}
|
|
56
|
+
function meter(item) {
|
|
57
|
+
number(item.value)
|
|
58
|
+
number(item.max)
|
|
59
|
+
tone(item.tone)
|
|
60
|
+
optionalText(item.label, 'label')
|
|
61
|
+
}
|
|
62
|
+
function children(node, depth, count) {
|
|
63
|
+
for (const child of list(node.children, 'children', true)) walk(child, depth + 1, count)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const NODES = {
|
|
67
|
+
stack(node, depth, count) { integer(node.gap, 255, 'gap'); children(node, depth, count) },
|
|
68
|
+
row(node, depth, count) { choice(node.align, ALIGNS, 'align'); children(node, depth, count) },
|
|
69
|
+
divider(node) { optionalText(node.label, 'label') },
|
|
70
|
+
text(node) {
|
|
71
|
+
optionalText(node.text, 'text')
|
|
72
|
+
spans(node.spans, true)
|
|
73
|
+
tone(node.tone)
|
|
74
|
+
choice(node.weight, WEIGHTS, 'weight')
|
|
75
|
+
flag(node.mono, 'mono')
|
|
76
|
+
},
|
|
77
|
+
badge(node) { text(node.label, 'label'); tone(node.tone) },
|
|
78
|
+
dot(node) { tone(node.tone) },
|
|
79
|
+
progress: meter,
|
|
80
|
+
segments(node) { entries(node.items, 'items', false, meter) },
|
|
81
|
+
card(node, depth, count) { optionalText(node.title, 'title'); tone(node.tone); children(node, depth, count) },
|
|
82
|
+
stat(node) {
|
|
83
|
+
text(node.label, 'label')
|
|
84
|
+
text(node.value, 'value')
|
|
85
|
+
tone(node.tone)
|
|
86
|
+
optionalText(node.hint, 'hint')
|
|
87
|
+
},
|
|
88
|
+
kv(node) {
|
|
89
|
+
entries(node.items, 'items', false, item => {
|
|
90
|
+
text(item.label, 'label')
|
|
91
|
+
text(item.value, 'value')
|
|
92
|
+
flag(item.mono, 'mono')
|
|
93
|
+
flag(item.copy, 'copy')
|
|
94
|
+
})
|
|
95
|
+
},
|
|
96
|
+
tabs(node) {
|
|
97
|
+
text(node.id, 'id')
|
|
98
|
+
optionalText(node.filters, 'filters')
|
|
99
|
+
action(node.action)
|
|
100
|
+
entries(node.items, 'items', false, item => {
|
|
101
|
+
text(item.id, 'id')
|
|
102
|
+
text(item.label, 'label')
|
|
103
|
+
optionalText(item.sublabel, 'sublabel')
|
|
104
|
+
tone(item.tone)
|
|
105
|
+
integer(item.count, 0xffffffff, 'count')
|
|
106
|
+
optionalText(item.tag, 'tag')
|
|
107
|
+
})
|
|
108
|
+
},
|
|
109
|
+
select(node) {
|
|
110
|
+
text(node.id, 'id')
|
|
111
|
+
text(node.label, 'label')
|
|
112
|
+
optionalText(node.filters, 'filters')
|
|
113
|
+
action(node.action)
|
|
114
|
+
entries(node.options, 'options', false, option => {
|
|
115
|
+
text(option.id, 'id')
|
|
116
|
+
text(option.label, 'label')
|
|
117
|
+
optionalText(option.group, 'group')
|
|
118
|
+
integer(option.count, 0xffffffff, 'count')
|
|
119
|
+
optionalText(option.tag, 'tag')
|
|
120
|
+
})
|
|
121
|
+
},
|
|
122
|
+
table(node, depth, count) {
|
|
123
|
+
const columns = list(node.columns, 'columns')
|
|
124
|
+
const rows = list(node.rows, 'rows', true)
|
|
125
|
+
ensure(columns.length <= VIEW_LIMITS.tableColumns, 'invalid_payload', 'Plugin table has more than 12 columns')
|
|
126
|
+
ensure(rows.length <= VIEW_LIMITS.tableRows, 'invalid_payload', 'Plugin table has more than 512 rows')
|
|
127
|
+
text(node.id, 'id')
|
|
128
|
+
entries(columns, 'columns', false, column => {
|
|
129
|
+
text(column.id, 'column id')
|
|
130
|
+
optionalText(column.label, 'column label')
|
|
131
|
+
if (column.width !== 'fill') integer(column.width, 0xffff, 'column width')
|
|
132
|
+
integer(column.maxWidth, 0xffff, 'column maxWidth')
|
|
133
|
+
choice(column.align, ALIGNS, 'align')
|
|
134
|
+
integer(column.priority, 255, 'column priority')
|
|
135
|
+
})
|
|
136
|
+
entries(rows, 'rows', false, row => {
|
|
137
|
+
text(row.id, 'row id')
|
|
138
|
+
tone(row.tone)
|
|
139
|
+
spans(row.note, true)
|
|
140
|
+
for (const tag of list(row.tags, 'tags', true)) text(tag, 'tag')
|
|
141
|
+
action(row.action)
|
|
142
|
+
ensure(row.cells === undefined || object(row.cells), 'invalid_payload', 'Plugin table cells must be an object')
|
|
143
|
+
for (const [column, cell] of Object.entries(row.cells ?? {})) {
|
|
144
|
+
text(column, 'cell column')
|
|
145
|
+
walk(cell, depth + 1, count)
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
},
|
|
149
|
+
log(node) { for (const line of list(node.lines, 'lines')) text(line, 'log line') },
|
|
150
|
+
button(node) { text(node.label, 'label'); action(node.action, false) },
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function walk(node, depth, count) {
|
|
154
|
+
ensure(depth <= VIEW_LIMITS.depth, 'invalid_payload', 'Plugin view nests deeper than 8 nodes')
|
|
155
|
+
ensure(++count.nodes <= VIEW_LIMITS.nodes, 'invalid_payload', 'Plugin view has more than 4096 nodes')
|
|
156
|
+
ensure(object(node) && typeof node.type === 'string', 'invalid_payload', 'Plugin view node requires a type')
|
|
157
|
+
// A node type this SDK does not know draws nothing on hosts that do not know it either.
|
|
158
|
+
if (Object.hasOwn(NODES, node.type)) NODES[node.type](node, depth, count)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The daemon checks below mirror crates/standardd/src/plugin_view_content.rs.
|
|
162
|
+
|
|
163
|
+
/** Visits the actions a viewer can activate, in the nodes the daemon searches. */
|
|
164
|
+
function visitActions(node, visit) {
|
|
165
|
+
switch (node.type) {
|
|
166
|
+
case 'stack': case 'row': case 'card':
|
|
167
|
+
for (const child of node.children ?? []) visitActions(child, visit)
|
|
168
|
+
break
|
|
169
|
+
case 'button': visit(node.action); break
|
|
170
|
+
case 'tabs': case 'select': if (node.action !== undefined) visit(node.action); break
|
|
171
|
+
case 'table':
|
|
172
|
+
for (const row of node.rows ?? []) {
|
|
173
|
+
if (row.action !== undefined) visit(row.action)
|
|
174
|
+
for (const cell of Object.values(row.cells ?? {})) visitActions(cell, visit)
|
|
175
|
+
}
|
|
176
|
+
break
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const ONE_LINE = ['text', 'badge', 'dot', 'progress', 'segments', 'stat', 'divider']
|
|
181
|
+
const PANEL_ONLY = ['card', 'kv', 'tabs', 'select', 'table', 'log', 'button']
|
|
182
|
+
/** Cards hold one-line summaries arranged by stacks and rows; unknown nodes draw nothing. */
|
|
183
|
+
function cardLines(node) {
|
|
184
|
+
if (node.type === 'stack') {
|
|
185
|
+
const children = node.children ?? []
|
|
186
|
+
return children.reduce((total, child) => total + cardLines(child), (node.gap ?? 0) * Math.max(children.length - 1, 0))
|
|
187
|
+
}
|
|
188
|
+
if (node.type === 'row') return (node.children ?? []).reduce((tallest, child) => Math.max(tallest, cardLines(child)), 0)
|
|
189
|
+
if (ONE_LINE.includes(node.type)) return 1
|
|
190
|
+
ensure(!PANEL_ONLY.includes(node.type), 'invalid_payload', `A card view cannot contain a ${node.type} node`)
|
|
191
|
+
return 0
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Throws a PluginError when a view tree breaks a protocol bound or a rule the
|
|
196
|
+
* serving daemon applies. `kind: 'card'` adds the card rules, and `manifest`
|
|
197
|
+
* requires every action `opens` to name one of its panels.
|
|
198
|
+
*/
|
|
199
|
+
export function validateView(root, { kind, manifest } = {}) {
|
|
200
|
+
walk(root, 1, { nodes: 0 })
|
|
201
|
+
jsonBytes(root, LIMITS.frameBytes)
|
|
202
|
+
visitActions(root, action => {
|
|
203
|
+
ensure(identifier(action.actionId), 'invalid_payload', 'Plugin view action id must be a plugin identifier')
|
|
204
|
+
ensure(action.value === undefined || Buffer.byteLength(action.value) <= VIEW_LIMITS.actionValueBytes,
|
|
205
|
+
'payload_too_large', 'Plugin view action value exceeds 512 bytes')
|
|
206
|
+
ensure(action.opens === undefined || identifier(action.opens), 'invalid_payload', 'Plugin view action opens must be a plugin identifier')
|
|
207
|
+
ensure(action.opens === undefined || !manifest ||
|
|
208
|
+
manifest.contributions.some(item => item.id === action.opens && item.kind === 'panel'),
|
|
209
|
+
'invalid_payload', 'Plugin view action opens must name a declared panel')
|
|
210
|
+
})
|
|
211
|
+
if (kind === 'card') {
|
|
212
|
+
ensure(cardLines(root) <= VIEW_LIMITS.cardLines, 'payload_too_large', 'A card view is taller than 6 lines')
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function compact(value) {
|
|
217
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined))
|
|
218
|
+
}
|
|
219
|
+
const node = (type, fields) => compact({ type, ...fields })
|
|
220
|
+
|
|
221
|
+
/** Optional builders. Each returns the plain protocol JSON for one node. */
|
|
222
|
+
export const ui = Object.freeze({
|
|
223
|
+
view: root => ({ kind: 'view', root }),
|
|
224
|
+
action: (actionId, { value, opens } = {}) => compact({ actionId, value, opens }),
|
|
225
|
+
span: (value, { tone, weight, mono } = {}) => compact({ text: value, tone, weight, mono }),
|
|
226
|
+
stack: (items, { gap } = {}) => node('stack', { gap, children: items }),
|
|
227
|
+
row: (items, { align } = {}) => node('row', { children: items, align }),
|
|
228
|
+
divider: label => node('divider', { label }),
|
|
229
|
+
/** A string sets `text`; an array of spans sets `spans`. */
|
|
230
|
+
text: (content, { tone, weight, mono } = {}) =>
|
|
231
|
+
node('text', { ...(Array.isArray(content) ? { spans: content.map(compact) } : { text: content }), tone, weight, mono }),
|
|
232
|
+
badge: (label, tone) => node('badge', { label, tone }),
|
|
233
|
+
dot: tone => node('dot', { tone }),
|
|
234
|
+
progress: (value, max, { tone, label } = {}) => node('progress', { value, max, tone, label }),
|
|
235
|
+
segments: items => node('segments', { items: items.map(compact) }),
|
|
236
|
+
card: (items, { title, tone } = {}) => node('card', { title, tone, children: items }),
|
|
237
|
+
stat: (label, value, { tone, hint } = {}) => node('stat', { label, value, tone, hint }),
|
|
238
|
+
kv: items => node('kv', { items: items.map(compact) }),
|
|
239
|
+
tabs: (id, items, { filters, action } = {}) => node('tabs', { id, items: items.map(compact), filters, action }),
|
|
240
|
+
select: (id, label, options, { filters, action } = {}) =>
|
|
241
|
+
node('select', { id, label, options: options.map(compact), filters, action }),
|
|
242
|
+
table: (id, columns, rows = []) => node('table', { id, columns: columns.map(compact), rows: rows.map(compact) }),
|
|
243
|
+
log: lines => node('log', { lines }),
|
|
244
|
+
/** `action` is an action object or an action id. */
|
|
245
|
+
button: (label, action) => node('button', { label, action: typeof action === 'string' ? { actionId: action } : action }),
|
|
246
|
+
})
|