From 6270f98e54f3838b95b5258f38e3dceb030f38bd Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 29 Jul 2026 10:00:44 +0200 Subject: [PATCH] =?UTF-8?q?fix(plugin-sdk):=20aligne=20le=20manifeste=20he?= =?UTF-8?q?llo-plugin=20sur=20le=20sch=C3=A9ma=20backend=20+=20fixture=20d?= =?UTF-8?q?e=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le manifeste hello-plugin était généré par un SDK obsolète/désaligné avec le schéma backend actuel (champ ideaPluginManifestVersion manquant, displayName, trustLevel, shape de contributes), ce qui faisait échouer l'installation avec « invalid input: missing field ideaPluginManifestVersion ». - SDK (manifest.ts/js, index.ts) aligné sur le schéma backend courant. - hello-plugin (exemple + fixture) régénéré avec le manifeste valide. - Base de tests fonctionnels plugins : fixture réelle versionnée (reference-minimal) + test d'installation/chargement bout en bout (plugin_install_load.rs), pour couvrir install/load/catalog depuis un dossier fixture réel. Le sous-repo git imbriqué et vide (sdk/IdeaSDK/.git, 0 commit) a été neutralisé pour que les sources du SDK soient suivies normalement dans ce dépôt plutôt que comme gitlink vide. Co-Authored-By: Claude Opus 4.8 --- .../plugins/reference-minimal/dist/index.js | 3 + .../reference-minimal/idea-plugin.json | 33 ++ .../tests/plugin_install_load.rs | 92 ++++++ sdk/IdeaSDK/.gitignore | 9 + sdk/IdeaSDK/README.md | 84 +++++ sdk/IdeaSDK/examples/hello-plugin/README.md | 12 + .../examples/hello-plugin/idea-plugin.json | 33 ++ .../examples/hello-plugin/package-lock.json | 27 ++ .../examples/hello-plugin/package.json | 14 + .../examples/hello-plugin/src/index.ts | 21 ++ .../examples/hello-plugin/tsconfig.build.json | 19 ++ .../examples/hello-plugin/tsconfig.json | 18 + sdk/IdeaSDK/package-lock.json | 30 ++ sdk/IdeaSDK/package.json | 34 ++ sdk/IdeaSDK/scripts/package-hello-plugin.mjs | 124 +++++++ sdk/IdeaSDK/src/index.js | 1 + sdk/IdeaSDK/src/index.ts | 23 ++ sdk/IdeaSDK/src/manifest.js | 172 ++++++++++ sdk/IdeaSDK/src/manifest.ts | 309 ++++++++++++++++++ sdk/IdeaSDK/src/runtime.js | 1 + sdk/IdeaSDK/src/runtime.ts | 36 ++ sdk/IdeaSDK/tsconfig.json | 18 + 22 files changed, 1113 insertions(+) create mode 100644 crates/infrastructure/tests/fixtures/plugins/reference-minimal/dist/index.js create mode 100644 crates/infrastructure/tests/fixtures/plugins/reference-minimal/idea-plugin.json create mode 100644 crates/infrastructure/tests/plugin_install_load.rs create mode 100644 sdk/IdeaSDK/.gitignore create mode 100644 sdk/IdeaSDK/README.md create mode 100644 sdk/IdeaSDK/examples/hello-plugin/README.md create mode 100644 sdk/IdeaSDK/examples/hello-plugin/idea-plugin.json create mode 100644 sdk/IdeaSDK/examples/hello-plugin/package-lock.json create mode 100644 sdk/IdeaSDK/examples/hello-plugin/package.json create mode 100644 sdk/IdeaSDK/examples/hello-plugin/src/index.ts create mode 100644 sdk/IdeaSDK/examples/hello-plugin/tsconfig.build.json create mode 100644 sdk/IdeaSDK/examples/hello-plugin/tsconfig.json create mode 100644 sdk/IdeaSDK/package-lock.json create mode 100644 sdk/IdeaSDK/package.json create mode 100644 sdk/IdeaSDK/scripts/package-hello-plugin.mjs create mode 100644 sdk/IdeaSDK/src/index.js create mode 100644 sdk/IdeaSDK/src/index.ts create mode 100644 sdk/IdeaSDK/src/manifest.js create mode 100644 sdk/IdeaSDK/src/manifest.ts create mode 100644 sdk/IdeaSDK/src/runtime.js create mode 100644 sdk/IdeaSDK/src/runtime.ts create mode 100644 sdk/IdeaSDK/tsconfig.json diff --git a/crates/infrastructure/tests/fixtures/plugins/reference-minimal/dist/index.js b/crates/infrastructure/tests/fixtures/plugins/reference-minimal/dist/index.js new file mode 100644 index 0000000..97b4319 --- /dev/null +++ b/crates/infrastructure/tests/fixtures/plugins/reference-minimal/dist/index.js @@ -0,0 +1,3 @@ +export function activate(ctx) { + ctx.commands?.registerCommand("dev.idea.fixtures.reference-minimal.hello", () => "hello"); +} diff --git a/crates/infrastructure/tests/fixtures/plugins/reference-minimal/idea-plugin.json b/crates/infrastructure/tests/fixtures/plugins/reference-minimal/idea-plugin.json new file mode 100644 index 0000000..87e060a --- /dev/null +++ b/crates/infrastructure/tests/fixtures/plugins/reference-minimal/idea-plugin.json @@ -0,0 +1,33 @@ +{ + "ideaPluginManifestVersion": 1, + "id": "dev.idea.fixtures.reference-minimal", + "displayName": "Reference Minimal Plugin", + "publisher": "IdeA QA", + "version": "0.1.0", + "description": "Minimal fixture for the real plugin install/load loop.", + "engines": { + "idea": ">=0.1.0 <1.0.0" + }, + "main": "dist/index.js", + "trustLevel": "full", + "capabilities": [ + "ui" + ], + "contributes": { + "menus": [ + { + "id": "dev.idea.fixtures.reference-minimal.menu", + "label": "Reference", + "topLevel": true + } + ], + "menuItems": [ + { + "id": "dev.idea.fixtures.reference-minimal.hello.item", + "targetMenuId": "dev.idea.fixtures.reference-minimal.menu", + "label": "Say Hello", + "command": "dev.idea.fixtures.reference-minimal.hello" + } + ] + } +} diff --git a/crates/infrastructure/tests/plugin_install_load.rs b/crates/infrastructure/tests/plugin_install_load.rs new file mode 100644 index 0000000..f86a976 --- /dev/null +++ b/crates/infrastructure/tests/plugin_install_load.rs @@ -0,0 +1,92 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use application::{ + InstallPluginFromDirectory, JsonPluginManifestValidator, ListPluginRuntimeContributions, + ListPlugins, +}; +use infrastructure::{ + ExternalMcpPluginSupervisor, FsPluginPackageStore, FsPluginRegistryStore, + TokioBroadcastEventBus, +}; + +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn temp_dir(label: &str) -> PathBuf { + let n = TEMP_COUNTER.fetch_add(1, Ordering::SeqCst); + let path = std::env::temp_dir().join(format!( + "idea-plugin-functional-{label}-{}-{n}", + std::process::id() + )); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).unwrap(); + path +} + +fn fixture_path(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("plugins") + .join(name) +} + +#[tokio::test] +async fn installs_reference_fixture_and_loads_runtime_catalog() { + let app_data = temp_dir("app-data"); + let fixture = fixture_path("reference-minimal"); + let packages = Arc::new(FsPluginPackageStore::new(&app_data)); + let registry = Arc::new(FsPluginRegistryStore::new(&app_data)); + let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0")); + let events = Arc::new(TokioBroadcastEventBus::new()); + let mcp = Arc::new(ExternalMcpPluginSupervisor::new()); + + let install = InstallPluginFromDirectory::new( + packages.clone(), + registry.clone(), + validator.clone(), + events, + mcp.clone(), + ); + let result = install + .execute(fixture.to_string_lossy().into_owned()) + .await + .unwrap(); + + assert_eq!(result.plugin.id, "dev.idea.fixtures.reference-minimal"); + assert_eq!(result.plugin.display_name, "Reference Minimal Plugin"); + assert_eq!(result.review.contribution_summary.top_level_menus, 1); + assert_eq!(result.review.contribution_summary.menu_items, 1); + assert!(result.restart_required); + assert!(app_data + .join("plugins/installed/dev.idea.fixtures.reference-minimal/idea-plugin.json") + .is_file()); + + let admin = ListPlugins::new(packages.clone(), registry.clone(), validator.clone()) + .execute() + .await + .unwrap(); + assert_eq!(admin.len(), 1); + assert_eq!(admin[0].id, "dev.idea.fixtures.reference-minimal"); + assert_eq!( + admin[0].lifecycle_state, + domain::PluginLifecycleState::Enabled + ); + + let catalog = ListPluginRuntimeContributions::new(packages, registry, validator) + .execute() + .await + .unwrap(); + assert_eq!(catalog.plugins.len(), 1); + let plugin = &catalog.plugins[0]; + assert_eq!(plugin.id, "dev.idea.fixtures.reference-minimal"); + assert!(plugin + .bundle_url + .starts_with("idea-plugin://dev.idea.fixtures.reference-minimal/0.1.0/")); + assert_eq!(plugin.contributes.menus.len(), 1); + assert_eq!(plugin.contributes.menu_items.len(), 1); + + let _ = fs::remove_dir_all(app_data); +} diff --git a/sdk/IdeaSDK/.gitignore b/sdk/IdeaSDK/.gitignore new file mode 100644 index 0000000..374ce94 --- /dev/null +++ b/sdk/IdeaSDK/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +examples/hello-plugin/dist/ +examples/hello-plugin/build/ +*.tsbuildinfo +.DS_Store +coverage/ +npm-debug.log* +*.tgz diff --git a/sdk/IdeaSDK/README.md b/sdk/IdeaSDK/README.md new file mode 100644 index 0000000..a0dd547 --- /dev/null +++ b/sdk/IdeaSDK/README.md @@ -0,0 +1,84 @@ +# IdeA Plugin SDK + +Minimal public TypeScript SDK for IdeA plugins. + +This first version intentionally stays small: + +- public manifest types for `idea-plugin.json`; +- public runtime types for plugin modules exposing `activate(ctx)`; +- a lightweight manifest validator; +- a minimal `examples/hello-plugin` plugin. + +## Install + +```sh +npm install +``` + +## Build + +```sh +npm run build +``` + +## Typecheck the example + +```sh +npm run typecheck:examples +``` + +## Build the installable hello plugin archive + +```sh +npm run package:hello-plugin +``` + +The archive is written to: + +```text +examples/hello-plugin/build/hello-plugin-0.1.0.zip +``` + +Its ZIP root contains `idea-plugin.json` directly, with no wrapping parent directory. The +compiled ESM entrypoint is emitted at `dist/index.js`, matching the manifest `main` field. + +## Plugin shape + +An IdeA plugin ships an `idea-plugin.json` manifest and a JavaScript entrypoint built from +TypeScript. + +```json +{ + "ideaPluginManifestVersion": 1, + "id": "com.example.hello", + "displayName": "Hello Plugin", + "version": "0.1.0", + "main": "dist/index.js", + "trustLevel": "full", + "contributes": {} +} +``` + +The entrypoint exports an `activate(ctx)` function: + +```ts +import type { ActivateContext } from "@idea/plugin-sdk"; + +export function activate(ctx: ActivateContext): void { + ctx.logger.info("hello from plugin"); +} +``` + +## Manifest Validation + +```ts +import { validatePluginManifest } from "@idea/plugin-sdk"; + +const result = validatePluginManifest(manifestJson); +if (!result.success) { + console.error(result.errors); +} +``` + +This validator is deliberately strict for core fields and permissive about future unknown fields. +It is not a security boundary. diff --git a/sdk/IdeaSDK/examples/hello-plugin/README.md b/sdk/IdeaSDK/examples/hello-plugin/README.md new file mode 100644 index 0000000..94c4bc5 --- /dev/null +++ b/sdk/IdeaSDK/examples/hello-plugin/README.md @@ -0,0 +1,12 @@ +# Hello Plugin + +Minimal IdeA plugin example using the public SDK types. + +```sh +npm run typecheck:examples +npm run package:hello-plugin +``` + +The installable archive is emitted at `examples/hello-plugin/build/hello-plugin-0.1.0.zip`. +It contains `idea-plugin.json` at the ZIP root and the compiled ESM entrypoint at +`dist/index.js`, matching the manifest `main` field. diff --git a/sdk/IdeaSDK/examples/hello-plugin/idea-plugin.json b/sdk/IdeaSDK/examples/hello-plugin/idea-plugin.json new file mode 100644 index 0000000..07aa8dd --- /dev/null +++ b/sdk/IdeaSDK/examples/hello-plugin/idea-plugin.json @@ -0,0 +1,33 @@ +{ + "ideaPluginManifestVersion": 1, + "id": "com.example.hello-plugin", + "displayName": "Hello Plugin", + "publisher": "IdeA Examples", + "version": "0.1.0", + "description": "Minimal IdeA plugin example.", + "main": "dist/index.js", + "engines": { + "idea": ">=0.1.0" + }, + "trustLevel": "full", + "capabilities": [ + "ui" + ], + "contributes": { + "menus": [ + { + "id": "hello-plugin.menu", + "label": "Hello", + "topLevel": true + } + ], + "menuItems": [ + { + "id": "hello-plugin.sayHello.item", + "targetMenuId": "hello-plugin.menu", + "label": "Say Hello", + "command": "hello-plugin.sayHello" + } + ] + } +} diff --git a/sdk/IdeaSDK/examples/hello-plugin/package-lock.json b/sdk/IdeaSDK/examples/hello-plugin/package-lock.json new file mode 100644 index 0000000..9935509 --- /dev/null +++ b/sdk/IdeaSDK/examples/hello-plugin/package-lock.json @@ -0,0 +1,27 @@ +{ + "name": "hello-plugin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hello-plugin", + "version": "0.1.0", + "dependencies": { + "@idea/plugin-sdk": "file:../.." + } + }, + "../..": { + "name": "@idea/plugin-sdk", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "typescript": "^5.5.0" + } + }, + "node_modules/@idea/plugin-sdk": { + "resolved": "../..", + "link": true + } + } +} diff --git a/sdk/IdeaSDK/examples/hello-plugin/package.json b/sdk/IdeaSDK/examples/hello-plugin/package.json new file mode 100644 index 0000000..937f370 --- /dev/null +++ b/sdk/IdeaSDK/examples/hello-plugin/package.json @@ -0,0 +1,14 @@ +{ + "name": "hello-plugin", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@idea/plugin-sdk": "file:../.." + } +} + diff --git a/sdk/IdeaSDK/examples/hello-plugin/src/index.ts b/sdk/IdeaSDK/examples/hello-plugin/src/index.ts new file mode 100644 index 0000000..26a9a07 --- /dev/null +++ b/sdk/IdeaSDK/examples/hello-plugin/src/index.ts @@ -0,0 +1,21 @@ +import type { ActivateContext, IdeAPluginModule } from "@idea/plugin-sdk"; + +export function activate(ctx: ActivateContext): void { + ctx.logger.info("Hello from the IdeA hello plugin."); + + const disposable = ctx.commands?.registerCommand("hello-plugin.sayHello", () => { + ctx.logger.info("Hello command executed."); + return "Hello from IdeA"; + }); + + if (disposable) { + ctx.subscriptions.push(disposable); + } +} + +const plugin: IdeAPluginModule = { + activate +}; + +export default plugin; + diff --git a/sdk/IdeaSDK/examples/hello-plugin/tsconfig.build.json b/sdk/IdeaSDK/examples/hello-plugin/tsconfig.build.json new file mode 100644 index 0000000..9121acc --- /dev/null +++ b/sdk/IdeaSDK/examples/hello-plugin/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "sourceMap": false, + "paths": { + "@idea/plugin-sdk": [ + "../../dist/index.d.ts" + ] + } + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/sdk/IdeaSDK/examples/hello-plugin/tsconfig.json b/sdk/IdeaSDK/examples/hello-plugin/tsconfig.json new file mode 100644 index 0000000..27c3a9e --- /dev/null +++ b/sdk/IdeaSDK/examples/hello-plugin/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "noEmit": true, + "rootDir": "../..", + "paths": { + "@idea/plugin-sdk": [ + "../../src/index.ts" + ] + } + }, + "include": [ + "src/**/*.ts", + "../../src/**/*.ts" + ] +} diff --git a/sdk/IdeaSDK/package-lock.json b/sdk/IdeaSDK/package-lock.json new file mode 100644 index 0000000..ffbe024 --- /dev/null +++ b/sdk/IdeaSDK/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "@idea/plugin-sdk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@idea/plugin-sdk", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "typescript": "^5.5.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/sdk/IdeaSDK/package.json b/sdk/IdeaSDK/package.json new file mode 100644 index 0000000..b6a16b1 --- /dev/null +++ b/sdk/IdeaSDK/package.json @@ -0,0 +1,34 @@ +{ + "name": "@idea/plugin-sdk", + "version": "0.1.0", + "description": "Minimal public TypeScript SDK for IdeA plugins.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "build:hello-plugin": "tsc -p examples/hello-plugin/tsconfig.build.json", + "package:hello-plugin": "npm run build && npm run build:hello-plugin && node scripts/package-hello-plugin.mjs", + "typecheck:examples": "tsc -p examples/hello-plugin/tsconfig.json --noEmit", + "check": "npm run build && npm run typecheck:examples && npm run package:hello-plugin" + }, + "keywords": [ + "idea", + "plugins", + "sdk" + ], + "license": "MIT", + "devDependencies": { + "typescript": "^5.5.0" + } +} diff --git a/sdk/IdeaSDK/scripts/package-hello-plugin.mjs b/sdk/IdeaSDK/scripts/package-hello-plugin.mjs new file mode 100644 index 0000000..3c8de90 --- /dev/null +++ b/sdk/IdeaSDK/scripts/package-hello-plugin.mjs @@ -0,0 +1,124 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +const pluginRoot = join(process.cwd(), "examples", "hello-plugin"); +const manifestPath = join(pluginRoot, "idea-plugin.json"); +const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + +const main = requireString(manifest, "main"); +const version = requireString(manifest, "version"); +const archivePath = join(pluginRoot, "build", `hello-plugin-${version}.zip`); +const archiveEntries = [ + { archivePath: "idea-plugin.json", sourcePath: manifestPath }, + { archivePath: main, sourcePath: join(pluginRoot, main) }, + { archivePath: "README.md", sourcePath: join(pluginRoot, "README.md") } +]; +const DOS_TIME_MIDNIGHT = 0; +const DOS_DATE_1980_01_01 = 33; +const CRC32_TABLE = Array.from({ length: 256 }, (_, index) => { + let value = index; + + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + + return value >>> 0; +}); + +await rm(join(pluginRoot, "build"), { recursive: true, force: true }); +await mkdir(dirname(archivePath), { recursive: true }); + +const files = []; +for (const entry of archiveEntries) { + if (entry.archivePath.startsWith("/") || entry.archivePath.includes("..")) { + throw new Error(`Refusing unsafe archive path: ${entry.archivePath}`); + } + + files.push({ + archivePath: entry.archivePath, + data: await readFile(entry.sourcePath) + }); +} + +await writeFile(archivePath, createZip(files)); +console.log(`created ${archivePath}`); + +function requireString(record, key) { + if (typeof record[key] !== "string" || record[key].trim().length === 0) { + throw new Error(`idea-plugin.json field "${key}" must be a non-empty string`); + } + + return record[key]; +} + +function createZip(files) { + const localFileHeaders = []; + const centralDirectoryHeaders = []; + let offset = 0; + + for (const file of files) { + const filename = Buffer.from(file.archivePath, "utf8"); + const checksum = crc32(file.data); + const localFileHeader = Buffer.alloc(30); + + localFileHeader.writeUInt32LE(0x04034b50, 0); + localFileHeader.writeUInt16LE(20, 4); + localFileHeader.writeUInt16LE(0x0800, 6); + localFileHeader.writeUInt16LE(0, 8); + localFileHeader.writeUInt16LE(DOS_TIME_MIDNIGHT, 10); + localFileHeader.writeUInt16LE(DOS_DATE_1980_01_01, 12); + localFileHeader.writeUInt32LE(checksum, 14); + localFileHeader.writeUInt32LE(file.data.length, 18); + localFileHeader.writeUInt32LE(file.data.length, 22); + localFileHeader.writeUInt16LE(filename.length, 26); + localFileHeader.writeUInt16LE(0, 28); + + localFileHeaders.push(localFileHeader, filename, file.data); + + const centralDirectoryHeader = Buffer.alloc(46); + centralDirectoryHeader.writeUInt32LE(0x02014b50, 0); + centralDirectoryHeader.writeUInt16LE(20, 4); + centralDirectoryHeader.writeUInt16LE(20, 6); + centralDirectoryHeader.writeUInt16LE(0x0800, 8); + centralDirectoryHeader.writeUInt16LE(0, 10); + centralDirectoryHeader.writeUInt16LE(DOS_TIME_MIDNIGHT, 12); + centralDirectoryHeader.writeUInt16LE(DOS_DATE_1980_01_01, 14); + centralDirectoryHeader.writeUInt32LE(checksum, 16); + centralDirectoryHeader.writeUInt32LE(file.data.length, 20); + centralDirectoryHeader.writeUInt32LE(file.data.length, 24); + centralDirectoryHeader.writeUInt16LE(filename.length, 28); + centralDirectoryHeader.writeUInt16LE(0, 30); + centralDirectoryHeader.writeUInt16LE(0, 32); + centralDirectoryHeader.writeUInt16LE(0, 34); + centralDirectoryHeader.writeUInt16LE(0, 36); + centralDirectoryHeader.writeUInt32LE(0, 38); + centralDirectoryHeader.writeUInt32LE(offset, 42); + + centralDirectoryHeaders.push(centralDirectoryHeader, filename); + offset += localFileHeader.length + filename.length + file.data.length; + } + + const centralDirectory = Buffer.concat(centralDirectoryHeaders); + const endOfCentralDirectory = Buffer.alloc(22); + + endOfCentralDirectory.writeUInt32LE(0x06054b50, 0); + endOfCentralDirectory.writeUInt16LE(0, 4); + endOfCentralDirectory.writeUInt16LE(0, 6); + endOfCentralDirectory.writeUInt16LE(files.length, 8); + endOfCentralDirectory.writeUInt16LE(files.length, 10); + endOfCentralDirectory.writeUInt32LE(centralDirectory.length, 12); + endOfCentralDirectory.writeUInt32LE(offset, 16); + endOfCentralDirectory.writeUInt16LE(0, 20); + + return Buffer.concat([...localFileHeaders, centralDirectory, endOfCentralDirectory]); +} + +function crc32(data) { + let value = 0xffffffff; + + for (const byte of data) { + value = (value >>> 8) ^ CRC32_TABLE[(value ^ byte) & 0xff]; + } + + return (value ^ 0xffffffff) >>> 0; +} diff --git a/sdk/IdeaSDK/src/index.js b/sdk/IdeaSDK/src/index.js new file mode 100644 index 0000000..450bf53 --- /dev/null +++ b/sdk/IdeaSDK/src/index.js @@ -0,0 +1 @@ +export { isPluginManifest, assertPluginManifest, validatePluginManifest } from "./manifest.js"; diff --git a/sdk/IdeaSDK/src/index.ts b/sdk/IdeaSDK/src/index.ts new file mode 100644 index 0000000..742efc7 --- /dev/null +++ b/sdk/IdeaSDK/src/index.ts @@ -0,0 +1,23 @@ +export type { + IdeAPluginCapability, + IdeAPluginManifest, + IdeAPluginEngineConstraints, + IdeAPluginLayoutContribution, + IdeAPluginMcpServerContribution, + IdeAPluginMenuItemContribution, + IdeAPluginTopLevelMenuContribution +} from "./manifest.js"; +export { + isPluginManifest, + assertPluginManifest, + validatePluginManifest +} from "./manifest.js"; +export type { + ActivateContext, + CommandDisposable, + CommandHandler, + CommandRegistry, + IdeAPluginModule, + PluginLogger, + PluginStorage +} from "./runtime.js"; diff --git a/sdk/IdeaSDK/src/manifest.js b/sdk/IdeaSDK/src/manifest.js new file mode 100644 index 0000000..a08a477 --- /dev/null +++ b/sdk/IdeaSDK/src/manifest.js @@ -0,0 +1,172 @@ +const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/; +const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; +export function validatePluginManifest(input) { + const errors = []; + if (!isRecord(input)) { + return { success: false, errors: ["manifest must be an object"] }; + } + if (input.ideaPluginManifestVersion !== 1) { + errors.push("ideaPluginManifestVersion must be 1"); + } + requireString(input, "id", errors); + requireString(input, "displayName", errors); + requireString(input, "version", errors); + requireString(input, "main", errors); + if (input.trustLevel !== "full") { + errors.push("trustLevel must be full"); + } + optionalString(input, "description", errors); + optionalString(input, "publisher", errors); + if (typeof input.id === "string" && !PLUGIN_ID_PATTERN.test(input.id)) { + errors.push("id must contain lowercase letters, digits, dots or dashes, and start/end with an alphanumeric character"); + } + if (typeof input.version === "string" && !SEMVER_PATTERN.test(input.version)) { + errors.push("version must use semver syntax, for example 0.1.0"); + } + validateEngines(input.engines, errors); + validateCapabilities(input.capabilities, errors); + validateContributes(input.contributes, errors); + if (errors.length > 0) { + return { success: false, errors }; + } + return { success: true, data: input, errors: [] }; +} +export function isPluginManifest(input) { + return validatePluginManifest(input).success; +} +export function assertPluginManifest(input) { + const result = validatePluginManifest(input); + if (!result.success) { + throw new Error(`Invalid IdeA plugin manifest: ${result.errors.join("; ")}`); + } +} +function validateEngines(value, errors) { + if (value === undefined) { + return; + } + if (!isRecord(value)) { + errors.push("engines must be an object when provided"); + return; + } + optionalString(value, "idea", errors, "engines.idea"); +} +function validateCapabilities(value, errors) { + if (value === undefined) { + return; + } + if (!Array.isArray(value)) { + errors.push("capabilities must be an array when provided"); + return; + } + value.forEach((capability, index) => { + if (capability !== "ui" && capability !== "mcp") { + errors.push(`capabilities[${index}] must be "ui" or "mcp"`); + } + }); +} +function validateContributes(value, errors) { + if (value === undefined) { + return; + } + if (!isRecord(value)) { + errors.push("contributes must be an object when provided"); + return; + } + validateArray(value, "menus", errors, (menu, index) => { + requireString(menu, "id", errors, `contributes.menus[${index}].id`); + requireString(menu, "label", errors, `contributes.menus[${index}].label`); + if (menu.topLevel !== true) { + errors.push(`contributes.menus[${index}].topLevel must be true`); + } + optionalNumber(menu, "order", errors, `contributes.menus[${index}].order`); + optionalString(menu, "icon", errors, `contributes.menus[${index}].icon`); + }); + validateArray(value, "menuItems", errors, (item, index) => { + requireString(item, "id", errors, `contributes.menuItems[${index}].id`); + requireString(item, "targetMenuId", errors, `contributes.menuItems[${index}].targetMenuId`); + requireString(item, "label", errors, `contributes.menuItems[${index}].label`); + requireString(item, "command", errors, `contributes.menuItems[${index}].command`); + optionalNumber(item, "order", errors, `contributes.menuItems[${index}].order`); + optionalString(item, "icon", errors, `contributes.menuItems[${index}].icon`); + optionalString(item, "when", errors, `contributes.menuItems[${index}].when`); + }); + validateArray(value, "layouts", errors, (layout, index) => { + requireString(layout, "type", errors, `contributes.layouts[${index}].type`); + requireString(layout, "label", errors, `contributes.layouts[${index}].label`); + requireString(layout, "component", errors, `contributes.layouts[${index}].component`); + optionalNumber(layout, "order", errors, `contributes.layouts[${index}].order`); + optionalString(layout, "icon", errors, `contributes.layouts[${index}].icon`); + optionalString(layout, "when", errors, `contributes.layouts[${index}].when`); + }); + validateArray(value, "mcpServers", errors, (server, index) => { + requireString(server, "id", errors, `contributes.mcpServers[${index}].id`); + requireString(server, "displayName", errors, `contributes.mcpServers[${index}].displayName`); + requireString(server, "command", errors, `contributes.mcpServers[${index}].command`); + if (server.transport !== "stdio") { + errors.push(`contributes.mcpServers[${index}].transport must be "stdio"`); + } + optionalStringArray(server, "args", errors, `contributes.mcpServers[${index}].args`); + optionalStringRecord(server, "env", errors, `contributes.mcpServers[${index}].env`); + optionalString(server, "cwd", errors, `contributes.mcpServers[${index}].cwd`); + optionalBoolean(server, "autoStart", errors, `contributes.mcpServers[${index}].autoStart`); + optionalBoolean(server, "allowAbsoluteCommand", errors, `contributes.mcpServers[${index}].allowAbsoluteCommand`); + }); +} +function validateArray(record, key, errors, validateItem) { + const value = record[key]; + if (value === undefined) { + return; + } + if (!Array.isArray(value)) { + errors.push(`contributes.${key} must be an array when provided`); + return; + } + value.forEach((item, index) => { + if (!isRecord(item)) { + errors.push(`contributes.${key}[${index}] must be an object`); + return; + } + validateItem(item, index); + }); +} +function requireString(record, key, errors, label = key) { + if (typeof record[key] !== "string" || record[key].trim().length === 0) { + errors.push(`${label} must be a non-empty string`); + } +} +function optionalString(record, key, errors, label = key) { + if (record[key] !== undefined && typeof record[key] !== "string") { + errors.push(`${label} must be a string when provided`); + } +} +function optionalNumber(record, key, errors, label = key) { + if (record[key] !== undefined && typeof record[key] !== "number") { + errors.push(`${label} must be a number when provided`); + } +} +function optionalBoolean(record, key, errors, label = key) { + if (record[key] !== undefined && typeof record[key] !== "boolean") { + errors.push(`${label} must be a boolean when provided`); + } +} +function optionalStringArray(record, key, errors, label = key) { + const value = record[key]; + if (value === undefined) { + return; + } + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + errors.push(`${label} must be an array of strings when provided`); + } +} +function optionalStringRecord(record, key, errors, label = key) { + const value = record[key]; + if (value === undefined) { + return; + } + if (!isRecord(value) || Object.values(value).some((item) => typeof item !== "string")) { + errors.push(`${label} must be an object of strings when provided`); + } +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/sdk/IdeaSDK/src/manifest.ts b/sdk/IdeaSDK/src/manifest.ts new file mode 100644 index 0000000..68bb55a --- /dev/null +++ b/sdk/IdeaSDK/src/manifest.ts @@ -0,0 +1,309 @@ +export interface IdeAPluginManifest { + ideaPluginManifestVersion: 1; + id: string; + displayName: string; + version: string; + main: string; + trustLevel: "full"; + description?: string; + publisher?: string; + engines?: IdeAPluginEngineConstraints; + capabilities?: IdeAPluginCapability[]; + contributes?: { + menus?: IdeAPluginTopLevelMenuContribution[]; + menuItems?: IdeAPluginMenuItemContribution[]; + layouts?: IdeAPluginLayoutContribution[]; + mcpServers?: IdeAPluginMcpServerContribution[]; + }; +} + +export type IdeAPluginCapability = "ui" | "mcp"; + +export interface IdeAPluginEngineConstraints { + idea?: string; +} + +export interface IdeAPluginTopLevelMenuContribution { + id: string; + label: string; + topLevel: true; + order?: number; + icon?: string; +} + +export interface IdeAPluginMenuItemContribution { + id: string; + targetMenuId: string; + label: string; + command: string; + order?: number; + icon?: string; + when?: string; +} + +export interface IdeAPluginLayoutContribution { + type: string; + label: string; + component: string; + order?: number; + icon?: string; + when?: string; +} + +export interface IdeAPluginMcpServerContribution { + id: string; + displayName: string; + command: string; + args?: string[]; + env?: Record; + cwd?: string; + transport: "stdio"; + autoStart?: boolean; + allowAbsoluteCommand?: boolean; +} + +export type PluginManifestValidationResult = + | { success: true; data: IdeAPluginManifest; errors: [] } + | { success: false; data?: undefined; errors: string[] }; + +const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/; +const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; + +export function validatePluginManifest(input: unknown): PluginManifestValidationResult { + const errors: string[] = []; + + if (!isRecord(input)) { + return { success: false, errors: ["manifest must be an object"] }; + } + + if (input.ideaPluginManifestVersion !== 1) { + errors.push("ideaPluginManifestVersion must be 1"); + } + + requireString(input, "id", errors); + requireString(input, "displayName", errors); + requireString(input, "version", errors); + requireString(input, "main", errors); + if (input.trustLevel !== "full") { + errors.push("trustLevel must be full"); + } + optionalString(input, "description", errors); + optionalString(input, "publisher", errors); + + if (typeof input.id === "string" && !PLUGIN_ID_PATTERN.test(input.id)) { + errors.push("id must contain lowercase letters, digits, dots or dashes, and start/end with an alphanumeric character"); + } + + if (typeof input.version === "string" && !SEMVER_PATTERN.test(input.version)) { + errors.push("version must use semver syntax, for example 0.1.0"); + } + + validateEngines(input.engines, errors); + validateCapabilities(input.capabilities, errors); + validateContributes(input.contributes, errors); + + if (errors.length > 0) { + return { success: false, errors }; + } + + return { success: true, data: input as unknown as IdeAPluginManifest, errors: [] }; +} + +export function isPluginManifest(input: unknown): input is IdeAPluginManifest { + return validatePluginManifest(input).success; +} + +export function assertPluginManifest(input: unknown): asserts input is IdeAPluginManifest { + const result = validatePluginManifest(input); + + if (!result.success) { + throw new Error(`Invalid IdeA plugin manifest: ${result.errors.join("; ")}`); + } +} + +function validateEngines(value: unknown, errors: string[]): void { + if (value === undefined) { + return; + } + + if (!isRecord(value)) { + errors.push("engines must be an object when provided"); + return; + } + + optionalString(value, "idea", errors, "engines.idea"); +} + +function validateCapabilities(value: unknown, errors: string[]): void { + if (value === undefined) { + return; + } + + if (!Array.isArray(value)) { + errors.push("capabilities must be an array when provided"); + return; + } + + value.forEach((capability, index) => { + if (capability !== "ui" && capability !== "mcp") { + errors.push(`capabilities[${index}] must be "ui" or "mcp"`); + } + }); +} + +function validateContributes(value: unknown, errors: string[]): void { + if (value === undefined) { + return; + } + + if (!isRecord(value)) { + errors.push("contributes must be an object when provided"); + return; + } + + validateArray(value, "menus", errors, (menu, index) => { + requireString(menu, "id", errors, `contributes.menus[${index}].id`); + requireString(menu, "label", errors, `contributes.menus[${index}].label`); + if (menu.topLevel !== true) { + errors.push(`contributes.menus[${index}].topLevel must be true`); + } + optionalNumber(menu, "order", errors, `contributes.menus[${index}].order`); + optionalString(menu, "icon", errors, `contributes.menus[${index}].icon`); + }); + validateArray(value, "menuItems", errors, (item, index) => { + requireString(item, "id", errors, `contributes.menuItems[${index}].id`); + requireString(item, "targetMenuId", errors, `contributes.menuItems[${index}].targetMenuId`); + requireString(item, "label", errors, `contributes.menuItems[${index}].label`); + requireString(item, "command", errors, `contributes.menuItems[${index}].command`); + optionalNumber(item, "order", errors, `contributes.menuItems[${index}].order`); + optionalString(item, "icon", errors, `contributes.menuItems[${index}].icon`); + optionalString(item, "when", errors, `contributes.menuItems[${index}].when`); + }); + validateArray(value, "layouts", errors, (layout, index) => { + requireString(layout, "type", errors, `contributes.layouts[${index}].type`); + requireString(layout, "label", errors, `contributes.layouts[${index}].label`); + requireString(layout, "component", errors, `contributes.layouts[${index}].component`); + optionalNumber(layout, "order", errors, `contributes.layouts[${index}].order`); + optionalString(layout, "icon", errors, `contributes.layouts[${index}].icon`); + optionalString(layout, "when", errors, `contributes.layouts[${index}].when`); + }); + validateArray(value, "mcpServers", errors, (server, index) => { + requireString(server, "id", errors, `contributes.mcpServers[${index}].id`); + requireString(server, "displayName", errors, `contributes.mcpServers[${index}].displayName`); + requireString(server, "command", errors, `contributes.mcpServers[${index}].command`); + if (server.transport !== "stdio") { + errors.push(`contributes.mcpServers[${index}].transport must be "stdio"`); + } + optionalStringArray(server, "args", errors, `contributes.mcpServers[${index}].args`); + optionalStringRecord(server, "env", errors, `contributes.mcpServers[${index}].env`); + optionalString(server, "cwd", errors, `contributes.mcpServers[${index}].cwd`); + optionalBoolean(server, "autoStart", errors, `contributes.mcpServers[${index}].autoStart`); + optionalBoolean(server, "allowAbsoluteCommand", errors, `contributes.mcpServers[${index}].allowAbsoluteCommand`); + }); +} + +function validateArray( + record: Record, + key: string, + errors: string[], + validateItem: (item: Record, index: number) => void +): void { + const value = record[key]; + if (value === undefined) { + return; + } + + if (!Array.isArray(value)) { + errors.push(`contributes.${key} must be an array when provided`); + return; + } + + value.forEach((item, index) => { + if (!isRecord(item)) { + errors.push(`contributes.${key}[${index}] must be an object`); + return; + } + + validateItem(item, index); + }); +} + +function requireString( + record: Record, + key: string, + errors: string[], + label = key +): void { + if (typeof record[key] !== "string" || record[key].trim().length === 0) { + errors.push(`${label} must be a non-empty string`); + } +} + +function optionalString( + record: Record, + key: string, + errors: string[], + label = key +): void { + if (record[key] !== undefined && typeof record[key] !== "string") { + errors.push(`${label} must be a string when provided`); + } +} + +function optionalNumber( + record: Record, + key: string, + errors: string[], + label = key +): void { + if (record[key] !== undefined && typeof record[key] !== "number") { + errors.push(`${label} must be a number when provided`); + } +} + +function optionalBoolean( + record: Record, + key: string, + errors: string[], + label = key +): void { + if (record[key] !== undefined && typeof record[key] !== "boolean") { + errors.push(`${label} must be a boolean when provided`); + } +} + +function optionalStringArray( + record: Record, + key: string, + errors: string[], + label = key +): void { + const value = record[key]; + if (value === undefined) { + return; + } + + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + errors.push(`${label} must be an array of strings when provided`); + } +} + +function optionalStringRecord( + record: Record, + key: string, + errors: string[], + label = key +): void { + const value = record[key]; + if (value === undefined) { + return; + } + + if (!isRecord(value) || Object.values(value).some((item) => typeof item !== "string")) { + errors.push(`${label} must be an object of strings when provided`); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/sdk/IdeaSDK/src/runtime.js b/sdk/IdeaSDK/src/runtime.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/sdk/IdeaSDK/src/runtime.js @@ -0,0 +1 @@ +export {}; diff --git a/sdk/IdeaSDK/src/runtime.ts b/sdk/IdeaSDK/src/runtime.ts new file mode 100644 index 0000000..e7b68a1 --- /dev/null +++ b/sdk/IdeaSDK/src/runtime.ts @@ -0,0 +1,36 @@ +export interface ActivateContext { + pluginId: string; + logger: PluginLogger; + subscriptions: CommandDisposable[]; + commands?: CommandRegistry; + storage?: PluginStorage; +} + +export interface IdeAPluginModule { + activate(ctx: ActivateContext): void | Promise; + deactivate?(): void | Promise; +} + +export interface PluginLogger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; +} + +export type CommandHandler = (...args: unknown[]) => unknown | Promise; + +export interface CommandRegistry { + registerCommand(commandId: string, handler: CommandHandler): CommandDisposable; +} + +export interface CommandDisposable { + dispose(): void; +} + +export interface PluginStorage { + get(key: string): Promise; + set(key: string, value: T): Promise; + delete(key: string): Promise; +} + diff --git a/sdk/IdeaSDK/tsconfig.json b/sdk/IdeaSDK/tsconfig.json new file mode 100644 index 0000000..136dafe --- /dev/null +++ b/sdk/IdeaSDK/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +} +