diff --git a/plugins/app-builder/skills/adobe-extension-scaffolder/SKILL.md b/plugins/app-builder/skills/adobe-extension-scaffolder/SKILL.md new file mode 100644 index 00000000..95f24970 --- /dev/null +++ b/plugins/app-builder/skills/adobe-extension-scaffolder/SKILL.md @@ -0,0 +1,829 @@ +--- +name: adobe-extension-scaffolder +description: >- + End-to-end scaffold, develop, and deploy Adobe App Builder UI extensions for + ANY supported surface — Content Hub (asset details panels, asset card actions, + selection bar / bulk actions), Content Fragment Console, Content Fragment + Editor, Universal Editor, Assets View, and Experience Cloud Shell SPAs. + Handles the complete customer journey from zero: asks which surface, asks + clarifying questions via MCQ options, bootstraps the Adobe Developer Console + project and workspace, writes all scaffold files for the chosen surface, + installs dependencies, builds, starts the local dev server, opens the + certificate page, gates the test URL behind cert acceptance, and opens the + target surface automatically. The shared Console/login/build/deploy core is + identical across every surface; only Step 0 (surface choice), Step 10 + (scaffold), Step 16 (open URL), and Step 18 (customization) vary. Use whenever + the user mentions a Content Hub extension, a Content Hub card action, a Content + Hub bulk action, a Content Hub selection bar extension, an AEM UI extension + (CF Console, CF Editor, Universal Editor, Assets View), an ExC Shell app, + aem/assets/contenthub/1, or asks generically to "create an extension" / "build + an Adobe extension" / "make an App Builder extension" without naming a surface + — in that case it FIRST asks which surface (Step 0). Never just print a test + URL — always open it via Bash after cert is accepted. +metadata: + category: app-builder +license: Apache-2.0 +compatibility: Requires aio CLI (`npm install -g @adobe/aio-cli`), Node.js 18+, npm. +allowed-tools: Bash(aio:*) Bash(npm:*) Bash(node:*) Bash(npx:*) Bash(mkdir:*) Bash(ls:*) Bash(grep:*) Bash(cat:*) Bash(sleep:*) Bash(open:*) Bash(lsof:*) Bash(kill:*) Read Write Edit +--- +# Adobe App Builder Extension Skill (multi-surface) + +Drives the complete lifecycle for an Adobe App Builder UI extension — from one prompt to a running, open browser tab — for **any** supported surface. Uses `AskUserQuestion` for every user decision. Never prints a test URL without opening it. Never asks the user to type anything. + +> **Why this skill is generic.** ~80% of the work (login, token validation, org/project/workspace setup, npm install, `aio app use`, build, dev server, cert page, deploy) is **identical for every surface**. Only four things vary per surface: which surface to build (Step 0), the scaffold files (Step 10), the URL to open (Step 16), and the follow-up customization map (Step 18). All four are driven by a single **Surface Config** entry, so adding a new surface in the future means adding one config row + one template section — not rewriting the workflow. See [Adding a New Surface](#adding-a-new-surface). + +--- + +## Surface Config (the heart of the generic design) + +Step 0 picks a surface and loads its config into a `surfaceConfig` object. Every downstream step reads from it instead of hardcoding Content Hub values. + +| `surfaceKey` | Surface | `extensionPointId` | `extDir` (`src/…`) | SDK | Templates | Available namespaces (Step 2) | +| --- | --- | --- | --- | --- | --- | --- | +| `content-hub` | Content Hub | `aem/assets/contenthub/1` | `aem-assets-contenthub-1` | `@adobe/uix-guest` | [`file-templates.md`](references/file-templates.md) | `assetDetails` (tab panels), `card` (per-asset card buttons), `selectionBar` (bulk action bar) | +| `cf-console` | Content Fragment Console | `aem/cf-console-admin/1` | `aem-cf-console-admin-1` | `@adobe/uix-guest` | [`aem-surface-templates.md` § CF Console](references/aem-surface-templates.md) | `actionBar`, `headerMenu`, `contentFragmentGrid` | +| `cf-editor` | Content Fragment Editor | `aem/cf-editor/1` | `aem-cf-editor-1` | `@adobe/uix-guest` | [`aem-surface-templates.md` § CF Editor](references/aem-surface-templates.md) | `headerMenu`, `rte` | +| `universal-editor` | Universal Editor | `aem/universal-editor/1` | `aem-universal-editor-1` | `@adobe/uix-guest` | [`aem-surface-templates.md` § Universal Editor](references/aem-surface-templates.md) | `headerMenu`, properties rail panel | +| `assets-view` | Assets View (Assets Ultimate license) | `aem/assets/1` | `aem-assets-1` | `@adobe/uix-guest` | [`aem-surface-templates.md` § Assets View](references/aem-surface-templates.md) | `actionBar`, `headerMenu` | +| `exc-shell` | Experience Cloud Shell SPA | `dx/excshell/1` | `dx-excshell-1` | `@adobe/exc-app` | [`excshell-templates.md`](references/excshell-templates.md) | n/a (standalone SPA, no extension points) | + +**Open URLs (used in Step 16), keyed by `surfaceKey`:** + +| `surfaceKey` | Local dev open URL | +| --- | --- | +| `content-hub` | `https://experience.adobe.com/?devMode=true&ext=https://localhost:9080#/assets/contenthub/` | +| `cf-console` | `https://experience.adobe.com/?devMode=true&ext=https://localhost:9080` → then navigate to **Content Fragments** | +| `cf-editor` | `https://experience.adobe.com/?devMode=true&ext=https://localhost:9080` → open a fragment in the **CF Editor** | +| `universal-editor` | `https://experience.adobe.com/?devMode=true&ext=https://localhost:9080` → open the **Universal Editor** | +| `assets-view` | `https://experience.adobe.com/?devMode=true&ext=https://localhost:9080` → navigate to **Assets** | +| `exc-shell` | `https://localhost:9080` (the cert page IS the running SPA) — to embed in the shell, use `https://experience.adobe.com/?devMode=true&ext=https://localhost:9080` | + +The AEM extension surfaces (CF Console / Editor / UE / Assets View) all load through the **same `?ext=` extension tester base** — they differ only in which AEM app you navigate to after the shell opens. Only Content Hub has a stable deep-link hash (`#/assets/contenthub/`). Do not fabricate deep-link hashes for the other surfaces — open the tester base and tell the user where to navigate. + +--- + +## What the skill does vs what the customer does + +| Skill (automatic) | Customer (3 moments only) | +| --- | --- | +| Ask which surface + all questions via MCQ — no typing | Accept self-signed cert at `https://localhost:9080` (once per browser session) | +| CLI install check, login detection, org/project/workspace setup | Navigate to the target surface in the shell (AEM surfaces only) | +| Generate all scaffold files for the chosen surface | Approve in Extension Manager (Production deployments only) | +| npm install → aio app use → aio app build → aio app run | | +| Open cert page automatically after dev server starts | | +| Ask "Done?" then open the target surface automatically | | +| Ask about deployment via MCQ | | + +--- + +## Interaction Rules (how to ask, decline, and resume) + +These rules govern every user-facing moment in the workflow. They keep the flow moving and make sure the user is never left guessing what a choice means or what to do after declining. + +**1. Always use `AskUserQuestion` for choices.** Every time a user needs to make a decision, use the `AskUserQuestion` tool with MCQ options. **Never print a numbered list and ask the user to type a number or name.** The user should never type anything except when they select "Other" in a question. + +**2. Every option must state what it does — never bare "Yes"/"No".** A user should be able to pick an option without inferring its meaning. Always give each option a `label` that names the action *and* a `description` that spells out the consequence. Write `label: "Yes — install the aio CLI now"` / `description: "Runs npm install -g @adobe/aio-cli, ~30s"`, not `label: "Yes"`. The "No"/decline option must say what happens instead (e.g. "No — I'll install it myself; I'll print the command and pause"). + +**3. Declining must never dead-end — always give a next step.** Whenever the user picks the "No"/decline/"I'll do it myself" option in any question, do NOT just stop. Respond with: (a) a one-line confirmation of what was skipped, (b) the exact command or manual action they need to perform, and (c) how to resume — tell them to type **`continue`** (or re-run the skill) once they've done it, and state which step you'll pick up from. Example after declining the dev-server auto-start: *"No problem — start it yourself with `cd && PORT=9080 aio app run`, then type `continue` and I'll open the cert page (Step 15)."* Never leave the user at a prompt with no idea what to do next. + +**4. Scaffolding is fully automatic — no confirmation, no per-file questions.** At Step 10, immediately write every scaffold file without asking. Do NOT ask "Ready to scaffold?", "Should I create this file?", or any variant. Just say "Scaffolding project files to ``..." and write everything. The only questions in the whole flow are genuine decisions (surface, name, extension points, workspace, output dir, install/login/deploy) — file writes are not one of them. + +**5. If the flow is interrupted mid-run, tell the user how to pick up.** If you stop partway (an error, the user steps away, the session breaks), end your message with a short resume hint: what completed, what's pending, and that typing **`continue`** resumes from the next step. State the step number so it's unambiguous (e.g. *"Stopped after npm install. Type `continue` to resume from Step 12 (wire to workspace)."*). + +**6. When a command is denied at the permission prompt, never silently halt.** Claude Code shows its own permission dialog before running a Bash command — "1. Yes / 2. Yes, and don't ask again / 3. No". This dialog is the *harness*, not our `AskUserQuestion` MCQ, so we can't change its buttons or add text to it. What we control is the response **after** the user picks **No (3)**: the tool call comes back denied. When that happens, do NOT just stop. Reply with (a) which command was blocked and what step it belongs to, (b) why it's needed (e.g. "this creates the project folders — the scaffold can't continue without them"), and (c) how to proceed: either approve it (and that picking **2. Yes, and don't ask again** avoids future prompts for similar commands) or run it themselves and type **`continue`**. Tip for the user: the scaffold runs many routine `mkdir`/`npm`/`aio` commands, so choosing **2. Yes, and don't ask again** early makes the rest of the run prompt-free. + +--- + +## Full Workflow + +### Step 0 — Identify the target surface (run FIRST, before Step 1) + +Do NOT assume every "create an extension" request means Content Hub. Decide based on the request: + +- **The request explicitly names a surface** (e.g. "Content Hub panel", "CF Console action bar", "Universal Editor header button", "ExC Shell app", or an extension point ID like `aem/cf-console-admin/1`) → set `surfaceConfig` from the matching row in [Surface Config](#surface-config-the-heart-of-the-generic-design) and skip straight to Step 1. +- **The request is generic** ("create an extension", "build an Adobe/App Builder extension", "I want to extend Adobe", no surface named) → you MUST ask which surface first. Use `AskUserQuestion` with exactly 3 options — this covers all 6 surfaces across two questions with no "Other" path needed: + +**Question 1 — surface family:** +``` +question: "What kind of Adobe App Builder extension do you want to build?" +options: + - label: "Content Hub panel" + description: "Add a tab panel to the Asset Details dialog in Content Hub — aem/assets/contenthub/1." + - label: "AEM UI Extension" + description: "Customize an AEM surface — Content Fragment Console, CF Editor, Universal Editor, or Assets View." + - label: "Experience Cloud Shell app" + description: "A standalone App Builder SPA inside the Experience Cloud Shell — dx/excshell/1." +``` + +- If **Content Hub** → `surfaceKey = content-hub`, continue to Step 1. +- If **Experience Cloud Shell** → `surfaceKey = exc-shell`, continue to Step 1. +- If **AEM UI Extension** → ask **Question 2** immediately: + +**Question 2 — which AEM surface (all 4 fit, no "Other" needed):** +``` +question: "Which AEM surface are you extending?" +options: + - label: "Content Fragment Console" + description: "Action bar buttons, header menu, or custom grid columns — aem/cf-console-admin/1." + - label: "Content Fragment Editor" + description: "Header menu buttons or RTE toolbar customizations — aem/cf-editor/1." + - label: "Universal Editor" + description: "Header menu buttons or a properties-rail panel — aem/universal-editor/1." + - label: "Assets View" + description: "Action bar / header menu buttons — aem/assets/1 (requires Assets Ultimate license)." +``` + +**After the answer:** look up the chosen surface in the [Surface Config](#surface-config-the-heart-of-the-generic-design) table and store the whole row as `surfaceConfig` (`surfaceKey`, `extensionPointId`, `extDir`, `sdk`, templates ref, open URL, available namespaces). **Every downstream step reads from `surfaceConfig` — there are no hardcoded Content Hub paths past this point.** Then continue to Step 1. + +--- + +### Step 1 — Extension Name + +Use the `AskUserQuestion` tool. Tailor the suggested names to `surfaceConfig.surfaceKey`: + +- `content-hub`: `asset-metadata-panel`, `asset-card-actions`, `asset-bulk-export` +- `cf-console` / `cf-editor`: `cf-export-action`, `cf-validate-button`, `cf-status-column` +- `universal-editor`: `ue-preview-button`, `ue-properties-panel` +- `assets-view`: `assets-bulk-action`, `assets-header-menu` +- `exc-shell`: `my-dashboard-app`, `reporting-spa` + +``` +question: "What should we name your extension?" +options: + - label: "" + - label: "" + - label: "" +``` + +The user can also select "Other" to type their own kebab-case name. Store the result as `extensionName`. + +Validate: must be kebab-case (lowercase letters and hyphens only). If the user types something invalid (e.g. `My Extension`), suggest the corrected form (`my-extension`) and use `AskUserQuestion` again: + +``` +question: "\"My Extension\" needs to be kebab-case. Use \"my-extension\"?" +options: + - label: "Yes, use \"my-extension\"" + - label: "Let me type a different name" +``` + +--- + +### Step 2 — Choose extension point(s) within the surface + +**Auto-select rule:** If the user's original prompt already names a specific namespace / extension point, do NOT ask — auto-select it and skip to Step 3. Examples: +- "Add buttons to asset cards" / "card action" / "asset card" → auto-select `namespaces = ["card"]` +- "Add a panel to asset details" / "tab panel" / "asset details panel" → auto-select `namespaces = ["assetDetails"]` +- "Add a bulk action" / "selection bar" / "bulk action bar" → auto-select `namespaces = ["selectionBar"]` +- "Content Hub extension with card and bulk actions" → auto-select `namespaces = ["card", "selectionBar"]` + +Only ask the multiSelect question below when the prompt is **generic** (e.g. "create a Content Hub extension" with no namespace hint). + +What you ask depends on `surfaceConfig.surfaceKey`: + +- **`content-hub`** — three namespaces are available. Use `AskUserQuestion` with `multiSelect: true`: + ``` + question: "Which Content Hub extension points should I wire up?" (multiSelect: true) + options: + - label: "Asset Details panel" + description: "Add custom tab panels to the Asset Details Dialog side rail — assetDetails namespace" + - label: "Asset card action" + description: "Add buttons to asset card menus and to collection tiles on the Collections grid — card namespace. getActionButtons receives actionContext (context: assets|collection|collections|share). onActionClick receives (resourceType, buttonId, resourceId, actionContext)." + - label: "Selection bar (bulk action)" + description: "Add buttons to the bulk-action bar shown when assets are selected — selectionBar namespace. getActionButtons receives actionContext with source + selection. onActionClick receives (buttonId, assetIds[])." + ``` + Store the selected namespace keys as `namespaces` (e.g. `["assetDetails", "card"]`). At least one must be selected. The `card` and `selectionBar` namespaces open a modal on click — their `onActionClick` methods call `guestConnection.host.modal.openDialog()`, which requires using `let guestConnection` (not `const`) so the closure can reference it after `register()` resolves. + +- **`cf-console`** — ask which extension point(s) to wire (multiSelect): + ``` + question: "Which CF Console extension points should I wire up?" (multiSelect: true) + options: + - label: "Action bar button" description: "Appears when fragments are selected — actionBar" + - label: "Header menu button" description: "Always visible in the console header — headerMenu" + - label: "Custom grid column" description: "Adds a column to the fragment list — contentFragmentGrid" + ``` +- **`cf-editor`** — multiSelect over `headerMenu` (header buttons) and `rte` (RTE toolbar buttons / badges). +- **`universal-editor`** — multiSelect over `headerMenu` and a properties-rail panel. +- **`assets-view`** — multiSelect over `actionBar` and `headerMenu`. Note the Assets Ultimate license requirement. +- **`exc-shell`** — **skip this step entirely.** An ExC Shell app has no extension points; set `namespaces = []` and continue. + +Store the selected namespace keys as `namespaces` — Step 10 uses them to decide which methods blocks / template sections to include. + +--- + +### Step 3 — Workspace + +Use the `AskUserQuestion` tool: + +``` +question: "Which workspace should this extension use?" +options: + - label: "Stage" + description: "For development and testing — recommended to start here" + - label: "Production" + description: "For final release" +``` + +Store `workspace` as the selected value. + +--- + +### Step 4 — Output Directory + +Use the `AskUserQuestion` tool: + +``` +question: "Where should the project be created?" +options: + - label: "~/Desktop/" + description: "Create on your Desktop (recommended)" + - label: "~/Documents/" + description: "Create in your Documents folder" +``` + +Resolve the final `outputPath` to an absolute path, e.g. `$HOME/Desktop/`. + +--- + +### Step 5 — Check aio CLI Installed + +```bash +aio --version 2>/dev/null +``` + +- **Installed:** Continue immediately. +- **Not installed:** Use `AskUserQuestion`: + +``` +question: "The Adobe I/O CLI (aio) is not installed. Should I install it for you?" +options: + - label: "Yes — install it now" + description: "Runs npm install -g @adobe/aio-cli (~30s), then continues automatically" + - label: "No — I'll install it myself" + description: "I'll print the install command and pause; type 'continue' once it's installed" +``` + +If "Yes": +```bash +npm install -g @adobe/aio-cli +``` + +If "No": print `npm install -g @adobe/aio-cli`, tell the user to run it, and that typing `continue` resumes from Step 5 (login check). Do not proceed until the CLI is present. + +--- + +### Step 6 — Check Login AND Validate the Token + +**`aio where` is NOT a sufficient login check.** A token can show fully logged-in in `aio where` (Org/Project/Workspace all populated) and an unexpired local `expiry`, yet still be **rejected server-side** by the Console API with: + +``` +401 - Unauthorized ({"title":"ErrInvalidOauthToken","status":401,"error_code":401013,"message":"Oauth token is not valid"}) +``` + +This is the #1 cause of "401 on everything" — a **stale token**, NOT a restricted org. No env-flag trick (`CI`, `unset`, `aio where`) can fix an invalid token. The ONLY fix is a fresh `aio login --force`. So this step does two checks: context presence, then a real token-validity probe. + +**6a — Context check (fast, no browser):** + +```bash +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio where 2>/dev/null +``` + +**If output is empty or contains "not logged":** Run `aio login` in a subshell with CI/AIO_CLI_NO_TTY completely **unset** — `env VAR=` only sets to empty string but the variable still exists; must use `unset`: + +```bash +bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio login' +``` + +The browser opens automatically. Wait up to 3 minutes for the command to exit 0. Do NOT ask permission — just run it. + +**6b — Token-validity probe (the critical addition):** Even if `aio where` showed a context, make ONE real Console API call to prove the token works server-side: + +```bash +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console org list --json 2>&1 | head -c 400 +``` + +- **If it returns a JSON array of orgs:** Token is valid. Cache this org list (reuse it in Step 7 — don't call `org list` twice). Continue. +- **If the output contains `401`, `Unauthorized`, `ErrInvalidOauthToken`, or `Oauth token is not valid`:** The token is stale. Refresh it **without asking permission** — the user already authorized the flow by running the skill: + + ```bash + bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio login --force' + ``` + + `--force` discards the stale token and forces a fresh browser OAuth. Wait up to 3 minutes for exit 0, then re-run the `aio console org list --json` probe to confirm it now returns orgs. After this, **every** downstream `aio console` call (org/project/workspace list, create, select) will succeed — there is no need for any "Console API is restricted" fallback. + +**Critical:** Never run `aio login`/`aio login --force` with `CI`, `AIO_CLI_NO_TTY`, or `TERM=dumb` in the environment — those flags suppress the browser and the login silently fails. + +--- + +### Step 7 — Resolve Org + +After Step 6, the token is proven valid and you already have the org list cached from the 6b probe. **Do NOT skip `aio console org list` or invent a "restricted org" fallback** — with a valid token it always returns the real orgs. + +Use the cached org list from Step 6b (or re-run if you didn't cache it): +```bash +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console org list --json 2>/dev/null +``` + +- **One org:** Auto-select, no user interaction: + ```bash + CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console org select + ``` + Store `activeOrgId`. +- **Multiple orgs:** Use `AskUserQuestion` (label = org name, description = orgId). Mark the org matching the `aio where` context with "← currently selected". After selection: `aio console org select `. Store `activeOrgId`. +- **Zero orgs:** This only happens with a broken token — re-run `bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio login --force'`, then retry org list. + +--- + +### Step 8 — Resolve Project + +Now that the org context is established (either from `aio where` or explicit select in Step 7), list projects **within that org**. This call does NOT need `aio console org list` to have succeeded first — the org is already in the aio context: + +```bash +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console project list --json 2>/dev/null +``` + +This returns the real list of all projects for the current org from Developer Console — not hardcoded, not from `aio where`. + +**If the list is empty (zero projects):** Auto-create without asking: +```bash +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console project create -n --json 2>/dev/null +``` +Select it. Store `activeProjectId`, `activeProjectName`. Set `isNewProject = true`. Continue to Step 9. + +**If projects exist:** Always use `AskUserQuestion` — never auto-select without user approval. Show the full real list from Console API. Mark the project matching `existingProject` (from `aio where`) with "← currently active": + +``` +question: "Which project should I use?" +options: (one per project from Console API, label = project name, description = "projectId: "; mark currently active) + + { label: "Create a new project", description: "I'll create a fresh App Builder project for you" } +``` + +**If user picks an existing project:** +```bash +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console project select +``` +Store `activeProjectId`, `activeProjectName`. Set `isNewProject = false`. + +**If user picks "Create a new project":** Ask for a name: +``` +question: "What should the new project be named? (alphanumeric only, no hyphens)" +options: + - label: "" + description: "Derived from your extension name" + - label: "appbuilderext" + description: "Generic name" +``` +Create it: +```bash +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console project create -n --json 2>/dev/null +``` +Select using the `id` from JSON output: +```bash +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console project select +``` +Store `activeProjectId`, `activeProjectName`. Set `isNewProject = true`. + +If `aio console project create` exits non-zero with "already exists": list projects, select the matching one, set `isNewProject = false`. + +**If `aio console project list` returns 401 here:** This should not happen after Step 6 — it means the token went stale mid-flow. Do NOT treat it as a restricted org and do NOT fall back to the `aio where` project name. Re-run the token refresh and retry the listing: +```bash +bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio login --force' +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console project list --json 2>/dev/null +``` + +--- + +### Step 9 — Select Workspace + I/O Runtime note + +After Step 6 the token is valid, so the Console API works — use the normal path below. (There is no "Console API restricted" path; a 401 at any point means a stale token → `aio login --force` and retry, never a fallback to `aio where` config.) + +Select the workspace by name: +```bash +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console workspace select +``` + +**Note on I/O Runtime (do NOT run `aio console workspace service add`):** That subcommand **does not exist** in current aio CLI (11.1.2+) — it errors `Command console:ws:service not found`. It also isn't needed: `aio console project create` makes an **App Builder** project, and every App Builder workspace gets an I/O Runtime namespace automatically (pattern `--`, e.g. `274796-myext-stage`). `aio app use` (Step 12) downloads those `AIO_RUNTIME_NAMESPACE`/`AIO_RUNTIME_AUTH` creds into `.env`. So skip any service-add here. **The #1 reason `.env` comes back empty is a STALE TOKEN (the same 401 issue as Step 6), NOT a missing entitlement** — if Step 6's token probe passed, `aio app use` will populate `.env` fine. + +If `isNewProject = false` (existing project): check if the chosen workspace exists and create if missing: +```bash +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console workspace list --projectName --json 2>/dev/null +``` +If `` not in list: +```bash +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio console workspace create --projectName --name --json 2>/dev/null +``` + +--- + +### Step 10 — Scaffold Project Files (surface-specific) + +**Non-interactive — write all files directly, no questions.** Say "Scaffolding project files to ``..." then immediately create directories and write every scaffold file. Do not explain permissions or ask anything. + +Create directories: + +```bash +mkdir -p /src//web-src/src/components +mkdir -p /src//actions/generic +mkdir -p /hooks +``` + +Read [`references/file-templates.md`](references/file-templates.md) and write **all scaffold files** by substituting placeholders (`{{EXTENSION_NAME}}`, `{{DISPLAY_NAME}}`, `{{EXTENSION_DESCRIPTION}}`, `{{EXTENSION_DIR}}`, `{{EXTENSION_POINT}}`): + +- `package.json` +- `app.config.yaml` +- `src//ext.config.yaml` +- `extension-manifest.json` +- `.eslintrc.js` +- `hooks/post-deploy.js` +- `src//web-src/index.html` +- `src//web-src/src/index.js` +- `src//web-src/src/index.css` +- `src//web-src/src/config.json` +- `src//web-src/src/components/Constants.js` +- `src//actions/utils.js` +- `src//actions/generic/index.js` +- `src//web-src/src/components/App.js` +- `src//web-src/src/components/ExtensionRegistration.js` +- Any namespace-specific components (e.g. `TabPanel.js` for `assetDetails`, `CardActionModal.js` for `card`, `SelectionBarModal.js` for `selectionBar`) — include only for the namespaces selected in Step 2 + +For non-Content-Hub AEM surfaces, also read [`references/aem-surface-templates.md`](references/aem-surface-templates.md) for the surface-specific `ext.config.yaml`. For ExC Shell, read [`references/excshell-templates.md`](references/excshell-templates.md) for the full SPA skeleton (different SDK). + +--- + +### Step 11 — npm install + +```bash +cd +npm install +``` + +If E401 (registry auth failure): +```bash +npm install --registry https://registry.npmjs.org +``` + +--- + +### Step 12 — Wire to Workspace (`aio app use`) + +**Must run in a subshell with CI/AIO_CLI_NO_TTY completely unset.** Without this, credential download is silently skipped and `.env` stays empty → 401 on build/deploy: + +```bash +cd +bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio app use -w --overwrite --no-input' +``` + +If that exits non-zero (the `.aio` config already has org/project context, so no extra flags needed): +```bash +cd +bash -c 'unset CI AIO_CLI_NO_TTY TERM; printf "y\ny\ny\ny\n" | aio app use -w --overwrite' +``` + +**Check `.env` validity:** + +```bash +node -e " +const fs = require('fs'); +try { + const c = fs.readFileSync('/.env', 'utf8'); + const ok = /AIO_RUNTIME_NAMESPACE=\S+/.test(c) && /AIO_RUNTIME_AUTH=\S+/.test(c); + console.log(ok ? 'VALID' : 'INVALID'); +} catch(e) { console.log('INVALID'); } +" +``` + +**If `INVALID`:** Almost always a **stale token** — the credential download in `aio app use` failed silently. Do NOT conclude the org lacks Runtime, and do NOT run `aio console workspace service add` (it doesn't exist). Fix it: + +```bash +bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio login --force' +cd +bash -c 'unset CI AIO_CLI_NO_TTY TERM; printf "y\ny\ny\ny\n" | aio app use -w --overwrite' 2>/dev/null +``` + +Re-run the `isEnvValid` check. Only if `.env` is STILL empty after a confirmed-fresh-token `aio app use` is the workspace genuinely without a Runtime namespace — rare; in that case the UI still works and only actions are unavailable. + +**If `VALID`:** Store `envValid = true`. + +> **ExC Shell note:** An ExC Shell SPA with no backend actions doesn't strictly need a Runtime namespace to render. An `INVALID` `.env` is non-blocking for `exc-shell` if the app has no actions — the SPA still serves. Only block on it if the app calls web actions. + +--- + +### Step 13 — Build + +```bash +cd +aio app build +``` + +If this fails with a config validation error: +```bash +aio app build --no-config-validation +``` +Fix the YAML issue then rebuild without the flag. + +If build fails for other reasons: read the error, fix it (usually a missing package or malformed template), then rebuild. + +--- + +### Step 14 — Start Dev Server + +Check if port 9080 is free: + +```bash +lsof -ti:9080 +``` + +If a PID is returned, kill it automatically — the user already authorized the full workflow by running the skill, so there is no need to ask permission to free the port: + +```bash +kill 2>/dev/null; sleep 2 +``` + +If the port is still in use after `kill`, use `kill -9 `. Only if that also fails, tell the user to free port 9080 manually and stop. + +**Do NOT ask "should I start the dev server?" after freeing the port.** Freeing the port and starting the server are one continuous action — once the port is clear, start the server immediately in the same step. The user already authorized the full workflow by running the skill (see [Interaction Rule 4](#interaction-rules-how-to-ask-decline-and-resume) and [[feedback_no_redundant_confirmations]]). The only question in this step is the failure case below (server never came up). + +Start the dev server in background. **Set `PORT=9080` explicitly.** Redirect output to a log and poll for the localhost URL: + +```bash +cd +PORT=9080 aio app run > /tmp/aio-run-.log 2>&1 & +AIO_PID=$! + +for i in $(seq 1 60); do + grep -qE "https?://localhost:[0-9]+" /tmp/aio-run-.log 2>/dev/null && break + sleep 2 +done + +cat /tmp/aio-run-.log +echo "PID: $AIO_PID" +``` + +**If `localhost:9080` appears in log:** Dev server is up. Proceed to Step 15. + +**If loop ends without the URL (120 seconds elapsed):** Use `AskUserQuestion`: + +``` +question: "The dev server didn't start automatically. Should I try starting it again?" +options: + - label: "Yes — rebuild and retry the dev server" + description: "Re-runs aio app build, then tries PORT=9080 aio app run again" + - label: "No — I'll start it manually" + description: "I'll print the command and pause; type 'continue' once localhost:9080 is up" +``` + +If "Yes": re-run `aio app build` then retry the dev server loop. +If "No": print `cd && PORT=9080 aio app run`, tell the user to run it, and that once the page loads they should type `continue` to resume from Step 15 (open cert page). Do not just stop without this hint. + +--- + +### Step 15 — Open Cert Page (after dev server URL confirmed) + +Open the cert page **only after** the dev server URL appears in the log: + +```bash +open "https://localhost:9080" +``` + +*(On Linux use `xdg-open`, on Windows use `start ""`)* + +Then use `AskUserQuestion`: + +``` +question: "I opened https://localhost:9080 in your browser. Accept the self-signed certificate there (click Advanced → Proceed to localhost, or type 'thisisunsafe'). The page will then show instructions to return here." +options: + - label: "Done — open the extension" + description: "Cert accepted; open the target surface now (Step 16)" + - label: "Reopen the cert page" + description: "Open https://localhost:9080 again so I can accept the cert" +``` + +> The browser page (localhost:9080) now shows a clear "Return to Claude Code" message after cert acceptance — that's what guides the user back. Keep the Claude Code question short; don't repeat the return instruction here. + +If "Reopen the cert page": +```bash +open "https://localhost:9080" +``` +Ask the same question again. + +--- + +### Step 16 — Open the Target Surface Automatically (surface-specific) + +Only after the user confirms "Done", open the surface via Bash — **never just print the URL**. Use the open URL for `surfaceConfig.surfaceKey` from the [Open URLs table](#surface-config-the-heart-of-the-generic-design): + +```bash +open "" +``` + +- **`content-hub`** — opens directly to the panel: + ```bash + open "https://experience.adobe.com/?devMode=true&ext=https://localhost:9080#/assets/contenthub/" + ``` +- **`cf-console` / `cf-editor` / `universal-editor` / `assets-view`** — open the extension tester base, then tell the user where to navigate (these surfaces have no stable deep-link hash): + ```bash + open "https://experience.adobe.com/?devMode=true&ext=https://localhost:9080" + ``` + Then say, e.g.: *"Navigate to **Content Fragments** — your action bar button appears when you select a fragment."* +- **`exc-shell`** — the SPA is already running at `https://localhost:9080` (the cert page). Confirm it loaded; to embed it in the shell, open `https://experience.adobe.com/?devMode=true&ext=https://localhost:9080`. + +**Rules (Content Hub specifics):** +- **No `&repo=`** — the scaffold sets `allowedRepos = []` so any repo works for local dev. +- **No `/index.html`** after `localhost:9080` — that is only for deployed (CDN) URLs. +- **Do not print and ask the user to click** — run `open` via Bash. + +--- + +### Step 17 — Ask About Deployment + +The workspace was already chosen in Step 3 — do NOT ask again which workspace to deploy to. Just ask whether to deploy now or later: + +``` +question: "Extension is running locally on . Deploy it now?" +options: + - label: "Yes — deploy to now" + description: "Runs aio app deploy, then opens the deployed CDN test URL automatically" + - label: "Not now — keep running locally" + description: "Stays on localhost:9080; type 'continue' or 'deploy' later to deploy from Step 17" +``` + +If "Yes": run the deploy flow below immediately — do not wait for the user to do anything. + +If "Not now": confirm the extension is still running locally and tell the user it can be deployed anytime by typing `deploy` (resumes at Step 17). Don't end the conversation with a bare "okay". + +**Deploy flow:** + +```bash +cd + +# Deploy — capture full output (workspace is already wired from Step 12) +aio app deploy 2>&1 | tee /tmp/aio-deploy-.log +``` + +After `aio app deploy` completes, **always** parse the CDN base domain from the log regardless of exit code, build the test URL, and **open it in the same command**. The deployed URL is built from the surface's open URL with the local `ext=https://localhost:9080` replaced by `ext=/index.html`: + +```bash +node -e " +const fs = require('fs'); +const log = fs.readFileSync('/tmp/aio-deploy-.log', 'utf8'); +const m = log.match(/(https:\/\/[a-z0-9-]+\.adobeio-static\.net)/i); +if (m) { console.log(m[1]); } else { process.exit(1); } +" | { read BASE; echo "Opening: $BASE"; + # For content-hub, append the deep-link hash. For other AEM surfaces, open the tester base and navigate. + open "https://experience.adobe.com/?devMode=true&ext=${BASE}/index.html#/assets/contenthub/"; } +``` + +> For surfaces other than `content-hub`, drop the `#/assets/contenthub/` hash — open `https://experience.adobe.com/?devMode=true&ext=${BASE}/index.html` and tell the user where to navigate. For `exc-shell`, the deployed SPA URL printed by `aio app deploy` is the app itself. + +**CRITICAL:** the deployed CDN test URL MUST be opened via `open`, never just printed. Only after the `open` has actually run may you print the link in the summary for reference. + +**Partial failure is still a success:** If web assets deployed but actions failed (org has no I/O Runtime, or 401 on action deploy), the extension UI still works. Open the CDN URL, note that actions are unavailable in this org — do not treat it as a blocking error. + +If the workspace from Step 3 is Production: after opening the deployed URL, use `AskUserQuestion`: +``` +question: "Extension deployed to Production! To make it visible to all users without the ext= parameter, approve it in Extension Manager." +options: + - label: "Open Extension Manager" + - label: "I'll approve it later" +``` +If "Open Extension Manager": `open "https://experience.adobe.com/aem/extension-manager"` + +**Switching workspaces at deploy time:** Only switch if the user *explicitly* asks to deploy to a different workspace than the one chosen in Step 3. In that case, run `aio app use --overwrite --no-input` with the new workspace config before deploying. Otherwise, use the workspace already wired in Step 12. + +**After Step 17 completes (whether deployed or kept local), ALWAYS print the "Where to edit" file map below.** Do not wait for a follow-up request — show it unconditionally, every run, every session. Keep it simple: just tell the customer which file to open for each kind of change. + +The output must look exactly like this (filter rows to only the namespaces selected in Step 2 — omit rows for namespaces that were not chosen): + +``` +## Where to edit your extension + +All UI files are under `src//web-src/src/components/` + +| What you want to change | File to edit | +|---|---| +| Which panels / buttons appear, their title, icon, or label | `ExtensionRegistration.js` | +| Asset Details panel content (tab UI) | `TabPanel.js` | ← only if assetDetails selected +| Card action modal content | `CardActionModal.js` | ← only if card selected +| Selection bar (bulk action) modal content | `SelectionBarModal.js` | ← only if selectionBar selected +| Server-side logic / AEM API calls | `actions/generic/index.js` | +``` + +Then add one line: *"Let me know what you'd like to build and I'll make the changes."* + +--- + +### Step 18 — Customization (follow-up requests) + +After printing the file map, respond to follow-up customization requests without asking — just act. Files live under `src//`. Full API reference per surface: + +**Content Hub (`content-hub`):** + +| Request | File | Action | +| --- | --- | --- | +| Change panel title / icon | `ExtensionRegistration.js` | Edit `title`, `tooltip`, `icon` in `assetDetails.getTabPanels()` | +| Add a second panel | `ExtensionRegistration.js` + `App.js` + new component | Read `references/file-templates.md` § Adding a Second Panel | +| Build the panel UI | `TabPanel.js` | React Spectrum components from `references/file-templates.md` § TabPanel | +| Change card button label / icon | `ExtensionRegistration.js` | Edit `label`, `icon` in `card.getActionButtons(actionContext)` | +| Show card buttons only on certain surfaces | `ExtensionRegistration.js` | In `card.getActionButtons(actionContext)`, check `actionContext.context` (`'assets'`, `'collection'`, `'collections'`, `'share'`) and return `[]` to hide, or the full array to show. `'collections'` = collection tile on the Collections grid. | +| Handle card button click | `ExtensionRegistration.js` + `CardActionModal.js` | Edit `card.onActionClick(resourceType, buttonId, resourceId, actionContext)` → `resourceType` is `'asset'` (asset cards) or `'collection'` (collection tiles). Calls `guestConnection.host.modal.openDialog({ title, contentUrl: '/#card-action-modal?resourceId=...&resourceType=...', type: 'modal', size: 'M' })`. **Single object — NO `{ id }`, NO `payload`** (the two-arg form silently times out). Pass data via the `contentUrl` query; read it in the modal with `new URLSearchParams(window.location.hash.split('?')[1])`. Close with `guestConnection.host.modal.closeDialog()`. | +| Change selection bar button label / icon | `ExtensionRegistration.js` | Edit `label`, `icon` in `selectionBar.getActionButtons(actionContext)` | +| Show buttons only in certain views | `ExtensionRegistration.js` | In `selectionBar.getActionButtons(actionContext)`, check `actionContext.context` (`'assets'`, `'collections'`, `'collection'`, `'share'`) and return `[]` to hide, or the full array to show. | +| Handle bulk action click | `ExtensionRegistration.js` + `SelectionBarModal.js` | Edit `selectionBar.onActionClick` → receives `(buttonId, assetIds[])`. Same modal pattern as card. | +| Call an AEM API | `actions/generic/index.js` + panel/modal component | Uncomment the fetch blocks; pass `accessToken` from `guestConnection.host.auth.getIMSInfo()` | +| Show a toast | any component | `guestConnection.host.toast.display({ variant, message })` — variants: `neutral`, `positive`, `negative`, `info` | +| Get IMS auth | any component | `guestConnection.host.auth.getIMSInfo()` → `{ imsOrg, imsOrgName, accessToken }` · `guestConnection.host.auth.getApiKey()` → string | +| Get AEM host URL | any component | `guestConnection.host.discovery.getAemHost()` → `https:///` | + +**AEM surfaces (`cf-console`, `cf-editor`, `universal-editor`, `assets-view`):** + +| Request | File | Action | +| --- | --- | --- | +| Add/change a button | `ExtensionRegistration.js` | Edit the relevant `getButtons()` in the `actionBar`/`headerMenu` block — see `references/aem-surface-templates.md` | +| Add a modal | `App.js` + new modal page | Add a route + `host.modal.showUrl({ url: '/index.html#/my-modal' })`; close with `attach()` + `host.modal.close()` | +| Add a grid column (CF Console) | `ExtensionRegistration.js` | Add to `contentFragmentGrid.getColumns()` | +| RTE button/badge (CF Editor) | `ExtensionRegistration.js` | Add to `rte.getCustomButtons()` / `rte.getBadges()` | +| Read auth/context | any page | `guestConnection.sharedContext.get('auth')` → `{ imsOrg, imsToken, apiKey }` | +| Show a toast | any page | `guestConnection.host.toaster.display({ variant, message })` | + +**ExC Shell (`exc-shell`):** + +| Request | File | Action | +| --- | --- | --- | +| Build the app UI | `App.js` / components | React Spectrum inside the `` — see `references/excshell-templates.md` | +| Read shell auth | `App.js` | From `runtime.ready({ onReady })` context: `{ imsOrg, imsToken, imsProfile, locale }` | +| Call a backend action | components + `actions/generic/index.js` | `fetch(actionUrl, { headers: { Authorization: 'Bearer ' + imsToken, 'x-gw-ims-org-id': imsOrg } })` | + +After UI changes, the dev server hot-reloads. After action changes, run `aio app build` in the project directory. + +--- + +## Adding a New Surface + +This is the payoff of the generic design. To support a brand-new App Builder surface in the future, **you do not touch the workflow (Steps 1–9, 11–15, 17)** — you only: + +1. **Add one row to the [Surface Config](#surface-config-the-heart-of-the-generic-design) table** — `surfaceKey`, `extensionPointId`, `extDir`, `sdk`, templates ref, and available namespaces. +2. **Add one row to the Open URLs table** — the `experience.adobe.com` URL (or other host URL) to open in Step 16. +3. **Add an option to the Step 0 question** so users can pick it. +4. **Add a template section** — if the surface uses `@adobe/uix-guest` and shares the standard skeleton, add a section to [`references/aem-surface-templates.md`](references/aem-surface-templates.md) with just its `ext.config.yaml` + `methods` block. If it uses a different SDK/skeleton (like ExC Shell), add a dedicated reference file. +5. **Add a Step 2 question branch** (which namespaces/extension points it offers) and a **Step 18 customization sub-table**. + +Everything else — login, token validation, org/project/workspace, install, build, dev server, cert, deploy — is reused unchanged because it reads from `surfaceConfig`. + +**Rule of thumb:** if a new surface uses `register()` from `@adobe/uix-guest`, it's ~90% reuse (skeleton + workflow), and you only author its `methods` object. If it uses a different SDK, you author a new skeleton but still reuse the entire Console/build/deploy workflow. + +--- + +## Failure Recovery + +| Symptom | Action | +| --- | --- | +| `aio where` shows "not logged" | Automatically run `bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio login'` — never ask permission, browser opens, wait for exit 0 | +| `aio console *` returns 401 / `ErrInvalidOauthToken` / `Oauth token is not valid` (even though `aio where` shows logged-in) | **Stale token, NOT a restricted org.** Run `bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio login --force'`, then retry. Never fall back to `aio where` config values. | +| `aio console project create` → "already exists" | List projects, select matching one, continue | +| `aio console workspace create` → "already exists" | Skip, continue | +| `aio app use` exits non-zero | Fallback: `bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio app use -w --overwrite --no-input'` | +| `.env` missing `AIO_RUNTIME_NAMESPACE` | Usually a STALE TOKEN — run `aio login --force` then re-run `aio app use -w --overwrite`. Do NOT run `workspace service add` (doesn't exist) or assume missing entitlement. For `exc-shell` with no actions, non-blocking. | +| `npm install` → E401 | `npm install --registry https://registry.npmjs.org` | +| `aio app build` → validation error | `aio app build --no-config-validation`; fix YAML; rebuild without flag | +| `aio app run` log never shows `localhost:9080` | Re-run `aio app build`; check `node -v` ≥ 18; check `cat /tmp/aio-run-.log` for errors | +| Panel/app blank in browser | Cert not accepted — run `open https://localhost:9080` and use `AskUserQuestion` again | +| Extension not visible (AEM surfaces) | Confirm you navigated to the correct AEM app; check `ext=https://localhost:9080` and `devMode=true` are in the URL; check the extension point ID in `ext.config.yaml` matches `surfaceConfig.extensionPointId` | +| Content Hub panel not visible | Check `allowedRepos` is empty; check URL uses `#/assets/contenthub/`; check `devMode=true` | +| ExC Shell stuck on spinner | `runtime.done()` was not called (or called too late) — call it at the `register` level, not after data fetch — see `references/excshell-templates.md` | +| Deploy: CDN URL not found in log | Check `cat /tmp/aio-deploy-.log`; look for any `.adobeio-static.net` URL manually | + +--- + +## Quality Checklist + +- [ ] `surfaceConfig` was set in Step 0 and every path/URL downstream reads from it (no hardcoded Content Hub values for non-CH surfaces) +- [ ] `app.config.yaml` uses `surfaceConfig.extensionPointId` +- [ ] Source directory is `src//` +- [ ] For `uix-guest` surfaces: `ExtensionRegistration.js` imports `register`, secondary pages import `attach`, both use the same `extensionId` from `Constants.js` +- [ ] For `exc-shell`: `App.js` imports `register` from `@adobe/exc-app` and calls `runtime.done()` at register level +- [ ] `npm install` succeeded (Step 11) +- [ ] `aio app use` ran in a subshell with `unset CI AIO_CLI_NO_TTY TERM` (Step 12) +- [ ] `.env` has `AIO_RUNTIME_NAMESPACE` and `AIO_RUNTIME_AUTH` (or non-blocking for actionless `exc-shell`) +- [ ] `aio app build` succeeded (Step 13) +- [ ] `PORT=9080 aio app run` is running and log shows `localhost:9080` (Step 14) +- [ ] Cert page opened via `open` **after** dev server URL confirmed +- [ ] Target surface opened via `open` (Bash), not just printed as text + +--- + +## Chaining + +- Chains FROM `appbuilder-project-init` for complex multi-workspace Console setups +- Works alongside `appbuilder-action-scaffolder` for production-grade action patterns +- Works alongside `appbuilder-ui-scaffolder` for advanced React Spectrum UI patterns and deeper per-surface host-API reference +- Chains TO `appbuilder-cicd-pipeline` for GitHub Actions deployment automation +- Chains TO `appbuilder-e2e-testing` for Playwright tests + +--- + +## References + +- [`references/file-templates.md`](references/file-templates.md) — Content Hub scaffold templates + the shared uix-guest skeleton reused by all AEM surfaces +- [`references/aem-surface-templates.md`](references/aem-surface-templates.md) — Per-surface `ext.config.yaml` + `ExtensionRegistration.js` methods blocks for CF Console, CF Editor, Universal Editor, Assets View +- [`references/excshell-templates.md`](references/excshell-templates.md) — Experience Cloud Shell SPA skeleton (`@adobe/exc-app`, `dx/excshell/1`) +- [`references/deployment.md`](references/deployment.md) — Stage and Production deployment, Extension Manager approval +- [`references/debugging.md`](references/debugging.md) — Troubleshooting for build, runtime, and surface integration failures diff --git a/plugins/app-builder/skills/adobe-extension-scaffolder/references/aem-surface-templates.md b/plugins/app-builder/skills/adobe-extension-scaffolder/references/aem-surface-templates.md new file mode 100644 index 00000000..dc21aa32 --- /dev/null +++ b/plugins/app-builder/skills/adobe-extension-scaffolder/references/aem-surface-templates.md @@ -0,0 +1,335 @@ +# AEM Surface Templates (CF Console, CF Editor, Universal Editor, Assets View) + +These four surfaces all use **`@adobe/uix-guest`** and share the **same project skeleton** as Content Hub. Reuse [`file-templates.md`](file-templates.md) for every shared file — `package.json`, `index.html`, `index.js`, `index.css`, `Constants.js`, `App.js`, `actions/utils.js`, `actions/generic/index.js`, `hooks/post-deploy.js`, `README.md`, `AGENTS.md`, `.eslintrc.js` — swapping only: + +- the directory `aem-assets-contenthub-1` → `surfaceConfig.extDir` +- the package name in `ext.config.yaml` → matches `extDir` +- the extension point in `app.config.yaml` → `surfaceConfig.extensionPointId` +- the `register()` `methods` object → the surface-specific block below + +Placeholders: `{{EXTENSION_NAME}}`, `{{DISPLAY_NAME}}`, `{{EXTENSION_DESCRIPTION}}`, `{{EXTENSION_DIR}}` (= `surfaceConfig.extDir`), `{{EXTENSION_POINT}}` (= `surfaceConfig.extensionPointId`). + +> **Auth on AEM surfaces differs from Content Hub.** These surfaces expose auth through `guestConnection.sharedContext`, not `host.auth`: +> ```js +> const ctx = guestConnection.sharedContext; +> const aemHost = ctx.get('aemHost'); // e.g. author-p12345-e67890.adobeaemcloud.com +> const imsOrg = ctx.get('auth').imsOrg; +> const imsToken = ctx.get('auth').imsToken; // Bearer token for AEM API calls +> const apiKey = ctx.get('auth').apiKey; +> ``` + +--- + +## Shared `app.config.yaml` (all AEM surfaces) + +```yaml +extensions: + {{EXTENSION_POINT}}: + $include: src/{{EXTENSION_DIR}}/ext.config.yaml +``` + +## Shared `ext.config.yaml` (all AEM surfaces) + +Same as Content Hub but with the package key renamed to `{{EXTENSION_DIR}}`: + +```yaml +$schema: https://unpkg.com/@adobe/aio-schemas@latest/schemas/aio.schema.json +actions: actions +web: web-src +runtimeManifest: + packages: + {{EXTENSION_DIR}}: + license: Apache-2.0 + actions: + generic: + function: actions/generic/index.js + web: 'yes' + runtime: 'nodejs:18' + inputs: + LOG_LEVEL: debug + annotations: + require-adobe-auth: false + final: true +operations: + view: + - type: web + impl: index.html +hooks: + post-deploy: hooks/post-deploy.js +``` + +## § CF Console — `aem/cf-console-admin/1` + +`ExtensionRegistration.js` — include only the namespace blocks the user selected in Step 2. + +```js +import React from 'react'; +import { Text } from '@adobe/react-spectrum'; +import { register } from '@adobe/uix-guest'; +import { extensionId } from './Constants'; + +function ExtensionRegistration() { + const init = async () => { + const guestConnection = await register({ + id: extensionId, + methods: { + // ── actionBar: buttons shown when fragments are selected ────────────── + actionBar: { + getButtons() { + return [ + { + id: '{{EXTENSION_NAME}}-action', + label: '{{DISPLAY_NAME}}', + icon: 'Export', // React Spectrum workflow icon name + onClick(selections) { + // selections = array of selected fragment objects + guestConnection.host.modal.showUrl({ + title: '{{DISPLAY_NAME}}', + url: '/index.html#/modal', // must match a in App.js + width: 600, + height: 'auto', + }); + }, + }, + ]; + }, + }, + + // ── headerMenu: buttons always visible in the console header ────────── + headerMenu: { + getButtons() { + return [ + { + id: '{{EXTENSION_NAME}}-header', + label: '{{DISPLAY_NAME}}', + icon: 'Import', + variant: 'action', // cta | primary | secondary | negative | action + onClick() { + guestConnection.host.modal.showUrl({ + title: '{{DISPLAY_NAME}}', + url: '/index.html#/modal', + }); + }, + }, + ]; + }, + }, + + // ── contentFragmentGrid: custom column in the fragment list ─────────── + contentFragmentGrid: { + getColumns() { + return [ + { + id: '{{EXTENSION_NAME}}-col', + label: '{{DISPLAY_NAME}}', + render: async (fragments) => + fragments.reduce((acc, f) => { + acc[f.id] = f.status || 'Draft'; + return acc; + }, {}), + }, + ]; + }, + }, + }, + }); + }; + + init().catch(console.error); + return IFrame for integration with Host (CF Console)...; +} + +export default ExtensionRegistration; +``` + +**Selections programmatically:** `await guestConnection.host.fragmentSelections.getSelections()`. + +--- + +## § CF Editor — `aem/cf-editor/1` + +```js +import React from 'react'; +import { Text } from '@adobe/react-spectrum'; +import { register } from '@adobe/uix-guest'; +import { extensionId } from './Constants'; + +function ExtensionRegistration() { + const init = async () => { + const guestConnection = await register({ + id: extensionId, + methods: { + // ── headerMenu: context-aware — can read the open fragment ──────────── + headerMenu: { + getButtons() { + return [ + { + id: '{{EXTENSION_NAME}}-validate', + label: '{{DISPLAY_NAME}}', + icon: 'CheckmarkCircle', + async onClick() { + const fragment = await guestConnection.host.contentFragment.getContentFragment(); + console.log('fragment path:', fragment.path, 'fields:', fragment.fields); + guestConnection.host.toaster.display({ variant: 'positive', message: 'Validated' }); + }, + }, + ]; + }, + }, + + // ── rte: custom RTE toolbar buttons & badges (deprecated API, works today) ── + rte: { + getCustomButtons: () => [ + { + id: '{{EXTENSION_NAME}}-insert', + tooltip: '{{DISPLAY_NAME}}', + icon: 'Info', + onClick: (state) => [ + // state = { html, text, selectedHtml, selectedText } + { type: 'replaceContent', value: state.html + '

Inserted by {{DISPLAY_NAME}}.

' }, + ], + }, + ], + getBadges: () => [ + { id: '{{EXTENSION_NAME}}-var', prefix: '{{', suffix: '}}', backgroundColor: '#D6F1FF', textColor: '#54719B' }, + ], + }, + }, + }); + }; + + init().catch(console.error); + return IFrame for integration with Host (CF Editor)...; +} + +export default ExtensionRegistration; +``` + +> The `rte` API (custom buttons, badges, core-button control) is deprecated and will be replaced when AEM adopts a new RTE engine. It works today; plan for migration. + +--- + +## § Universal Editor — `aem/universal-editor/1` + +```js +import React from 'react'; +import { Text } from '@adobe/react-spectrum'; +import { register } from '@adobe/uix-guest'; +import { extensionId } from './Constants'; + +function ExtensionRegistration() { + const init = async () => { + const guestConnection = await register({ + id: extensionId, + methods: { + headerMenu: { + getButtons() { + return [ + { + id: '{{EXTENSION_NAME}}-preview', + label: '{{DISPLAY_NAME}}', + icon: 'Preview', + onClick() { + guestConnection.host.modal.showUrl({ + title: '{{DISPLAY_NAME}}', + url: '/index.html#/panel', + fullscreen: true, + }); + }, + }, + ]; + }, + }, + }, + }); + }; + + init().catch(console.error); + return IFrame for integration with Host (Universal Editor)...; +} + +export default ExtensionRegistration; +``` + +**Properties-rail panel:** render a separate page that reconnects with `attach()`: + +```js +// src/{{EXTENSION_DIR}}/web-src/src/components/Panel.js +import React from 'react'; +import { attach } from '@adobe/uix-guest'; +import { extensionId } from './Constants'; +// In useEffect: const guestConnection = await attach({ id: extensionId }); +// Read context via guestConnection.sharedContext; render Spectrum UI. +``` + +--- + +## § Assets View — `aem/assets/1` + +> **Prerequisite:** AEM Assets Ultimate license. If the org lacks it, the extension won't load — surface this to the user. + +```js +import React from 'react'; +import { Text } from '@adobe/react-spectrum'; +import { register } from '@adobe/uix-guest'; +import { extensionId } from './Constants'; + +function ExtensionRegistration() { + const init = async () => { + const guestConnection = await register({ + id: extensionId, + methods: { + actionBar: { + getButtons() { + return [ + { + id: '{{EXTENSION_NAME}}-action', + label: '{{DISPLAY_NAME}}', + icon: 'Edit', + onClick(selections) { + guestConnection.host.modal.showUrl({ + title: '{{DISPLAY_NAME}}', + url: '/index.html#/modal', + }); + }, + }, + ]; + }, + }, + headerMenu: { + getButtons() { + return [ + { + id: '{{EXTENSION_NAME}}-header', + label: '{{DISPLAY_NAME}}', + icon: 'Settings', + onClick() { + guestConnection.host.modal.showUrl({ title: '{{DISPLAY_NAME}}', url: '/index.html#/modal' }); + }, + }, + ]; + }, + }, + }, + }); + }; + + init().catch(console.error); + return IFrame for integration with Host (Assets View)...; +} + +export default ExtensionRegistration; +``` + +The Assets View extension surface is newer and evolving — see [`../appbuilder-ui-scaffolder/references/aem-extensions.md`](../../appbuilder-ui-scaffolder/references/aem-extensions.md) and the [Assets View docs](https://developer.adobe.com/uix/docs/services/aem-assets-view/) for the latest extension points. + +> **Modal pages and host utilities** (toaster, progressCircle, modal.showUrl) are covered in [`../appbuilder-ui-scaffolder/references/aem-extensions.md`](../../appbuilder-ui-scaffolder/references/aem-extensions.md) § Modal Dialogs and § Host Utilities — read that file for patterns; only the scaffold files above are surface-specific. + +## Quick reference + +| Surface | Extension Point | Namespaces | Key host APIs | +| --- | --- | --- | --- | +| CF Console | `aem/cf-console-admin/1` | `actionBar`, `headerMenu`, `contentFragmentGrid` | `fragmentSelections`, `modal`, `toaster`, `progressCircle` | +| CF Editor | `aem/cf-editor/1` | `headerMenu`, `rte` | `contentFragment`, `modal`, `toaster` | +| Universal Editor | `aem/universal-editor/1` | `headerMenu`, properties panel | `modal` | +| Assets View | `aem/assets/1` | `actionBar`, `headerMenu` | `modal` | diff --git a/plugins/app-builder/skills/adobe-extension-scaffolder/references/debugging.md b/plugins/app-builder/skills/adobe-extension-scaffolder/references/debugging.md new file mode 100644 index 00000000..1d618dbb --- /dev/null +++ b/plugins/app-builder/skills/adobe-extension-scaffolder/references/debugging.md @@ -0,0 +1,217 @@ +# Adobe App Builder Extension Debugging + +Troubleshooting guide for build, runtime, and integration failures across all supported surfaces (Content Hub, CF Console, CF Editor, Universal Editor, Assets View, ExC Shell). + +--- + +## Dev Server / Build Issues + +### `aio app run` exits immediately or never shows the localhost URL + +**Cause:** Build failed, or Node.js version too old. + +```bash +# Check Node version (must be 18+) +node -v + +# Confirm npm install completed without errors +npm install + +# Rebuild first +aio app build + +# Then start +aio app run +``` + +If `aio app build` fails with a manifest validation error: +```bash +aio app build --no-config-validation +``` +Fix the underlying YAML issue in `app.config.yaml` or `ext.config.yaml`, then rebuild without the flag. + +### `aio app build` fails: "function not found" or "action file missing" + +The action file path in `ext.config.yaml` does not match the actual file location. + +Check: +```yaml +# ext.config.yaml +actions: + generic: + function: actions/generic/index.js # relative to ext.config.yaml's directory +``` + +The file must exist at `src//actions/generic/index.js` (where `` is `surfaceConfig.extDir`, e.g. `aem-assets-contenthub-1` for Content Hub). + +--- + +## Local Testing Issues + +### Panel or app is blank / nothing loads + +**Most common cause:** Self-signed certificate not trusted. + +``` +Fix: +1. Open https://localhost:9080 in the same browser +2. Click Advanced → Proceed to localhost (unsafe) + OR type "thisisunsafe" in Chrome +3. Reload the extension test URL +``` + +If the cert page shows an error (not just a warning), `aio app run` may not be running. Check the terminal. + +### Extension not visible after opening the test URL + +Check in order: + +1. **`aio app run` not running** — Confirm the dev server is up and the terminal shows `-> https://localhost:9080`. + +2. **`devMode=true` missing** — The local `ext=` URL param only works with `devMode=true` in the URL: + ``` + https://experience.adobe.com/?devMode=true&ext=https://localhost:9080 + ``` + +3. **Wrong extension point in `app.config.yaml`** — The declared extension point must match the target surface (e.g. `aem/assets/contenthub/1` for Content Hub, `aem/cf-console-admin/1` for CF Console). Do not use deprecated IDs. + +4. **Content Hub only — `allowedRepos` filtering** — The `repo` URL parameter doesn't match `allowedRepos`. Either empty the array for dev, or add the repo you're testing with. + +5. **`register()` not called** — Check the browser console for errors. If `ExtensionRegistration` threw, `register()` never ran. + +### `attach()` fails: "No extension with id ... found" + +The `extensionId` in `TabPanel.js` does not match the `id` in `ExtensionRegistration.js`. + +Both must import `extensionId` from `Constants.js`. Check that `Constants.js` has not been edited and both files import from it: +```js +import { extensionId } from './Constants'; +``` + +### Host API returns undefined or throws + +`attach()` must fully resolve before calling `host.*` methods. Always `await attach()` before using the connection: + +```js +// Wrong — calling host API before attach resolves +const connection = attach({ id: extensionId }); +const asset = await connection.host.assetDetails.getCurrentAsset(); // undefined connection + +// Correct +const connection = await attach({ id: extensionId }); +const asset = await connection.host.assetDetails.getCurrentAsset(); +``` + +### Content Hub — panel shows but `getCurrentAsset()` returns null + +This happens if the panel iframe loads before Content Hub has populated the asset context. Wrap in a try/catch and retry with a delay if needed, or check that the panel route is only rendered after `attach()` resolves. + +> **Note:** `getCurrentAsset()` returns a plain string asset ID (e.g. `"urn:aaid:aem:..."`), not `{ id }`. Normalize it: `const asset = typeof raw === 'string' ? { id: raw } : raw;` + +--- + +## Authentication Issues + +### `aio login` needed / OAuth token expired + +Run in a subshell with `CI`/`AIO_CLI_NO_TTY` unset — otherwise the browser is suppressed and login silently fails: + +```bash +bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio login' +# If the token is stale (401 even when aio where shows logged in): +bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio login --force' +``` + +### `aio console project create` fails with 401 + +Token is expired or the wrong org is selected. Run `aio login`, then `aio console org select `. + +### `npm install` fails with E401 + +Adobe's internal npm registry config in `.npmrc` may be conflicting. Try: +```bash +npm install --registry https://registry.npmjs.org +``` + +--- + +## Developer Console Bootstrap Issues + +### `aio console project create` returns "already exists" + +```bash +aio console project list --json +# Find the project name/id and reuse it +``` + +### `aio console workspace create` returns "already exists" + +```bash +aio console workspace list --projectName --json +# Stage workspace already there — skip to workspace api add +``` + +### `aio console workspace api add` returns "product profile required" + +Content Hub extensions only need I/O Runtime, which is free-tier and should not require a profile. If it does for your org: +```bash +aio console api list --json | grep -A5 AdobeIORuntime +# Find the required profile name, then: +aio console workspace api add \ + --projectName

--workspaceName Stage \ + --service-code AdobeIORuntime \ + --license-config "AdobeIORuntime=" --json +``` + +### `aio app use` fails: workspace not found + +Use explicit IDs rather than names: +```bash +PROJECT_ID=$(aio console project list --json | node -e " + const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); + console.log((d.find(p=>p.name==='')||{}).id||''); +") +aio console project select "$PROJECT_ID" + +WORKSPACE_ID=$(aio console workspace list --projectId "$PROJECT_ID" --json | node -e " + const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); + console.log((d.find(w=>w.name==='Stage')||{}).id||''); +") +aio console workspace select "$WORKSPACE_ID" +aio app use --no-input +``` + +--- + +## Post-Deployment Issues + +### Extension deployed but invisible to some users + +The App Builder project has extra services (Cloud Manager, AEM Assets Author API, etc.) that not all users are entitled to. + +**Fix:** In Adobe Developer Console, remove all services except Runtime from the project. Redeploy and reapprove. + +### Extension approved but not showing (no `ext=` param) + +Wrong workspace. Approved in Stage but deployed to Production, or vice versa. +```bash +aio app use -w Production +aio app deploy +# Reapprove in Extension Manager +``` + +--- + +## Quick Diagnosis Checklist + +Run through this in order when the extension doesn't work: + +1. `aio where` — correct org, project, workspace? +2. `aio app run` output — localhost URL shown? +3. Browser console — any JS errors in `ExtensionRegistration`? +4. Test URL — includes `devMode=true` and `ext=https://localhost:9080`? +5. `https://localhost:9080` in browser — cert trusted? +6. `app.config.yaml` — extension point matches the target surface (correct, non-deprecated ID)? +7. `Constants.js` `extensionId` — imported correctly in both `ExtensionRegistration.js` and the panel/modal page? +8. **Content Hub only** — `allowedRepos` empty (dev) or contains the test repo? +9. **ExC Shell only** — `runtime.done()` called at the `register` level (not inside `onReady` or after a fetch)? diff --git a/plugins/app-builder/skills/adobe-extension-scaffolder/references/deployment.md b/plugins/app-builder/skills/adobe-extension-scaffolder/references/deployment.md new file mode 100644 index 00000000..aa1d23df --- /dev/null +++ b/plugins/app-builder/skills/adobe-extension-scaffolder/references/deployment.md @@ -0,0 +1,149 @@ +# Adobe App Builder Extension Deployment + +Complete deployment workflow for App Builder extensions from Stage through Production approval. Applies to all surfaces (Content Hub, CF Console, CF Editor, Universal Editor, Assets View, ExC Shell). + +--- + +## Prerequisites + +Before deploying: +1. `aio app run` has succeeded locally (extension visible with `ext=` URL param) +2. `allowedRepos` in `ExtensionRegistration.js` is populated with delivery repo IDs +3. The correct org and project are selected (`aio where` shows the right state) + +--- + +## Stage Deployment + +Stage is used for QA and stakeholder review before Production. + +```bash +# Switch workspace — unset CI/AIO_CLI_NO_TTY in subshell so credentials are downloaded +bash -c 'unset CI AIO_CLI_NO_TTY TERM; printf "y\ny\ny\ny\n" | aio app use -w Stage --overwrite' 2>/dev/null + +# Deploy — capture output +aio app deploy 2>&1 | tee /tmp/aio-deploy.log +``` + +After deploy completes, **always** parse the CDN URL from the log — even if actions failed: + +```bash +CDN_URL=$(grep -Eo 'https://[^ ]+adobeio-static\.net[^ ]*' /tmp/aio-deploy.log | grep 'index\.html' | tail -1) +# If no index.html variant found, take any adobeio-static.net URL: +CDN_URL=${CDN_URL:-$(grep -Eo 'https://[^ ]+adobeio-static\.net[^ ]*' /tmp/aio-deploy.log | tail -1)} +``` + +Open the target surface automatically with the deployed URL — **do not just print it**. Use `surfaceConfig.openUrl` with the local `ext=https://localhost:9080` replaced by `ext=/index.html`. For Content Hub specifically: + +```bash +open "https://experience.adobe.com/?devMode=true&ext=${CDN_URL}/index.html#/assets/contenthub/" +``` + +For other AEM surfaces (`cf-console`, `cf-editor`, `universal-editor`, `assets-view`), drop the hash: +```bash +open "https://experience.adobe.com/?devMode=true&ext=${CDN_URL}/index.html" +``` + +For ExC Shell, the CDN URL printed by `aio app deploy` is the deployed SPA URL — open it directly. + +**Partial failure rule:** If web assets deployed but actions failed (Runtime not provisioned in the org, or 401 on action deploy), still open the CDN URL. The extension UI loads correctly. Actions will fail only when called. Do not block on action deployment errors. + +--- + +## Production Deployment + +```bash +# Switch to Production workspace — unset CI/AIO_CLI_NO_TTY in subshell +bash -c 'unset CI AIO_CLI_NO_TTY TERM; printf "y\ny\ny\ny\n" | aio app use -w Production --overwrite' 2>/dev/null + +# Deploy to Production +aio app deploy +``` + +Production deployment must be followed by an approval step before the extension is visible to all users in the org. + +--- + +## Extension Manager Approval + +1. Open `https://experience.adobe.com/aem/extension-manager` +2. Find your extension by name +3. Click **Approve** +4. The extension becomes visible to all users in your org — no `ext=` URL parameter needed + +--- + +## Workspace Selection (`aio app use`) + +`aio app use` rewrites `.aio` and `.env` to point at the specified workspace. Always run it before `aio app deploy` when switching targets. + +```bash +# Check current workspace +CI=true AIO_CLI_NO_TTY=true NO_COLOR=1 aio where + +# Switch workspace — unset CI/AIO_CLI_NO_TTY in subshell so credentials are downloaded +bash -c 'unset CI AIO_CLI_NO_TTY TERM; printf "y\ny\ny\ny\n" | aio app use -w Stage --overwrite' 2>/dev/null +bash -c 'unset CI AIO_CLI_NO_TTY TERM; printf "y\ny\ny\ny\n" | aio app use -w Production --overwrite' 2>/dev/null +``` + +If `aio app use -w ` does not find the workspace by name, use explicit flags (also in clean subshell): +```bash +bash -c 'unset CI AIO_CLI_NO_TTY TERM; aio app use --org --project --workspace Stage --no-input' +``` + +--- + +## Re-deploy After Code Changes + +```bash +# Rebuild and redeploy (from the project directory) +aio app deploy +``` + +For UI-only changes (no action changes), `aio app deploy` is still the right command — it rebuilds the web-src bundle and pushes to CDN. + +--- + +## Troubleshooting Deployments + +### Extension visible with `ext=` but not after approval + +The extension was approved in the wrong workspace. The `ext=` URL param bypasses workspace checks. + +```bash +aio where # confirm you're on Production +aio app use -w Production +aio app deploy +# Then re-approve in Extension Manager +``` + +### Extension invisible to some users + +The App Builder project includes extra Adobe services (AEM Assets Author API, Cloud Manager, etc.). Only users entitled to those services see the extension. + +**Fix:** Remove all non-required services from the App Builder project in Adobe Developer Console. Keep only Runtime. Redeploy and reapprove. + +### `aio app deploy` fails with auth error + +`.env` is missing or stale. Re-wire the workspace (unset CI in a subshell so credentials are downloaded): +```bash +bash -c 'unset CI AIO_CLI_NO_TTY TERM; printf "y\ny\ny\ny\n" | aio app use -w Stage --overwrite' 2>/dev/null +aio app deploy +``` + +### CDN propagation delay + +After `aio app deploy` succeeds, CDN propagation can take 1-2 minutes. If the extension shows the old version immediately after deploy, wait a moment and hard-refresh. + +--- + +## Full Deployment Checklist + +- [ ] `aio where` shows the correct org, project, and workspace +- [ ] `aio app use -w ` run in a clean subshell (`unset CI AIO_CLI_NO_TTY TERM`) before deploying +- [ ] `aio app deploy` completed without errors (partial web-only success is still usable) +- [ ] Tested with the deployed CDN URL (not localhost) +- [ ] **Content Hub / AEM surfaces** — deployed URL opened with `?devMode=true&ext=/index.html` +- [ ] **Content Hub only** — `allowedRepos` populated with target delivery repo IDs before Production deploy +- [ ] For Production: approved in Extension Manager +- [ ] For Production: verified without `ext=` URL parameter diff --git a/plugins/app-builder/skills/adobe-extension-scaffolder/references/excshell-templates.md b/plugins/app-builder/skills/adobe-extension-scaffolder/references/excshell-templates.md new file mode 100644 index 00000000..5d1d536d --- /dev/null +++ b/plugins/app-builder/skills/adobe-extension-scaffolder/references/excshell-templates.md @@ -0,0 +1,192 @@ +# Experience Cloud Shell SPA Templates (`dx/excshell/1`) + +The ExC Shell surface is **not** a UIX extension — it's a standalone App Builder SPA that runs inside the Experience Cloud Shell iframe. It uses **`@adobe/exc-app`** (`register()` + `runtime.done()`), NOT `@adobe/uix-guest`. There are no extension points and no `methods` object. + +Reuse from [`file-templates.md`](file-templates.md): `index.html`, `index.css`, `actions/utils.js`, `actions/generic/index.js`, `hooks/post-deploy.js`, `.eslintrc.js`. Everything below is ExC-Shell-specific. + +Placeholders: `{{EXTENSION_NAME}}`, `{{DISPLAY_NAME}}`, `{{EXTENSION_DESCRIPTION}}`, `{{EXTENSION_DIR}}` (= `dx-excshell-1`). + +--- + +## `package.json` + +Same as Content Hub but swap `@adobe/uix-guest` for `@adobe/exc-app`: + +```json +{ + "name": "{{EXTENSION_NAME}}", + "version": "1.0.0", + "description": "{{EXTENSION_DESCRIPTION}}", + "license": "Apache-2.0", + "scripts": { + "test": "jest --passWithNoTests --testPathIgnorePatterns web-src", + "dev": "aio app run", + "build": "aio app build", + "deploy": "aio app deploy", + "undeploy": "aio app undeploy" + }, + "dependencies": { + "@adobe/aio-sdk": "^5.0.0", + "@adobe/exc-app": "^1.4.17", + "@adobe/react-spectrum": "^3.33.0", + "chalk": "^4.0.0", + "js-yaml": "^4.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-error-boundary": "^4.0.0" + }, + "devDependencies": { + "@adobe/eslint-config-aio-lib-config": "^3.0.0", + "eslint": "^8.0.0", + "jest": "^29.0.0" + }, + "engines": { "node": "^18 || ^20" } +} +``` + +--- + +## `app.config.yaml` + +```yaml +extensions: + dx/excshell/1: + $include: src/dx-excshell-1/ext.config.yaml +``` + +--- + +## `src/dx-excshell-1/ext.config.yaml` + +```yaml +$schema: https://unpkg.com/@adobe/aio-schemas@latest/schemas/aio.schema.json +actions: actions +web: web-src +runtimeManifest: + packages: + dx-excshell-1: + license: Apache-2.0 + actions: + generic: + function: actions/generic/index.js + web: 'yes' + runtime: 'nodejs:18' + inputs: + LOG_LEVEL: debug + annotations: + require-adobe-auth: true + final: true +operations: + view: + - type: web + impl: index.html +hooks: + post-deploy: hooks/post-deploy.js +``` + +> Unlike the AEM surfaces, ExC Shell actions typically set `require-adobe-auth: true` because the SPA forwards the shell's IMS token. If the app has no backend, you may delete the `actions`/`runtimeManifest` blocks entirely. + +> `index.js` and `index.css` are identical to the shared skeleton in [`file-templates.md`](file-templates.md) — reuse those directly. + +## `src/dx-excshell-1/web-src/src/components/App.js` + +The entire shell handshake lives here. **`runtime.done()` MUST be called at the `register` level** (not after data fetch) or the shell shows an infinite spinner. + +```js +import React, { useState, useEffect } from 'react'; +import { Provider, defaultTheme, View, Heading, Text, Button, ProgressCircle, Divider } from '@adobe/react-spectrum'; +import { register } from '@adobe/exc-app'; +import actions from '../config.json'; + +export default function App() { + const [shellReady, setShellReady] = useState(false); + const [ctx, setCtx] = useState({}); // { imsOrg, imsToken, imsProfile, locale } + const [actionResponse, setActionResponse] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + register({ id: '{{EXTENSION_NAME}}' }, (runtime) => { + runtime.ready({ + onReady: ({ imsOrg, imsToken, imsProfile, locale }) => { + setCtx({ imsOrg, imsToken, imsProfile, locale }); + setShellReady(true); + }, + }); + // CRITICAL: dismiss the shell loading spinner immediately — never wait for data here. + runtime.done(); + }); + }, []); + + async function callAction() { + try { + const res = await fetch(actions['dx-excshell-1/generic'], { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${ctx.imsToken}`, + 'x-gw-ims-org-id': ctx.imsOrg, + }, + body: JSON.stringify({}), + }); + setActionResponse(await res.json()); + } catch (e) { + setError(e.message); + } + } + + if (!shellReady) { + return ( + + + + + + ); + } + + return ( + + + {{DISPLAY_NAME}} + + Signed in as {ctx.imsProfile?.name} — org {ctx.imsOrg} + {error && {error}} + {actionResponse && ( + + {JSON.stringify(actionResponse, null, 2)} + + )} + + + + ); +} +``` + +--- + +## `src/dx-excshell-1/web-src/src/config.json` + +```json +{ + "dx-excshell-1/generic": "https://localhost:9080/api/v1/web/dx-excshell-1/generic" +} +``` + +Overwritten by `aio app run` (localhost) and `aio app deploy` (CDN). Do not edit by hand. + +--- + +## Key differences from the AEM/Content Hub surfaces + +| | AEM / Content Hub (`uix-guest`) | ExC Shell (`exc-app`) | +| --- | --- | --- | +| SDK | `@adobe/uix-guest` | `@adobe/exc-app` | +| Entry call | `register({ id, methods })` | `register({ id }, (runtime) => …)` | +| Auth | `host.auth` / `sharedContext.get('auth')` | `runtime.ready({ onReady })` context | +| Extension points | yes (`assetDetails`, `actionBar`, …) | none — it's a full SPA | +| Must call `runtime.done()` | no | **yes — or the shell spins forever** | +| Local view | inside an AEM surface via `?ext=` | the SPA serves directly at `https://localhost:9080` | + +See [`../appbuilder-ui-scaffolder/references/shell-integration.md`](../../appbuilder-ui-scaffolder/references/shell-integration.md) for pitfalls, host-API reference, and IMS token passthrough patterns. diff --git a/plugins/app-builder/skills/adobe-extension-scaffolder/references/file-templates.md b/plugins/app-builder/skills/adobe-extension-scaffolder/references/file-templates.md new file mode 100644 index 00000000..9736307f --- /dev/null +++ b/plugins/app-builder/skills/adobe-extension-scaffolder/references/file-templates.md @@ -0,0 +1,1031 @@ +# Content Hub Extension File Templates (+ shared uix-guest skeleton) + +Complete templates for all scaffold files. Before writing, substitute: +- `{{EXTENSION_NAME}}` → kebab-case name (e.g. `my-asset-viewer`) +- `{{DISPLAY_NAME}}` → Title Case name (e.g. `My Asset Viewer`) +- `{{EXTENSION_DESCRIPTION}}` → one-sentence description +- `{{EXTENSION_DIR}}` → for Content Hub, `aem-assets-contenthub-1`; for other AEM surfaces, `surfaceConfig.extDir` + +Extension point used throughout: `aem/assets/contenthub/1` +Source directory: `src/aem-assets-contenthub-1/` + +> **Dual role.** This file is both the Content Hub template set AND the **shared `@adobe/uix-guest` skeleton** reused by the other AEM surfaces (CF Console, CF Editor, Universal Editor, Assets View). When scaffolding one of those surfaces, reuse every file here — `package.json`, `index.html`, `index.js`, `index.css`, `Constants.js`, `App.js`, `actions/*`, `hooks/post-deploy.js`, `README`/`AGENTS`, `.eslintrc.js` — swapping `aem-assets-contenthub-1` → `surfaceConfig.extDir`, the extension point → `surfaceConfig.extensionPointId`, and the `register()` `methods` object → the surface block in [`aem-surface-templates.md`](aem-surface-templates.md). ExC Shell does NOT share this skeleton (different SDK) — see [`excshell-templates.md`](excshell-templates.md). + +--- + +## `package.json` + +```json +{ + "name": "{{EXTENSION_NAME}}", + "version": "1.0.0", + "description": "{{EXTENSION_DESCRIPTION}}", + "author": "", + "license": "Apache-2.0", + "scripts": { + "test": "jest --passWithNoTests --testPathIgnorePatterns web-src", + "dev": "aio app run", + "build": "aio app build", + "deploy": "aio app deploy", + "undeploy": "aio app undeploy" + }, + "dependencies": { + "@adobe/aio-sdk": "^5.0.0", + "@adobe/uix-guest": "0.10.5", + "@adobe/react-spectrum": "^3.0.0", + "chalk": "^4.0.0", + "js-yaml": "^4.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-router-dom": "^6.0.0", + "react-error-boundary": "^4.0.0" + }, + "devDependencies": { + "@adobe/eslint-config-aio-lib-config": "^3.0.0", + "eslint": "^8.0.0", + "jest": "^29.0.0" + }, + "engines": { + "node": "^18 || ^20" + } +} +``` + +--- + +## `app.config.yaml` + +```yaml +extensions: + aem/assets/contenthub/1: + $include: src/aem-assets-contenthub-1/ext.config.yaml +``` + +**Critical:** Use `aem/assets/contenthub/1` — not the deprecated `aem/contenthub/assets/details/1`. If the user's existing project has the old extension point ID, update both this file and the directory name. + +--- + +## `src/aem-assets-contenthub-1/ext.config.yaml` + +```yaml +$schema: https://unpkg.com/@adobe/aio-schemas@latest/schemas/aio.schema.json +actions: actions +web: web-src +runtimeManifest: + packages: + aem-assets-contenthub-1: + license: Apache-2.0 + actions: + generic: + function: actions/generic/index.js + web: 'yes' + runtime: 'nodejs:18' + inputs: + LOG_LEVEL: debug + annotations: + require-adobe-auth: false + final: true +operations: + view: + - type: web + impl: index.html +hooks: + post-deploy: hooks/post-deploy.js +``` + +--- + +## `extension-manifest.json` + +```json +{ + "name": "{{EXTENSION_NAME}}", + "id": "aem-assets-contenthub-1", + "description": "{{EXTENSION_DESCRIPTION}}", + "version": "1.0.0", + "engines": { + "aio-cli": ">=10.0.0" + }, + "keywords": ["contenthub", "assets", "extension", "uix"], + "author": "", + "license": "Apache-2.0", + "extensionPoints": ["aem/assets/contenthub/1"] +} +``` + +--- + +## `.eslintrc.js` + +```js +module.exports = { + root: true, + extends: ['@adobe/eslint-config-aio-lib-config'], + env: { + node: true, + es2020: true, + browser: true + }, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + ecmaFeatures: { jsx: true } + } +}; +``` + +--- + +## `AGENTS.md` + +```markdown +# {{DISPLAY_NAME}} — Agent Guidelines + +## Extension Point + +`aem/assets/contenthub/1` — Content Hub extensibility. + +## Active Namespaces + +- `assetDetails` — tab panels in the Asset Details Dialog side rail +- `card` — action buttons on asset cards (Assets grid, collection, link share) and on collection tiles in the Collections grid +- `selectionBar` — bulk action buttons in the selection bar (multi-select) + +(Remove entries for namespaces not wired in ExtensionRegistration.js) + +## Project Structure + +- `src/aem-assets-contenthub-1/web-src/` — React SPA (Spectrum UI, UIX Guest) +- `src/aem-assets-contenthub-1/actions/` — Adobe I/O Runtime web actions +- `app.config.yaml` — declares `aem/assets/contenthub/1` extension point +- `src/aem-assets-contenthub-1/ext.config.yaml` — runtime manifest + +## Building & Running + +Use `aio` CLI commands (not npm scripts directly): +- `aio app build` — build and deploy actions to I/O Runtime, compile web-src +- `aio app run` — start local dev server at https://localhost:9080 +- `aio app deploy` — full deployment to Stage/Production + +## Local Test URL + +``` +https://experience.adobe.com/?devMode=true&ext=https://localhost:9080#/assets/contenthub/ +``` + +First time: accept the self-signed cert at https://localhost:9080. + +## Key Files to Customize + +- `ExtensionRegistration.js` — declares all namespaces. Uses `let guestConnection` so `onActionClick` can call `host.modal.openDialog()` after `register()` resolves. +- `TabPanel.js` — panel UI for `assetDetails` namespace (rendered in iframe) +- `CardActionModal.js` — modal UI for `card` namespace; reads its data from the `contentUrl` query (`URLSearchParams`), NOT `getPayload()` +- `SelectionBarModal.js` — modal UI for `selectionBar` namespace; reads `assetIds[]` from the `contentUrl` query (`URLSearchParams` + `JSON.parse`), NOT `getPayload()` +- `actions/generic/index.js` — server-side logic for AEM API calls + +## Host APIs (via `guestConnection.host` in any attached page) + +- `auth.getIMSInfo()` → `{ imsOrg, imsOrgName, accessToken }` +- `auth.getApiKey()` → API key string ← note: `getApiKey`, not `getAPIKey` +- `discovery.getAemHost()` → `https:///` +- `toast.display({ variant, message })` → show toast; variants: `neutral`, `positive`, `negative`, `info` +- `i18n.getLocalizationInfo()` → `{ locale }` +- `modal.openDialog({ title, contentUrl, type?, size? })` → open modal dialog. **Single config object — NOT `({ id }, {...})`, and there is NO `payload` field.** Pass data to the modal in the `contentUrl` query string (e.g. `/#card-action-modal?resourceId=...&resourceType=...`). +- `modal.closeDialog()` → close the current modal (call from the modal page after `attach()`) +- There is **no `getPayload()`** — the modal reads its data from `window.location.hash` query params. + +## assetDetails: getCurrentAsset() + +`assetDetails.getCurrentAsset()` returns the asset id as a plain **STRING** (e.g. `"urn:aaid:aem:..."`), NOT an object. Normalize: `const currentAsset = typeof id === 'string' ? { id } : id`. + +## card: getActionButtons receives actionContext + +The host calls `getActionButtons(actionContext)` with: +- `actionContext.context`: `'assets'` | `'collection'` | `'collections'` | `'share'` — the source view. + `assets`, `collection`, and `share` are asset-card surfaces; `collections` is a collection tile + on the Collections grid. Use it to vary buttons per surface, or ignore it to show the same set. + +The same `card` namespace serves both asset cards and collection tiles — distinguish them via +`actionContext.context` (and `resourceType` on click). + +## card: onActionClick signature + +Called by the host as `onActionClick(resourceType, buttonId, resourceId, actionContext)`: +- `resourceType`: `'asset'` (asset cards) or `'collection'` (collection tiles) +- `buttonId`: the `id` from `getActionButtons()` +- `resourceId`: the asset or collection URN string +- `actionContext`: `{ context }` — same surface values as above + +## selectionBar: getActionButtons receives actionContext + +The host calls `selectionBar.getActionButtons(actionContext)` with: +- `actionContext.context`: `'assets'` | `'collections'` | `'collection'` | `'share'` — the source view +- `actionContext.resourceSelection.resources`: `[{id: string}, ...]` — the current selection + +Use this to conditionally show/hide buttons depending on where the bar appears, or ignore it. + +## selectionBar: onActionClick signature + +Called by the host as `onActionClick(buttonId, assetIds)`: +- `buttonId`: the `id` from `getActionButtons()` +- `assetIds`: `string[]` — array of selected asset URNs + +Note: the host prefixes the rendered button id as `ext::` to avoid collisions with native actions. Your extension code uses the original `btn.id` — the prefix is host-internal only. +``` + +--- + +## `hooks/post-deploy.js` + +```js +const chalk = require('chalk'); +const fs = require('fs'); +const yaml = require('js-yaml'); + +module.exports = (config) => { + try { + const yamlFile = fs.readFileSync(`${config.root}/app.config.yaml`, 'utf8'); + const yamlData = yaml.load(yamlFile); + const { extensions } = yamlData; + const extension = Object.keys(extensions)[0]; + const previewData = { + extensionPoint: extension, + url: config.project.workspace.app_url, + }; + const base64EncodedData = Buffer.from(JSON.stringify(previewData)).toString('base64'); + console.log(chalk.magenta(chalk.bold('For a developer preview of your UI extension in the Content Hub environment, follow the URL:'))); + const env = process.env.AIO_CLI_ENV === 'stage' ? '-stage' : ''; + console.log(chalk.magenta(chalk.bold(` -> https://experience${env}.adobe.com/aem/extension-manager/preview/${base64EncodedData}`))); + } catch (_) { + // Non-fatal: just skip the preview URL + } +}; +``` + +--- + +## `src/aem-assets-contenthub-1/web-src/index.html` + +```html + + + + + + {{EXTENSION_NAME}} + + + +

+ + + +``` + +--- + +## `src/aem-assets-contenthub-1/web-src/src/index.js` + +```js +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './components/App.js'; +import './index.css'; + +const root = createRoot(document.getElementById('root')); +root.render(); +``` + +--- + +## `src/aem-assets-contenthub-1/web-src/src/index.css` + +```css +html, body { + margin: 0; +} +``` + +--- + +## `src/aem-assets-contenthub-1/web-src/src/config.json` + +```json +{ + "aem-assets-contenthub-1/generic": "https://localhost:9080/api/v1/web/aem-assets-contenthub-1/generic" +} +``` + +This file is overwritten by `aio app run` (localhost URL) and `aio app deploy` (cloud URL). Do not edit manually. + +--- + +## `src/aem-assets-contenthub-1/web-src/src/components/Constants.js` + +```js +export const extensionId = 'aem-assets-contenthub-1'; +``` + +The `extensionId` **must** be identical in `register()` (ExtensionRegistration.js) and `attach()` (TabPanel.js). Both import from this file. + +--- + +## `src/aem-assets-contenthub-1/web-src/src/components/App.js` + +Include only the imports and routes for the namespaces selected in Step 2. Remove unused imports/routes. + +```js +import React from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { HashRouter as Router, Routes, Route } from 'react-router-dom'; +import ExtensionRegistration from './ExtensionRegistration'; +import TabPanel from './TabPanel'; // assetDetails namespace — remove if not selected +import CardActionModal from './CardActionModal'; // card namespace — remove if not selected +import SelectionBarModal from './SelectionBarModal'; // selectionBar namespace — remove if not selected + +function App() { + return ( + + + + } /> + } /> + {/* assetDetails namespace — remove if not selected */} + } /> + {/* card namespace — remove if not selected */} + } /> + {/* selectionBar namespace — remove if not selected */} + } /> + + + + ); + + function onError(e, componentStack) { + console.error('Extension error:', e, componentStack); + } + + function fallbackComponent({ componentStack, error }) { + return ( + +

Extension rendering error

+
{componentStack + '\n' + error.message}
+
+ ); + } +} + +export default App; +``` + +**Adding more panels:** Add a new `} />` here, and a matching `contentUrl: '/#my-second-panel'` entry in `ExtensionRegistration.js`. + +--- + +## `src/aem-assets-contenthub-1/web-src/src/components/ExtensionRegistration.js` + +Contains all three Content Hub namespaces. **When scaffolding, include only the namespace blocks the user selected in Step 2.** Each block is clearly marked — remove unused ones. `card` and `selectionBar` require `let guestConnection` (not `const`) because `onActionClick` is called after `register()` resolves. + +```js +import React from 'react'; +import { Text, View } from '@adobe/react-spectrum'; +import { register } from '@adobe/uix-guest'; +import { extensionId } from './Constants'; + +// Restrict extension to specific repos. +// Format: 'delivery-pXXX-eYYY.adobeaemcloud.com' +// Empty array = allow any repo (safe for development; populate before deploying to Production). +const allowedRepos = [ + // 'delivery-p12345-e167890.adobeaemcloud.com', +]; + +function getRepo() { + return new URLSearchParams(window.location.search).get('repo'); +} + +function shouldSkipRegistration(repo) { + return allowedRepos.length > 0 && !allowedRepos.includes(repo); +} + +function ExtensionRegistration() { + const repo = getRepo(); + + if (shouldSkipRegistration(repo)) { + return IFrame for integration with Host (Content Hub), Skipped registration as repo is not allowed; + } + + // `let` (not `const`) so card/selectionBar onActionClick can reference it after register() resolves. + let guestConnection; + + const init = async () => { + guestConnection = await register({ + id: extensionId, + methods: { + + // ── ASSET DETAILS NAMESPACE ────────────────────────────────────────── + // Tab panels in the Asset Details Dialog side rail. + // Remove this block if assetDetails was not selected. + assetDetails: { + getTabPanels() { + return [ + { + id: '{{EXTENSION_NAME}}-panel', + title: '{{DISPLAY_NAME}}', + tooltip: '{{DISPLAY_NAME}}', + icon: 'Extension', // React-Spectrum workflow icon name + contentUrl: '/#tab-panel', // must match a in App.js + }, + ]; + }, + }, + + // ── ASSET CARD NAMESPACE ───────────────────────────────────────────── + // Buttons on individual asset card menus (3-dot / overlay) AND on collection + // tiles in the Collections grid (the tile's ⋯ menu). Remove this block if card + // was not selected. + // getActionButtons receives an actionContext from the host: + // { context: 'assets'|'collection'|'collections'|'share' } + // 'assets' (browse grid), 'collection' (assets inside a collection), + // 'share' (link share view) are asset-card surfaces; + // 'collections' is a collection tile on the Collections grid. + // onActionClick is called with (resourceType, buttonId, resourceId, actionContext) + // resourceType: 'asset' (asset cards) | 'collection' (collection tiles) + card: { + getActionButtons(actionContext) { + // Vary buttons by actionContext.context, or ignore it to show the same set. + return [ + { + id: '{{EXTENSION_NAME}}-card-action', + label: '{{DISPLAY_NAME}}', + icon: 'Edit', // React-Spectrum workflow icon name + }, + ]; + }, + async onActionClick(resourceType, buttonId, resourceId, actionContext) { + // openDialog takes a SINGLE config object — NO { id } first arg, NO payload field. + // Pass data to the modal via the contentUrl query string (read it there with URLSearchParams). + await guestConnection.host.modal.openDialog({ + title: '{{DISPLAY_NAME}}', + contentUrl: `/#card-action-modal?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}`, // route in App.js + query data + type: 'modal', + size: 'M', + }); + }, + }, + + // ── SELECTION BAR NAMESPACE ────────────────────────────────────────── + // Bulk action buttons in the selection bar (shown when assets are selected). + // Remove this block if selectionBar was not selected. + // + // getActionButtons receives an actionContext from the host: + // { context: 'assets'|'collections'|'collection'|'share', + // resourceSelection: { resources: [{id: string}, ...] } } + // Use it to conditionally show/hide buttons per source, or ignore it to always show. + // + // onActionClick is called by the host with (buttonId, assetIds[]) + selectionBar: { + getActionButtons(actionContext) { + // actionContext.context tells you where the selection bar is shown: + // 'assets' (browse grid), 'collections' (collections list), + // 'collection' (inside a collection), 'share' (link share view). + // actionContext.resourceSelection.resources is the current selection as [{id}, ...]. + return [ + { + id: '{{EXTENSION_NAME}}-bulk-action', + label: '{{DISPLAY_NAME}}', + icon: 'Download', // React-Spectrum workflow icon name + }, + ]; + }, + async onActionClick(buttonId, assetIds) { + // Single config object — NO { id }, NO payload. Pass assetIds via the contentUrl query. + const ids = encodeURIComponent(JSON.stringify(assetIds || [])); + await guestConnection.host.modal.openDialog({ + title: `{{DISPLAY_NAME}} (${assetIds.length} asset${assetIds.length !== 1 ? 's' : ''})`, + contentUrl: `/#selection-bar-modal?assetIds=${ids}`, // route in App.js + query data + type: 'modal', + size: 'M', + }); + }, + }, + + }, + }); + }; + + init().catch(console.error); + return ( + + + + Certificate accepted! + + + Return to Claude Code in your terminal and click{' '} + "Done — open the extension" to continue. + + + ); +} + +export default ExtensionRegistration; +``` + +--- + +## `src/aem-assets-contenthub-1/web-src/src/components/TabPanel.js` + +This is the panel content rendered inside Content Hub's iframe. All Host API calls go through `guestConnection.host`. + +```js +import React, { useState, useEffect } from 'react'; +import { attach } from '@adobe/uix-guest'; +import { + Provider, + defaultTheme, + View, + Heading, + Text, + Button, + ProgressCircle, + Divider, +} from '@adobe/react-spectrum'; +import { extensionId } from './Constants'; +import actions from '../config.json'; + +export default function TabPanel() { + const [guestConnection, setGuestConnection] = useState(null); + const [asset, setAsset] = useState(null); + const [loading, setLoading] = useState(true); + const [actionResponse, setActionResponse] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + try { + // Reconnect to the registered extension. + // extensionId must match the id used in register() — both come from Constants.js. + const connection = await attach({ id: extensionId }); + setGuestConnection(connection); + + // Get the current asset from Content Hub. + // getCurrentAsset() returns the asset id as a plain STRING (e.g. "urn:aaid:aem:..."), + // NOT an object. Normalize to { id } so `asset.id` works and we're robust to either shape. + // Note: this is Content Hub's API. Assets View uses a different method. + const currentAssetId = await connection.host.assetDetails.getCurrentAsset(); + const currentAsset = typeof currentAssetId === 'string' ? { id: currentAssetId } : currentAssetId; + setAsset(currentAsset); + + // ── Call a web action with the asset ID ──────────────────────────── + // Uncomment and customize for AEM API calls: + // + // const { accessToken, imsOrg } = await connection.host.auth.getIMSInfo(); + // const apiKey = await connection.host.auth.getApiKey(); + // const aemHost = await connection.host.discovery.getAemHost(); + // const actionUrl = actions['aem-assets-contenthub-1/generic']; + // + // const response = await fetch(actionUrl, { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // // Add these when action has require-adobe-auth: true: + // // 'Authorization': `Bearer ${accessToken}`, + // // 'x-gw-ims-org-id': imsOrg, + // }, + // body: JSON.stringify({ assetId: currentAsset.id, aemHost, apiKey, imsOrg }), + // }); + // const data = await response.json(); + // setActionResponse(data); + + } catch (err) { + console.error('Panel initialization error:', err); + setError('Failed to initialize panel: ' + err.message); + } finally { + setLoading(false); + } + })(); + }, []); + + function displayToast(variant, message) { + if (guestConnection) { + guestConnection.host.toast.display({ variant, message }); + } + } + + if (loading) { + return ( + + + + + + + + ); + } + + return ( + + + {{DISPLAY_NAME}} + + + + {error && ( + + {error} + + )} + + {asset && ( + + Asset ID: + + + {asset.id} + + + + )} + + {actionResponse && ( + + + Action Response: + + {JSON.stringify(actionResponse, null, 2)} + + + )} + + + + + ); +} +``` + +--- + +## `src/aem-assets-contenthub-1/web-src/src/components/CardActionModal.js` + +Modal opened when a card action button is clicked. Reads the data passed via the `contentUrl` query string (Content Hub's `openDialog` has **no payload channel** — there is no `getPayload()`), and uses `attach()` only so it can call `closeDialog()`. Only scaffold this file if `card` was selected in Step 2. + +```js +import React, { useState, useEffect } from 'react'; +import { attach } from '@adobe/uix-guest'; +import { + Provider, + defaultTheme, + View, + Text, + Button, + ProgressCircle, +} from '@adobe/react-spectrum'; +import { extensionId } from './Constants'; + +export default function CardActionModal() { + const [guestConnection, setGuestConnection] = useState(null); + const [payload, setPayload] = useState(null); + + useEffect(() => { + (async () => { + // Read data from the modal URL query — openDialog has no payload channel. + // contentUrl was `/#card-action-modal?resourceId=...&resourceType=...`. + const params = new URLSearchParams(window.location.hash.split('?')[1] || ''); + setPayload({ resourceId: params.get('resourceId'), resourceType: params.get('resourceType') }); + // attach() is only needed so the Close button can call host.modal.closeDialog(). + const connection = await attach({ id: extensionId }); + setGuestConnection(connection); + })(); + }, []); + + if (!payload) { + return ( + + + + + + + + ); + } + + return ( + + + + + Resource Type + + {payload.resourceType} + + + + + Resource ID + + + + {payload.resourceId} + + + + + {/* Add your custom UI here */} + + + + + ); +} +``` + +--- + +## `src/aem-assets-contenthub-1/web-src/src/components/SelectionBarModal.js` + +Modal opened when a selection bar (bulk action) button is clicked. Reads `assetIds[]` from the `contentUrl` query (no `getPayload()` — Content Hub `openDialog` has no payload channel). Only scaffold this file if `selectionBar` was selected in Step 2. + +```js +import React, { useState, useEffect } from 'react'; +import { attach } from '@adobe/uix-guest'; +import { + Provider, + defaultTheme, + View, + Text, + Button, + ProgressCircle, + ListView, + Item, +} from '@adobe/react-spectrum'; +import { extensionId } from './Constants'; + +export default function SelectionBarModal() { + const [guestConnection, setGuestConnection] = useState(null); + const [payload, setPayload] = useState(null); + + useEffect(() => { + (async () => { + // Read assetIds from the modal URL query — openDialog has no payload channel. + // contentUrl was `/#selection-bar-modal?assetIds=`. + const params = new URLSearchParams(window.location.hash.split('?')[1] || ''); + const raw = params.get('assetIds'); + setPayload({ assetIds: raw ? JSON.parse(decodeURIComponent(raw)) : [] }); + // attach() is only needed so the Close button can call host.modal.closeDialog(). + const connection = await attach({ id: extensionId }); + setGuestConnection(connection); + })(); + }, []); + + if (!payload) { + return ( + + + + + + + + ); + } + + return ( + + + + + {payload.assetIds.length} asset{payload.assetIds.length !== 1 ? 's' : ''} selected + + + + + ({ id }))}> + {item => ( + + {item.id} + + )} + + + + {/* Add your bulk-action logic here */} + + + + + ); +} +``` + +--- + +## `src/aem-assets-contenthub-1/actions/utils.js` + +```js +function errorResponse(statusCode, message) { + return { statusCode, body: { error: message } }; +} + +function getBearerToken(params) { + const authHeader = params.__ow_headers?.authorization || params.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + return authHeader.substring(7); + } + throw new Error('Missing or invalid authorization header'); +} + +function checkMissingRequestInputs(params, requiredParams) { + const missing = requiredParams.filter(param => !params[param]); + if (missing.length > 0) { + return `Missing required parameters: ${missing.join(', ')}`; + } + return null; +} + +module.exports = { errorResponse, getBearerToken, checkMissingRequestInputs }; +``` + +--- + +## `src/aem-assets-contenthub-1/actions/generic/index.js` + +The web action called by the panel. Customize with real AEM API logic. + +```js +const { errorResponse, getBearerToken, checkMissingRequestInputs } = require('../utils'); + +/** + * Generic Web Action — {{DISPLAY_NAME}} + * + * Called from TabPanel.js with the current asset ID. + * Customize this to call AEM Assets Author API. + * + * Params passed from the panel: + * assetId — asset urn from host.assetDetails.getCurrentAsset() + * aemHost — AEM author URL from host.discovery.getAemHost() + * apiKey — from host.auth.getApiKey() (never hardcode) + * imsOrg — from host.auth.getIMSInfo() + * + * To call authenticated AEM APIs: + * 1. Set require-adobe-auth: true in ext.config.yaml + * 2. Send Authorization header from the panel + * 3. Uncomment the fetch block below + */ +async function main(params) { + console.log('{{EXTENSION_NAME}} action called', JSON.stringify({ assetId: params.assetId }, null, 2)); + + try { + // Uncomment for authenticated AEM API calls: + // const token = getBearerToken(params); + // const { assetId, aemHost, apiKey, imsOrg } = params; + // + // const response = await fetch(`https://${aemHost}/adobe/assets/${assetId}/metadata`, { + // headers: { + // 'Authorization': `Bearer ${token}`, + // 'X-Api-Key': apiKey, // always from frontend — never hardcode + // 'x-gw-ims-org-id': imsOrg, + // 'Content-Type': 'application/json', + // }, + // }); + // const data = await response.json(); + // const metadata = data.value ?? data; + // return { statusCode: 200, body: { metadata } }; + + return { + statusCode: 200, + body: { + message: 'Action executed successfully', + extension: '{{EXTENSION_NAME}}', + timestamp: new Date().toISOString(), + assetId: params.assetId || null, + }, + }; + + } catch (error) { + console.error('Action error:', error); + return errorResponse(500, `Action failed: ${error.message}`); + } +} + +exports.main = main; +``` + +--- + +## `README.md` + +```markdown +# {{DISPLAY_NAME}} + +Content Hub UI extension for the `aem/assets/contenthub/1` extension point. + +## Extension Point + +`aem/assets/contenthub/1` — adds custom tab panels to the Asset Details Dialog. + +## Project Structure + +``` +src/aem-assets-contenthub-1/ + web-src/src/components/ + ExtensionRegistration.js — registers tab panels with Content Hub + TabPanel.js — custom panel UI (rendered in iframe) + App.js — React routing + Constants.js — extensionId (shared between register + attach) + actions/ + generic/index.js — I/O Runtime web action (AEM API calls) + utils.js — shared action utilities + ext.config.yaml — runtime manifest + action definitions +app.config.yaml — declares aem/assets/contenthub/1 extension point +``` + +## Development + +```bash +npm install +aio app build +aio app run +``` + +Test URL (replace with your repo): +``` +https://experience.adobe.com/?devMode=true&ext=https://localhost:9080&repo=delivery-p12345-e123456.adobeaemcloud.com#/assets/contenthub/ +``` + +> First time only: accept the self-signed cert at https://localhost:9080 before loading the test URL. + +## Allowed Repos + +Update `allowedRepos` in `ExtensionRegistration.js` with your delivery repo IDs before deploying: + +```js +const allowedRepos = ['delivery-p12345-e167890.adobeaemcloud.com']; +``` + +## Deploy + +**Stage:** `aio app use -w Stage && aio app deploy` + +**Production:** `aio app use -w Production && aio app deploy` + +Approve in [Extension Manager](https://experience.adobe.com/aem/extension-manager) after deploying to Production. +``` + +--- + +## Adding a Second Panel + +When the user wants a second tab panel, make these changes: + +### In `ExtensionRegistration.js` — add to the `getTabPanels()` array: + +```js +getTabPanels() { + return [ + { + id: '{{EXTENSION_NAME}}-panel', + title: '{{DISPLAY_NAME}}', + tooltip: '{{DISPLAY_NAME}}', + icon: 'Extension', + contentUrl: '/#tab-panel', + }, + { + id: '{{EXTENSION_NAME}}-second-panel', + title: 'Second Panel', + tooltip: 'Second Panel', + icon: 'Info', + contentUrl: '/#second-panel', // must match the route added in App.js + }, + ]; +}, +``` + +### In `App.js` — add a route: + +```js +import SecondPanel from './SecondPanel'; // create this component + +} /> +``` + +### Create `SecondPanel.js` — copy the `TabPanel.js` template, rename the component and customize the UI. diff --git a/plugins/app-builder/skills/appbuilder-ui-scaffolder/SKILL.md b/plugins/app-builder/skills/appbuilder-ui-scaffolder/SKILL.md index 6feaa04e..e787b712 100644 --- a/plugins/app-builder/skills/appbuilder-ui-scaffolder/SKILL.md +++ b/plugins/app-builder/skills/appbuilder-ui-scaffolder/SKILL.md @@ -5,16 +5,21 @@ description: >- Provides patterns for pages, forms, data tables, dialogs, and navigation using @adobe/react-spectrum. Guides ExC Shell integration with @adobe/exc-app including runtime.done(), IMS token passthrough, and shell theming. Guides AEM UI Extension development with @adobe/uix-guest for Content Fragment - Console, CF Editor, Universal Editor, and Assets View surfaces. Trigger on: building App Builder UI, - React Spectrum components, ExC Shell pages, forms, data tables, dialogs, modals, navigation, theming, - web-src, Spectrum design system, @adobe/exc-app, AEM extension, AEM UI extension, Content Fragment - Console, Universal Editor extension, uix-guest, @adobe/uix-guest, extension points for AEM, - customizing AEM surfaces. + Console, CF Editor, Universal Editor, and Assets View surfaces. Guides Content Hub extension + development with @adobe/uix-guest for the aem/assets/contenthub/1 extension point across all + three surfaces — asset details tab panels (assetDetails), asset card action buttons (card), and + selection bar / bulk action buttons (selectionBar) — including the host.modal dialog flow. + Trigger on: building App Builder UI, React Spectrum components, ExC Shell pages, + forms, data tables, dialogs, modals, navigation, theming, web-src, Spectrum design system, + @adobe/exc-app, AEM extension, AEM UI extension, Content Fragment Console, Universal Editor + extension, uix-guest, @adobe/uix-guest, extension points for AEM, customizing AEM surfaces, + Content Hub extension, contenthub, asset details panel, asset card action, selection bar, + bulk action, aem/assets/contenthub/1. metadata: category: frontend license: Apache-2.0 compatibility: Requires Node.js 18+, npm, and @adobe/react-spectrum -allowed-tools: Bash(npm:*) Bash(node:*) Bash(npx:*) Read Write Edit +allowed-tools: Bash(aio:*) Bash(npm:*) Bash(node:*) Bash(npx:*) Bash(mkdir:*) Bash(ls:*) Read Write Edit --- # App Builder UI Scaffolder @@ -33,7 +38,9 @@ Identify the user's intent, then read the referenced sections to generate tailor | Navigation layout | `references/ui-patterns.md` § Navigation | `Tabs`, `Breadcrumbs`, `Flex` | | ExC Shell setup | `references/shell-integration.md` | `@adobe/exc-app`, `Provider`, `defaultTheme` | | Connect UI to backend actions | `references/action-integration.md` | `fetch()` with IMS token | -| AEM UI Extension (CF Console, CF Editor, Universal Editor) | `references/aem-extensions.md` | `@adobe/uix-guest`, `register()`, `sharedContext` | +| AEM UI Extension — customize existing (CF Console, CF Editor, Universal Editor) | `references/aem-extensions.md` | `@adobe/uix-guest`, `register()`, `sharedContext` | +| Any extension — scaffold NEW from scratch (Content Hub, CF Console/Editor, Universal Editor, Assets View, ExC Shell) | Use `adobe-extension-scaffolder` skill instead | Handles full flow for every surface: Console setup, file generation, build, dev server, deploy | +| Content Hub extension (customize existing — panels, card actions, bulk actions) | `references/contenthub-extensions.md` | `@adobe/uix-guest`, `register()`, `attach()`, `assetDetails.getTabPanels()`, `card`/`selectionBar` `getActionButtons(actionContext)` + `onActionClick(resourceType, buttonId, resourceId, actionContext)`, `host.modal` | | Debug UI issues | `references/debugging.md` | Shell spinner, CORS, blank screen, auth | ## Fast Path (for clear requests) @@ -52,9 +59,13 @@ Examples of fast-path triggers: - "Add a confirmation dialog" → Read `references/ui-patterns.md` § Dialog, generate directly - "Set up the shell integration" → Read `references/shell-integration.md`, generate directly -- "Build a Content Fragment Console extension" → Read `references/aem-extensions.md` § CF Console, generate directly -- "Add a header menu button to the Universal Editor" → Read `references/aem-extensions.md` § Universal Editor, generate directly -- "Create an AEM extension with uix-guest" → Read `references/aem-extensions.md` § Core Registration, generate directly +- "Add a header menu button to the Universal Editor" → Read `references/aem-extensions.md` § Universal Editor, generate directly +- "Customize an existing AEM extension's UI with uix-guest" → Read `references/aem-extensions.md` § Core Registration, generate directly +- "Create / scaffold a new extension from scratch" — for ANY surface (Content Hub, CF Console, CF Editor, Universal Editor, Assets View, ExC Shell) → Redirect to the `adobe-extension-scaffolder` skill (it asks which surface in Step 0 and handles the full Console + scaffold + build + dev server + deploy flow) +- "Add a tab panel to Content Hub asset details" (existing project) → Read `references/contenthub-extensions.md` § Asset Details Extension, generate directly +- "Add a Content Hub asset card action / card button" → Read `references/contenthub-extensions.md` § Asset Card Actions, generate directly +- "Add a Content Hub bulk action / selection bar button" → Read `references/contenthub-extensions.md` § Selection Bar / Bulk Actions, generate directly +- "Customize the Content Hub panel/modal UI" → Read `references/contenthub-extensions.md` § Tab Panel Component / Modal Component, generate directly If there is any ambiguity — multiple patterns could fit, constraints are unclear, or the user hasn't specified enough — fall through to the full workflow below. @@ -93,6 +104,12 @@ If there is any ambiguity — multiple patterns could fit, constraints are uncle - "Build a Content Fragment Console extension with an action bar button." - "Add a custom RTE toolbar button in the Content Fragment Editor." - "Create a Universal Editor extension with a header menu button." +- "Create a Content Hub extension." +- "Add a custom panel to the Content Hub Asset Details Dialog." +- "Build a Content Hub extension that shows asset metadata in a side panel." +- "Add an action button to Content Hub asset cards." +- "Add a bulk action to the Content Hub selection bar." +- "Scaffold a Content Hub App Builder extension for aem/assets/contenthub/1." ## Inputs To Request @@ -126,6 +143,7 @@ If there is any ambiguity — multiple patterns could fit, constraints are uncle - Use `references/action-integration.md` for calling backend actions from the SPA. - Use `references/checklist.md` for pre-handoff UI quality validation. - Use `references/aem-extensions.md` for AEM UI Extension patterns (`@adobe/uix-guest`, Content Fragment Console/Editor, Universal Editor, Assets View). +- Use `references/contenthub-extensions.md` for Content Hub extension patterns (`@adobe/uix-guest`, `aem/assets/contenthub/1`, asset details tab panels, Host APIs, web actions). - Use `references/debugging.md` for common SPA debugging scenarios (shell spinner, CORS, auth, blank screen, performance). ## Chaining @@ -133,4 +151,4 @@ If there is any ambiguity — multiple patterns could fit, constraints are uncle - Chains FROM `appbuilder-project-init` (after SPA project is scaffolded with `dx/excshell/1` extension) - Works alongside `appbuilder-action-scaffolder` for full-stack features (UI calls backend actions) - Chains TO `appbuilder-testing` (test generated UI components) -- Chains TO `appbuilder-cicd-pipeline` (deploy frontend changes) +- Delegates TO `adobe-extension-scaffolder` skill for scaffolding any NEW extension from scratch on any surface — Content Hub, CF Console/Editor, Universal Editor, Assets View, or ExC Shell (that skill owns the full Developer Console + scaffold + build + dev server + deploy workflow). This skill handles UI patterns and customizing extensions that already exist. diff --git a/plugins/app-builder/skills/appbuilder-ui-scaffolder/references/contenthub-extensions.md b/plugins/app-builder/skills/appbuilder-ui-scaffolder/references/contenthub-extensions.md new file mode 100644 index 00000000..817dada1 --- /dev/null +++ b/plugins/app-builder/skills/appbuilder-ui-scaffolder/references/contenthub-extensions.md @@ -0,0 +1,580 @@ +# Content Hub UI Extension Patterns + +API patterns and Host API reference for customizing Content Hub UI extensions using `@adobe/uix-guest`. These extensions run as App Builder apps inside iframes and can extend **three** Content Hub surfaces from a single `register()` call: + +- **Asset Details Dialog** (`assetDetails` namespace) — custom tab panels in the side rail. +- **Asset card actions** (`card` namespace) — buttons on asset cards (Assets grid, inside a collection, link share) and on collection tiles in the Collections grid. +- **Selection bar / bulk actions** (`selectionBar` namespace) — buttons in the multi-select action bar. + +> **Scaffolding:** To create a new Content Hub extension from scratch, use the `adobe-extension-scaffolder` skill — it handles the full workflow (Developer Console, file generation, build, dev server) and already wires all three namespaces. This file is a pattern reference for customizing and extending an already-scaffolded project. + +**Extension point:** `aem/assets/contenthub/1` (unified — all three Content Hub surfaces via method namespaces in one `register()` call) + +--- + +## Core Registration Pattern + +Every Content Hub extension starts with `register()` from `@adobe/uix-guest`. This establishes the two-way communication channel between the extension (guest) and Content Hub (host). + +```js +import { register } from '@adobe/uix-guest'; + +// `let` (not `const`): card/selectionBar onActionClick handlers reference the +// connection AFTER register() resolves, to open a modal via host.modal.openDialog(). +let guestConnection; + +guestConnection = await register({ + id: 'my.company.extension-name', // unique ID — use reverse-domain format + methods: { + // Declare which surfaces to extend via namespaces — opt into any combination: + assetDetails: { + getTabPanels() { ... } // tab panels in the Asset Details Dialog + }, + card: { + getActionButtons(actionContext) { ... } // buttons on asset cards + collection tiles + async onActionClick(resourceType, buttonId, resourceId, actionContext) { ... } + }, + selectionBar: { + getActionButtons(actionContext) { ... } // buttons in the bulk-action bar + async onActionClick(buttonId, assetIds) { ... } + }, + }, +}); +``` + +The single extension point `aem/assets/contenthub/1` covers all three Content Hub surfaces. One `register()` call handles every namespace the extension opts into. + +**Currently available namespaces:** `assetDetails`, `card`, and `selectionBar` — all three are live in the host. The `card` namespace covers both asset cards (Assets grid, inside a collection, link share) **and** collection tiles on the Collections grid — the host passes the surface in `actionContext.context` so a single implementation handles all of them. Card and selection-bar actions are gated by the `EXTENSIBILITY_AEM_CONTENTHUB` feature flag (`ASSETS-66401_extensibility_aem_contenthub`); when the flag is off the host returns no extension buttons for those surfaces, but `assetDetails` panels still render. The host invokes each namespace's methods from `useExtensionTabPanelsAssetDetails` / `useExtensionCardActions` (exports both `useExtensionAssetCardActions` and `useExtensionCollectionCardActions`) / `useExtensionBulkActionBar` (Content Hub repo, `src/utils/hooks/extensibility/`). There is no separate `collections` namespace — collection tiles reuse `card`. + +### `register()` vs `attach()` + +- **`register()`** — Used on the extension entry page. Declares capabilities. Returns a connection. Call once on load. +- **`attach()`** — Used on secondary pages (panel content inside an iframe). Reconnects without re-declaring capabilities. + +```js +// In a tab panel page rendered inside the Content Hub iframe +import { attach } from '@adobe/uix-guest'; + +const guestConnection = await attach({ + id: 'my.company.extension-name', // must exactly match the id in register() +}); +``` + +**Critical:** Both `register()` and `attach()` must use the same `id`. Export it from `Constants.js` and import it in both files. + +--- + +## Extension Point Configuration + +### `app.config.yaml` + +```yaml +extensions: + aem/assets/contenthub/1: + $include: src/aem-assets-contenthub-1/ext.config.yaml +``` + +### Project Structure + +``` +app.config.yaml ← declares aem/assets/contenthub/1 +src/aem-assets-contenthub-1/ + ext.config.yaml ← runtime manifest + action definitions + web-src/src/components/ + Constants.js ← extensionId (shared) + ExtensionRegistration.js ← register() call (all namespaces) + App.js ← React routing + TabPanel.js ← assetDetails panel UI (uses attach()) + CardActionModal.js ← card namespace modal (uses attach()) + SelectionBarModal.js ← selectionBar namespace modal (uses attach()) + actions/ + generic/index.js ← web action (AEM API calls) + utils.js ← shared utilities +``` + +> Only scaffold the component for the namespaces you use — `TabPanel.js` for `assetDetails`, `CardActionModal.js` for `card`, `SelectionBarModal.js` for `selectionBar`. + +--- + +## Asset Details Extension (`assetDetails` namespace) + +The `assetDetails` namespace adds custom tab panels to the Asset Details Dialog side rail. Content Hub manages panel toggling, deep-linking, and header rendering — the extension only provides the panel content via an iframe. + +### Registering Tab Panels + +```js +methods: { + assetDetails: { + getTabPanels() { + return [ + { + id: 'my-panel', // unique within this extension + title: 'My Panel', // panel header (rendered by Content Hub) + tooltip: 'My Panel', // side-rail icon tooltip + icon: 'Extension', // React-Spectrum workflow icon name + contentUrl: '/#tab-panel', // hash route in this guest app + }, + ]; + }, + }, +} +``` + +**Panel descriptor properties:** + +| Property | Type | Description | +| --- | --- | --- | +| `id` | string | Unique panel ID within this extension | +| `title` | string | Panel header (Content Hub renders this) | +| `tooltip` | string | Tooltip on the side-rail icon | +| `icon` | string | React-Spectrum workflow icon name | +| `contentUrl` | string | Hash route (e.g. `/#tab-panel`) — must match an `` in `App.js` | + +### Restricting to Specific Repos + +```js +const allowedRepos = [ + 'delivery-p12345-e167890.adobeaemcloud.com', +]; + +function shouldSkipRegistration(repo) { + return allowedRepos.length > 0 && !allowedRepos.includes(repo); +} +``` + +Empty `allowedRepos` = loads for any repo (safe for development). + +--- + +## Asset Card Actions (`card` namespace) + +The `card` namespace adds action buttons to asset cards (the overflow / hover menu on each card in the Assets grid, inside a collection, or the link-share view) **and** to collection tiles on the Collections grid (the tile's ⋯ menu). The host passes the current surface in `actionContext`, so one implementation serves every card surface. The host renders each button and, on click, calls back into the extension. + +### Registering card buttons + +```js +methods: { + card: { + // Host calls getActionButtons(actionContext) with context about the current surface. + // actionContext.context: 'assets' | 'collection' | 'collections' | 'share' + // 'assets' — Assets browse grid (asset card) + // 'collection' — assets inside a collection (asset card) + // 'share' — link-share view (asset card) + // 'collections' — collection tile on the Collections grid + getActionButtons(actionContext) { + // Vary buttons by actionContext.context, or ignore it to show the same set everywhere. + return [ + { + id: 'my-card-action', // unique within this extension + label: 'Edit Metadata', // button label (card uses `label`, NOT `title`) + icon: 'Edit', // React-Spectrum workflow icon name + }, + ]; + }, + // Host calls this on click, passing (resourceType, buttonId, resourceId, actionContext). + async onActionClick(resourceType, buttonId, resourceId, actionContext) { + // resourceType: 'asset' (asset cards) | 'collection' (collection tiles) + // buttonId: the `id` from getActionButtons() + // resourceId: the asset or collection URN string + // actionContext: { context } — same surface values as above + // openDialog takes a SINGLE config object — NO { id } first arg, NO payload field. + // Pass data to the modal via the contentUrl query string. + await guestConnection.host.modal.openDialog({ + title: 'Edit Metadata', + contentUrl: `/#card-action-modal?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}`, // route in App.js + query + type: 'modal', + size: 'M', + }); + }, + }, +} +``` + +**Button descriptor (card):** only `id`, `label`, and `icon` are read by the host — `title`/`tooltip`/`variant` are ignored for card buttons. + +**`getActionButtons(actionContext)`** — the host passes `{ context }` (the source surface). Use it to conditionally show/hide buttons per surface, or ignore it to always show the same set. + +**`onActionClick(resourceType, buttonId, resourceId, actionContext)`** — exact argument order the host uses. It's optional (the host guards with `?.`): omit it for a no-op button, but you need it to open a modal. Because it fires *after* `register()` resolves, declare the connection with `let guestConnection` so the handler can reference it. + +> Host proof: `extension.apis.card.onActionClick?.(ResourceType.ASSET, btn.id, assetId, {context: source})` (asset cards) and `extension.apis.card.onActionClick?.(ResourceType.COLLECTION, btn.id, collectionId, {context: 'collections'})` (collection tiles) in `useExtensionCardActions.ts`. + +--- + +## Selection Bar / Bulk Actions (`selectionBar` namespace) + +The `selectionBar` namespace adds buttons to the bulk-action bar shown when one or more assets are selected. Unlike `card`, the host passes an **action context** to `getActionButtons`, telling the extension which view the bar is in and what's selected. + +### Registering selection-bar buttons + +```js +methods: { + selectionBar: { + // Host calls getActionButtons(actionContext) with context about the current view + selection. + // actionContext shape: + // { context: 'assets'|'collections'|'collection'|'share', + // resourceSelection: { resources: [{id: string}, ...] } } + // + // context values: + // 'assets' — browse grid + // 'collections' — collections list + // 'collection' — inside a single collection + // 'share' — link share view + getActionButtons(actionContext) { + // Optional: filter buttons by source view + // if (actionContext.context === 'share') return []; // hide in link-share view + return [ + { + id: 'my-bulk-action', + label: 'Bulk Export', // selectionBar uses `label`, NOT `title` + icon: 'Download', // React-Spectrum workflow icon name + }, + ]; + }, + // Host calls this on click, passing (buttonId, assetIds[]). + async onActionClick(buttonId, assetIds) { + // buttonId: the `id` from getActionButtons() + // assetIds: string[] — URNs of every currently selected asset + // Single config object — NO { id }, NO payload. Pass assetIds via the contentUrl query. + const ids = encodeURIComponent(JSON.stringify(assetIds || [])); + await guestConnection.host.modal.openDialog({ + title: `Bulk Export (${assetIds.length})`, + contentUrl: `/#selection-bar-modal?assetIds=${ids}`, // route in App.js + query + type: 'modal', + size: 'M', + }); + }, + }, +} +``` + +**Button descriptor (selectionBar):** same as card — `id`, `label`, `icon`. + +**`getActionButtons(actionContext)`** — unlike `card.getActionButtons()` (no args), selectionBar receives context about the source view and current selection. Use it to conditionally show/hide buttons, or ignore the parameter to always show. + +**`onActionClick(buttonId, assetIds)`** — note this signature differs from `card`: **no `resourceType`**, and the second arg is an **array** of asset IDs. Like card, it's optional and fires after `register()` resolves (use `let guestConnection`). + +**Button ID prefixing:** The host renders each selection-bar extension button with a prefixed id (`ext::`) to avoid collisions with native action bar items. This is host-internal — your extension code uses the original `btn.id` as returned from `getActionButtons`. The `onActionClick` handler also receives the original `btn.id`, not the prefixed form. + +> Host proof: `extension.apis.selectionBar.getActionButtons(actionContext)` and `extension.apis.selectionBar.onActionClick(btn.id, assetIds)` in `useExtensionBulkActionBar.ts`. + +--- + +## Opening a Modal for an Action (`modal` namespace) + +Card and selection-bar actions have no panel of their own — they open a modal dialog whose content is another route in the same guest app. The `modal` Host API is available to all three Content Hub surfaces. + +**Critical signature:** `openDialog` takes a **single config object** — NOT `({ id }, {...})`. There is **no `{ id }` argument and no `payload` field**. Passing the two-argument form makes the host receive a malformed request and the call retries until it times out (`... timed out after 10000ms`, then `[object Object] doesn't exist`). Pass data to the modal through the `contentUrl` **query string** instead. + +```js +// From an onActionClick handler (register page): +await guestConnection.host.modal.openDialog({ + title: 'Dialog title', + contentUrl: `/#card-action-modal?resourceId=${encodeURIComponent(resourceId)}&resourceType=${encodeURIComponent(resourceType)}`, // hash route + query data + type: 'modal', // optional: 'modal' | 'fullscreen' + size: 'M', // optional: 'S' | 'M' | 'L' +}); +``` + +Inside the modal page (a route rendered in its own iframe), read the data from the URL query and reconnect with `attach()` only so you can close: + +```js +import { attach } from '@adobe/uix-guest'; +import { extensionId } from './Constants'; + +// Data comes from the contentUrl query — there is NO getPayload(). +const params = new URLSearchParams(window.location.hash.split('?')[1] || ''); +const resourceId = params.get('resourceId'); +const resourceType = params.get('resourceType'); // 'asset' | 'collection' + +const connection = await attach({ id: extensionId }); +// ... render UI, run the action ... +await connection.host.modal.closeDialog(); // dismiss the dialog +``` + +| Method | Signature | Purpose | +| --- | --- | --- | +| `modal.openDialog` | `({ title, contentUrl, type?, size? })` | Open a dialog rendering `contentUrl`. Single object — no `{ id }`, no `payload`. | +| `modal.closeDialog` | `() => void` | Close the current dialog (call from the modal page after `attach()`) | + +--- + +## React Routing (`App.js`) + +Extensions use hash routing. Every panel has its own route; `contentUrl` in `getTabPanels()` must match a ``. + +```js +import { HashRouter as Router, Routes, Route } from 'react-router-dom'; +import ExtensionRegistration from './ExtensionRegistration'; +import TabPanel from './TabPanel'; // assetDetails +import CardActionModal from './CardActionModal'; // card +import SelectionBarModal from './SelectionBarModal'; // selectionBar + + + + } /> + } /> + } /> {/* assetDetails contentUrl */} + } /> {/* card modal contentUrl */} + } />{/* selectionBar modal contentUrl */} + {/* One route per panel/modal — each contentUrl maps to a route here */} + + +``` + +Keep only the routes for the namespaces you use. Each `contentUrl` in a `getTabPanels()` / `openDialog()` call must match a `` here. + +--- + +## Tab Panel Component + +The panel renders inside Content Hub's iframe. Use `attach()` to reconnect and access Host APIs. + +```js +import { attach } from '@adobe/uix-guest'; +import { extensionId } from './Constants'; + +export default function TabPanel() { + const [guestConnection, setGuestConnection] = useState(null); + const [asset, setAsset] = useState(null); + + useEffect(() => { + (async () => { + const connection = await attach({ id: extensionId }); + setGuestConnection(connection); + + // Get the current asset. + // getCurrentAsset() returns a plain STRING id (e.g. "urn:aaid:aem:..."), NOT { id }. + // Normalize so asset.id always works regardless of future API shape changes. + const raw = await connection.host.assetDetails.getCurrentAsset(); + const currentAsset = typeof raw === 'string' ? { id: raw } : raw; + setAsset(currentAsset); + })(); + }, []); + + // Render UI with React Spectrum components... +} +``` + +--- + +## Modal Component (card / selectionBar) + +The modal page is just another `attach()`-based component, distinguished by reading its data **from the URL query** (there is no `getPayload()`). Same shape for `CardActionModal.js` and `SelectionBarModal.js` — only the query fields differ (`resourceId` + `resourceType` for card, `assetIds` JSON for selectionBar). + +```js +import { attach } from '@adobe/uix-guest'; +import { extensionId } from './Constants'; + +export default function CardActionModal() { + const [guestConnection, setGuestConnection] = useState(null); + const [data, setData] = useState(null); + + useEffect(() => { + (async () => { + // openDialog has no payload channel — read what onActionClick put in the contentUrl query. + const params = new URLSearchParams(window.location.hash.split('?')[1] || ''); + setData({ + resourceId: params.get('resourceId'), + resourceType: params.get('resourceType'), // 'asset' | 'collection' + }); + // (selectionBar: const ids = JSON.parse(decodeURIComponent(params.get('assetIds') || '[]'))) + const connection = await attach({ id: extensionId }); // only needed for closeDialog() + setGuestConnection(connection); + })(); + }, []); + + // Render UI with data.resourceId + data.resourceType (card) / data.assetIds (selectionBar), + // then close with: guestConnection.host.modal.closeDialog() +} +``` + +--- + +## Host APIs + +All APIs are available via `guestConnection.host`. Both `register()` and `attach()` return the same connection object. All invocations are asynchronous and return a Promise. + +### Authentication (`auth` namespace) + +```js +const { imsOrg, imsOrgName, accessToken } = await guestConnection.host.auth.getIMSInfo(); +const apiKey = await guestConnection.host.auth.getApiKey(); +// Never hardcode apiKey — always get it via this method +``` + +### Discovery (`discovery` namespace) + +```js +const aemHost = await guestConnection.host.discovery.getAemHost(); +// e.g. "author-p12345-e67890.adobeaemcloud.com" +// Use in web actions for AEM Assets Author API calls +``` + +### Toast (`toast` namespace) + +```js +guestConnection.host.toast.display({ variant: 'positive', message: 'Saved!' }); +// variant: 'neutral' | 'positive' | 'info' | 'negative' +``` + +### i18n (`i18n` namespace) + +```js +const { locale } = await guestConnection.host.i18n.getLocalizationInfo(); +// e.g. "en-US" +``` + +### Modal (`modal` namespace) + +Available on all three surfaces. Used by `card`/`selectionBar` to open a dialog, and by any modal page to read its payload and close: + +```js +guestConnection.host.modal.openDialog({ title, contentUrl, type, size }); // single object — no { id }, no payload +// inside the modal page, read data from the URL query (no getPayload): +const params = new URLSearchParams(window.location.hash.split('?')[1] || ''); +guestConnection.host.modal.closeDialog(); +``` + +### Asset Details (`assetDetails` namespace) + +Only available when registered under the `assetDetails` namespace: + +```js +const assetId = await guestConnection.host.assetDetails.getCurrentAsset(); +// Returns the asset id as a plain STRING (e.g. "urn:aaid:aem:..."), NOT { id }. +// Normalize in your component: const asset = typeof assetId === 'string' ? { id: assetId } : assetId; +// Pass asset.id to a web action to call AEM Assets Author API +``` + +--- + +## Calling Web Actions from a Panel + +Never call AEM APIs directly from the browser (CORS blocks them). Route all AEM API calls through web actions. + +```js +// In TabPanel.js +const { accessToken, imsOrg } = await guestConnection.host.auth.getIMSInfo(); +const apiKey = await guestConnection.host.auth.getApiKey(); +const aemHost = await guestConnection.host.discovery.getAemHost(); +const currentAsset = await guestConnection.host.assetDetails.getCurrentAsset(); +const actionUrl = actions['aem-assets-contenthub-1/generic']; // from config.json + +const response = await fetch(actionUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Include these when action has require-adobe-auth: true: + // 'Authorization': `Bearer ${accessToken}`, + // 'x-gw-ims-org-id': imsOrg, + }, + body: JSON.stringify({ assetId: currentAsset.id, aemHost, apiKey, imsOrg }), +}); +const data = await response.json(); +``` + +**In the web action (`actions/generic/index.js`):** + +```js +async function main(params) { + const { assetId, aemHost, apiKey, imsOrg } = params; + const token = params.__ow_headers?.authorization?.substring(7); // when require-adobe-auth: true + + const response = await fetch(`https://${aemHost}/adobe/assets/${assetId}/metadata`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'X-Api-Key': apiKey, + 'x-gw-ims-org-id': imsOrg, + }, + }); + const data = await response.json(); + return { statusCode: 200, body: { metadata: data.value ?? data } }; +} +``` + +--- + +## Local Development + +### Run the dev server + +```bash +aio app build +aio app run +``` + +### Test URL + +``` +https://experience.adobe.com/?devMode=true&ext=https://localhost:9080&repo=delivery-p12345-e123456.adobeaemcloud.com#/assets/contenthub/ +``` + +| Parameter | Purpose | +| --- | --- | +| `devMode=true` | Enables developer mode | +| `ext=https://localhost:9080` | Loads local extension | +| `repo=` | Identifies the AEM environment; must match `allowedRepos` | + +**Self-signed cert (first time only):** Navigate to `https://localhost:9080` and click "Proceed to localhost (unsafe)". The extension panel will be blank until the cert is trusted. + +--- + +## Common Gotchas + +1. **Wrong extension point ID** — Use `aem/assets/contenthub/1`. The deprecated ID `aem/contenthub/assets/details/1` still works during the transition period but should not be used in new projects. + +2. **`attach()` ID mismatch** — The `id` in `attach()` must exactly match the `id` in `register()`. Both should import `extensionId` from `Constants.js`. + +3. **Panel blank (cert not trusted)** — Navigate to `https://localhost:9080` and accept the cert before loading the Content Hub URL. + +4. **`getCurrentAsset()` returns a STRING** — `host.assetDetails.getCurrentAsset()` returns the asset id as a plain string (e.g. `"urn:aaid:aem:..."`), NOT `{ id }`. Normalize: `const asset = typeof raw === 'string' ? { id: raw } : raw`. (Assets View is a different surface — it uses `host.details.getCurrentResourceInfo()` with a different shape. Do not mix them.) + +5. **`const guestConnection` breaks card/selectionBar** — Their `onActionClick` handlers fire *after* `register()` resolves and must reference the connection to call `host.modal.openDialog()`. Declare it `let guestConnection;` and assign inside `register()`, or the handler closes over `undefined`. + +5a. **`openDialog` signature — single object, no `{ id }`, no `payload`** — Content Hub's `host.modal.openDialog` takes **one** config object: `openDialog({ title, contentUrl, type, size })`. It is NOT `openDialog({ id }, {...})`, and there is **no `payload` field** and **no `getPayload()`**. The two-argument form makes the host receive a malformed request; the uix-guest client then retries the call every 500ms until it fails with `... timed out after 10000ms` and `[object Object] doesn't exist` — and no dialog ever appears (a `host.toast.display` in the same handler still works, which makes it look like the connection is fine). Pass data to the modal via the `contentUrl` query string (`/#card-action-modal?resourceId=...&resourceType=...`) and read it in the modal page with `new URLSearchParams(window.location.hash.split('?')[1])`. Close with `host.modal.closeDialog()`. (Note: this is the **opposite** of the other AEM surfaces — CF Console/Editor/UE/Assets View use `host.modal.showUrl({ title, url })` + `close()`. Content Hub uses `openDialog`/`closeDialog`. Don't cross them.) + +6. **`card` vs `selectionBar` signatures differ** — Both `getActionButtons(actionContext)` receive a context, but the shapes differ: card gets `{ context }` only, while selectionBar gets `{ context, resourceSelection }`. On click, card `onActionClick(resourceType, buttonId, resourceId, actionContext)` gets a single resource + its type (`'asset'` or `'collection'`); selectionBar `onActionClick(buttonId, assetIds)` gets no resource type and `assetIds` is an array. Don't copy one signature for the other. + +7. **Card/selectionBar buttons use `label`, not `title`** — only `id`, `label`, `icon` are read for those two. (`assetDetails` panels use `title`/`tooltip`.) A button with only `title` set renders blank. + +8. **Card/selectionBar buttons missing entirely** — they're gated by the `EXTENSIBILITY_AEM_CONTENTHUB` feature flag. If the flag is off in the environment, the host returns no extension buttons for those surfaces (asset-details panels still show). + +9. **CORS on AEM API calls** — Never fetch AEM APIs from the browser. Use web actions (I/O Runtime server-side). + +10. **`config.json` action URL** — This file is overwritten by `aio app run` (localhost). Never edit manually. + +--- + +## Quick Reference + +**Extension point:** `aem/assets/contenthub/1` +**Source directory:** `src/aem-assets-contenthub-1/` + +| Surface | Namespace | Key method(s) | Click handler | +| --- | --- | --- | --- | +| Asset Details Dialog | `assetDetails` | `getTabPanels()` | — (renders `contentUrl` in a tab) | +| Asset card + collection tile | `card` | `getActionButtons(actionContext)` | `onActionClick(resourceType, buttonId, resourceId, actionContext)` | +| Selection bar (bulk) | `selectionBar` | `getActionButtons(actionContext)` | `onActionClick(buttonId, assetIds[])` | + +**Button/panel descriptor fields:** + +| Namespace | `get…()` returns array of | +| --- | --- | +| `assetDetails` | `{ id, title, tooltip, icon, contentUrl }` | +| `card` | `{ id, label, icon }` | +| `selectionBar` | `{ id, label, icon }` | + +**Host API summary:** + +| Namespace | Method | Returns | +| --- | --- | --- | +| `auth` | `getIMSInfo()` | `{ imsOrg, imsOrgName, accessToken }` | +| `auth` | `getApiKey()` | API key string | +| `discovery` | `getAemHost()` | AEM author URL | +| `toast` | `display({ variant, message })` | void | +| `i18n` | `getLocalizationInfo()` | `{ locale }` | +| `modal` | `openDialog({ title, contentUrl, type?, size? })` | void — single object, NO `{ id }`, NO `payload`; pass data via the `contentUrl` query | +| `modal` | `closeDialog()` | void | +| `assetDetails` | `getCurrentAsset()` | string asset ID — normalize to `{ id }` in your component | + +**Toast variants:** `neutral` | `positive` | `info` | `negative`