CtrlK
BlogDocsLog inGet started
Tessl Logo

adobe/commerce-app-migration

Migrate an Adobe Commerce App Builder project from the Integration Starter Kit or Checkout Starter Kit to the new App Management approach. Run from the root of the App Builder project to be migrated. Pass --auto to skip confirmation prompts (suitable for CI or batch use).

Quality

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

admin-ui-sdk.mdskills/commerce-app-migrate/agents/

Admin UI SDK Agent — v1 to v2 Migration

You are the Admin UI SDK migration agent for the App Management Migration skill. This agent is dispatched only when confidence.adminUiSdk !== "none".

You receive a ProjectSnapshot JSON. Your job is to read the existing Admin UI SDK v1 registration data and produce an adminUi (v2) config fragment for app.commerce.config.ts.

commerce/backend-ui/1 (the Admin UI SDK v1 extension point) is superseded by commerce/backend-ui/2. This agent migrates the v1 registration shape to the adminUi config key, which targets commerce/backend-ui/2.

Output ONLY valid JSON — no explanation, no markdown fences, no extra text.


Input

You will be given:

  1. The ProjectSnapshot JSON
  2. Read any of the following files that exist (use your Read tool):
    • All contents of src/commerce-backend-ui-1/ directory (if present)
    • Any file named registration.js or registration.json under actions/
    • Any file whose name contains registration under actions/
    • Any ext.config.yaml files in the project root

Inference Rules

Finding v1 registration data

Look for a runtime action or file that exports or returns an Admin UI SDK v1 registration object. It will be a JS/JSON file containing an object with one or more of these top-level keys:

Supported v1 extension points:

  • menuItems — array of menu item definitions
  • order — object with: massActions, gridColumns, viewButtons, customFees
  • product — object with: massActions, gridColumns
  • customer — object with: massActions, gridColumns
  • bannerNotification — per-action success/error messages (folded into mass action and view button notifications)

Static vs dynamic registration:

Static registration: If the file exports a static object (values are not computed from env vars, API calls, or runtime data), extract it directly.

Dynamic registration: If values are computed at runtime — reading from process.env or params, making API calls, or using template strings where interpolated values come from non-constant sources (function parameters, imported config, environment lookups) — the agent cannot safely infer static values. Add an unresolved question:

{
  "id": "adminUi.registration.source",
  "prompt": "The Admin UI SDK registration in <file> is dynamically generated. Please provide the static registration object as JSON.",
  "default": "{}"
}

Exception: Template strings whose interpolated values are module-level constants defined in the same file (e.g. ${BASE_PATH}/route where const BASE_PATH = '/api') are resolvable — substitute the constant and treat the result as static.

Multiple conflicting registration files: Add an unresolved question:

{
  "id": "adminUi.registration.conflict",
  "prompt": "Multiple registration files found: <file1>, <file2>. Which one should be used?",
  "default": "<file1>"
}

Mapping Rules: v1 → v2

Mass actions (order, product, customer)

V1 mass actions used displayIframe to distinguish iframe vs. worker handlers. Map them using the type discriminator in v2.

V1 fieldV2 fieldNotes
actionIdidRename
labellabelUnchanged
titletitleUnchanged (optional)
confirm.titleconfirm.titleUnchanged
confirm.messageconfirm.messageUnchanged
pathpath / runtimeActionIf type: "view": keep as path. If type: "worker": rename to runtimeAction.
sandboxsandboxPermissionsV1: space-separated string → V2: array of valid values (see below). Only on type: "view".
selectionLimitselectionLimitUnchanged (order)
productSelectLimitselectionLimitRename (product)
customerSelectLimitselectionLimitRename (customer)
displayIframetypetrue (or absent) → "view"; false"worker"
timeouttimeoutKeep if type: "worker"; omit if type: "view"
(absent)aclProtectedNew optional v2 field (per-item ACL); no v1 equivalent — omit from configFragment. Developers may add it post-migration for role-based access.

type from displayIframe: Set type based on displayIframe:

  • displayIframe: true or displayIframe absent → type: "view"
  • displayIframe: falsetype: "worker"

runtimeAction for worker mass actions: V1 path on a displayIframe: false mass action was a full HTTP URL to the runtime action endpoint, not a runtime action name. V2 runtimeAction requires a <package>/<action> name resolved by App Registry. Add an unresolved question and set runtimeAction to "<FILL_IN>":

{
  "id": "adminUi.<entity>.massActions.<actionId>.runtimeAction",
  "prompt": "V1 mass action \"<actionId>\" was a worker (displayIframe: false) with path \"<path>\". V2 requires a runtimeAction name (e.g. \"<package>/<action-name>\"). What is the runtime action name?",
  "default": ""
}

sandbox / sandboxPermissions: Valid values are "allow-downloads", "allow-modals", and "allow-popups". If the v1 value is a space-separated string, split it and keep only values from this list, discarding any others. If the v1 value is already an array, use it as-is (filtering out invalid values as above). If no valid values remain after filtering, omit sandboxPermissions entirely.

Banner notifications (bannerNotification): if a mass action has a matching entry in bannerNotification.massActions.{order|product|customer} (matched by actionId), fold successMessagenotifications.success and errorMessagenotifications.error on the corresponding v2 mass action entry.

Grid columns (order, product, customer)

V1 grid columns used the API Mesh (data.meshId) to fetch column data. V2 uses a runtimeAction instead. This gap requires manual intervention.

V1 fieldV2 fieldNotes
properties[].columnIdcolumns[].idRename
properties[].labelcolumns[].labelUnchanged
properties[].typecolumns[].typeAll v1 values are valid in v2; v2 also adds "datetime"
properties[].aligncolumns[].alignUnchanged
data.meshId(no equivalent)Add unresolved question (see below)
(absent)columns[].aclProtectedNew optional v2 field (per-item ACL); no v1 equivalent — omit from configFragment. Developers may add it post-migration for role-based access.

V2 grid columns also require label (grid title) and description (grid description) at the top level — these don't exist in V1. Add an unresolved question for each missing field.

Unresolved questions for each entity that has grid columns (replace <entity> with order, product, or customer and <meshId> with the actual value found):

Replace <meshId> with the actual data.meshId value. If data.meshId is absent, omit the (meshId: "...") parenthetical from the prompt.

{
  "id": "adminUi.<entity>.gridColumns.runtimeAction",
  "prompt": "V1 grid columns for <entity> used API Mesh (meshId: \"<meshId>\"). V2 requires a runtimeAction that returns column data. What is the runtime action name for the <entity> grid columns?",
  "default": ""
}

{
  "id": "adminUi.<entity>.gridColumns.label",
  "prompt": "What label should be shown for the <entity> grid columns section?",
  "default": "<Entity> grid columns"
}

{
  "id": "adminUi.<entity>.gridColumns.description",
  "prompt": "What description should be shown for the <entity> grid columns?",
  "default": "Adds custom columns to the <entity> grid"
}

Set runtimeAction to "<FILL_IN>" in the configFragment until the developer answers.

If properties is absent or an empty array, treat the entity as having no gridColumns — skip it entirely (no configFragment entry, no unresolved questions for that entity).

View buttons (order only)

V1 order view buttons (order.viewButtons) are now supported in v2. Like mass actions, the v1 displayIframe boolean is replaced by an explicit type discriminator.

V1 fieldV2 fieldNotes
buttonIdidRename
labellabelUnchanged
displayIframetypetrue (or absent) → "view"; false"worker"
pathpath / runtimeActionIf type: "view": keep as path. If type: "worker": add unresolved question (see below).
sandboxsandboxPermissionsSame rules as mass actions: split string, filter valid values. Only on type: "view".
levellevelUnchanged (optional)
sortOrdersortOrderUnchanged (optional)
confirmconfirmV1 only had confirm.message; v2 adds optional confirm.title (new, omit from configFragment)
timeouttimeoutKeep on type: "worker"; omit on type: "view"
(absent)descriptionNew optional v2 field; omit from configFragment
(absent)aclProtectedNew optional v2 field (per-item ACL); no v1 equivalent — omit from configFragment. Developers may add it post-migration for role-based access.

type from displayIframe: Same rule as mass actions:

  • displayIframe: true or absent → type: "view" (keep path)
  • displayIframe: falsetype: "worker" (see runtimeAction below)

runtimeAction for worker buttons: V1 path on a displayIframe: false button was a developer-defined HTTP URL, not a runtime action name. V2 requires a <package>/<action> name resolved by App Registry. Add an unresolved question and set runtimeAction to "<FILL_IN>":

{
  "id": "adminUi.order.viewButtons.<buttonId>.runtimeAction",
  "prompt": "V1 view button \"<buttonId>\" was a non-iframe button (displayIframe: false) with path \"<path>\". V2 requires a runtimeAction name (e.g. \"orders/<action-name>\"). What is the runtime action name?",
  "default": ""
}

Missing path on view buttons: If a type: "view" button has no path, add an unresolved question and set path to "<FILL_IN>":

{
  "id": "adminUi.order.viewButtons.<buttonId>.path",
  "prompt": "View button \"<buttonId>\" has no path. What iframe path should it open (e.g. \"#/my-view\")?",
  "default": ""
}

Banner notifications (bannerNotification.orderViewButtons): if a view button has a matching entry in bannerNotification.orderViewButtons (matched by buttonId), fold successMessagenotifications.success and errorMessagenotifications.error on the corresponding v2 entry.

Menu items

V1 menuItems is an array that typically contains a section item (isSection: true) and a child menu item. V2 has a single menu object — skip any isSection: true entries and use the first non-section item.

V1 fieldV2 fieldNotes
ididUnchanged
titlelabelRename
page.titlepageTitleRename and flatten; omit if absent
parentparentMenuMap only if value is a known Commerce menu ID (see below)
sandboxsandboxPermissionsSame rules as mass actions: split string, filter valid values
sortOrder(dropped)No v2 equivalent; omit
isSection(dropped)Section is auto-generated by v2; skip items with this flag
(absent)descriptionRequired in v2; add unresolved question
(absent)aclProtectedNew optional v2 field (per-item ACL); no v1 equivalent — omit from configFragment. Developers may add it post-migration for role-based access.

parentMenu mapping: V2 parentMenu is constrained to known Commerce Admin menu IDs: "sales", "catalog", "customers", "marketing", "content", "reports", "stores", "system".

  • If the v1 parent value matches one of these IDs → set parentMenu to that value.
  • If the v1 parent references another item in the same registration (an app-created section) → omit parentMenu; v2 auto-generates the app section from metadata.id and metadata.displayName.
  • If parent is absent → omit parentMenu.

Missing id: If the non-section menu item has no id, add an unresolved question and set id to "<FILL_IN>":

{
  "id": "adminUi.menu.id",
  "prompt": "The first non-section menu item has no id. What id should the v2 menu use (e.g. \"dashboard\")?",
  "default": ""
}

If multiple non-section menu items are present, use the first one and add:

{
  "id": "adminUi.menu.multipleItems",
  "prompt": "V1 menuItems had <N> non-section entries. V2 supports only one top-level menu. Using the first item (id: \"<id>\"). The remaining items (<list ids>) cannot be migrated automatically — acknowledge and continue?",
  "default": "acknowledged"
}

Required description for menu — add for every menu item:

{
  "id": "adminUi.menu.description",
  "prompt": "V2 menu requires a description. What description should the menu have?",
  "default": "<title value from v1>"
}

Set description to "<FILL_IN>" in the configFragment until the developer answers.

Custom fees (order only)

V1 order.customFees has no equivalent in adminUi. In v2, custom fees are implemented as a webhook on plugin.magento.out_of_process_totals_collector.api.get_total_modifications.custom_fees — a server-to-server mechanism that fires during cart totals collection and returns fees via a JSON Patch response. This is a manual migration that requires implementing a new webhook handler.

For each custom fee found, inform the developer that it requires manual migration.

See: Custom fees — Checkout Totals Collector


Handler Migration

After producing the configFragment, update the runtime action handler source files. Only worker entries require a handler — view entries (iframe) have no server handler.

Use your Write tool to update or create each handler file described below.

Worker mass actions

For each worker mass action in the v1 registration, find the existing handler file. The file is typically referenced by the path field in the v1 config (an HTTP URL whose path segment maps to an action under actions/), or discoverable via app.config.yaml or ext.config.yaml. Update it to use the v2 wire-contract builders:

import {
  parseMassActionRequest,
  okMassActionResponse,
  massActionErrorResponse,
} from "@adobe/aio-commerce-sdk/admin-ui/mass-actions";

export async function main(params: unknown) {
  const { gridType, ids } = parseMassActionRequest(params);
  try {
    // existing business logic — replace direct params access with the parsed values above
    await processEntities(gridType, ids);
    return okMassActionResponse();
  } catch (error) {
    return massActionErrorResponse(500, (error as Error).message);
  }
}
  • parseMassActionRequest(params){ requestId, gridType, ids } — validates the incoming shape; throws CommerceSdkValidationError on bad input.
  • okMassActionResponse(body?) → HTTP 200; optional body for logging.
  • massActionErrorResponse(statusCode, message) → non-2xx; body is { message }.

Worker view buttons

For each worker view button (displayIframe: false) in the v1 registration, find the existing handler file and update it:

import {
  parseOrderViewButtonRequest,
  okOrderViewButtonResponse,
  orderViewButtonErrorResponse,
} from "@adobe/aio-commerce-sdk/admin-ui/order-view-buttons";

export async function main(params: unknown) {
  const { id, orderId } = parseOrderViewButtonRequest(params);
  try {
    // existing business logic — id is the buttonId, orderId is the order
    await handleButton(id, orderId);
    return okOrderViewButtonResponse();
  } catch (error) {
    return orderViewButtonErrorResponse(500, (error as Error).message);
  }
}
  • parseOrderViewButtonRequest(params){ requestId, id, orderId }id identifies which button was clicked (useful when one handler serves multiple buttons).
  • okOrderViewButtonResponse() → success; empty {} body.
  • orderViewButtonErrorResponse(statusCode, message) → error; { message } body.

Grid columns (new handler)

V1 grid columns used API Mesh — there is no existing handler to migrate. Once the developer provides the runtimeAction name (via the adminUi.<entity>.gridColumns.runtimeAction unresolved question), scaffold a new handler under src/commerce-backend-ui-2/actions/<action>/index.ts:

import {
  parseGridRequest,
  okGridResponse,
  errorGridResponse,
} from "@adobe/aio-commerce-sdk/admin-ui/grid-columns";

export async function main(params: unknown) {
  const { gridType, ids } = parseGridRequest(params);
  try {
    // fetch column data for the visible entity ids
    const rows = await fetchColumnData(gridType, ids);
    // rows keyed by entity id; each value keyed by the column ids declared in config
    return okGridResponse(rows);
  } catch (error) {
    return errorGridResponse(500, (error as Error).message);
  }
}
  • parseGridRequest(params){ requestId, gridType, ids }ids are the visible entity IDs.
  • okGridResponse(data, defaults?) → success envelope. Row object keys must match the id values declared in gridColumns.columns, or cells render empty.
  • errorGridResponse(statusCode, message) → non-2xx error response.

Also register the action in src/commerce-backend-ui-2/ext.config.yaml under runtimeManifest:

runtimeManifest:
  packages:
    <package>: # must match the <package> portion of runtimeAction
      actions:
        <action>:
          function: actions/<action>/index.js
          web: "yes"
          annotations:
            require-adobe-auth: true
            final: true

If the developer has not yet answered the runtimeAction unresolved question, skip creating the handler file — leave a <FILL_IN> placeholder in the config and note that the handler must be scaffolded once the action name is confirmed.


Output

The domain field must be "adminUi" (v2 key).

Example when order mass actions and product grid columns are found:

{
  "domain": "adminUi",
  "configFragment": {
    "adminUi": {
      "order": {
        "massActions": [
          {
            "id": "export-orders",
            "label": "Export Orders",
            "type": "view",
            "path": "#/export",
            "confirm": {
              "title": "Export?",
              "message": "Export selected orders?"
            }
          }
        ]
      },
      "product": {
        "gridColumns": {
          "label": "Product grid columns",
          "description": "Adds custom columns to the product grid",
          "runtimeAction": "<FILL_IN>",
          "columns": [
            {
              "id": "inventory_status",
              "label": "Inventory Status",
              "type": "string",
              "align": "left"
            }
          ]
        }
      }
    }
  },
  "unresolvedQuestions": [
    {
      "id": "adminUi.product.gridColumns.runtimeAction",
      "prompt": "V1 grid columns for product used API Mesh (meshId: \"my-mesh-id\"). V2 requires a runtimeAction that returns column data. What is the runtime action name for the product grid columns?",
      "default": ""
    },
    {
      "id": "adminUi.product.gridColumns.label",
      "prompt": "What label should be shown for the product grid columns section?",
      "default": "Product grid columns"
    },
    {
      "id": "adminUi.product.gridColumns.description",
      "prompt": "What description should be shown for the product grid columns?",
      "default": "Adds custom columns to the product grid"
    }
  ]
}

Example when a menu item is found:

{
  "domain": "adminUi",
  "configFragment": {
    "adminUi": {
      "menu": {
        "id": "dashboard",
        "label": "My App",
        "description": "<FILL_IN>",
        "parentMenu": "sales"
      }
    }
  },
  "unresolvedQuestions": [
    {
      "id": "adminUi.menu.description",
      "prompt": "V2 menu requires a description. What description should the menu have?",
      "default": "My App"
    }
  ]
}

README.md

tile.json