mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 00:27:11 +09:00
Extensions can now contribute Settings sections: a settings.section PluginSlot renders below the active Settings tab with the tab id as context. _validate_manifest grew shape checks for everything the platform consumes declaratively — module:attr refs (entry_point, socket_entry, models, lifecycle hooks, job handlers), jobs/schedules shapes, and per-kind required contribution keys (nav/routes/tabs/ palette/widgets/layouts) — so a malformed block fails the install with a message naming each problem instead of being silently dropped at runtime. Unknown contribution kinds only warn (forward compat). The scaffolder gained --validate <path> mirroring the same rules for authors, wired into the conversion recipe in docs/EXTENSIONS.md. 3 new lint tests incl. one asserting every shipped builtin manifest passes; full backend suite 1227 green; lint 0 errors; build clean; --validate smoke-tested on a good and a bad manifest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
296 lines
11 KiB
JavaScript
296 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Scaffold a new ServerKit extension (task #40).
|
|
*
|
|
* node scripts/new-extension.mjs <slug> [--backend] [--builtin]
|
|
* node scripts/new-extension.mjs --validate <path>
|
|
*
|
|
* <slug> kebab-case extension name, e.g. serverkit-uptime-badge
|
|
* --backend also scaffold a backend/ blueprint + lifecycle skeleton
|
|
* --builtin scaffold under builtin-extensions/<slug>/ (in-repo, pre-bundled)
|
|
* instead of a standalone ./<slug>/ folder for a third-party repo
|
|
* --validate lint an existing plugin.json (or a folder containing one)
|
|
* against the same shape rules the panel enforces at install
|
|
* time (#52): module:attr refs, jobs/schedules shapes, required
|
|
* keys per contribution kind. Exits non-zero on problems;
|
|
* unknown contribution kinds only warn.
|
|
*
|
|
* After scaffolding, install it into a dev panel with:
|
|
* - builtin: one-click from the Marketplace (Built-in), or
|
|
* POST /api/v1/plugins/builtin/<slug>/install
|
|
* - standalone: POST /api/v1/plugins/install-local { "path": "<abs path>" }
|
|
*
|
|
* See docs/EXTENSIONS.md for the full authoring guide.
|
|
*/
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
|
|
const args = process.argv.slice(2);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// --validate mode: lint a manifest with the same rules plugin_service enforces
|
|
// at install. Keep in sync with _validate_manifest + GET /plugins/manifest-spec.
|
|
// ---------------------------------------------------------------------------
|
|
const MODULE_REF_RE = /^[A-Za-z_][\w.]*:[A-Za-z_]\w*$/;
|
|
const REQUIRED_CONTRIB_KEYS = {
|
|
nav: ['id', 'label', 'route'],
|
|
routes: ['path', 'component'],
|
|
tabs: ['group', 'to', 'label'],
|
|
command_palette: ['label', 'path'],
|
|
widgets: ['slot', 'component'],
|
|
layouts: ['id', 'component'],
|
|
};
|
|
const KNOWN_CONTRIB_KINDS = new Set([...Object.keys(REQUIRED_CONTRIB_KEYS), 'page_titles', 'ai']);
|
|
|
|
function validateManifest(manifest) {
|
|
const problems = [];
|
|
const warnings = [];
|
|
|
|
for (const field of ['name', 'display_name', 'version']) {
|
|
if (!manifest[field]) problems.push(`missing required field: ${field}`);
|
|
}
|
|
if (manifest.name && !/^[a-zA-Z0-9_-]+$/.test(manifest.name)) {
|
|
problems.push(`name must be alphanumeric/dashes/underscores: ${manifest.name}`);
|
|
}
|
|
|
|
for (const field of ['entry_point', 'socket_entry', 'models']) {
|
|
const val = manifest[field];
|
|
if (val && !(typeof val === 'string' && MODULE_REF_RE.test(val))) {
|
|
problems.push(`${field} must be a 'module:attr' string (got ${JSON.stringify(val)})`);
|
|
}
|
|
}
|
|
|
|
const lifecycle = manifest.lifecycle;
|
|
if (lifecycle != null) {
|
|
if (typeof lifecycle !== 'object' || Array.isArray(lifecycle)) {
|
|
problems.push('lifecycle must be an object of phase -> module:func');
|
|
} else {
|
|
for (const [phase, target] of Object.entries(lifecycle)) {
|
|
if (!(typeof target === 'string' && MODULE_REF_RE.test(target))) {
|
|
problems.push(`lifecycle.${phase} must be a 'module:func' string`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (manifest.jobs != null) {
|
|
if (!Array.isArray(manifest.jobs)) {
|
|
problems.push('jobs must be a list of {kind, handler}');
|
|
} else {
|
|
manifest.jobs.forEach((j, i) => {
|
|
if (!(j && typeof j === 'object' && j.kind
|
|
&& typeof j.handler === 'string' && MODULE_REF_RE.test(j.handler))) {
|
|
problems.push(`jobs[${i}] must be {kind, handler: 'module:func'}`);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
if (manifest.schedules != null) {
|
|
if (!Array.isArray(manifest.schedules)) {
|
|
problems.push('schedules must be a list of {name, kind, cron?|interval_seconds?}');
|
|
} else {
|
|
manifest.schedules.forEach((s, i) => {
|
|
if (!(s && typeof s === 'object' && s.name && s.kind)) {
|
|
problems.push(`schedules[${i}] needs name and kind`);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
const contrib = manifest.contributions;
|
|
if (contrib != null && (typeof contrib !== 'object' || Array.isArray(contrib))) {
|
|
problems.push('contributions must be an object');
|
|
} else if (contrib) {
|
|
for (const key of Object.keys(contrib)) {
|
|
if (!KNOWN_CONTRIB_KINDS.has(key)) {
|
|
warnings.push(`unknown contribution kind '${key}' (ignored by the panel)`);
|
|
}
|
|
}
|
|
for (const [kind, req] of Object.entries(REQUIRED_CONTRIB_KEYS)) {
|
|
const entries = contrib[kind];
|
|
if (entries == null) continue;
|
|
if (!Array.isArray(entries)) {
|
|
problems.push(`contributions.${kind} must be a list`);
|
|
continue;
|
|
}
|
|
entries.forEach((entry, i) => {
|
|
if (!entry || typeof entry !== 'object') {
|
|
problems.push(`contributions.${kind}[${i}] must be an object`);
|
|
return;
|
|
}
|
|
const missing = req.filter((k) => !entry[k]);
|
|
if (missing.length) {
|
|
problems.push(`contributions.${kind}[${i}] missing ${missing.join(', ')}`);
|
|
}
|
|
});
|
|
}
|
|
if (contrib.page_titles != null
|
|
&& (typeof contrib.page_titles !== 'object' || Array.isArray(contrib.page_titles))) {
|
|
problems.push('contributions.page_titles must be an object of path -> title');
|
|
}
|
|
}
|
|
|
|
return { problems, warnings };
|
|
}
|
|
|
|
const validateIdx = args.indexOf('--validate');
|
|
if (validateIdx !== -1) {
|
|
const target = path.resolve(args[validateIdx + 1] || '.');
|
|
let manifestPath = target;
|
|
try {
|
|
if ((await fs.stat(target)).isDirectory()) {
|
|
manifestPath = path.join(target, 'plugin.json');
|
|
}
|
|
} catch {
|
|
console.error(`Not found: ${target}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
let manifest;
|
|
try {
|
|
manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
|
} catch (e) {
|
|
console.error(`Cannot read ${manifestPath}: ${e.message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const { problems, warnings } = validateManifest(manifest);
|
|
for (const w of warnings) console.warn(` ⚠ ${w}`);
|
|
if (problems.length) {
|
|
for (const p of problems) console.error(` ✘ ${p}`);
|
|
console.error(`\n${problems.length} problem(s) in ${path.relative(process.cwd(), manifestPath)}`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`✔ ${path.relative(process.cwd(), manifestPath)} is a valid ServerKit extension manifest`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const slug = args.find((a) => !a.startsWith('--'));
|
|
const withBackend = args.includes('--backend');
|
|
const asBuiltin = args.includes('--builtin');
|
|
|
|
if (!slug || !/^[a-zA-Z0-9_-]+$/.test(slug)) {
|
|
console.error('Usage: node scripts/new-extension.mjs <slug> [--backend] [--builtin]');
|
|
console.error(' node scripts/new-extension.mjs --validate <path>');
|
|
console.error(' <slug> must be alphanumeric/dashes/underscores.');
|
|
process.exit(1);
|
|
}
|
|
|
|
const componentName = slug
|
|
.split(/[-_]/)
|
|
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
.join('') + 'Page';
|
|
|
|
const baseDir = asBuiltin
|
|
? path.join(ROOT, 'builtin-extensions', slug)
|
|
: path.join(ROOT, slug);
|
|
|
|
const manifest = {
|
|
name: slug,
|
|
display_name: slug.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
version: '0.1.0',
|
|
description: 'A ServerKit extension.',
|
|
author: '',
|
|
license: 'MIT',
|
|
category: 'utility',
|
|
permissions: [],
|
|
min_panel_version: null,
|
|
contributions: {
|
|
nav: [{
|
|
id: slug,
|
|
label: slug.replace(/[-_]/g, ' '),
|
|
route: `/${slug}`,
|
|
category: 'system',
|
|
icon: '<circle cx="12" cy="12" r="9"/><path d="M12 8v8M8 12h8"/>',
|
|
}],
|
|
routes: [{ path: slug, component: componentName }],
|
|
page_titles: { [`/${slug}`]: slug },
|
|
command_palette: [{ label: slug, path: `/${slug}`, category: 'Pages', keywords: slug }],
|
|
},
|
|
};
|
|
|
|
if (withBackend) {
|
|
manifest.entry_point = 'blueprint:ext_bp';
|
|
manifest.url_prefix = `/api/v1/${slug}`;
|
|
manifest.lifecycle = { install: 'lifecycle:on_install', uninstall: 'lifecycle:on_uninstall' };
|
|
}
|
|
|
|
const frontendIndex = `// ${manifest.display_name} — extension UI entry.
|
|
// Exports named components referenced by contributions.routes[].component.
|
|
export function ${componentName}() {
|
|
return (
|
|
<div className="sk-tabgroup__inner">
|
|
<h1>${manifest.display_name}</h1>
|
|
<p>Replace this with your extension UI.</p>
|
|
</div>
|
|
);
|
|
}
|
|
`;
|
|
|
|
const backendBlueprint = `"""${manifest.display_name} backend blueprint."""
|
|
from flask import Blueprint, jsonify
|
|
from app.plugins_sdk import jwt_required, current_user, logger
|
|
|
|
ext_bp = Blueprint('${slug.replace(/-/g, '_')}', __name__)
|
|
log = logger(__name__)
|
|
|
|
|
|
@ext_bp.route('/ping', methods=['GET'])
|
|
@jwt_required()
|
|
def ping():
|
|
return jsonify({'ok': True, 'plugin': '${slug}'})
|
|
`;
|
|
|
|
const backendLifecycle = `"""Lifecycle hooks for ${manifest.display_name}."""
|
|
from app.plugins_sdk import logger
|
|
|
|
log = logger(__name__)
|
|
|
|
|
|
def on_install(plugin):
|
|
log.info('Installing ${slug}')
|
|
|
|
|
|
def on_uninstall(plugin, purge=False):
|
|
log.info('Uninstalling ${slug} (purge=%s)', purge)
|
|
`;
|
|
|
|
async function writeFile(rel, content) {
|
|
const full = path.join(baseDir, rel);
|
|
await fs.mkdir(path.dirname(full), { recursive: true });
|
|
await fs.writeFile(full, content);
|
|
console.log(` created ${path.relative(ROOT, full)}`);
|
|
}
|
|
|
|
async function main() {
|
|
try {
|
|
await fs.access(baseDir);
|
|
console.error(`Refusing to overwrite existing directory: ${baseDir}`);
|
|
process.exit(1);
|
|
} catch { /* doesn't exist — good */ }
|
|
|
|
console.log(`Scaffolding ${asBuiltin ? 'builtin ' : ''}extension "${slug}"…`);
|
|
await writeFile('plugin.json', JSON.stringify(manifest, null, 2) + '\n');
|
|
await writeFile('frontend/index.jsx', frontendIndex);
|
|
if (withBackend) {
|
|
await writeFile('backend/blueprint.py', backendBlueprint);
|
|
await writeFile('backend/lifecycle.py', backendLifecycle);
|
|
}
|
|
|
|
console.log('\nDone.');
|
|
if (asBuiltin) {
|
|
console.log('Pre-bundle the frontend: node scripts/sync-builtin-frontends.mjs');
|
|
console.log('Then install it from the Marketplace (Built-in).');
|
|
} else {
|
|
console.log(`Install into a dev panel: POST /api/v1/plugins/install-local { "path": "${baseDir}" }`);
|
|
}
|
|
console.log('Guide: docs/EXTENSIONS.md');
|
|
}
|
|
|
|
main().catch((e) => { console.error(e); process.exit(1); });
|