Align official plugins with v2 runtime
This commit is contained in:
+28
-5
@@ -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
@@ -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
|
||||
|
||||
Executable
+236
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user