From 812d27e8cf27beb3de3ba4753dd6e1a5db58d003 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:14:38 +0800 Subject: [PATCH] feat(plugins): list community plugins from a registry in the marketplace The marketplace index can now carry plugins of other publishers, published from their authors' repositories through the l0ng-ai/openidea-plugins registry. - `openidea-plugin index` with a key replaces only that publisher's plugins (it used to drop every other entry); without a key it indexes each package against the publishers the index already lists, checking the key and the id prefix instead of only the signature itself. - `openidea-plugin merge` takes another index's publishers, plugins and revocations in place of every publisher but `--keep`; the other index can't list or revoke the kept publishers' plugins. - `openidea-plugin check` accepts a `.oiplugin` file. - Releases merge the registry's index into plugins-index.json and attach the Linux x64 tool; plugins-index.yml refreshes the community plugins in the latest release hourly or when the registry signals a change. - plugins/README.md and CONTRIBUTING.md describe official and community plugins. --- .github/workflows/plugins-index.yml | 46 +++++ .github/workflows/release.yml | 15 +- CONTRIBUTING.md | 5 + crates/ide-extension/src/package.rs | 121 +++++++++++ .../ide-host/src/bin/openidea-plugin/main.rs | 192 +++++++++++++++--- crates/ide-host/src/plugins/mod.rs | 5 +- plugins/README.md | 30 +++ 7 files changed, 381 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/plugins-index.yml diff --git a/.github/workflows/plugins-index.yml b/.github/workflows/plugins-index.yml new file mode 100644 index 000000000..bbf1d256a --- /dev/null +++ b/.github/workflows/plugins-index.yml @@ -0,0 +1,46 @@ +name: Plugin index + +# Refreshes the community plugins in the latest release's plugins-index.json +# (what released apps read) from the registry, l0ng-ai/openidea-plugins. The +# official plugins, their key and revocations stay as the release wrote them. +on: + schedule: + - cron: "17 * * * *" + workflow_dispatch: + # Sent by the registry when its index changes. + repository_dispatch: + types: [community-plugins] + +permissions: + contents: write + +concurrency: + group: plugins-index + +env: + COMMUNITY_PLUGINS_INDEX: https://github.com/l0ng-ai/openidea-plugins/releases/download/index/index.json + +jobs: + merge: + runs-on: ubuntu-latest + steps: + - name: Merge the community index + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + cd "$RUNNER_TEMP" + tag="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q .tagName)" + tool=openidea-plugin-linux-x86_64 + if ! gh release download "$tag" --repo "$GITHUB_REPOSITORY" -p plugins-index.json -p "$tool.gz"; then + echo "::notice::$tag has no plugin index or openidea-plugin tool yet" + exit 0 + fi + gunzip "$tool.gz" && chmod +x "$tool" + cp plugins-index.json before.json + "./$tool" merge plugins-index.json "$COMMUNITY_PLUGINS_INDEX" --keep openidea + if cmp -s before.json plugins-index.json; then + echo "unchanged" + else + gh release upload "$tag" plugins-index.json --repo "$GITHUB_REPOSITORY" --clobber + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3874f17e7..706614794 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,7 @@ env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: 0 TAG: ${{ github.event.inputs.tag || github.ref_name }} + COMMUNITY_PLUGINS_INDEX: https://github.com/l0ng-ai/openidea-plugins/releases/download/index/index.json jobs: # Creates (or reuses) one draft release that every platform build uploads into. @@ -184,7 +185,11 @@ jobs: # (releases/latest/download/plugins-index.json). They are not in the # installers: users install them from Settings › Plugins. Without the # signing key (secret PLUGIN_SIGNING_KEY: the file `openidea-plugin keygen` - # writes) nothing is published and the release goes on. + # writes) nothing is published and the release goes on. The index also lists + # the community plugins of the registry (COMMUNITY_PLUGINS_INDEX; refreshed + # between releases by plugins-index.yml), and the release carries the + # openidea-plugin tool for Linux x64, which that workflow and the registry's + # checks run. plugins: needs: create-release name: Official plugins @@ -237,11 +242,15 @@ jobs: version="$(sed -n 's/^version *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)" "$tool" pack "$dir" --key "$key" --publisher openidea --out "$out/$id-$version.oiplugin" done - # The published index's revocations and earlier keys carry over. + # The published index's revocations, earlier keys and community plugins carry over. curl -fsSL "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/plugins-index.json" \ -o "$out/plugins-index.json" || rm -f "$out/plugins-index.json" "$tool" index "$out" --publisher openidea --name OpenIDEA --key "$key" --out "$out/plugins-index.json" - gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" --clobber + "$tool" merge "$out/plugins-index.json" "$COMMUNITY_PLUGINS_INDEX" --keep openidea || + echo "::warning::the community plugins were not refreshed" + gzip -9 -c "$tool" > "$out/openidea-plugin-linux-x86_64.gz" + gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" \ + "$out/openidea-plugin-linux-x86_64.gz" --clobber # The updater manifest (latest.json), built once from the signed bundles every build uploaded. # Fails when a platform is missing, so a release never ships a manifest without it. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d10aacf5..5dfa9ffd9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,5 +31,10 @@ Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md). - If a change needs a product decision you can't make, ask in the issue and stop rather than guessing. +Plugins: pull requests here add or change official plugins (`plugins/official/`) +and the plugin API. Community plugins live in their authors' repositories and +are listed through [l0ng-ai/openidea-plugins](https://github.com/l0ng-ai/openidea-plugins); +see [`plugins/README.md`](plugins/README.md) § 8.1. + A check on each PR adds `needs-info` when the issue link or the verification section is missing, and removes it once they're filled in. diff --git a/crates/ide-extension/src/package.rs b/crates/ide-extension/src/package.rs index b8d3988d2..0b70494b8 100644 --- a/crates/ide-extension/src/package.rs +++ b/crates/ide-extension/src/package.rs @@ -335,6 +335,53 @@ impl Index { sig.check(id, version, digest)?; Ok(publisher.id.clone()) } + + /// Replaces what this index lists for every publisher but `keep` with what + /// `other` lists: its publishers, their plugins and their revocations + /// (`other`'s downloads should already be absolute). `other` may only + /// speak for its own publishers, and none of them may be in `keep`. + pub fn include(&mut self, other: Index, keep: &[&str]) -> Result<(), String> { + let mut theirs: Vec<&str> = Vec::new(); + for p in &other.publishers { + if !publisher_id_ok(&p.id) { + return Err(format!("invalid publisher id {:?}: use a-z, 0-9, '-' and '_'", p.id)); + } + if keep.contains(&p.id.as_str()) { + return Err(format!("may not list publisher {:?}", p.id)); + } + if theirs.contains(&p.id.as_str()) { + return Err(format!("publisher {:?} is listed twice", p.id)); + } + theirs.push(&p.id); + } + let owned_by = |id: &str| theirs.iter().any(|p| owns(p, id)); + for e in &other.plugins { + if !theirs.contains(&e.publisher.as_str()) || !owns(&e.publisher, &e.id) { + return Err(format!("{}: not a plugin of publisher {:?}", e.id, e.publisher)); + } + } + if let Some(r) = other.revoked.iter().find(|r| !owned_by(&r.id)) { + return Err(format!("{}: revoked by an index that doesn't publish it", r.id)); + } + let kept = |id: &str| keep.iter().any(|p| owns(p, id)); + self.publishers.retain(|p| keep.contains(&p.id.as_str())); + self.plugins.retain(|e| keep.contains(&e.publisher.as_str())); + self.revoked.retain(|r| kept(&r.id)); + self.publishers.extend(other.publishers); + self.plugins.extend(other.plugins); + self.revoked.extend(other.revoked); + Ok(()) + } +} + +/// Whether the plugin `id` is in `publisher`'s namespace. +fn owns(publisher: &str, id: &str) -> bool { + id.strip_prefix(publisher).is_some_and(|rest| rest.starts_with('.')) +} + +/// A publisher id is a single segment of a plugin id. +pub fn publisher_id_ok(id: &str) -> bool { + !id.contains('.') && crate::validate_plugin_id(id).is_ok() } #[cfg(test)] @@ -399,6 +446,80 @@ mod tests { assert_eq!(PublisherKey::from_base64(&key.seed_base64()).unwrap().public_base64(), key.public_base64()); } + fn entry(id: &str, publisher: &str) -> IndexEntry { + IndexEntry { + id: id.into(), + name: id.into(), + version: "1.0.0".into(), + description: String::new(), + authors: Vec::new(), + publisher: publisher.into(), + download: format!("https://example.com/{id}.oiplugin"), + digest: "d".into(), + capabilities: Vec::new(), + } + } + + fn publisher(id: &str) -> Publisher { + Publisher { id: id.into(), name: id.into(), keys: vec![KeyEntry { key: format!("{id}-key"), revoked: false }] } + } + + fn revocation(id: &str) -> Revocation { + Revocation { id: id.into(), versions: Vec::new(), reason: "r".into() } + } + + #[test] + fn including_an_index_replaces_everything_but_the_kept_publishers() { + let mut official = Index { + format: INDEX_FORMAT, + publishers: vec![publisher("openidea"), publisher("gone")], + plugins: vec![entry("openidea.a", "openidea"), entry("gone.x", "gone")], + revoked: vec![revocation("openidea.old"), revocation("gone.x")], + }; + let community = Index { + format: INDEX_FORMAT, + publishers: vec![publisher("alice")], + plugins: vec![entry("alice.arthas", "alice")], + revoked: vec![revocation("alice.bad")], + }; + official.include(community.clone(), &["openidea"]).unwrap(); + let ids = |v: &[IndexEntry]| v.iter().map(|e| e.id.clone()).collect::>(); + assert_eq!(official.publishers.iter().map(|p| p.id.as_str()).collect::>(), ["openidea", "alice"]); + assert_eq!(ids(&official.plugins), ["openidea.a", "alice.arthas"]); + assert_eq!(official.revoked.iter().map(|r| r.id.as_str()).collect::>(), ["openidea.old", "alice.bad"]); + + // Including again is idempotent. + let once = official.clone(); + official.include(community.clone(), &["openidea"]).unwrap(); + assert_eq!(official, once); + } + + #[test] + fn an_included_index_speaks_only_for_its_own_publishers() { + let base = Index { format: INDEX_FORMAT, publishers: vec![publisher("openidea")], ..Index::default() }; + let with = |publishers: Vec, plugins: Vec, revoked: Vec| Index { + format: INDEX_FORMAT, + publishers, + plugins, + revoked, + }; + let cases = [ + (with(vec![publisher("openidea")], vec![], vec![]), "may not list"), + (with(vec![publisher("alice"), publisher("alice")], vec![], vec![]), "listed twice"), + (with(vec![publisher("openidea.x")], vec![], vec![]), "invalid publisher"), + (with(vec![publisher("alice")], vec![entry("openidea.a", "alice")], vec![]), "not a plugin of"), + (with(vec![publisher("alice")], vec![entry("alice.a", "bob")], vec![]), "not a plugin of"), + (with(vec![publisher("alice")], vec![entry("alicex.a", "alice")], vec![]), "not a plugin of"), + (with(vec![publisher("alice")], vec![], vec![revocation("openidea.a")]), "doesn't publish"), + ]; + for (other, error) in cases { + let mut index = base.clone(); + let err = index.include(other, &["openidea"]).unwrap_err(); + assert!(err.contains(error), "{err}"); + assert_eq!(index, base); + } + } + #[test] fn unpacking_refuses_paths_leaving_the_package() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/src/bin/openidea-plugin/main.rs b/crates/ide-host/src/bin/openidea-plugin/main.rs index 99b736308..2d54c607e 100644 --- a/crates/ide-host/src/bin/openidea-plugin/main.rs +++ b/crates/ide-host/src/bin/openidea-plugin/main.rs @@ -3,13 +3,14 @@ //! ```text //! openidea-plugin new [--template command|theme|icon-theme|language] [--id ID] [--name NAME] [--sdk PATH] //! openidea-plugin build [dir] [--debug] # cargo → dist/plugin.wasm, then `check` -//! openidea-plugin check [dir] # validate the package as the IDE would install it +//! openidea-plugin check [dir|package] # validate the package as the IDE would install it //! openidea-plugin link [dir] # load the folder in the IDE without installing it //! openidea-plugin unlink [dir] //! openidea-plugin links # list linked folders //! openidea-plugin keygen # a publisher's Ed25519 key //! openidea-plugin pack [dir] [--key F --publisher ID] [--out FILE] # -.oiplugin -//! openidea-plugin index --publisher ID --name NAME --key F # marketplace index.json +//! openidea-plugin index [--publisher ID --name NAME --key F] # marketplace index.json +//! openidea-plugin merge --keep ID # list another index's publishers //! ``` //! //! `link` writes `links.json` in the IDE's plugins directory @@ -20,7 +21,7 @@ mod templates; use ide_extension::{Manifest, validate_plugin_id}; -use ide_host::plugins::PluginHost; +use ide_host::plugins::{PluginHost, read_market_url, resolve_market_href}; use ide_plugin::{Limits, PluginEngine}; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; @@ -33,15 +34,20 @@ commands: new [--template T] [--id ID] [--name NAME] [--sdk PATH] create a plugin; T = command (default), theme, icon-theme, language build [dir] [--debug] compile a Rust plugin to dist/plugin.wasm and check it - check [dir] validate the package as the IDE would install it + check [dir|package] validate the package (folder or .oiplugin) as the IDE would install it link [dir] load the folder in the IDE without installing it unlink [dir] stop loading a linked folder links list linked folders keygen create a publisher signing key (keep the file secret) pack [dir] [--key F --publisher ID] [--out FILE] write -.oiplugin, signed with the key - index --publisher ID --name NAME --key F [--out FILE] - write the marketplace index.json for the packages in dir + index [--publisher ID --name NAME --key F] [--out FILE] + write the marketplace index.json for the packages in dir: with a + key, register it and replace that publisher's plugins; without, + replace all plugins, each checked against the index's publishers + merge --keep ID [--out FILE] + replace every publisher but ID in index with those of other-index + (a path or URL), their plugins and revocations environment: OPENIDEA_PLUGINS_DIR the IDE's plugins directory (default: the app data directory)"; @@ -51,13 +57,14 @@ fn main() -> ExitCode { let result = match args.first().map(String::as_str) { Some("new") => cmd_new(&args[1..]), Some("build") => cmd_build(&args[1..]), - Some("check") => dir_arg(&args[1..]).and_then(|d| check(&d)), + Some("check") => dir_arg(&args[1..]).and_then(|d| check_path(&d)), Some("link") => dir_arg(&args[1..]).and_then(|d| link(&d, true)), Some("unlink") => dir_arg(&args[1..]).and_then(|d| link(&d, false)), Some("links") => list_links(), Some("keygen") => cmd_keygen(&args[1..]), Some("pack") => cmd_pack(&args[1..]), Some("index") => cmd_index(&args[1..]), + Some("merge") => cmd_merge(&args[1..]), Some("--version" | "-V") => { println!("openidea-plugin {} (plugin API {})", env!("CARGO_PKG_VERSION"), ide_extension::ENGINE_VERSION); Ok(()) @@ -323,6 +330,18 @@ fn ensure_wasm_target() { /// Validates `dir` exactly as Install from Disk would, and checks the /// component imports only the plugin API. +/// [`check`] of a folder, or of a package file unpacked as installing would. +fn check_path(path: &Path) -> Result<(), String> { + if !path.is_file() { + return check(path); + } + let scratch = std::env::temp_dir().join(format!("openidea-plugin-unpack-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&scratch); + let result = package::unpack(path, &scratch).and_then(|_| check(&scratch)); + let _ = std::fs::remove_dir_all(&scratch); + result +} + fn check(dir: &Path) -> Result<(), String> { let dir = canonical(dir)?; let scratch = std::env::temp_dir().join(format!("openidea-plugin-check-{}", std::process::id())); @@ -524,27 +543,37 @@ fn cmd_pack(args: &[String]) -> Result<(), String> { fn cmd_index(args: &[String]) -> Result<(), String> { let a = Args::parse(args, &[], &["publisher", "name", "key", "out"])?; let dir = canonical(&a.dir()?)?; - let publisher = a.value("publisher").ok_or("--publisher is required")?; - let name = a.value("name").unwrap_or(publisher); - let key = read_key(a.value("key").ok_or("--key is required")?)?; let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| dir.join("index.json")); // Revocations and other publishers of an existing index are kept. let mut index = match std::fs::read_to_string(&out) { Ok(text) => Index::parse(&text)?, Err(_) => Index { format: package::INDEX_FORMAT, ..Index::default() }, }; - // Earlier keys of the publisher stay listed (packages they signed remain valid until revoked). - let current = key.public_base64(); - let earlier: Vec = index - .publishers - .iter() - .filter(|p| p.id == publisher) - .flat_map(|p| p.keys.iter().filter(|k| k.key != current).cloned()) - .collect(); - index.publishers.retain(|p| p.id != publisher); - let keys: Vec = std::iter::once(KeyEntry { key: current, revoked: false }).chain(earlier).collect(); - index.publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys }); - index.plugins.clear(); + let publisher = match (a.value("publisher"), a.value("key")) { + (Some(publisher), Some(key)) => { + let key = read_key(key)?; + let name = a.value("name").unwrap_or(publisher); + // Earlier keys of the publisher stay listed (packages they signed remain valid until revoked). + let current = key.public_base64(); + let earlier: Vec = index + .publishers + .iter() + .filter(|p| p.id == publisher) + .flat_map(|p| p.keys.iter().filter(|k| k.key != current).cloned()) + .collect(); + index.publishers.retain(|p| p.id != publisher); + let keys: Vec = + std::iter::once(KeyEntry { key: current, revoked: false }).chain(earlier).collect(); + index.publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys }); + index.plugins.retain(|e| e.publisher != publisher); + Some(publisher) + } + (None, None) => { + index.plugins.clear(); + None + } + _ => return Err("--publisher and --key go together".into()), + }; let mut packages: Vec = std::fs::read_dir(&dir) .map_err(|e| e.to_string())? .flatten() @@ -555,32 +584,62 @@ fn cmd_index(args: &[String]) -> Result<(), String> { for path in packages { let scratch = std::env::temp_dir().join(format!("openidea-plugin-index-{}", std::process::id())); let _ = std::fs::remove_dir_all(&scratch); - let unpacked = package::unpack(&path, &scratch)?; let entry = (|| { + let unpacked = package::unpack(&path, &scratch)?; let m = Manifest::load(&scratch)?; let files = with_scratch_host(|h| h.package_files_of(&scratch).map_err(|e| e.to_string()))?; let digest = package::package_digest(&scratch, &files).map_err(|e| e.to_string())?; - let sig = unpacked.signature.as_ref().ok_or_else(|| format!("{}: not signed", path.display()))?; - sig.check(&m.id, &m.version, &digest).map_err(|e| format!("{}: {e}", path.display()))?; + let sig = unpacked.signature.as_ref().ok_or("not signed")?; + let signer = index.verify(sig, &m.id, &m.version, &digest)?; + if publisher.is_some_and(|p| p != signer) { + return Err(format!("signed by {signer}, not {}", publisher.unwrap_or_default())); + } + if index.plugins.iter().any(|e| e.id == m.id) { + return Err(format!("another package of {} is in the folder", m.id)); + } Ok::<_, String>(IndexEntry { id: m.id.clone(), name: m.name.clone(), version: m.version.clone(), description: m.description.clone(), authors: m.authors.clone(), - publisher: sig.publisher.clone(), + publisher: signer, download: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), digest, capabilities: m.capabilities.all().map(|c| c.to_string()).collect(), }) })(); let _ = std::fs::remove_dir_all(&scratch); - let entry = entry?; + let entry = entry.map_err(|e| format!("{}: {e}", path.display()))?; println!(" {} {}", entry.id, entry.version); index.plugins.push(entry); } - let json = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?; - std::fs::write(&out, json).map_err(|e| format!("{}: {e}", out.display()))?; + write_index(&index, &out) +} + +fn cmd_merge(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &[], &["keep", "out"])?; + let [base, other] = a.positional.as_slice() else { + return Err("usage: openidea-plugin merge --keep ID [--out FILE]".into()); + }; + let keep = a.value("keep").ok_or("--keep is required")?; + let out = a.value("out").unwrap_or(base); + let mut index = Index::parse(&std::fs::read_to_string(base).map_err(|e| format!("{base}: {e}"))?)?; + let bytes = read_market_url(other, 16 << 20)?; + let mut theirs = Index::parse(&String::from_utf8(bytes).map_err(|_| format!("{other}: not UTF-8"))?)?; + // Their downloads are relative to their index, not to ours. + for e in &mut theirs.plugins { + e.download = resolve_market_href(other, &e.download); + } + let count = theirs.plugins.len(); + index.include(theirs, &[keep]).map_err(|e| format!("{other}: {e}"))?; + println!(" {count} plugins from {other}"); + write_index(&index, Path::new(out)) +} + +fn write_index(index: &Index, out: &Path) -> Result<(), String> { + let json = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?; + std::fs::write(out, json).map_err(|e| format!("{}: {e}", out.display()))?; println!("-> {} ({} plugins)", out.display(), index.plugins.len()); Ok(()) } @@ -601,6 +660,81 @@ mod tests { assert_eq!(relative(Path::new("/x/y"), Path::new("/z")), None); } + /// A theme package `id` signed by `publisher` with `key`, packed into `out`. + fn signed_theme(tmp: &Path, id: &str, publisher: &str, key: &str, out: &Path) { + let src = tmp.join("src").join(id); + let vars = templates::Vars { id, name: id, sdk: None }; + for (rel, content) in templates::render(templates::Template::Theme, &vars) { + let path = src.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + } + std::fs::create_dir_all(out).unwrap(); + let args = [src.to_str().unwrap(), "--key", key, "--publisher", publisher, "--out"] + .into_iter() + .map(String::from) + .chain([out.join(format!("{id}-0.1.0.oiplugin")).to_string_lossy().into_owned()]) + .collect::>(); + cmd_pack(&args).unwrap(); + } + + fn strings(args: &[&str]) -> Vec { + args.iter().map(|a| a.to_string()).collect() + } + + #[test] + fn official_and_community_indexes_merge() { + let tmp = tempfile::tempdir().unwrap(); + let t = tmp.path(); + let mut keys = Vec::new(); + for (name, seed) in [("openidea", 1), ("alice", 2), ("carol", 3)] { + let file = t.join(format!("{name}.key")); + let key = PublisherKey::from_seed([seed; 32]); + std::fs::write(&file, key.seed_base64()).unwrap(); + keys.push((file.to_string_lossy().into_owned(), key.public_base64())); + } + let (official, community) = (t.join("official"), t.join("community")); + signed_theme(t, "openidea.night", "openidea", &keys[0].0, &official); + signed_theme(t, "alice.dawn", "alice", &keys[1].0, &community); + let official_index = official.join("index.json"); + let community_index = community.join("index.json"); + let release = strings(&[official.to_str().unwrap(), "--publisher", "openidea", "--key", &keys[0].0]); + cmd_index(&release).unwrap(); + + // The community index lists its publishers; `index` without a key checks packages against them. + let skeleton = Index { + format: package::INDEX_FORMAT, + publishers: vec![package::Publisher { + id: "alice".into(), + name: "Alice".into(), + keys: vec![KeyEntry { key: keys[1].1.clone(), revoked: false }], + }], + ..Index::default() + }; + write_index(&skeleton, &community_index).unwrap(); + cmd_index(&strings(&[community.to_str().unwrap()])).unwrap(); + + cmd_merge(&strings(&[ + official_index.to_str().unwrap(), + community_index.to_str().unwrap(), + "--keep", + "openidea", + ])) + .unwrap(); + // A release rebuilds the official plugins and keeps the community's. + cmd_index(&release).unwrap(); + let index = Index::parse(&std::fs::read_to_string(&official_index).unwrap()).unwrap(); + let listed: Vec<_> = index.plugins.iter().map(|e| (e.id.as_str(), e.publisher.as_str())).collect(); + assert_eq!(listed, [("alice.dawn", "alice"), ("openidea.night", "openidea")]); + assert_eq!(index.plugins[1].download, "openidea.night-0.1.0.oiplugin"); + assert!(Path::new(&index.plugins[0].download).is_absolute(), "{}", index.plugins[0].download); + + // A package by a publisher the index doesn't know is refused. + signed_theme(t, "carol.noon", "carol", &keys[2].0, &community); + let err = cmd_index(&strings(&[community.to_str().unwrap()])).unwrap_err(); + assert!(err.contains("unknown publisher"), "{err}"); + } + #[test] fn every_template_is_a_valid_package() { for t in templates::Template::ALL { diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 208da1385..6068c7b53 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -36,7 +36,10 @@ pub mod web; pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore}; pub use icons::{map_asset as icon_theme_map_asset, sanitize_svg, svg_prefix as icon_theme_svg_prefix}; -pub use market::{INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX}; +pub use market::{ + INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX, + read_url as read_market_url, resolve as resolve_market_href, +}; pub use points::{COMMANDS, CommandDecl, register_builtin_points}; pub(crate) use providers::editor_providers as editor_providers_for; pub use providers::{ProviderKind, set_editor_providers}; diff --git a/plugins/README.md b/plugins/README.md index d90839c11..85c9e91cb 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -758,6 +758,8 @@ files). openidea-plugin keygen acme.key # once; keep the file secret openidea-plugin pack plugins/acme-tools --key acme.key --publisher acme openidea-plugin index market/ --publisher acme --name "ACME" --key acme.key +openidea-plugin check acme.tools-1.0.0.oiplugin # a package as installing would check it +openidea-plugin merge market/index.json https://example.com/index.json --keep acme ``` A **marketplace** is static: an `index.json` and the packages next to it, on @@ -776,6 +778,34 @@ and the publisher's key, then shows the install page. their code could run. A publisher rotates keys by adding the new one; older keys stay valid until revoked. +`index` with a key registers the publisher and replaces its plugins, keeping +the other publishers'; without one, it lists every package in the folder, +each checked against the publishers the index already has. `merge` takes the +publishers, plugins and revocations of another index (downloads made absolute) +in place of every publisher but `--keep`; `check` also takes a `.oiplugin`. + +### 8.1 Official and community plugins + +The IDE's default marketplace is `plugins-index.json` of the latest release. +It lists two kinds of plugins: + +- **Official** (`plugins/official/`, ids `openidea.*`): maintained in this + repository, built and signed by its release workflow, and kept working as + the API changes. New official plugins are proposed in an issue first; they + cover general needs or show how to use part of the API. +- **Community** (their authors' ids and keys): developed in their authors' + repositories and listed through the registry, + [l0ng-ai/openidea-plugins](https://github.com/l0ng-ai/openidea-plugins). + An author registers a publisher (an id prefix, its public key and the GitHub + accounts that may change it) and adds each plugin version by pull request; + the registry checks the package (digest, signature, `openidea-plugin check`) + and publishes its index, which `plugins-index.yml` merges into the release's + within the hour. Its README has the rules. + +Pull requests that add a plugin to this repository are for official plugins. +A plugin that needs a new extension point brings the extension point here, as +its own pull request, and the plugin to the registry. + ## 9. Testing the host The host's integration tests (`crates/ide-host/tests/plugins.rs`) load the