Compare commits

..
16 changed files with 300 additions and 13 deletions

No files matched your search

+18
View File
@@ -1235,6 +1235,24 @@ Highlight the focused split pane:
'deepLink.jms.invalid': 'Invalid JumpServer link',
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
'settings.tab.plugins': 'Plugins',
"settings.plugins.manager.installed": "Installed plugins",
"settings.plugins.manager.import": "Import .ncpkg",
"settings.plugins.manager.preview": "Developer preview. Imported packages remain disabled until you review and enable them.",
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
"settings.plugins.manager.enabled": "Enabled",
"settings.plugins.manager.disabled": "Disabled",
"settings.plugins.manager.enable": "Enable",
"settings.plugins.manager.disable": "Disable",
"settings.plugins.manager.remove": "Uninstall",
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
"settings.plugins.manager.permissions": "Declared permissions",
"settings.plugins.manager.permissionNote": "Read from the installed manifest. Declarations are not grants; sensitive operations still require host approval.",
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
"settings.plugins.manager.required": "Required",
"settings.plugins.manager.optional": "Optional",
"settings.plugins.manager.browser": "Browser runtime: privileged access goes through host permission brokers.",
"settings.plugins.manager.advanced": "Advanced runtime: full Node filesystem and network authority. Broker permissions cannot restrict this access. Development preview only.",
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
'settings.plugins.title': 'Plugin settings',
'settings.plugins.description': 'Native, validated settings contributed by development plugins.',
'settings.plugins.loading': 'Loading plugin settings…',
+18
View File
@@ -1235,6 +1235,24 @@ Resalta el panel dividido enfocado:
'deepLink.jms.invalid': 'Enlace JumpServer inválido',
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
'settings.tab.plugins': 'Plugins',
"settings.plugins.manager.installed": "Plugins instalados",
"settings.plugins.manager.import": "Importar .ncpkg",
"settings.plugins.manager.preview": "Vista previa para desarrolladores. Los paquetes importados permanecen desactivados hasta que los revises y actives.",
"settings.plugins.manager.empty": "Importa un paquete local para empezar.",
"settings.plugins.manager.enabled": "Activado",
"settings.plugins.manager.disabled": "Desactivado",
"settings.plugins.manager.enable": "Activar",
"settings.plugins.manager.disable": "Desactivar",
"settings.plugins.manager.remove": "Desinstalar",
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
"settings.plugins.manager.permissions": "Permisos declarados",
"settings.plugins.manager.permissionNote": "Obtenidos del manifiesto instalado. Las declaraciones no son autorizaciones; las operaciones sensibles requieren aprobación del anfitrión.",
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
"settings.plugins.manager.required": "Obligatorio",
"settings.plugins.manager.optional": "Opcional",
"settings.plugins.manager.browser": "Entorno de navegador: el acceso privilegiado pasa por los controles de permisos del anfitrión.",
"settings.plugins.manager.advanced": "Entorno avanzado: acceso completo de Node al sistema de archivos y a la red. Los permisos del intermediario no restringen este acceso. Solo para desarrollo.",
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
'settings.plugins.title': 'Configuración de plugins',
'settings.plugins.description': 'Ajustes nativos y validados que aportan los plugins de desarrollo.',
'settings.plugins.loading': 'Cargando ajustes del plugin…',
+18
View File
@@ -1130,6 +1130,24 @@ export const ruCoreMessages: Messages = {
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
'settings.tab.plugins': 'Плагины',
"settings.plugins.manager.installed": "Установленные плагины",
"settings.plugins.manager.import": "Импорт .ncpkg",
"settings.plugins.manager.preview": "Предварительная версия для разработчиков. Импортированные пакеты отключены до проверки и включения.",
"settings.plugins.manager.empty": "Импортируйте локальный пакет плагина.",
"settings.plugins.manager.enabled": "Включён",
"settings.plugins.manager.disabled": "Отключён",
"settings.plugins.manager.enable": "Включить",
"settings.plugins.manager.disable": "Отключить",
"settings.plugins.manager.remove": "Удалить",
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
"settings.plugins.manager.permissions": "Заявленные разрешения",
"settings.plugins.manager.permissionNote": "Из установленного манифеста. Объявление не является разрешением; чувствительные операции требуют одобрения приложения.",
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
"settings.plugins.manager.required": "Обязательно",
"settings.plugins.manager.optional": "Необязательно",
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
'settings.plugins.title': 'Настройки плагинов',
'settings.plugins.description': 'Проверенные нативные настройки от плагинов разработки.',
'settings.plugins.loading': 'Загрузка настроек плагинов…',
+18
View File
@@ -1150,6 +1150,24 @@ Vaults 标签保持扁平,不使用这块背景。
'qs.localShells': '本地 Shell',
'qs.default': '默认',
'settings.tab.plugins': '插件',
"settings.plugins.manager.installed": "已安装插件",
"settings.plugins.manager.import": "导入 .ncpkg",
"settings.plugins.manager.preview": "开发者预览。导入的插件保持停用,请检查后再启用。",
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
"settings.plugins.manager.enabled": "已启用",
"settings.plugins.manager.disabled": "已停用",
"settings.plugins.manager.enable": "启用",
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸载",
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
"settings.plugins.manager.permissions": "声明的权限",
"settings.plugins.manager.permissionNote": "来自已安装的 manifest。声明不等于授权,敏感操作仍须通过宿主批准。",
"settings.plugins.manager.noPermissions": "未声明特权权限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可选",
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
'settings.plugins.title': '插件设置',
'settings.plugins.description': '由开发插件提供、经 Netcatty 原生组件验证的设置。',
'settings.plugins.loading': '正在加载插件设置…',
+18
View File
@@ -1153,6 +1153,24 @@ Vaults 分頁維持扁平,不使用這塊背景。
'qs.default': '預設',
'settings.tab.plugins': '外掛程式',
"settings.plugins.manager.installed": "已安裝插件",
"settings.plugins.manager.import": "導入 .ncpkg",
"settings.plugins.manager.preview": "開發者預覽。導入的插件保持停用,請檢查後再啟用。",
"settings.plugins.manager.empty": "導入本地插件包以開始使用。",
"settings.plugins.manager.enabled": "已啟用",
"settings.plugins.manager.disabled": "已停用",
"settings.plugins.manager.enable": "啟用",
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸載",
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
"settings.plugins.manager.permissions": "聲明的權限",
"settings.plugins.manager.permissionNote": "來自已安裝的 manifest。聲明不等於授權,敏感操作仍須通過宿主批准。",
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可選",
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
'settings.plugins.title': '外掛程式設定',
'settings.plugins.description': '由開發外掛程式提供、經 Netcatty 原生元件驗證的設定。',
'settings.plugins.loading': '正在載入外掛程式設定…',
+56
View File
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginManager() {
const [plugins, setPlugins] = useState<NetcattyInstalledPlugin[]>([]);
const [available, setAvailable] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const generation = useRef(0);
const mutation = useRef(false);
const refresh = useCallback(async () => {
const current = ++generation.current;
const bridge = netcattyBridge.get();
const status = await bridge?.getPluginRuntimeStatus?.();
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
if (current !== generation.current) return;
setAvailable(status?.available === true);
setPlugins(list);
}, []);
useEffect(() => {
const update = () => { void refresh().catch((cause) => setError(String(cause))); };
update();
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(update);
const refreshGeneration = generation;
return () => { refreshGeneration.current++; unsubscribe?.(); };
}, [refresh]);
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
if (mutation.current) return;
mutation.current = true;
setBusy(true);
setError(null);
try { await action(netcattyBridge.require()); }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally {
try { await refresh(); } catch (cause) { setError(String(cause)); }
mutation.current = false;
setBusy(false);
}
}, [refresh]);
return {
plugins, available, busy, error,
importPackage: () => run(async (bridge) => {
if (!bridge.selectFile || !bridge.installPluginPackage) throw new Error('Plugin import unavailable');
const path = await bridge.selectFile('Import plugin package', undefined, [{ name: 'Netcatty plugin', extensions: ['ncpkg'] }]);
if (path) await bridge.installPluginPackage(path, { enable: false });
}),
setEnabled: (id: string, enabled: boolean) => run(async (bridge) => {
if (!bridge.setPluginEnabled) throw new Error('Plugin management unavailable');
return bridge.setPluginEnabled(id, enabled);
}),
uninstall: (id: string) => run(async (bridge) => {
if (!bridge.uninstallPlugin) throw new Error('Plugin management unavailable');
return bridge.uninstallPlugin(id);
}),
};
}
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginPackageDetails } from './PluginPackageManager';
test('package detail renders manifest resources as text and explains advanced authority', () => {
const plugin = {
id: 'com.example.synthetic', enabled: false, activeVersion: '1.0.0',
archiveSha256: 'a'.repeat(64),
manifest: {
manifestVersion: 1, id: 'com.example.synthetic', name: 'synthetic', version: '1.0.0', publisher: 'example',
engines: { netcatty: '>=1.0.0 <2.0.0', api: '0.1.0-internal' },
main: { node: 'index.js' },
permissions: { required: [{ permission: 'network', resources: ['https://example.com'], reason: '<script>attack()</script>' }] },
},
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null },
} as NetcattyInstalledPlugin;
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginPackageDetails plugin={plugin} /></I18nProvider>);
assert.match(html, /full Node filesystem and network authority/);
assert.match(html, /https:\/\/example.com/);
assert.match(html, /&lt;script&gt;/);
assert.doesNotMatch(html, /<script>/);
assert.match(html, /Declarations are not grants/);
assert.match(html, /aaaaaaaaaaaaaaaa/);
});
@@ -0,0 +1,72 @@
import React, { useState } from 'react';
import { Package, Upload } from 'lucide-react';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlugin }) {
const { t } = useI18n();
const manifest = plugin.manifest;
if (!manifest) return null;
const permissions = [
...(manifest.permissions?.required ?? []).map((value) => ({ value, required: true })),
...(manifest.permissions?.optional ?? []).map((value) => ({ value, required: false })),
];
const advanced = !manifest.main.browser || Boolean(manifest.companionExecutables?.length)
|| manifest.contributes?.providers?.some((p) => p.kind === 'terminal.interceptor.input' || p.kind === 'terminal.interceptor.output');
return <div className="space-y-4 border-t border-border/60 pt-4 text-sm">
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
<dt className="text-muted-foreground">ID</dt><dd className="break-all font-mono">{plugin.id}</dd>
<dt className="text-muted-foreground">Netcatty</dt><dd>{manifest.engines.netcatty}</dd>
<dt className="text-muted-foreground">API</dt><dd>{manifest.engines.api}</dd>
<dt className="text-muted-foreground">SHA-256</dt><dd className="break-all font-mono">{plugin.archiveSha256 ?? '-'}</dd>
</dl>
<p className={advanced ? 'text-destructive' : 'text-muted-foreground'}>{t(advanced ? 'settings.plugins.manager.advanced' : 'settings.plugins.manager.browser')}</p>
<div className="space-y-2">
<h4 className="font-medium">{t('settings.plugins.manager.permissions')}</h4>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.permissionNote')}</p>
{permissions.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.manager.noPermissions')}</p>}
{permissions.map(({ value, required }, index) => <div key={index} className="rounded-md bg-muted/40 p-3">
<div className="flex justify-between gap-3"><code className="break-all text-xs">{typeof value === 'string' ? value : value.permission}</code><span className="text-xs text-muted-foreground">{t(required ? 'settings.plugins.manager.required' : 'settings.plugins.manager.optional')}</span></div>
{typeof value !== 'string' && <><p className="mt-1 whitespace-pre-wrap break-all text-xs">{value.resources.join('\n')}</p>{value.reason && <p className="mt-1 text-xs text-muted-foreground">{value.reason}</p>}</>}
</div>)}
</div>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.data')}</p>
</div>;
}
export function PluginPackageManager() {
const { t, resolvedLocale } = useI18n();
const manager = usePluginManager();
const [selected, setSelected] = useState<string | null>(null);
const [removing, setRemoving] = useState<string | null>(null);
if (!manager.available) return null;
return <section className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
<div className="flex items-center justify-between gap-3">
<h3 className="font-medium">{t('settings.plugins.manager.installed')} <span className="text-muted-foreground">{manager.plugins.length}</span></h3>
<Button variant="outline" size="sm" disabled={manager.busy} onClick={() => void manager.importPackage()}><Upload size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
</div>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.preview')}</p>
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
{!manager.plugins.length && <div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">{t('settings.plugins.manager.empty')}</div>}
{manager.plugins.map((plugin) => <article key={plugin.id} className="space-y-4 rounded-xl border border-border/70 bg-background p-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-muted p-2"><Package size={20} /></div>
<button type="button" className="min-w-0 flex-1 text-left" aria-expanded={selected === plugin.id} onClick={() => setSelected(selected === plugin.id ? null : plugin.id)}>
<div className="truncate text-sm font-medium">{typeof plugin.manifest?.displayName === 'string' ? plugin.manifest.displayName : plugin.manifest?.displayName?.[resolvedLocale] ?? plugin.manifest?.displayName?.en ?? plugin.manifest?.name ?? plugin.id}</div>
<div className="text-xs text-muted-foreground">{plugin.activeVersion} · {t(plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}</div>
</button>
<Button variant="outline" size="sm" disabled={manager.busy} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>{t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')}</Button>
</div>
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{plugin.runtime.lastError}</p>}
{selected === plugin.id && <>
<PluginPackageDetails plugin={plugin} />
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
<p className="text-xs">{t('settings.plugins.manager.removeNote')}</p>
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
<Button variant="ghost" size="sm" onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => setRemoving(plugin.id)}>{t('settings.plugins.manager.remove')}</Button>}
</>}
</article>)}
</section>;
}
@@ -1,3 +1,4 @@
import { PluginPackageManager } from './PluginPackageManager';
import { FolderOpen } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
@@ -307,6 +308,7 @@ export default function SettingsPluginsTab() {
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
</div>
<PluginPackageManager />
{contextualScopes.size > 0 && (
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{[...contextualScopes].map((kind) => (
+7 -5
View File
@@ -216,7 +216,9 @@ class PackageStore {
const version = assertPluginStorageSegment(manifest.version, "version");
const targetDirectory = resolveInstalledVersionDirectory(this.paths, pluginId, version);
const previousPlugin = this.database.getActivePlugin(pluginId);
const enableAfterActivation = options.enable === true || previousPlugin?.enabled === true;
const enableAfterActivation = options.enable === false
? false
: options.enable === true || previousPlugin?.enabled === true;
let activationPrepared = false;
const prepareActivation = async (reason) => {
if (activationPrepared) return;
@@ -243,7 +245,7 @@ class PackageStore {
await rm(targetDirectory, { recursive: true, force: true });
}
if (existingValidation) {
if (previousPlugin?.activeVersion !== version) await prepareActivation("switch-active-version");
if (previousPlugin?.activeVersion !== version || options.enable === false) await prepareActivation("switch-active-version");
const existingPackage = path.join(targetDirectory, PACKAGE_DIRECTORY);
this.database.installVersion({
pluginId,
@@ -251,7 +253,7 @@ class PackageStore {
manifest: existingValidation.manifest,
archiveSha256: existing.archiveSha256,
packageRelativePath: path.relative(this.paths.packages, existingPackage),
}, { enable: enableAfterActivation });
}, { enable: enableAfterActivation, forceDisabled: options.enable === false });
return this.database.getActivePlugin(pluginId);
}
}
@@ -270,7 +272,7 @@ class PackageStore {
await writeDurableMetadata(path.join(stagingDirectory, INSTALL_METADATA_FILE), metadata);
await this.syncDirectory(stagingDirectory);
await mkdir(path.dirname(targetDirectory), { recursive: true, mode: 0o700 });
if (previousPlugin?.activeVersion !== version) await prepareActivation("switch-active-version");
if (previousPlugin?.activeVersion !== version || options.enable === false) await prepareActivation("switch-active-version");
await rename(stagingDirectory, targetDirectory);
await this.syncDirectory(path.dirname(targetDirectory));
const packageRelativePath = path.relative(
@@ -284,7 +286,7 @@ class PackageStore {
manifest,
archiveSha256: snapshot.sha256,
packageRelativePath,
}, { enable: enableAfterActivation });
}, { enable: enableAfterActivation, forceDisabled: options.enable === false });
} catch (error) {
this.verifiedArchives.delete(this.#versionKey(pluginId, version));
await rm(targetDirectory, { recursive: true, force: true });
+11
View File
@@ -410,3 +410,14 @@ for (const target of [
assert.equal(fixture.database.getActivePlugin(installed.id).enabled, false);
});
}
test("explicit disabled import never auto-enables an update of an enabled plugin", async (context) => {
const fixture = createStore(context);
await fixture.store.initialize();
const first = await createPackage(fixture.root);
await fixture.store.install(first.archive, { enable: true });
const update = await createPackage(fixture.root, { manifest: { version: "1.1.0" } });
const installed = await fixture.store.install(update.archive, { enable: false });
assert.equal(installed.activeVersion, "1.1.0");
assert.equal(installed.enabled, false);
});
+1 -1
View File
@@ -646,7 +646,7 @@ function registerPluginBridge(ipcMain, options) {
handle(CHANNELS.list, async (activeManager) => activeManager.list());
handle(CHANNELS.install, async (activeManager, payload) => activeManager.install(
payload?.archivePath,
{ enable: payload?.enable === true },
{ enable: typeof payload?.enable === "boolean" ? payload.enable : undefined },
));
handle(CHANNELS.setEnabled, async (activeManager, payload) => activeManager.setEnabled(
payload?.pluginId,
+1 -1
View File
@@ -52,7 +52,7 @@ class PluginManager {
let plugin;
try {
plugin = await this.packageStore.install(archivePath, {
enable: options?.enable === true,
enable: options?.enable,
beforeActivate: async ({ pluginId, previousPlugin }) => {
if (!previousPlugin?.enabled) return;
this.database.setEnabled(pluginId, false);
+29 -3
View File
@@ -9,9 +9,9 @@ From the repository root:
```bash
npm run build:plugin-packages
npm exec -- netcatty-plugin validate examples/plugins/hello-netcatty
npm exec -- netcatty-plugin compatibility examples/plugins/hello-netcatty --netcatty 0.0.0
npm exec -- netcatty-plugin pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
node packages/plugin-cli/dist/cli.js validate examples/plugins/hello-netcatty
node packages/plugin-cli/dist/cli.js compatibility examples/plugins/hello-netcatty --netcatty 0.0.0
node packages/plugin-cli/dist/cli.js pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
```
After installation, change **Greeting** under **Settings → Plugins**, then run
@@ -21,3 +21,29 @@ sandboxed plugin runtime. Type `netc` at a shell prompt to exercise the bounded
completion Provider, and print `Hello from Netcatty` to exercise the declarative
decoration Provider. Neither Provider receives raw xterm objects or sensitive
terminal input/output streams.
## Import and inspect
Launch a development build with `NETCATTY_PLUGIN_DEV=1`, using a fresh Electron
user-data directory for demonstrations. In **Settings > Plugins**, choose
**Import .ncpkg**, select the generated archive, and open its details. Review the
manifest permissions, engine ranges and SHA-256, then choose **Enable**. Native
permission prompts still mediate activation and sensitive operations.
Importing an update is a manual action and leaves it disabled, including when
the previous version was enabled. Review the new package before enabling it.
Changing unsigned package contents changes its security principal; previous
persistent grants are not transferred to different bytes.
An incompatible application/API upgrade disables installed plugins before
publishing their contributions. The reason remains in the installed list, and
package files and local data remain available. Returning to a compatible host
requires explicit re-enable. Uninstall is different: package code and local KV
are deleted; user-owned settings, secret storage and sync sidecars are retained.
For a synthetic demonstration, use only this first-party example, change the
Greeting, invoke **Examples: Say Hello**, disable/re-enable, restart the app and
inspect the package again. Do not connect real hosts or a cloud account. The
existing greeting setting is local; declaring `sync: true` on a non-secret
setting opts it into the existing encrypted settings sidecar. Generic SDK KV
storage does not sync.
Vendored
+2 -1
View File
@@ -337,7 +337,8 @@ declare global {
id: string;
enabled: boolean;
activeVersion: string | null;
manifest: unknown;
manifest: import("@netcatty/plugin-contract").PluginManifest | null;
archiveSha256?: string | null;
runtime: {
status: string;
kind: 'browser' | 'utility' | null;
+2 -2
View File
@@ -581,11 +581,11 @@ test("example README commands use repository-root CLI paths", async () => {
assert.match(readme, /npm run build:plugin-packages/);
assert.match(
readme,
/npm exec -- netcatty-plugin validate examples\/plugins\/hello-netcatty/,
/node packages\/plugin-cli\/dist\/cli\.js validate examples\/plugins\/hello-netcatty/,
);
assert.match(
readme,
/npm exec -- netcatty-plugin compatibility examples\/plugins\/hello-netcatty --netcatty 0\.0\.0/,
/node packages\/plugin-cli\/dist\/cli\.js compatibility examples\/plugins\/hello-netcatty --netcatty 0\.0\.0/,
);
assert.doesNotMatch(readme, /npm exec --workspace @netcatty\/plugin-cli/);
});