Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Eklenti yazmak

Bu sayfa, Open Play’e yeni bir eklenti (plugin) yazmak isteyenler içindir: eklentinin parçalarını, küçük çalışan bir örneği, sürüm yükseltirken izlenecek güvenli sırayı ve gerçek olaylardan çıkan yaygın hataları gösterir. Eklentiyi kullanmak için eklentilere genel bakış, eklentileri çağırma ve bağlamalar sayfalarına bakın.

Bu sayfa kimin için

Uyarı

Barındırılan (hosted) stüdyolar bugün eklenti kodu yükleyemez. Eklentiler Open Play sunucusunun içine derlenen Rust kodudur ve güvenilir kod sayılır. Sandbox’lı çalıştırma, kaynak ölçümü ve imzalı dağıtım henüz yoktur. Bu rehber, Open Play’i kendi sunucusunda çalıştıran (self-hosted) ekipler ve Open Play’e katkı verenler içindir.

Kendi sunucunuzda kod derleyemiyorsanız oyun mantığınızı bugün şu yollarla bağlarsınız: panelde oyun kuralları, webhook’lar ya da kendi sunucunuz + sunucu anahtarı (uç nokta başvurusu).

Eklentinin anatomisi

Bir eklenti, tek bir Rust crate’idir (örneğin crx-plugin-arena, crx-plugin-work-items, crx-plugin-package-ride). Parçalar şunlardır:

ParçaNe işe yararNerede tanımlanır
ManifestEklentinin kimliği (PluginId), sürümü (PluginVersion), host API major’ı, sağladığı ve gerektirdiği eklentiler, izinleri (PluginPermission), yapılandırma şeması, mesajları, migration özeti ve gerçek zamanlı modülleriOpenPlayPlugin::manifest
Tipli mesajlarKomut/sorgu girdisi ve çıktısı; her biri tek yönlü (CommandInput, CommandOutput, QueryInput, QueryOutput, gerçek zamanlı girdi/durum, olay), katı serde tipiPluginMessage uygulayan struct’lar
KomutDurumu değiştiren, kalıcı ve tekrar oynatması güvenli işlem. Karar fonksiyonu saftır: I/O, saat, rastgelelik yokturTypedCommand::decide, OpenPlayPlugin::register
SorguSalt-okunur okuma; yalnız girdi/çıktı kodekleri kaydedilirOpenPlayPlugin::register_queries
Artifact (şema belgesi)İstemci SDK’larının ve bağlamaların okuduğu plugin.schema.json: şemalar, işlem tablosu, modüller, schemaSha256OpenPlayPlugin::artifact, PluginArtifact::from_manifest
Gerçek zamanlı modül (isteğe bağlı)Sunucu-otoriter canlı girdi/durum indirgeyicisi (reducer)OpenPlayPlugin::register_realtime, TypedRealtimeModule
DispatcherKomutun veritabanına gerçekten yazıldığı, sorgunun okunduğu yer; sunucudaki ortak işlem sınırını (PluginUnitOfWork) kullanırPluginCommandDispatcher, PluginQueryDispatcher, PluginCommandResultLookup
Panel yüzeyi (isteğe bağlı)Panelde bildirimsel yönetim ekranları, özel bileşenler, oyun kuralı kataloğu, leaderboard bildirimiadmin_surface, ui_components, game_rule_catalog, leaderboards

Sorumluluk bölümü önemlidir: sunucu (host) idempotency’yi, iyimser kilidi (optimistic lock), tek veritabanı işlemini, olay (event) ve outbox yazımını, makbuzu (receipt) ve yetki kontrolünü yapar. Eklenti yalnızca mesajları, saf kararı ve yazılacak kayıtların planını (PluginMutationPlan) sağlar. Kalıcı sonuç (ödeme, XP, eşya, ödül) her zaman komut yolundan geçer; gerçek zamanlı durum yalnız hız içindir ve ekonomik etki üretemez.

Küçük örnek: “hello counter”

Aşağıdaki örnek, en küçük gerçek eklentilerin (crx-plugin-arena, crx-plugin-scene) kalıbını izler: bir komut (increment) ve bir sorgu (getCounter). Kod parçaları depodaki docs/developer/examples/rust/ dosyalarından birebir alınmıştır ve crx-plugin-arena crate’inin bir testinde gerçekten derlenip kayıt defterine kaydedilir; bu yüzden sayfadaki kod eskiyemez.

Üç parça aynı modülün içindedir (birinde yapılan use, diğerlerinde de görünür).

1. Mesajlar

Her mesaj kimliği eklenti kimliğiyle başlar (hello-counter. ön eki; aksi hâlde kayıt reddedilir). int64 değerler için PluginI64 kullanın: tel üzerinde ondalık metin olarak gider (sayılar ve kodlama). deny_unknown_fields bilinmeyen alanı reddeder.

#![allow(unused)]
fn main() {
// Mesajlar: her biri tek bir yönü olan, katı (deny_unknown_fields) serde tipleridir.
use crx_plugin::{MessageDirection, PluginI64, PluginMessage};
use serde::{Deserialize, Serialize};

pub const PLUGIN_ID: &str = "hello-counter";

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct IncrementInput {
    pub by: i32,
}
impl PluginMessage for IncrementInput {
    const MESSAGE_ID: &'static str = "hello-counter.increment.input";
    const SCHEMA_VERSION: u32 = 1;
    const DIRECTION: MessageDirection = MessageDirection::CommandInput;
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct IncrementOutput {
    pub value: PluginI64, // int64 tel üzerinde ondalık metindir
}
impl PluginMessage for IncrementOutput {
    const MESSAGE_ID: &'static str = "hello-counter.increment.output";
    const SCHEMA_VERSION: u32 = 1;
    const DIRECTION: MessageDirection = MessageDirection::CommandOutput;
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GetInput {}
impl PluginMessage for GetInput {
    const MESSAGE_ID: &'static str = "hello-counter.get.input";
    const SCHEMA_VERSION: u32 = 1;
    const DIRECTION: MessageDirection = MessageDirection::QueryInput;
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GetOutput {
    pub value: PluginI64,
}
impl PluginMessage for GetOutput {
    const MESSAGE_ID: &'static str = "hello-counter.get.output";
    const SCHEMA_VERSION: u32 = 1;
    const DIRECTION: MessageDirection = MessageDirection::QueryOutput;
}
}

2. Eklenti: manifest, komut, sorgu

Komutun karar fonksiyonu decide eşzamansız değildir ve I/O yapmaz; yalnızca girdiyi doğrular ve sonucu üretir. Zaman ve tohum gerekiyorsa dispatcher bunları host’tan alır ve olaya yazar (eval_now_ms, eval_seed); decide içinde SystemTime veya rand kullanılmaz.

#![allow(unused)]
fn main() {
// Eklenti: manifest + komut/sorgu kaydı. Kalıcı yazım burada değil, dispatcher'ın mutasyon planındadır.
use crx_application::{
    plugin_platform::{OpenPlayPlugin, PluginRegistryError},
    plugin_runtime::{PluginRegistrar, PluginRuntimeError, TypedCommand},
};
use crx_plugin::{ManifestError, PluginArtifact, PluginId, PluginManifest, PluginPermission, PluginVersion};
use serde_json::json;

pub struct IncrementCommand;
impl TypedCommand for IncrementCommand {
    type Input = IncrementInput;
    type Output = IncrementOutput;

    // Saf ve eşzamansız olmayan karar: yalnız doğrular, I/O yapmaz.
    fn decide(&self, input: &Self::Input) -> Result<Self::Output, PluginRuntimeError> {
        if !(1..=100).contains(&input.by) {
            return Err(PluginRuntimeError::HandlerRejected);
        }
        Ok(IncrementOutput { value: PluginI64::new(i64::from(input.by)) })
    }
}

pub struct HelloCounterPlugin;

fn plugin_id() -> PluginId {
    PluginId::try_new(PLUGIN_ID).expect("static plugin id")
}

impl OpenPlayPlugin for HelloCounterPlugin {
    fn manifest(&self) -> Result<PluginManifest, ManifestError> {
        PluginManifest::new(
            plugin_id(),
            PluginVersion::new(1, 0, 0),
            1,                       // host API major
            vec![plugin_id()],       // provides
            vec![],                  // requires
            vec![PluginPermission::Outbox],
            json!({"type":"object","additionalProperties":false,"properties":{}}),
            vec![
                IncrementInput::descriptor().expect("descriptor"),
                IncrementOutput::descriptor().expect("descriptor"),
                GetInput::descriptor().expect("descriptor"),
                GetOutput::descriptor().expect("descriptor"),
            ],
            "0".repeat(64),          // migration checksum (64 onaltılık karakter)
            vec![],                  // realtime modülleri
        )
    }

    fn register(&self, registrar: &mut PluginRegistrar) -> Result<(), PluginRuntimeError> {
        registrar.register_command(IncrementCommand)?;
        Ok(())
    }

    fn register_queries(&self, registrar: &mut PluginRegistrar) -> Result<(), PluginRuntimeError> {
        registrar.register_query::<GetInput, GetOutput>()?;
        Ok(())
    }

    fn artifact(&self) -> Result<Option<PluginArtifact>, PluginRegistryError> {
        Ok(Some(plugin_artifact()?))
    }
}
}

3. Artifact

Artifact, manifestteki her mesajı bir işleme veya modüle bağlamak zorundadır; fazla ya da eksik mesaj kayıtta hata verir. Komutlarda idempotency_required: true, pozitif bir makbuz saklama süresi ve Read olmayan bir retry politikası zorunludur; sorgularda tersi geçerlidir.

#![allow(unused)]
fn main() {
// Artifact: SDK'nın ve bağlamaların okuduğu şema belgesi; manifestle birebir eşleşmek zorundadır.
use std::collections::BTreeMap;

use crx_plugin::{
    MessageId, PluginAccess, PluginArtifactSpec, PluginHttpMethod, PluginJournalPolicy,
    PluginOperationDefinition, PluginOperationKind, PluginRetryPolicy,
};

fn operation(
    name: &str,
    kind: PluginOperationKind,
    request: (&str, &str),  // (şema adı, mesaj kimliği)
    response: (&str, &str),
) -> PluginOperationDefinition {
    let is_command = kind == PluginOperationKind::Command;
    let segment = if is_command { "commands" } else { "queries" };
    PluginOperationDefinition {
        name: name.to_owned(),
        kind,
        path: format!("/v1/plugins/{PLUGIN_ID}/{segment}/{}", request.1),
        method: if is_command { PluginHttpMethod::Post } else { PluginHttpMethod::Get },
        request_schema: request.0.to_owned(),
        response_schema: response.0.to_owned(),
        schema_version: 1,
        retry: if is_command { PluginRetryPolicy::SameKeyCommand } else { PluginRetryPolicy::Read },
        journal: if is_command { PluginJournalPolicy::Full } else { PluginJournalPolicy::None },
        resource: None,
        result_path: is_command
            .then(|| format!("/v1/plugins/{PLUGIN_ID}/commands/{}/result", request.1)),
        success: Some(200),
        access: PluginAccess::Player,
        request_message_type: MessageId::try_new(request.1).expect("message id"),
        response_message_type: MessageId::try_new(response.1).expect("message id"),
        receipt_retention_seconds: is_command.then_some(86_400),
        idempotency_required: is_command,
        deprecated: None,
    }
}

pub fn plugin_artifact() -> Result<PluginArtifact, PluginRegistryError> {
    let value = json!({
        "type":"object","additionalProperties":false,
        "properties":{"value":{"type":"integer","format":"int64"}},
        "required":["value"]
    });
    let schemas = BTreeMap::from([
        ("IncrementInput".to_owned(), json!({
            "type":"object","additionalProperties":false,
            "properties":{"by":{"type":"integer","format":"int32","minimum":1,"maximum":100}},
            "required":["by"]
        })),
        ("IncrementOutput".to_owned(), value.clone()),
        ("GetInput".to_owned(), json!({"type":"object","additionalProperties":false,"properties":{}})),
        ("GetOutput".to_owned(), value),
    ]);
    let spec = PluginArtifactSpec {
        schemas,
        commands: BTreeMap::from([("increment".to_owned(), operation(
            "increment",
            PluginOperationKind::Command,
            ("IncrementInput", IncrementInput::MESSAGE_ID),
            ("IncrementOutput", IncrementOutput::MESSAGE_ID),
        ))]),
        queries: BTreeMap::from([("getCounter".to_owned(), operation(
            "getCounter",
            PluginOperationKind::Query,
            ("GetInput", GetInput::MESSAGE_ID),
            ("GetOutput", GetOutput::MESSAGE_ID),
        ))]),
        modules: BTreeMap::new(),
    };
    let manifest = HelloCounterPlugin.manifest().map_err(PluginRegistryError::Manifest)?;
    Ok(PluginArtifact::from_manifest(&manifest, spec)?)
}
}

Örneğin yapmadığı şey: kalıcı yazım

IncrementCommand::decide yalnızca girdiyi doğrular. Sayacı gerçekten artıran kod, dispatcher’dadır. Dispatcher şunları yapar (gerçek örnek: crx-plugin-work-items, prepare_create ve prepare_claim):

  1. PluginRegistry::prepare_command ile çağrıyı yetkilendirir, girdiyi katı çözer ve parmak izini (fingerprint) çıkarır.
  2. Hedef kaydı okur (PluginRecordQuery), iyimser kilit için sürümünü kontrol eder.
  3. Bir PluginMutationPlan kurar: yazılacak kayıtlar (PluginRecordWrite, beklenen sürüm ve indeksleriyle) ve eklenecek olaylar (PluginEventAppend).
  4. Planı PluginUnitOfWork ile tek veritabanı işleminde uygular; sonuç ancak commit’ten (ya da aynı anahtarın makbuzundan) sonra döner.

Eklentiler kayıtlarını genel plugin_records (ve indeks, olay, makbuz) tablolarına yazar; eklenti başına özel SQL tablosu çoğunlukla gerekmez. Özel SQL gerekirse eklemeli bir migration ile ve eklentinin kendi migration alanında açılır.

Not

Bugün sunucuda komut yönlendirmesi eklenti başına dispatcher’larla yapılır ve bileşim kökünde (bin/crx-api) bağlanır. Komutu olan yeni bir eklenti, kendi dispatcher’ını ve bu bağlamayı da gerektirir; yalnız manifest + register yetmez. Yalnız realtime modülü olan eklentilerde (crx-plugin-arena, crx-plugin-scene) bu adım yoktur.

Sunucuya kaydetmek

Eklenti crate’i, bin/crx-api (ve zamanlayıcı/outbox işi varsa bin/crx-worker) bağımlılıklarına eklenir ve açılışta kayıt defterine verilir:

#![allow(unused)]
fn main() {
let mut registry = PluginRegistryBuilder::new();
registry.register_plugin(HelloCounterPlugin)?;
let registry = registry.freeze()?;
}

freeze kayıt defterini tüketip değiştirilemez hâle getirir. Tekrarlanan eklenti/mesaj/modül, eksik ya da uyumsuz bağımlılık, desteklenmeyen host API major’ı, karışık kodek ve ad alanı ihlali açılışı durdurur; hata çalışma zamanında değil, derlemeden hemen sonra görünür.

Artifact, sabitleme (pin) ve release lock

schemaSha256, artifact’taki şemaların kanonik JSON özetidir. Şemalar değişirse özet değişir; yalnız sürüm metni değişirse değişmez. İstemciler bu özeti bağlamalarına sabitler (bağlamalar).

Depoda üç yer aynı kaynaktan, aynı değişiklikte güncellenir:

  1. artifacts/plugins/<eklenti>.plugin.schema.json: Rust’tan dışa aktarılır (PluginArtifact::to_pretty_json; örnek: crx-plugin-arena içindeki export_arena_artifact).
  2. artifacts/plugins/release-lock.json: eklenti başına artifact yolu, üretilmiş bağlama yolu, tam sürüm, schemaSha256, protokol major’ı, kodek profili ve modül kimlikleri.
  3. Üretilmiş istemci bağlaması: elle düzenlenmez, üreticiyle yenilenir.

Bir test, “depodaki JSON’un Rust’ın ürettiğinin tam kopyası olduğunu” doğrular (checked_in_artifacts_are_exact_rust_exports, crx-plugin-arena). Pin uyuşmazlığı yayını durdurur; eski özeti kabul eden bir uyumluluk katmanı eklenmez. Yeniden üretin.

Oyun istemcileri için bağlama üretmek

Oyun geliştirici, eklentinin artifact’ını alıp kendi bağlamasını üretir ve özeti bilinçli olarak sabitler:

npx crx plugins generate \
  --input hello-counter.plugin.schema.json \
  --schema-sha256 <artifact içindeki schemaSha256> \
  --output src/generated/hello-counter.ts

CI’da --check ile dosyanın güncelliği doğrulanır. Ayrıntı ve Unity sınırı: bağlamalar. Unity için hazır bağlama bugün yalnız package-ride eklentisi içindir.

Sürümleme ve güvenli yayın

Ortamın eklenti yetkisi (grant) tam sürüm ile eşleşmelidir. Sunucuda yeni bir eklenti sürümü çalışırken ortamın yetkisi hâlâ eskisini gösteriyorsa, o ortamda eklenti ve gerçek zamanlı çağrıları reddedilir (403 forbidden; şema ucu 404). Bu, gerçekte yaşanmış bir olaydır: sürüm artırıp yetkiyi yeni sürüme geçirmeyi unutmak.

Sürüm yükseltme sırası:

  1. Kapılar: eklentinin testleri, artifact tam eşleşme testi ve cargo xtask check yeşil olmalı; migration varsa up + down çalışmalı.
  2. Drain (boşaltma): ortamın eklenti yetkisini Enabled → Draining yapın. Yeni komut ve girdi hemen reddedilir. En az drain asgarisi kadar bekleyin: manifestteki en uzun gerçek zamanlı durum saklama süresi + 15 sn; gerçek zamanlı modülü olmayan eklentide 15 sn.
  3. İkili: yeni sunucu imajını (varsa migration ile) dağıtın; migration’lar eklemeli (additive) olmalı.
  4. Yetkiyi yeni sürüme geçirin: panelde Sürüme geç (Eklentiler). Atlanırsa yeni ikili eklenti çağrılarını reddeder.
  5. İstemci: yeni bağlama ve (gerekirse) SDK ile oyun istemcisini yayınlayın.
  6. Doğrulama: komut sonucu, makbuz tekrar oynatma ve reddedilen çağrı sayılarını izleyin.

Geri alma kuralları: eklenti yeni sürümde henüz yazı üretmediyse Draining → önceki uyumlu ikili ve yetki. Yazı üretildikten sonra kör ikili geri alma yapılmaz: yazmaları ReadOnly ya da Disabled ile durdurun, makbuz/olay kayıtlarıyla durumu doğrulayın ve ileri düzeltme (forward-fix) sürümü çıkarın.

Ortam başına açmak

Eklenti, sunucuya derlenmiş olması yetmez; her ortamda ayrı bir yetki satırı gerekir. Panelde Eklentiler ekranından eklentiyi oyuna bağlarsınız; durumlar Enabled, ReadOnly, Draining, Disabled’dır (durumların anlamı: eklentilere genel bakış). Eklentinin izni başka bir ürün modülüne bağlıysa (örneğin leaderboard izni Competition modülüne), o modül kapalıyken geçiş 409 plugin_requires_module verir. Kanarya için önce ayrı bir test ortamında ReadOnly, sonra Enabled yapıp gözlemleyin.

Panel entegrasyonu

Eklentinin panelde ekran sunması isteğe bağlıdır ve eklentiye özel panel kodu gerektirmez:

  • admin_surface: bildirimsel yönetim yüzeyi. Yalnız server erişimli operatör işlemlerine bağlanır ve kayıtta manifest ve artifact’a karşı doğrulanır. Yüzey artifact’ın parçası değildir: oyunculara ve SDK’ya sunulmaz, schemaSha256’yı, sürümü ve yetkiyi değiştirmez.
  • ui_components: eklentinin include_str! ile derlemeye gömdüğü tek dosyalık HTML bileşenleri (izole çerçevede çalışır). Bildirimdeki özet, varlıktan hesaplanır; uyuşmazlık kayıtta reddedilir.
  • game_rule_catalog: panelden ayarlanabilir oyun kuralı değerleri (oyun kuralları).
  • leaderboards: eklentinin core leaderboard’a puan yazacağı tabloların bildirimi (PluginPermission::Leaderboard gerekir).

Yüzey kaydı için eklentinin bir artifact’ı olmalıdır; artifact’sız yüzey kayıtta reddedilir.

Testler

En az şunları ekleyin:

  • Manifest ↔ artifact tam eşleşmesi ve şema özeti kayması (PluginArtifact::validate_against).
  • Depodaki artifact JSON’u ve release-lock.json satırı Rust çıktısıyla aynı mı.
  • int64 kodeği, sınırlı girdi (uzunluk, aralık) ve reddedilen sınır değerleri.
  • Tek dondurulmuş kayıt defterinde birden çok eklenti (PluginRegistryBuilder; örnek: crx-plugin-arena/tests/two_game_contract.rs).
  • Kalıcı komutlarda gerçek PostgreSQL ile işlem testi: aynı komutun tekrarında tek uygulama, sürüm çakışması, makbuz (örnek: crx-infra-postgres/tests/plugin_uow_d1_integration.rs).

Kapanışta cargo xtask check ve izole bağımlılıklı tam kapı çalıştırılır; yalnız --lib koşusu tam kanıt sayılmaz.

Kontrol listesi

  • Eklenti kimliği küçük harf ve kısa çizgiyle; her mesaj eklenti. ön ekli.
  • Tüm girdi/çıktı tipleri deny_unknown_fields; sınırlar şemada açık; int64 için PluginI64.
  • Karar fonksiyonu saf (I/O, saat, rastgelelik yok); kalıcı sonuç dispatcher’ın planında.
  • Komutlar idempotent ve makbuz süreleri tanımlı; sorgular salt-okunur ve erişimi (player, server, public) açık.
  • Artifact, depodaki JSON, release-lock.json ve üretilmiş bağlama aynı değişiklikte güncellendi.
  • Kayıt bin/crx-api (gerekirse bin/crx-worker) içinde yapıldı; açılış freeze ile geçiyor.
  • Yayın sırası uygulandı: drain, ikili, yetkiyi yeni sürüme geçirme, istemci.
  • Ortam yetkisi açık ve durumu doğru.

Sık yapılan hatalar

HataBelirtiDoğrusu
Sürüm artırıldı, yetki geçirilmediEklenti ve gerçek zamanlı çağrılar 403 forbidden, şema ucu 404Drain, ikili, sonra Sürüme geç
Şema değişti, artifact/pin yenilenmediArtifact tam eşleşme testi kırmızı; istemcide schemaSha256 uyuşmazlığıArtifact’ı Rust’tan yeniden üretin; release lock ve bağlamayı aynı değişiklikte güncelleyin
Belirsiz sonuçlu komut yeni anahtarla tekrar gönderildiAynı işlem iki kez uygulanırAynı Idempotency-Key ve aynı gövde; emin değilseniz önce sonuç sorgusu (çağırma)
int64 JSON sayısı olarak tanımlandıBüyük değerlerde kayıp ya da kodek hatasıPluginI64; tel üzerinde ondalık metin
decide içinde saat, rastgelelik veya ağTekrar oynatma farklı sonuç verirZaman ve tohum host bağlamından gelir; kalıcı etki dispatcher planında
Ekonomik sonuç gerçek zamanlı reducer’daRedis kaybında sonuç kaybolurKalıcı sonuç komut yoluna
Artifact’ta olmayan ya da fazla mesajAçılış freeze/kayıt hatasıManifest ve artifact aynı Rust kaynağından türesin
Komut için istemci API anahtarı401Oyuncu oturumu ya da sunucu anahtarı
Ayrı dispatcher bağlanmadan komut eklendiKomut yönlendirilemezDispatcher yazın ve bileşim kökünde bağlayın

Bilinen sınırlar

  • Üçüncü taraf eklenti yüklemek, sandbox’lı çalıştırma ve imzalı dağıtım yoktur.
  • Depo dışında (out-of-tree) eklenti yazmak için ayrı bir SDK yoktur; eklenti, Open Play depo ağacındaki bir crate’tir.
  • Yeni eklenti iskeleti (scaffold) üreten bir komut yoktur; kalıp, mevcut küçük eklentilerden kopyalanır.
  • Komutu olan eklentiler için dispatcher bağlaması elle yapılır.

Sıradaki adım