技能 编程开发 插件清单验证与构建阶段

插件清单验证与构建阶段

v20260903
generate-ppmplugin-manifest
此技能用于管理.ppmplugin插件的清单文件(manifest.json)。它充当一个关键的预检门禁,负责验证清单的结构、协调声明的入口点与目标平台,并生成用于构建的阶段性副本。它支持两种模式:验证现有提交的清单,或在无清单时从源代码完整生成。确保插件描述符在正式构建前是正确的。
获取技能
355 次下载
概览

/generate-ppmplugin-manifest

Stages the manifest.json that goes inside a .ppmplugin bundle — the small descriptor the wrap runtime and upload service read to identify the plugin and route calls into it. In the normal flow the committed ./manifest.json already exists (authored by /generate-native-extension next to the code it describes); this stage's job is to validate it, reconcile its entrypoints to the shipped target(s), and write the staged build copy ppmplugin/staging/manifest.json. It is the "get the strings right" gate: it runs instantly, needs no build tools, and catches common upload failures before any Android build. If the repo has no ./manifest.json (a hand-authored native module that skipped the scaffold), this stage authors it from source as a fallback — see Step 2.

Read shared/ppmplugin-format.md — it is the source of truth for the schema, the derivation table, and the validation rules this skill enforces.

Two manifests, one contract. ./manifest.json (repo root, committed) is the source-of-truth contract — it declares every platform the module supports. ppmplugin/staging/manifest.json (gitignored) is the build copy this stage produces — same contract, but entrypoints trimmed to the platform(s) actually being shipped. The build/assemble skills only ever read the staged copy; the committed root file is what the PCF and humans read.

What this skill does NOT do

  • Does not compile anything — no Gradle, no DEX. That's /build-android-binary.
  • Does not zip the bundle — that's /assemble-ppmplugin.
  • Does not verify a declared platform's binary actually exists — it stages the intended entrypoints; /assemble-ppmplugin reconciles them against the binaries actually staged and gates on any mismatch.
  • Does not rewrite the committed ./manifest.json on the normal path — it reads it. It only writes ./manifest.json in the fallback case (no committed manifest existed). It never touches src/, ios/, android/, PRD, or package.json beyond reading them.

Step 1 — Read shared docs + prereq block

  1. Read shared/shared-instructions.md, shared/naming-conventions.md, and shared/ppmplugin-format.md.
  2. This skill does no installs / auth / network. Print the zero-prereq block (per shared-instructions §9.2):
Prereq check — /generate-ppmplugin-manifest: skipped (skill does no installs / auth / network — failures surface at validation).
  1. Confirm the working directory is a third-party-control repo: a package.json (the dev-only, private, plain <kebab>-control name — NOT a published @powerapps/extension-* scope; this track ships a binary, not an npm package — see repo-layout.md) and an android/ and/or ios/ native module exist. If not, STOP with NEEDS_CONTEXT: not a third-party-control repo (no package.json / native module).
  2. Read .extension-state.md — if it carries a ## ppmplugin (third-party controls) block, note the last target choice and last manifest write (the re-run mode below uses them).

Step 1.5 — Locate the source manifest (which mode are we in?)

This stage has two modes, decided by whether the committed ./manifest.json exists:

  • Validate-and-stage mode (the normal flow). ./manifest.json exists at the repo root (authored by /generate-native-extension). This is the source of truth — do not re-author it. Read it, validate it (Step 3), reconcile its entrypoints to the chosen target (Step 2 → Target only), and write the staged copy (Step 4). Step 2's field-derivation is skipped — the contract is already authored; you're verifying and staging it, not regenerating it. (Optionally re-derive methods from the Android module source and, if they've drifted from ./manifest.json — e.g. a @ReactMethod was added by hand after scaffold — surface the diff and offer to update the committed file; never silently rewrite it.)

  • Author-from-source mode (fallback). No ./manifest.json exists — a hand-authored native module that skipped the scaffold. Derive every field mechanically from source (Step 2 in full), then write both the committed ./manifest.json and the staged copy (Step 4).

Re-run within a build session. If the staged copy ppmplugin/staging/manifest.json already exists from a prior run, don't blindly overwrite — diff the target/entrypoints against the committed source and ask via AskUserQuestion: Update (re-stage from the current ./manifest.json + target) / Keep as-is (report and stop). Default the target to the prior choice in .extension-state.md; don't re-ask an answered question.


Step 2 — Determine target(s); derive fields only in author-from-source mode

The structure preflight + target selection below run in both modes (you always need to know which platforms are viable and which to ship). The field-derivation sub-section (items 1–6 + the compute block) runs only in author-from-source mode (Step 1.5) — in the normal validate-and-stage mode the fields already live in ./manifest.json; read them from there and skip derivation, keeping only the target choice.

First, run a structure preflight. The ppmplugin path expects the canonical PAM-extension layout — the shape /generate-native-extension produces (see shared/repo-layout.md). A hand-rolled, foreign, or drifted repo may be missing pieces; flag exactly what, here and now, rather than failing later with a cryptic Gradle/Xcode error or a silently-wrong manifest. Print a visible ✓/✗ block:

  • Common: package.json (dev-only <kebab>-control name — there is no @powerapps/extension-* scope and no src/ TS layer in this track); class name resolvable from the native module (android/.../<Pascal>Module.kt / ios/RCT<Pascal>Module.m), ./manifest.json, or .extension-state.md.
  • Android (if android/ present): android/build.gradle; a Kotlin module class extending ReactContextBaseJavaModule with an override fun getName(); a ReactPackage class; ≥1 @ReactMethod.
  • iOS (if ios/ present): ios/RCT<Pascal>Module.h declaring <RCTBridgeModule>; .m with + (NSString *)moduleName (NOT RCT_EXPORT_MODULE) and ≥1 RCT_EXPORT_METHOD.

A platform whose structure is incomplete is not a valid target — exclude it and say which element is missing. If neither platform is structurally complete, STOP with NEEDS_CONTEXT: repo doesn't match the expected PAM-extension layout — missing <list> pointing at shared/repo-layout.md. (Working on a staged copy protects the source; it does NOT make a missing module appear — that's what this preflight is for.)

Then determine which platform(s) this bundle targets (only from the structurally-complete ones) — it controls which entrypoints get declared:

  • Confirm via AskUserQuestion: Android-only / iOS-only / Both — offer only the targets that passed the preflight. Default to what's present, or — on a re-run in Update mode — to the prior target recorded in .extension-state.md.
  • Note availability: /build-android-binary is stable; /build-ios-binary is v0 (Mac-only; known limitation: its React-Core weak-link config still needs validation against a live PAM/wrap shell). If the user targets iOS/Both, the manifest declares entrypoints.ios; /assemble-ppmplugin will still gate if the iOS binary isn't staged at packaging time.

Then derive fields from the actual files, not from assumptions (author-from-source mode only — in validate-and-stage mode skip to Step 3 with the fields read from ./manifest.json):

  1. Class name <Pascal> — from the native module (the Android <Pascal>Module.kt filename / its getName() = "<Pascal>Module", or the iOS RCT<Pascal>Module), or the .extension-state.md Identity block. There is no src/<Pascal>Extension.ts in this track. This is the basis for name, nativeModule, receivers[].name.

  2. versionpackage.json version.

  3. nativeModule — read the Android module's override fun getName(): String = "<X>". Use <X> verbatim.

  4. packageClass — read the ReactPackage .kt file: combine its package <...> line with the class name → FQN (e.g. com.powerapps.peninput.PenInputPackage).

  5. methods — scan the Android module (via the Read/Grep tools, not a shell-specific command — this skill is OS-neutral) for @ReactMethod and collect each annotated function name. Cross-check the count against the operations in ARCHITECTURE.md §3 / PRD §4; if a documented operation has no matching @ReactMethod, surface it as a warning (the manifest reflects what the binary actually exposes, but the mismatch usually means an operation wasn't wired).

  6. iOS entrypoint fields (only when targeting iOS / Both) — read ios/RCT<Pascal>Module.h for the class name (@interface RCT<Pascal>Module : NSObject <RCTBridgeModule>) → that is entrypoints.ios.moduleClass. Read ios/RCT<Pascal>Module.m for + (NSString *)moduleName { return @"<X>"; } and assert <X> equals the nativeModule from step 3 — it's the same bridge symbol on both platforms; a mismatch means the iOS and Android modules disagree. (The .m must not use RCT_EXPORT_MODULE — that macro's +load registration is invisible to the framework's dlopen flat namespace, so the module never loads on device.)

Then compute, per ppmplugin-format.md §3:

  • name = kebab(className)from the class name, not the capability/repo name. If kebab(className) differs from the repo's capability kebab (e.g. repo powerapps-pdf-control but class PdfViewername: pdf-viewer), print a one-line note so the user knows the .ppmplugin filename won't match the repo name. This is required for the canonical-prefix rule to pass.
  • receivers[].name — take it from the PCF if one exists, do NOT blindly default. If a sibling PCF is present (pcf/<…>/index.ts), grep it for the dispatch key — COMPOSITE_KEY = "<name>/<receiver>" (or the ReceiverKey it binds) — and use that <receiver> value, because the PCF already dispatches to it; a manifest that registers a different receiver name will fail on first dispatch (real bug: PCF dispatched to Snapshot while the manifest defaulted to DeviceInfoExtension). Only if no PCF exists, fall back to <Pascal>Extension. Either way, surface the chosen value as a confirmation point: "PCF dispatches to <name>/<receiver>; the manifest will register receiver <receiver> — confirm?" (Audit re-checks this — see /audit-ppmplugin pcf-composite-key-matches-receiver.)

receivers[] IS the runtime dispatch contract (ppmplugin-format §2Runtime dispatch contract). The wrap host routes a call by the composite key <name>/<receivers[].name> to NativeModules.<nativeModule>.<method> — there is no TS handleMessageAsync / sendAsync layer in a native-only bundle. So nativeModule must equal the module's getName(), and every entry in methods must be a real @ReactMethod / RCT_EXPORT_METHOD name (the host calls it directly; an absent method = method '<m>' not found on device). Derive methods from the module source, never guess.

  • entrypoints — declare ONLY the chosen target(s):
    • Android / Both → entrypoints.android = { dex: "<Pascal>Plugin.dex", packageClass: "<FQN from step 4>" }
    • iOS / Both → entrypoints.ios = { framework: "<Pascal>Plugin", moduleClass: "RCT<Pascal>Module" }

Step 3 — Validate locally (pre-flight gate)

Run every rule from ppmplugin-format.md §4 against the derived manifest. For each, pass or fail with the exact rule:

  • name matches ^[a-z0-9][a-z0-9-]{0,63}$
  • each nativeModule starts with the canonical prefix of name — computed precisely as split name on -/_, PascalCase each segment, join (ppmplugin-format §3), Ordinal/case-sensitive. (Note the helloworldHelloworld subtlety; kebab(className) must hyphenate at camelCase boundaries so the round-trip holds.)
  • no nativeModule starts with a reserved prefix (case-insensitive list in §4)
  • no nativeModule is a known incompatible exact name — block (Ordinal, case-sensitive) if it matches the locally checked subset in §4 (DeviceInfo, AuthenticationHelper, NetworkClient, DataverseOfflineProvider, IntuneMAM). The subset is non-exhaustive: passing it locally does not guarantee the upload service will accept the name. Rename the module's getName() (and iOS +moduleName) to a non-reserved form — add a Module suffix (DeviceInfoDeviceInfoModule) or a vendor prefix (ContosoDeviceInfo) — and re-derive nativeModule to match.
  • nativeModule looks like a generic platform noun → WARNING. If nativeModule matches ^(Device|Network|File|Audio|Camera|Sensor|Location|Storage|Notification|Bluetooth|Wifi|Media|Photo|Contact|Calendar|Battery), warn that generic single-noun names are both upload-conflict-prone and collision-prone (NativeModules is a shared namespace across every plugin the wrap host loads). Recommend a Module suffix or vendor prefix. Surface it; let the user proceed if deliberate.
  • methods non-empty; ≤32; each matches ^[a-zA-Z_$][a-zA-Z0-9_$]{0,127}$
  • each receivers[].name matches the same JS-identifier regex
  • no SDK-era / JS-layer fields — the manifest carries none of entrypoints.js, entrypoints.ts, extension.js, extension.hbc, extensionClassName, jsLayer (the bundle is native-only; these are leakage the wrap runtime no longer reads — §4). Since this skill authors the manifest it won't emit them, but the re-run mode reads an existing manifest, so assert it.

Print the result as a visible block:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 manifest.json validation
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 🟢 ✓ name 'pen-input' — valid kebab identifier
 🟢 ✓ nativeModule 'PenInputModule' starts with canonical prefix 'PenInput'
 🟢 ✓ nativeModule 'PenInputModule' — no reserved prefix
 🟢 ✓ nativeModule 'PenInputModule' — not a known reserved name
 🟢 ✓ methods ['capturePenInput'] — non-empty, valid identifiers
 🟢 ✓ receiver name 'PenInputExtension' — valid identifier
 🟢 ✓ no SDK-era / JS-layer fields (native-only manifest)
 🟢 7 checks passed, 0 failed.
 ⓘ Known incompatible-name subset checked locally; the upload service may apply additional checks.

If any check fails, STOP with BLOCKED: manifest validation — <rule> and the concrete fix (e.g. "rename the Kotlin module's getName() so it starts with PenInput"). Do NOT write an invalid manifest.


Step 4 — Confirmation gate + write

Show the manifest (the validated source + the target-reconciled entrypoints) and wait for confirmation (shared-instructions §7.1). On approval:

  1. Ensure ppmplugin/ is in .gitignore (append the line if absent). Do not add manifest.json (the committed root file stays tracked).
  2. Write the staged build copy ppmplugin/staging/manifest.json — the contract with entrypoints trimmed to the chosen target(s). This is what /assemble-ppmplugin zips.
  3. Author-from-source mode only: also write the committed ./manifest.json at the repo root (the full contract, all viable platforms) — this is the source of truth the scaffold would normally have produced. In validate-and-stage mode, leave ./manifest.json untouched (it's already the source) unless the user accepted a drift-update offer in Step 1.5.
  4. Update the ## ppmplugin (third-party controls) block in .extension-state.md (create it if absent — schema in repo-layout.md): set Target, Manifest: staged <ISO timestamp>. This is what the re-run mode (Step 1.5) and the build/assemble skills read for state.

Return DONE with the manifest path. Then surface next steps via AskUserQuestion (shared-instructions §9.1), offering the build skill(s) for the chosen target(s):

  • Run /build-android-binary — if target is Android or Both (compile the Kotlin module → DEX)
  • Run /build-ios-binary — if target is iOS or Both (compile the Obj-C module → framework; Mac-only)
  • Run /assemble-ppmplugin — only if the binary/binaries already exist
  • Stay — I'll review the manifest first

(For a Both target, list both build skills. Cap at 4 options per AskUserQuestion.)

When the user picks a Run /… option, immediately invoke that skill via the Skill tool in the same turn (sub-skill invocation, shared-instructions §8 + §9.1 "Execute, don't describe"). Do NOT print a "run it when ready" instruction and stop — selecting the option IS the request to run it. Only "Stay" ends the run.


Worked example (pen-input)

{
  "name": "pen-input",
  "version": "0.1.4",
  "abi": { "compatibleShells": ">=1.0.0", "builtAgainst": "1.0.0" },
  "entrypoints": {
    "android": { "dex": "PenInputPlugin.dex", "packageClass": "com.powerapps.peninput.PenInputPackage" }
  },
  "receivers": [
    { "name": "PenInputExtension", "nativeModule": "PenInputModule", "methods": ["capturePenInput"] }
  ]
}

(This example targets Android-only, so entrypoints.ios is omitted. For a Both target it would also carry "ios": { "framework": "PenInputPlugin", "moduleClass": "RCTPenInputModule" }. name = pen-input = kebab of class PenInput; nativeModule = PenInputModule (the Module suffix avoids reserved bare names) and starts with canonical prefix PenInput ✓; not a reserved prefix ✓.)

信息
Category 编程开发
Name generate-ppmplugin-manifest
版本 v20260903
大小 19.06KB
更新时间 2026-09-06
语言