feat: 新增脚手架/开发模式/权限白名单/system-monitor插件
- nebula create mod/key/list-templates 模组脚手架 - nebula dev 开发模式热重载 - manifest permissions.imports 权限白名单机制 - system-monitor 系统监控仪表盘插件 - 默认端口统一为 10086 - 修复 _init_nbpf 误读 Ed25519 私钥为 RSA 的 bug - 更新 README.md 文档
This commit is contained in:
302
oss/cli.py
302
oss/cli.py
@@ -147,7 +147,7 @@ def info(ctx):
|
||||
|
||||
@cli.command(name="cli")
|
||||
@click.option('--connect-host', default='127.0.0.1', help='后端地址(默认 127.0.0.1)')
|
||||
@click.option('--connect-port', default=8080, help='后端端口(默认 8080)')
|
||||
@click.option('--connect-port', default=10086, help='后端端口(默认 10086)')
|
||||
def cli_command(connect_host, connect_port):
|
||||
"""启动 TUI 前端(前后端分离,连接已有后端)"""
|
||||
click.echo("NebulaShell TUI 客户端(待实现)")
|
||||
@@ -373,6 +373,306 @@ def keygen(output_dir, name):
|
||||
click.echo(f" nebula nbpf pack ./my-plugin --ed25519-key {private_dir / f'{name}_ed25519.pem'} --rsa-key {private_dir / f'{name}_rsa.pem'} --rsa-pub {rsa_dir / f'{name}.pem'} --signer {name}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# create 命令 — 模组脚手架
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@cli.group()
|
||||
def create():
|
||||
"""创建模组/密钥等资源"""
|
||||
pass
|
||||
|
||||
|
||||
@create.command("mod")
|
||||
@click.argument("name", type=str, required=False, default=None)
|
||||
@click.option("--author", "-a", type=str, default=None, help="作者名")
|
||||
@click.option("--description", "-d", type=str, default=None, help="模组描述")
|
||||
@click.option("--type", "-t", "mod_type", type=click.Choice(["example", "adapter", "service", "security", "tool"]), default="example", help="模组类型")
|
||||
@click.option("--with-keys", is_flag=True, default=False, help="同时生成签名密钥")
|
||||
@click.option("--output", "-o", type=str, default=None, help="输出目录")
|
||||
@click.pass_context
|
||||
def create_mod(ctx, name, author, description, mod_type, with_keys, output):
|
||||
"""创建新模组脚手架"""
|
||||
import string as _string
|
||||
from pathlib import Path as _Path
|
||||
|
||||
# 交互式输入(如果参数缺失)
|
||||
if not name:
|
||||
name = click.prompt("📛 模组名称", type=str)
|
||||
if not author:
|
||||
author = click.prompt("👤 作者", type=str, default="anonymous")
|
||||
if not description:
|
||||
description = click.prompt("📝 描述", type=str, default="")
|
||||
|
||||
# 校验模组名
|
||||
valid_chars = set(_string.ascii_lowercase + _string.digits + "-_")
|
||||
safe_name = "".join(c for c in name.lower().replace(" ", "-") if c in valid_chars)
|
||||
if not safe_name:
|
||||
click.echo("❌ 模组名称无效,请使用字母、数字、连字符")
|
||||
raise click.Abort()
|
||||
|
||||
# 确定输出目录
|
||||
output_dir = _Path(output or safe_name)
|
||||
if output_dir.exists():
|
||||
click.echo(f"❌ 目录 '{output_dir}' 已存在")
|
||||
raise click.Abort()
|
||||
|
||||
# 渲染模板
|
||||
templates_dir = _Path(__file__).parent / "templates" / "mod"
|
||||
if not templates_dir.exists():
|
||||
click.echo("❌ 模板目录不存在,请检查安装")
|
||||
raise click.Abort()
|
||||
|
||||
# 替换变量
|
||||
replacements = {
|
||||
"{{ mod_name }}": safe_name,
|
||||
"{{ author }}": author,
|
||||
"{{ description }}": description,
|
||||
"{{ mod_type }}": mod_type,
|
||||
}
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for tmpl_file in templates_dir.iterdir():
|
||||
if tmpl_file.is_file():
|
||||
content_tmpl = tmpl_file.read_text(encoding="utf-8")
|
||||
for old, new in replacements.items():
|
||||
content_tmpl = content_tmpl.replace(old, new)
|
||||
out_path = output_dir / tmpl_file.name
|
||||
out_path.write_text(content_tmpl, encoding="utf-8")
|
||||
click.echo(f" ✅ 创建: {out_path.name}")
|
||||
|
||||
click.echo("")
|
||||
click.echo(f"🎉 模组 '{safe_name}' 创建成功!")
|
||||
click.echo(f"📂 位置: {output_dir.resolve()}")
|
||||
click.echo("")
|
||||
click.echo("下一步:")
|
||||
click.echo(f" cd {safe_name}")
|
||||
click.echo(" # 编辑 main.py 实现功能")
|
||||
click.echo(" # 然后打包:")
|
||||
click.echo(f' nebula nbpf pack ./{safe_name} -o mods/{safe_name}.nbpf --ed25519-key <key> --rsa-key <key> --rsa-pub <key> --signer "{author}"')
|
||||
|
||||
# 可选生成密钥
|
||||
if with_keys:
|
||||
click.echo("")
|
||||
click.echo("🔑 正在生成签名密钥...")
|
||||
try:
|
||||
from oss.core.nbpf.crypto import NBPCrypto
|
||||
keys_dir = output_dir / "keys"
|
||||
keys_dir.mkdir(exist_ok=True)
|
||||
|
||||
ed_priv, ed_pub = NBPCrypto.generate_ed25519_keypair()
|
||||
(keys_dir / "ed25519.pem").write_bytes(ed_priv)
|
||||
(keys_dir / "ed25519.pub.pem").write_bytes(ed_pub)
|
||||
|
||||
rsa_priv, rsa_pub = NBPCrypto.generate_rsa_keypair(key_size=2048)
|
||||
(keys_dir / "rsa.pem").write_bytes(rsa_priv)
|
||||
(keys_dir / "rsa.pub.pem").write_bytes(rsa_pub)
|
||||
|
||||
click.echo(f" ✅ Ed25519 密钥: {keys_dir}/ed25519.pem")
|
||||
click.echo(f" ✅ RSA 密钥: {keys_dir}/rsa.pem")
|
||||
click.echo("")
|
||||
click.echo("打包命令:")
|
||||
click.echo(f" nebula nbpf pack ./{safe_name} -o mods/{safe_name}.nbpf")
|
||||
click.echo(f" --ed25519-key {keys_dir}/ed25519.pem")
|
||||
click.echo(f" --rsa-key {keys_dir}/rsa.pem")
|
||||
click.echo(f" --rsa-pub {keys_dir}/rsa.pub.pem")
|
||||
except Exception as e:
|
||||
click.echo(f" ⚠ 密钥生成失败: {e}")
|
||||
|
||||
|
||||
@create.command("key")
|
||||
@click.option("--output", "-o", type=str, default="./keys", help="密钥输出目录")
|
||||
@click.option("--name", type=str, default="default", help="密钥名称")
|
||||
def create_key(output, name):
|
||||
"""生成 Ed25519 + RSA 签名密钥对"""
|
||||
from oss.core.nbpf.crypto import NBPCrypto
|
||||
from pathlib import Path as _Path
|
||||
|
||||
output_path = _Path(output)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
click.echo(f"🔑 生成密钥对到: {output_path.resolve()}")
|
||||
|
||||
ed_priv, ed_pub = NBPCrypto.generate_ed25519_keypair()
|
||||
(output_path / f"{name}_ed25519.pem").write_bytes(ed_priv)
|
||||
(output_path / f"{name}_ed25519.pub.pem").write_bytes(ed_pub)
|
||||
click.echo(f" ✅ Ed25519: {output_path / f'{name}_ed25519.pem'}")
|
||||
|
||||
rsa_priv, rsa_pub = NBPCrypto.generate_rsa_keypair(key_size=2048)
|
||||
(output_path / f"{name}_rsa.pem").write_bytes(rsa_priv)
|
||||
(output_path / f"{name}_rsa.pub.pem").write_bytes(rsa_pub)
|
||||
click.echo(f" ✅ RSA: {output_path / f'{name}_rsa.pem'}")
|
||||
|
||||
click.echo("")
|
||||
click.echo("密钥生成完成!")
|
||||
|
||||
|
||||
@create.command("list-templates")
|
||||
def list_templates():
|
||||
"""列出可用的模板"""
|
||||
from pathlib import Path as _Path
|
||||
templates_base = _Path(__file__).parent / "templates"
|
||||
if not templates_base.exists():
|
||||
click.echo("没有可用的模板")
|
||||
return
|
||||
for tdir in templates_base.iterdir():
|
||||
if tdir.is_dir():
|
||||
files = [f.name for f in tdir.iterdir() if f.is_file()]
|
||||
click.echo(f" 📦 {tdir.name}/")
|
||||
for f in files:
|
||||
click.echo(f" ├── {f}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# dev 命令 — 开发模式热重载
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@cli.command()
|
||||
@click.argument("mod_dir", type=str, required=False, default=None)
|
||||
@click.option("--port", "-p", type=int, default=None, help="HTTP API 端口")
|
||||
@click.option("--host", type=str, default=None, help="监听地址")
|
||||
@click.option("--skip-sign", is_flag=True, default=False, help="跳过签名验证(调试用)")
|
||||
@click.pass_context
|
||||
def dev(ctx, mod_dir, port, host, skip_sign):
|
||||
"""开发模式 — 监听模组文件变化并自动热重载"""
|
||||
import time as _time
|
||||
import hashlib as _hashlib
|
||||
from pathlib import Path as _Path
|
||||
from oss.core.watcher import FileWatcher
|
||||
from oss.logger.logger import Log as _Log
|
||||
|
||||
config = ctx.obj.get("config")
|
||||
if port:
|
||||
config.set("HTTP_API_PORT", port)
|
||||
else:
|
||||
config.set("HTTP_API_PORT", 10086)
|
||||
if host:
|
||||
config.set("HOST", host)
|
||||
|
||||
# 确定监听目录
|
||||
watch_dirs = []
|
||||
if mod_dir:
|
||||
mod_path = _Path(mod_dir).resolve()
|
||||
if not mod_path.exists():
|
||||
click.echo(f"❌ 目录不存在: {mod_dir}")
|
||||
raise click.Abort()
|
||||
watch_dirs.append(mod_path)
|
||||
click.echo(f"📁 监听目录: {mod_path}")
|
||||
else:
|
||||
# 默认监听 mods/ 和当前目录
|
||||
watch_dirs.append(_Path.cwd())
|
||||
click.echo(f"📁 监听目录: {_Path.cwd()}")
|
||||
click.echo("")
|
||||
|
||||
# 启动 NebulaShell 服务
|
||||
from oss.core.manager import PluginManager as _PluginManager
|
||||
|
||||
plugin_mgr = _PluginManager()
|
||||
plugin_mgr.load_all()
|
||||
# 同时加载 mods/ 目录下的 .nbpf 模组
|
||||
from pathlib import Path as _P
|
||||
mods_path = _P("mods")
|
||||
if mods_path.exists():
|
||||
for f in sorted(mods_path.iterdir()):
|
||||
if f.suffix == ".nbpf":
|
||||
plugin_mgr.load(f)
|
||||
plugin_mgr.start_all()
|
||||
|
||||
# 启动 HTTP 服务
|
||||
try:
|
||||
plugin_mgr.start_http_server()
|
||||
_Log.ok("Dev", f"HTTP API: http://{config.host}:{config.http_api_port}")
|
||||
except Exception as e:
|
||||
_Log.warn("Dev", f"HTTP 服务启动失败: {e}")
|
||||
|
||||
click.echo("")
|
||||
click.echo("🔧 NebulaShell 开发模式已启动")
|
||||
click.echo("=" * 50)
|
||||
click.echo(f" HTTP: http://{config.host}:{config.http_api_port}")
|
||||
click.echo(f" 监听: {', '.join(str(d) for d in watch_dirs)}")
|
||||
click.echo(f" 签名验证: {'跳过' if skip_sign else '开启'}")
|
||||
click.echo(f" 模组数: {len(plugin_mgr.plugins)}")
|
||||
click.echo("=" * 50)
|
||||
click.echo(" 按 Ctrl+C 停止")
|
||||
click.echo("")
|
||||
|
||||
# 文件变更缓存
|
||||
_file_hashes: dict[str, str] = {}
|
||||
|
||||
def _get_file_hash(path: _Path) -> str:
|
||||
"""计算文件 hash"""
|
||||
try:
|
||||
return _hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _get_dir_hash(directory: _Path) -> dict[str, str]:
|
||||
"""获取目录下所有文件的 hash"""
|
||||
result = {}
|
||||
for f in sorted(directory.rglob("*")):
|
||||
if f.is_file() and ".nbpf" not in f.suffix and "__pycache__" not in str(f):
|
||||
h = _get_file_hash(f)
|
||||
if h:
|
||||
result[str(f)] = h
|
||||
return result
|
||||
|
||||
# 初始化 hash
|
||||
for wd in watch_dirs:
|
||||
if wd.is_dir():
|
||||
_file_hashes.update(_get_dir_hash(wd))
|
||||
|
||||
# 主循环
|
||||
try:
|
||||
while True:
|
||||
_time.sleep(1)
|
||||
changed = False
|
||||
|
||||
for wd in watch_dirs:
|
||||
if not wd.exists():
|
||||
continue
|
||||
current = _get_dir_hash(wd)
|
||||
# 检查新增/修改
|
||||
for fpath, h in current.items():
|
||||
old_h = _file_hashes.get(fpath)
|
||||
if old_h is None:
|
||||
_Log.info("Dev", f"🆕 新增文件: {_Path(fpath).name}")
|
||||
changed = True
|
||||
elif old_h != h:
|
||||
_Log.info("Dev", f"📝 文件变更: {_Path(fpath).name}")
|
||||
changed = True
|
||||
_file_hashes[fpath] = h
|
||||
# 检查删除
|
||||
for fpath in list(_file_hashes.keys()):
|
||||
if fpath not in current:
|
||||
_Log.info("Dev", f"🗑 文件删除: {_Path(fpath).name}")
|
||||
_file_hashes.pop(fpath)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
_Log.info("Dev", "检测到变更,尝试热重载...")
|
||||
try:
|
||||
# 重新加载所有模组
|
||||
plugin_mgr.stop_all()
|
||||
# 清空并重新加载
|
||||
plugin_mgr.plugins.clear()
|
||||
plugin_mgr._plugin_dirs.clear()
|
||||
plugin_mgr.load_all()
|
||||
from pathlib import Path as _P2
|
||||
for f in sorted(_P2("mods").iterdir()):
|
||||
if f.suffix == ".nbpf":
|
||||
plugin_mgr.load(f)
|
||||
plugin_mgr.start_all()
|
||||
_Log.ok("Dev", f"热重载完成!当前模组数: {len(plugin_mgr.plugins)}")
|
||||
except Exception as e:
|
||||
_Log.error("Dev", f"热重载失败: {e}")
|
||||
except KeyboardInterrupt:
|
||||
click.echo("")
|
||||
_Log.info("Dev", "正在停止开发模式...")
|
||||
plugin_mgr.stop_all()
|
||||
_Log.info("Dev", "开发模式已停止")
|
||||
|
||||
|
||||
def main():
|
||||
cmd_name = os.path.basename(sys.argv[0])
|
||||
if cmd_name in ("oss", "oss.exe"):
|
||||
|
||||
Reference in New Issue
Block a user