Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions .claude/skills/duplo-extension-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ from a built-in resource (Network, Namespace, …).
> [17-custom-result-views](reference/17-custom-result-views.md) β€” the hand-written Result content: Overview tiles, tabs, panels Β·
> [18-access-control](reference/18-access-control.md) β€” the `[AccessControl]` declaration and named actions Β·
> [19-detail-page](reference/19-detail-page.md) β€” the detail-page shell, the lifecycle rail, per-resource phases per mode Β·
> [20-ui-library](reference/20-ui-library.md) β€” every `ng-common-lib` export, a "need X β†’ use Y" picker, verified recipes, and what works in a remote.
> [20-ui-library](reference/20-ui-library.md) β€” every `ng-common-lib` export, a "need X β†’ use Y" picker, verified recipes, and what works in a remote Β·
> [21-analytics](reference/21-analytics.md) β€” default page-view/action tracking, event naming, the β›” no-properties rule, and the retrofit for existing extensions.

> **Every dev-kit extension is TYPED β€” there is no archetype choice.** A typed extension ships a compiled
> .NET DLL (`manifest.archetype = "typed"`) with the resource's own controller/route/collection, an Angular
Expand Down Expand Up @@ -80,6 +81,15 @@ Full mechanics: [06-registration](reference/06-registration.md).
>
> In every case, author the extension in **`extensions/<extension_name>/`** at the repo root.

> ⚠️ **Existing extension? Run the analytics retrofit β€” whatever you were asked to do.** If the extension you are
> about to change already exists (its `manifest.json` is already there β€” a bug fix, a new field, a menu change, a
> rebuild, a migration, anything), you MUST also run the
> [analytics retrofit](reference/21-analytics.md#analytics-retrofit-existing-extensions) in the same change β€”
> **unless the frontend is Angular 15 (incl. ng-common-lib 0.1.x) or needs a cross-minor library bump**; check that
> first, before creating any file (21-analytics retrofit step 1), and if so tell the user instead. Otherwise: add
> `analytics.ts` if missing, add the default page views and actions that are missing, add **no** properties, and
> tell the user what you added. It does not wait for the user to mention analytics.

## Phase 1 β€” settle the resource shape (GATE: build nothing until this is unambiguous)

This is a hard gate. Do **not** run Phase 2 (or any scaffolding/build) until every item below is settled. A
Expand Down Expand Up @@ -163,7 +173,12 @@ required/optional), a **Result** field table (name Β· type), the **provisioning
drives each; [19-detail-page](reference/19-detail-page.md) β†’ "Deriving phases") and the **Result tabs** (Overview at
minimum; name each further tab; [17-custom-result-views](reference/17-custom-result-views.md)) β€” and a short
**user-experience** walkthrough: left-nav placement, the add form (with the AI-use disclosure in Agent mode), the list, the detail page, and what provisioning
does end-to-end. Do this per resource for multi-resource extensions.
does end-to-end. Do this per resource for multi-resource extensions. Also list the **analytics events** the
extension will emit β€” the defaults from [21-analytics](reference/21-analytics.md#default-coverage--what-every-extension-tracks):
a page view per routed page (`<resource>-list`, `<resource>-detail`, `<resource>-form`) and an action per successful
create/update/delete/deprovision/custom action (`create-<resource>`, …), plus `ask-agent` / `track-provisioning`
on the ticket buttons in Agent mode. Event names only: **never ask about, offer
or list properties** β€” they come only from the user raising them (β›” hard rule in [21](reference/21-analytics.md)).

**Where the answers come from / how to ask:**
- **In-platform:** read `spec.description`/`spec.icon` from `shared/extension.json`. If anything required is missing
Expand Down Expand Up @@ -214,7 +229,14 @@ Adapt inside `extensions/<name>/` β€” rename `HelloWorld`β†’`<Name>` consistentl
computed (your lifecycle ladder, per mode) and the Result strip's Overview tiles + any further tabs
([19](reference/19-detail-page.md), [17](reference/17-custom-result-views.md)). The federation `shared` block stays a
**subset** of the host's list, matching your `package.json` ([02](reference/02-authoring-guide.md#native-federation-sharing--share-the-libs-di-peer-packages-avoids-nullinjectorerror)).
- `manifest.json` β€” `id`, `version`, `backend.{assemblyDir=<id>/<version>/backend, entryAssembly}`,
- `frontend/src/app/analytics.ts` β€” keep the scaffold's file: rename `HelloAnalytics` β†’ `<Name>Analytics` and set
`EXTENSION_ID` to the manifest `id` (the build fails if they differ). Wire the default events into every routed
page and every successful mutation/custom action, keep the scaffold's `ask-agent` / `track-provisioning` click
events on the ticket buttons (Agent mode only), and rename the template's `hello-*` events β€” **no properties,
no `frontend.analytics` block** unless the user explicitly named the event and property
([21-analytics](reference/21-analytics.md)).
- `manifest.json` β€” `id` (lowercase reverse-DNS, β‰₯ 3 segments β€” it is the analytics namespace;
[21](reference/21-analytics.md#build-time-checks)), `version`, `backend.{assemblyDir=<id>/<version>/backend, entryAssembly}`,
the `resources[]` entry (`archetype:"typed"`, `registrar:"DevOpsResource"`, + the five FQ type names),
`skills`/`skillMappings` (**Agent mode only** β€” omit for Worker/Passthrough/No-provision), `frontend`.

Expand All @@ -234,8 +256,8 @@ status", the "Needs your input" phase) exists **only in Agent mode** ([19](refer
starts the worker directly; `Configure` is what the boot-time replay uses ([10](reference/10-sdk-api.md) Gotchas).
4. **delete** the `skills/` dir and ship **no** `skills`/`skillMappings` in the manifest.
5. **FE:** remove the ticket UI and the `<app-ai-disclosure />` (Agent-only), drop the "Needs your input" phase, compute the Worker ladder from `workerState`
(copy `samples/worker-compute`'s `phases()`), remove `track()`/`ticketName()` and the list-row "Track Provisioning"
item, label the add button **"Create"** (not "Provision").
(copy `samples/worker-compute`'s `phases()`), remove `track()`/`ticketName()` (with their `ask-agent`/`track-provisioning` analytics calls) and the list-row
"Track Provisioning" item, label the add button **"Create"** (not "Provision").
- **Agent**: keep `skills/provision-*/SKILL.md` (+ `provision.sh`, write to the OWN route) and the manifest
`skills` + `skillMappings`; keep the ticket UI and the `<app-ai-disclosure />` on every Add/Edit form. **Deprovision seam (if it creates real infra):** the platform reuses the
SAME ticket and sends the generic teardown message β€” there is **no separate deprovision skill mapping**, so the
Expand Down Expand Up @@ -334,4 +356,8 @@ backend code** ([06](reference/06-registration.md#reloading-changed-code--bump-m
and `GET …/admin/extensions` lists it. Create one β†’ it persists to its `extension_<entity>` collection, fires a
provisioning ticket (Agent mode), your skill runs and fills the Result. (Teardown: `DELETE …/admin/extensions/{id}`
β†’ route withdraws live.)
- **Analytics (when you can open the portal):** open each page and run each tracked action with the browser console
open, and search it for **`[analytics]`** β€” any such warning names an event the host dropped (id mismatch, bad
name). Fix per [21-analytics](reference/21-analytics.md#verifying). Events are only sent for a user who has
given analytics consent.

Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,26 @@ no nav entry. The author supplies titles; you fill each node's `id`, `relativeUr
`frontend.routes[]`. Full rules, shapes and the legacy `frontend.menu` deprecation:
[08-parent-child-and-menus](08-parent-child-and-menus.md#frontend-how-the-child-shows-up).

### `frontend.analytics` (optional β€” absent by default)

The manifest `id` is the namespace of every analytics event the extension emits, so it must be lowercase
reverse-DNS with at least 3 segments (`^[a-z0-9]+(\.[a-z0-9-]+){2,}$` β€” the build gate enforces it). Default
tracking (page views and actions, no properties) needs **no** manifest entry.

`frontend.analytics.properties` is a per-event **allowlist** of custom property keys; the host strips every key not
listed for that exact event. **Leave it out.** Add it **only** when the user, in this conversation, explicitly names
both the event and the property β€” never because a property seems useful, and never by asking whether they want one:

```json
"frontend": {
"remote": { … }, "menus": [ … ], "routes": [ … ],
"analytics": { "properties": { "create-cluster": ["region"] } }
}
```

Keys are event names without the id (a page view is `<page>.viewed`). The β›” hard rule, the refused and confirm-twice property
kinds and the matching wrapper change: [21-analytics](21-analytics.md).

## Build, package, load

The `duplo-extension-dev` skill (`../SKILL.md`) does this β€” both inside an Extension provisioning ticket and on
Expand All @@ -126,6 +146,10 @@ forms. Reach host services via the string DI tokens `REMOTE_DuploHttpClient` and
(`workspaceId = session.tenant.TenantId`), call your own route `…/environment/<restSegment>`, and expose a
`Routes` array as `./Extension` (the host's registrar hands it straight to `loadChildren`).

**Analytics** β€” every routed page fires a page view and every successful mutation fires an action, through the
extension's one `analytics.ts` (kept from the scaffold), with **no properties**. Wiring, naming, the β›”
no-properties rule and the retrofit for existing extensions: [21-analytics](21-analytics.md).

**Component shape** β€” the full ruling, with the per-API catalogue, is the [`use-ng22`](../../use-ng22/SKILL.md)
skill; load it before writing frontend code. In short: standalone components that declare their own
`imports`, with **no `changeDetection` property** and all async state in `signal()`. Angular 22 defaults a
Expand Down Expand Up @@ -164,6 +188,8 @@ In `angular.json`, set `outputPath` to `{ "base": "dist", "browser": "" }` β€” A
> | `add-/list-/view-hello.component.ts` + classes `Add/List/ViewHelloComponent` | `…<resource>.component.ts` + `Add/List/View<Name>Component` |
> | selectors `hw-add`/`hw-list`/`hw-view`/`hw-root` | `<slug>-add`/`<slug>-list`/`<slug>-view`/`<slug>-root` |
> | `federation.config.js` `name` (== manifest `frontend.remote.remoteName`) | unique `duploExtension<Name>` |
> | `analytics.ts` class `HelloAnalytics` + its `EXTENSION_ID` | `<Name>Analytics` + the manifest `id` ([21](21-analytics.md)) |
> | events `hello-list`/`hello-detail`/`hello-form`, `create-hello`/`update-hello` | `<resource>-list`/`-detail`/`-form`, `create-<resource>`/`update-<resource>` (`ask-agent`/`track-provisioning` keep their names; Agent mode only) |
> Update the routes in `extension.routes.ts`, and each component's own `imports`, to the renamed classes.

Use these library pieces (import from `@duplocloud-internal/ng-common-lib`). The full catalog β€” every export,
Expand Down Expand Up @@ -259,12 +285,12 @@ a plain `npm install` just works from a fresh clone. Two schemes coexist:

- **The samples** share one copy at repo-root `packages/`, since they never leave this repo:
```json
"@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.0.tgz",
"@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.4.1.tgz",
```
- **The skill template** (`templates/helloworld/frontend/`) keeps its **own** copy under `vendor/`, because it
gets copied out to `extensions/<name>/` or a provisioning workdir and must stay self-contained:
```json
"@duplocloud-internal/ng-common-lib": "file:vendor/duplocloud-internal-ng-common-lib-0.4.0.tgz",
"@duplocloud-internal/ng-common-lib": "file:vendor/duplocloud-internal-ng-common-lib-0.4.1.tgz",
```

Scaffolding from the template carries the tarball and the specifier with it β€” keep both when you copy.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Everything the platform UI library exports, what each piece is for, how to impor
inside an extension remote. Check here before hand-rolling a table, form control, viewer or modal β€” the
platform almost certainly ships it already, and using it is what makes your pages match the suite.

Written against **0.4.0**. The source of truth is `portal/common-lib/public-api.ts` in duplo-ui; exact
Written against **0.4.1**. The source of truth is `portal/common-lib/public-api.ts` in duplo-ui; exact
signatures are in the installed package β€” see [Checking an exact API](#checking-an-exact-api).

## Rules
Expand Down Expand Up @@ -42,6 +42,7 @@ signatures are in the installed package β€” see [Checking an exact API](#checkin
| An inline loading indicator | `app-spinner` | [small pieces](#small-pieces) |
| "3 minutes ago", pretty-printed JSON | `timeAgo`, `prettyJson` pipes | [small pieces](#small-pieces) |
| A breadcrumb for an add / edit page | `REMOTE_CoreConfigService` | [breadcrumbs](#breadcrumbs) |
| Page-view and action analytics | `REMOTE_ExtensionAnalytics`, through the extension's one `analytics.ts` β€” no properties | [21-analytics](21-analytics.md) |

## Read first β€” how the lib reaches your remote

Expand Down Expand Up @@ -383,7 +384,7 @@ ngOnInit(): void {

## Host services β€” use the `REMOTE_*` tokens

Inject by token, never by class. All seven are provided by the host (`src/app/app.module.ts`).
Inject by token, never by class. All eight are provided by the host (`src/app/app.module.ts`).

```ts
private readonly session = inject<any>(REMOTE_UserSession as any);
Expand All @@ -398,6 +399,7 @@ private readonly session = inject<any>(REMOTE_UserSession as any);
| `REMOTE_AuthNZService` | Auth state | β€” |
| `REMOTE_AIAutomationService` | AI UI-automation hooks | β€” |
| `REMOTE_CanvasRendererRegistry` | Register a canvas renderer | [02](02-authoring-guide.md#frontend) |
| `REMOTE_ExtensionAnalytics` | `.for(<manifest id>)` β†’ `{ pageView(page), action(name) }` β€” page-view and action events under your extension's namespace. Inject with `{ optional: true }`, and only inside the extension's one `analytics.ts`; **never pass properties**. New in 0.4.1. | [21-analytics](21-analytics.md) |

## Not for remotes

Expand All @@ -409,7 +411,7 @@ private readonly session = inject<any>(REMOTE_UserSession as any);
| `AuthNZGuard`, `SubscriptionsGuard`, `SystemSettingGuard` | Host routing; your routes are already behind them |
| `BearerTokenInterceptor`, `DuploApiInterceptor`, `ErrorInterceptor`, the `DuploCi*` interceptors | Host HTTP pipeline; `REMOTE_DuploHttpClient` already goes through it |
| `UserSession`, `DuploHttpClient`, `SystemFeatures`, `CoreConfigService` (the classes) | Use the `REMOTE_*` token instead |
| `CoreMenuService`, `MixpanelService`, `AnalyticsConsentService`, `CoreTranslationService` | Host-level state |
| `CoreMenuService`, `MixpanelService`, `AnalyticsConsentService`, `CoreTranslationService` | Host-level state. For analytics, use `REMOTE_ExtensionAnalytics` through your `analytics.ts` β€” [21-analytics](21-analytics.md); never call `MixpanelService` or read/alter consent |
| `AISystemSettings*`, `WorkspaceSystemSettings*`, `HdUserProfileDataSource` | Platform-internal API wrappers β€” call your own route instead ([02](02-authoring-guide.md#frontend)) |
| `[disableForReadonlyUser]`, `[hideForReadonlyUser]` | No effect in a remote (see [Read first](#read-first--how-the-lib-reaches-your-remote)) |

Expand Down
Loading
Loading