Align official plugins with v2 runtime

This commit is contained in:
2026-06-27 12:36:31 +08:00
parent 321c8e58d1
commit 39d8df339b
14 changed files with 702 additions and 208 deletions
+28 -5
View File
@@ -36,6 +36,7 @@ package_plugin() {
local plugin_dir="$1"
local plugin_name="$2"
local dist_dir="$ROOT/dist/$plugin_name"
local copied_plain_source=0
echo " → packaging dist/$plugin_name"
rm -rf "$dist_dir"
@@ -47,14 +48,35 @@ package_plugin() {
fi
# 2. frontend/dist/
if [ -d "$plugin_dir/frontend/dist" ]; then
if [ ! -f "$plugin_dir/frontend/package.json" ] && [ -f "$plugin_dir/frontend/src/index.js" ]; then
mkdir -p "$dist_dir/frontend/dist"
cp "$plugin_dir/frontend/src/index.js" "$dist_dir/frontend/dist/index.js"
copied_plain_source=1
echo " └─ frontend/dist/index.js (from frontend/src/index.js)"
elif [ -d "$plugin_dir/frontend/dist" ]; then
mkdir -p "$dist_dir/frontend/dist"
cp -r "$plugin_dir/frontend/dist/." "$dist_dir/frontend/dist/"
echo " └─ frontend/dist ($(find "$dist_dir/frontend/dist" -type f | wc -l) file(s))"
elif [ -f "$plugin_dir/frontend/src/index.js" ]; then
mkdir -p "$dist_dir/frontend/dist"
cp "$plugin_dir/frontend/src/index.js" "$dist_dir/frontend/dist/index.js"
echo " └─ frontend/dist/index.js (from frontend/src/index.js)"
fi
if [ "$copied_plain_source" -eq 1 ] && [ -f "$dist_dir/plugin.json" ]; then
if command -v python3 &>/dev/null; then
python3 -c "
import json
path = '$dist_dir/plugin.json'
with open(path, encoding='utf-8') as f:
manifest = json.load(f)
frontend = manifest.setdefault('frontend', {})
frontend['entry'] = 'frontend/dist/index.js'
with open(path, 'w', encoding='utf-8') as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
f.write('\n')
"
echo " └─ plugin.json frontend.entry -> frontend/dist/index.js"
else
echo " ❌ python3 required to rewrite packaged plugin.json for plain JS frontend"
return 1
fi
fi
# 3. backend binary
@@ -88,6 +110,7 @@ HAS_DEPS=1
if ! command -v node &>/dev/null; then echo " ❌ node: not found"; HAS_DEPS=0; else echo " ✅ node $(node --version)"; fi
if ! command -v npm &>/dev/null; then echo " ❌ npm: not found"; HAS_DEPS=0; fi
if ! command -v go &>/dev/null; then echo " ❌ go: not found"; HAS_DEPS=0; else echo " ✅ go $(go version | grep -oP 'go\S+')"; fi
if ! command -v python3 &>/dev/null; then echo " ❌ python3: not found"; HAS_DEPS=0; else echo " ✅ python3 $(python3 --version | awk '{print $2}')"; fi
if [ "$HAS_DEPS" -eq 0 ]; then
echo " ⚠️ some deps missing — will skip matching plugin parts"
fi
+115 -20
View File
@@ -24,44 +24,51 @@ if [ "$HAS_PYTHON" -eq 1 ]; then
echo "[manifest validation]"
SDK_SCHEMA="$ROOT/../verstak-sdk/schemas/manifest.json"
if [ -f "$SDK_SCHEMA" ]; then
set +e
python3 -c "
import json, glob
import json, glob, os, sys
from jsonschema import Draft202012Validator
skipped = []
problems = []
with open('$SDK_SCHEMA') as f:
schema = json.load(f)
validator = Draft202012Validator(schema)
for plugin_dir in glob.glob('$ROOT/plugins/*/'):
manifest_path = plugin_dir + 'plugin.json'
plugin_name = os.path.basename(os.path.dirname(manifest_path))
try:
with open(manifest_path) as f:
manifest = json.load(f)
except FileNotFoundError:
skipped.append(plugin_dir.split('/')[-2])
skipped.append(plugin_name)
continue
except json.JSONDecodeError as e:
problems.append(plugin_dir.split('/')[-2] + ': invalid JSON ' + str(e))
problems.append(plugin_name + ': invalid JSON - ' + str(e))
continue
checks = {
'id': isinstance(manifest.get('id'), str) and '.' in manifest['id'],
'version': isinstance(manifest.get('version'), str),
'schemaVersion': manifest.get('schemaVersion') == 1,
'provides': isinstance(manifest.get('provides'), list),
'requires': isinstance(manifest.get('requires'), list),
}
for check, ok in checks.items():
if not ok:
problems.append(manifest.get('id', plugin_dir.split('/')[-2]) + ': missing/empty \"' + check + '\"')
for err in sorted(validator.iter_errors(manifest), key=lambda e: list(e.path)):
where = '.'.join(str(part) for part in err.path) or '<root>'
problems.append(manifest.get('id', plugin_name) + ': ' + where + ': ' + err.message)
for field in ('requires', 'optionalRequires'):
if 'verstak/core/notes/v1' in manifest.get(field, []):
problems.append(manifest.get('id', plugin_name) + ': ' + field + ': core notes capability is not part of v2 platform contract')
if skipped:
print(' \u26a0\ufe0f skipped (no plugin.json): ' + ', '.join(skipped))
print(' warning: skipped (no plugin.json): ' + ', '.join(skipped))
if problems:
for p in problems:
print(' \u274c ' + p)
print(' FAIL ' + p)
sys.exit(1)
else:
print(' \u2705 all manifests valid')
print(' OK all manifests valid')
"
report "manifests valid" $?
STATUS=$?
set -e
report "manifests valid" "$STATUS"
else
echo " ️ SDK schema not found at $SDK_SCHEMA — run build.sh in verstak-sdk first"
fi
@@ -69,6 +76,91 @@ else
echo " ️ python3 not available — skipping manifest validation"
fi
echo ""
# Guard official plugins against bypassing the v2 plugin API for note features.
echo "[frontend API boundary]"
if [ "$HAS_PYTHON" -eq 1 ]; then
set +e
python3 -c "
import os, re, sys
root = '$ROOT/plugins'
forbidden = re.compile(r\"api\\.backend\\.call|api\\.request\\.open|window(?:\\.go|\\[['\\\"]go['\\\"]\\])\")
problems = []
for dirpath, _, filenames in os.walk(root):
if '/node_modules/' in dirpath:
continue
for filename in filenames:
if not filename.endswith(('.js', '.svelte', '.ts')):
continue
path = os.path.join(dirpath, filename)
with open(path, encoding='utf-8') as f:
for lineno, line in enumerate(f, 1):
if forbidden.search(line):
problems.append(f'{os.path.relpath(path, \"$ROOT\")}:{lineno}: {line.strip()}')
if problems:
for p in problems:
print(' FAIL ' + p)
sys.exit(1)
print(' OK official plugins use public VerstakPluginAPI only')
"
STATUS=$?
set -e
report "frontend API boundary" "$STATUS"
else
echo " ⚠️ python3 not available — skipping frontend API boundary"
fi
echo ""
# Ensure source manifests do not require ignored dist files for plain JS plugins.
echo "[frontend entry source contract]"
if [ "$HAS_PYTHON" -eq 1 ]; then
set +e
python3 -c "
import json, os, sys
root = '$ROOT/plugins'
problems = []
for plugin_name in sorted(os.listdir(root)):
plugin_dir = os.path.join(root, plugin_name)
manifest_path = os.path.join(plugin_dir, 'plugin.json')
if not os.path.isfile(manifest_path):
continue
with open(manifest_path, encoding='utf-8') as f:
manifest = json.load(f)
frontend = manifest.get('frontend') or {}
entry = frontend.get('entry')
if not entry:
continue
entry_path = os.path.join(plugin_dir, entry)
has_build_step = os.path.isfile(os.path.join(plugin_dir, 'frontend', 'package.json'))
has_plain_source = os.path.isfile(os.path.join(plugin_dir, 'frontend', 'src', 'index.js'))
if has_build_step:
if entry != 'frontend/dist/index.js':
problems.append(f'{plugin_name}: build frontend entry must be frontend/dist/index.js, got {entry}')
continue
if has_plain_source and entry != 'frontend/src/index.js':
problems.append(f'{plugin_name}: plain JS frontend entry must be frontend/src/index.js, got {entry}')
continue
if not os.path.isfile(entry_path):
problems.append(f'{plugin_name}: frontend entry does not exist: {entry}')
if problems:
for p in problems:
print(' FAIL ' + p)
sys.exit(1)
print(' OK source manifests reference tracked frontend entries')
"
STATUS=$?
set -e
report "frontend entry source contract" "$STATUS"
else
echo " ⚠️ python3 not available — skipping frontend entry source contract"
fi
echo ""
# Check all scripts in plugins are executable
echo "[script permissions]"
@@ -88,6 +180,8 @@ echo "[frontend smoke]"
if command -v node &>/dev/null; then
node "$ROOT/scripts/smoke-platform-frontend.js"
report "platform-test frontend components mount" $?
node "$ROOT/scripts/smoke-notes-plugin.js"
report "notes frontend behavior" $?
else
echo " ⚠️ node not available — skipping frontend smoke"
fi
@@ -104,10 +198,11 @@ if command -v node &>/dev/null; then
entry=$(node -e "const m=require('$manifest');console.log(m.frontend&&m.frontend.entry||'')" 2>/dev/null)
if [ -z "$entry" ]; then continue; fi
bundle="$plugin_dir$entry"
if [ ! -f "$bundle" ] && [ "$entry" = "frontend/dist/index.js" ] && [ -f "$plugin_dir/frontend/src/index.js" ]; then
bundle="$plugin_dir/frontend/src/index.js"
fi
if [ ! -f "$bundle" ]; then
if [ -f "$plugin_dir/frontend/package.json" ]; then
echo " ⚠️ $plugin_id: bundle not built at $entry — run scripts/build.sh for packaged frontend smoke"
continue
fi
echo "$plugin_id: bundle not found at $entry"
BUNDLE_FAILED=1
continue
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'notes', 'frontend', 'src', 'index.js');
const source = fs.readFileSync(sourcePath, 'utf8');
class FakeNode {
constructor(tagName) {
this.tagName = String(tagName || '').toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = {};
this.style = {};
this.className = '';
this.id = '';
this.value = '';
this._innerHTML = '';
this._textContent = '';
}
appendChild(node) {
if (!(node instanceof FakeNode)) throw new TypeError('appendChild expects FakeNode');
this.children.push(node);
node.parentNode = this;
return node;
}
remove() {
if (!this.parentNode) return;
this.parentNode.children = this.parentNode.children.filter((child) => child !== this);
this.parentNode = null;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
if (name === 'id') this.id = String(value);
}
getAttribute(name) {
return this.attributes[name];
}
addEventListener(type, handler) {
this.listeners[type] = this.listeners[type] || [];
this.listeners[type].push(handler);
}
dispatchEvent(type, event = {}) {
const handlers = this.listeners[type] || [];
handlers.forEach((handler) => handler({ stopPropagation() {}, ...event }));
}
click() {
this.dispatchEvent('click');
}
focus() {}
select() {}
set innerHTML(value) {
this._innerHTML = String(value || '');
this.children = [];
}
get innerHTML() {
return this._innerHTML;
}
set textContent(value) {
this._textContent = String(value || '');
this.children = [];
}
get textContent() {
if (this.tagName === '#TEXT') return this._textContent;
return this._textContent + this.children.map((child) => child.textContent).join('');
}
}
function walk(node, fn) {
if (fn(node)) return node;
for (const child of node.children) {
const found = walk(child, fn);
if (found) return found;
}
return null;
}
function makeDocument() {
const body = new FakeNode('body');
return {
body,
head: new FakeNode('head'),
createElement(tagName) {
return new FakeNode(tagName);
},
createTextNode(text) {
const node = new FakeNode('#text');
node.textContent = text;
return node;
},
getElementById() {
return null;
},
};
}
function loadNotesComponent(document) {
const registry = {};
const sandbox = {
console,
setTimeout,
clearTimeout,
document,
window: {
VerstakPluginRegister(pluginId, bundle) {
registry[pluginId] = bundle.components || {};
},
},
};
sandbox.window.window = sandbox.window;
sandbox.window.document = document;
vm.runInNewContext(source, sandbox, { filename: sourcePath });
const component = registry['verstak.notes'] && registry['verstak.notes'].NotesView;
if (!component) throw new Error('NotesView was not registered');
return component;
}
function makeApi(options = {}) {
const entries = new Map();
const opened = [];
return {
entries,
opened,
files: {
list: async (relativeDir) => {
const prefix = relativeDir ? `${relativeDir}/` : '';
if (!entries.has(relativeDir)) throw new Error(`not-found: ${relativeDir}`);
return Array.from(entries.entries())
.filter(([entryPath]) => entryPath.startsWith(prefix) && entryPath !== relativeDir && !entryPath.slice(prefix.length).includes('/'))
.map(([entryPath, entry]) => ({
name: path.basename(entryPath),
relativePath: entryPath,
type: entry.type,
}));
},
metadata: async (relativePath) => {
if (options.metadataAlwaysExists) return { relativePath, type: 'file' };
const entry = entries.get(relativePath);
if (!entry) throw new Error(`not-found: ${relativePath}`);
return { relativePath, type: entry.type };
},
createFolder: async (relativePath) => {
if (entries.has(relativePath)) throw new Error(`conflict: ${relativePath}`);
entries.set(relativePath, { type: 'folder' });
},
writeText: async (relativePath, content, writeOptions = {}) => {
if (entries.has(relativePath) && !writeOptions.overwrite) throw new Error(`conflict: ${relativePath}`);
const parent = relativePath.split('/').slice(0, -1).join('/');
if (parent && !entries.has(parent)) throw new Error(`parent-not-found: ${parent}`);
entries.set(relativePath, { type: 'file', content });
},
move: async (fromRelativePath, toRelativePath) => {
const entry = entries.get(fromRelativePath);
if (!entry) throw new Error(`not-found: ${fromRelativePath}`);
if (entries.has(toRelativePath)) throw new Error(`conflict: ${toRelativePath}`);
entries.set(toRelativePath, entry);
entries.delete(fromRelativePath);
},
},
workbench: {
openResource: async (request) => {
opened.push(request);
return { status: 'opened' };
},
},
};
}
async function flush() {
for (let i = 0; i < 8; i++) {
await Promise.resolve();
}
}
async function mountNotes(api) {
const document = makeDocument();
const component = loadNotesComponent(document);
const container = new FakeNode('div');
component.mount(container, { workspaceNode: { name: 'Project' } }, api);
await flush();
return { container, document, component };
}
(async () => {
const emptyApi = makeApi();
const emptyMounted = await mountNotes(emptyApi);
if (walk(emptyMounted.container, (node) => node.getAttribute && node.getAttribute('data-action') === 'overview')) {
throw new Error('NotesView must not render a persistent Overview button');
}
if (emptyMounted.container.textContent.includes('<svg')) {
throw new Error('NotesView empty state renders raw SVG text');
}
const createApi = makeApi({ metadataAlwaysExists: true });
const { container, document } = await mountNotes(createApi);
const createButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-action') === 'create');
if (!createButton) throw new Error('create button not found');
createButton.click();
const input = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-create-input') !== undefined);
if (!input) throw new Error('create input not found');
input.value = 'First Note';
const confirm = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create');
if (!confirm) throw new Error('create confirm button not found');
confirm.click();
await flush();
const created = createApi.entries.get('Project/Notes/First_Note.md');
if (!created || created.content !== '# First Note\n') {
throw new Error('create note did not write the markdown file');
}
if (document.body.children.some((node) => node.className === 'notes-modal-overlay')) {
throw new Error('create note showed a conflict modal for a new file');
}
if (!createApi.opened.some((request) => request.path === 'Project/Notes/First_Note.md')) {
throw new Error('create note did not open the newly created file');
}
console.log('notes plugin smoke passed');
})().catch((err) => {
console.error(err);
process.exit(1);
});