@acmekit/acmekit 2.13.86 → 2.13.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist/templates/app/.claude/agents/code-reviewer.md +18 -0
  2. package/dist/templates/app/.claude/agents/test-writer.md +28 -18
  3. package/dist/templates/app/.claude/rules/admin-components.md +18 -4
  4. package/dist/templates/app/.claude/rules/admin-data.md +73 -0
  5. package/dist/templates/app/.claude/rules/admin-patterns.md +20 -0
  6. package/dist/templates/app/.claude/rules/api-routes.md +25 -1
  7. package/dist/templates/app/.claude/rules/modules.md +169 -0
  8. package/dist/templates/app/.claude/rules/testing.md +96 -16
  9. package/dist/templates/app/.claude/rules/workflows.md +2 -0
  10. package/dist/templates/app/.claude/skills/admin-customization/SKILL.md +10 -0
  11. package/dist/templates/app/.claude/skills/write-test/SKILL.md +7 -0
  12. package/dist/templates/app/CLAUDE.md +2 -1
  13. package/dist/templates/plugin/.claude/agents/code-reviewer.md +18 -0
  14. package/dist/templates/plugin/.claude/agents/test-writer.md +30 -23
  15. package/dist/templates/plugin/.claude/rules/admin-components.md +18 -4
  16. package/dist/templates/plugin/.claude/rules/admin-data.md +73 -0
  17. package/dist/templates/plugin/.claude/rules/admin-patterns.md +20 -0
  18. package/dist/templates/plugin/.claude/rules/api-routes.md +25 -1
  19. package/dist/templates/plugin/.claude/rules/modules.md +169 -0
  20. package/dist/templates/plugin/.claude/rules/testing.md +187 -20
  21. package/dist/templates/plugin/.claude/rules/workflows.md +2 -0
  22. package/dist/templates/plugin/.claude/skills/admin-customization/SKILL.md +10 -0
  23. package/dist/templates/plugin/.claude/skills/write-test/SKILL.md +8 -0
  24. package/dist/templates/plugin/CLAUDE.md +2 -1
  25. package/package.json +39 -39
@@ -396,6 +396,149 @@ Options flow: host config `providers[].options` → constructor second argument
396
396
 
397
397
  Available domain abstracts: `AbstractNotificationProviderService`, `AbstractFileProviderService`, `AbstractAuthModuleProvider`, `AbstractAnalyticsProviderService`, `AbstractModuleProvider<TConfig>` (generic base).
398
398
 
399
+ ### Provider Container Scoping
400
+
401
+ **Providers are module-scoped.** The `container` passed to a provider constructor is the **parent module's** awilix cradle — not the application root container. This is by design: modules are isolated, self-contained units.
402
+
403
+ **Providers CAN access:**
404
+ - Sibling registrations within the parent module (e.g., `logger`, services registered by the module's loaders)
405
+ - Their own `this.options_` (from `providers[].options` in host config)
406
+
407
+ **Providers CANNOT access:**
408
+ - Other modules — a provider under `RELAYER_MODULE` cannot resolve `TRON_MODULE`, `Modules.USER`, or any module outside its parent
409
+ - Application-level container registrations not propagated to the module scope
410
+
411
+ **Attempting to resolve another module from the provider's container silently returns `undefined` or throws at runtime** — there is no compile-time error.
412
+
413
+ #### Cradle vs AcmeKitContainer — Two Different Objects
414
+
415
+ AcmeKit has two container representations. Confusing them causes silent `undefined` at runtime.
416
+
417
+ | | **Cradle** (awilix proxy) | **AcmeKitContainer** |
418
+ |---|---|---|
419
+ | What is it | Proxy object — property access triggers resolution | Full container with `.resolve()` method |
420
+ | Access pattern | `container.logger`, `container[MODULE]` | `container.resolve(MODULE)` |
421
+ | Who gets it | Provider constructors, service constructors | Workflow steps (`{ container }`), `req.scope` |
422
+ | Type | `Record<string, unknown>` (or typed cradle) | `AcmeKitContainer` from `@acmekit/framework/types` |
423
+
424
+ ```typescript
425
+ // Provider constructor — receives CRADLE (property access)
426
+ constructor(container: Cradle, options: Options) {
427
+ this.logger_ = container.logger // ✅ property access
428
+ this.logger_ = container.resolve("logger") // ❌ cradle has no .resolve()
429
+ }
430
+
431
+ // Workflow step — receives ACMEKIT CONTAINER (.resolve() method)
432
+ async (input, { container }) => {
433
+ const svc = container.resolve(MY_MODULE) // ✅ .resolve() method
434
+ const svc = container[MY_MODULE] // ❌ undefined — not a cradle
435
+ }
436
+ ```
437
+
438
+ **When passing the application container to a provider method, the provider must use `.resolve()` — not bracket access:**
439
+
440
+ ```typescript
441
+ import type { AcmeKitContainer } from "@acmekit/framework/types"
442
+
443
+ class MyProvider {
444
+ async doWork(request: Request, appContainer?: AcmeKitContainer) {
445
+ // appContainer is from the workflow step — use .resolve()
446
+ const otherService = appContainer?.resolve<IOtherService>(OTHER_MODULE) // ✅
447
+ const otherService = appContainer?.[OTHER_MODULE] // ❌ undefined!
448
+ }
449
+ }
450
+ ```
451
+
452
+ #### Simple Providers — No Cross-Module Dependencies
453
+
454
+ When a provider's job is a single external operation (send email, upload file, call API), it needs only `this.options_` and sibling registrations. No special handling required.
455
+
456
+ ```typescript
457
+ class SendGridNotification extends AbstractNotificationProviderService {
458
+ static identifier = "sendgrid"
459
+
460
+ async send(notification) {
461
+ // Only needs this.options_.apiKey — no other modules
462
+ return this.client_.send({ to: notification.to, template: notification.template })
463
+ }
464
+ }
465
+ ```
466
+
467
+ #### Complex Providers — Cross-Module Dependencies via Method Parameters
468
+
469
+ Some providers need other modules to do their job — e.g., a chain relayer provider that calls external APIs but also manages chain-specific DB state in a companion module. The provider can't resolve the companion module from `this.container_` (module-scoped), and splitting into workflow steps isn't always possible (the calling workflow belongs to a different plugin/package).
470
+
471
+ **The correct pattern: the workflow step passes the `AcmeKitContainer` through the parent module's service method to the provider. The provider uses `.resolve()` on the passed container — never bracket access.**
472
+
473
+ ```typescript
474
+ import type { AcmeKitContainer } from "@acmekit/framework/types"
475
+
476
+ // 1. Workflow step — passes the full application container
477
+ export const executeOnChainStep = createStep(
478
+ { name: "execute-on-chain-step", noCompensation: true },
479
+ async ({ request }, { container }) => {
480
+ const relayerService = container.resolve<IRelayerModuleService>(RELAYER_MODULE)
481
+ // Pass container (AcmeKitContainer) — provider will .resolve() what it needs
482
+ const result = await relayerService.relay(request, container)
483
+ return new StepResponse(result)
484
+ },
485
+ async () => {}
486
+ )
487
+
488
+ // 2. Parent module service — forwards AcmeKitContainer to the provider
489
+ class RelayerModuleService {
490
+ @InjectManager()
491
+ async relay(
492
+ request: RelayRequest,
493
+ appContainer?: AcmeKitContainer,
494
+ @AcmeKitContext() sharedContext: Context = {}
495
+ ) {
496
+ return await this.relay_(request, appContainer, sharedContext)
497
+ }
498
+
499
+ @InjectTransactionManager()
500
+ protected async relay_(
501
+ request: RelayRequest,
502
+ appContainer?: AcmeKitContainer,
503
+ @AcmeKitContext() sharedContext: Context = {}
504
+ ) {
505
+ const provider = this.resolveChainRelayer(request.chain)
506
+ const wallet = await this.selectWallet_(request.chain, sharedContext)
507
+ return await provider.relay({ ...request, wallet }, appContainer)
508
+ }
509
+ }
510
+
511
+ // 3. Provider — uses .resolve() on the passed AcmeKitContainer
512
+ class TronChainRelayer implements IChainRelayer {
513
+ static identifier = "tron-chain-relayer"
514
+
515
+ async relay(request: RelayRequest, appContainer?: AcmeKitContainer): Promise<RelayResult> {
516
+ // .resolve() with optional chaining — NOT bracket access
517
+ const tronService = appContainer?.resolve<ITronModuleService>(TRON_MODULE)
518
+ const delegation = await tronService.createDelegationRecords([...])
519
+ // this.options_ for own config (module-scoped — always available)
520
+ const client = new TronWeb({ fullHost: this.options_.fullNodeUrl })
521
+ const txResult = await client.broadcast(request.payload)
522
+ await tronService.reconcileDelegation(delegation.id, txResult)
523
+ return txResult
524
+ }
525
+ }
526
+ ```
527
+
528
+ **Key constraints:**
529
+ - Provider constructor container (`this.container_`) is a **cradle** — property access only, ONLY for sibling registrations
530
+ - The `AcmeKitContainer` passed as a method parameter uses **`.resolve()`** — never bracket access
531
+ - The `appContainer` parameter is optional (`?`) — the provider interface uses the looser `Record<string, unknown>` type; implementations narrow to `AcmeKitContainer`
532
+ - The workflow step is the only place that has the full `AcmeKitContainer`
533
+ - Import: `import type { AcmeKitContainer } from "@acmekit/framework/types"`
534
+
535
+ **When to use which tier:**
536
+
537
+ | Provider type | Cross-module deps? | Pattern |
538
+ |---|---|---|
539
+ | File storage, notifications, SMS | No | Use `this.options_` and `this.container_` only |
540
+ | Payment processors, chain relayers | Yes — companion modules for state/records | Receive deps as method params from workflow step |
541
+
399
542
  ### Module() vs ModuleProvider()
400
543
 
401
544
  | | `Module()` | `ModuleProvider()` |
@@ -590,6 +733,32 @@ class MyProvider {
590
733
  }
591
734
  }
592
735
 
736
+ // WRONG — provider resolving another module from its own container (cradle)
737
+ // Provider container is scoped to the PARENT module — other modules are not visible
738
+ class TronRelayer implements IChainRelayer {
739
+ async relay(request: RelayRequest) {
740
+ const tronService = this.container_[TRON_MODULE] // ❌ undefined — not in module scope
741
+ await tronService.createDelegationRecords([...]) // crashes
742
+ }
743
+ }
744
+
745
+ // WRONG — using bracket access on AcmeKitContainer (it's NOT a cradle)
746
+ class TronRelayer implements IChainRelayer {
747
+ async relay(request: RelayRequest, appContainer?: AcmeKitContainer) {
748
+ const svc = appContainer[TRON_MODULE] // ❌ undefined — wrong access pattern!
749
+ const svc2 = appContainer[TRON_MODULE] as any // ❌ same bug, hidden by cast
750
+ }
751
+ }
752
+
753
+ // RIGHT — use .resolve() on AcmeKitContainer passed from workflow step
754
+ class TronRelayer implements IChainRelayer {
755
+ async relay(request: RelayRequest, appContainer?: AcmeKitContainer) {
756
+ const tronService = appContainer?.resolve<ITronModuleService>(TRON_MODULE) // ✅
757
+ await tronService.createDelegationRecords([...])
758
+ return await this.client_.broadcast(request.payload)
759
+ }
760
+ }
761
+
593
762
  // WRONG — hardcoding prefix/identifiersKey inline in multiple files
594
763
  // A typo between createProvidersLoader and AcmeKitService silently breaks resolution
595
764
  createProvidersLoader({ prefix: "cr_", identifiersKey: "chain_relayer_providers_identifier" })
@@ -8,7 +8,30 @@ paths:
8
8
 
9
9
  # Testing Rules
10
10
 
11
- ## Critical Read Before Writing Any Test
11
+ ## Pre-Flight Checklist (MANDATORY before writing any test)
12
+
13
+ Complete these steps IN ORDER before writing a single line of test code:
14
+
15
+ 1. **Build the plugin.** Run `pnpm build` (or verify `pnpm dev` is running). Stale `.acmekit/server/` output causes cryptic errors like `__joinerConfig is not a function` or missing module services. The test runner loads compiled output from `.acmekit/server/`, not source files.
16
+
17
+ 2. **Verify jest config buckets.** Read `jest.config.js` and confirm the test type you need exists:
18
+ ```
19
+ integration:plugin → integration-tests/plugin/
20
+ integration:http → integration-tests/http/
21
+ integration:modules → src/modules/*/__tests__/
22
+ unit → src/**/__tests__/*.unit.spec.ts
23
+ ```
24
+ If the bucket is missing, add the jest config entry BEFORE writing the test. A test file in a directory with no matching jest config is silently ignored.
25
+
26
+ 3. **Read the source code under test.** Read service methods, route handlers, workflow steps, subscriber handlers — understand actual method signatures, return types, and error paths.
27
+
28
+ 4. **Read mock interfaces.** When your test depends on auto-injected mocks (`MockEventBusService`, `MockLockingService`, `MockSecretsService`), read the mock source in `@acmekit/test-utils` to verify method names and signatures. Do NOT assume mock methods match the real service 1:1 — mocks have convenience methods (e.g., `setSecret()`, `getEmittedEvents()`) and may omit advanced features.
29
+
30
+ 5. **Identify the correct test tier.** Match your test to the right mode and file location (see Test Runner Selection below).
31
+
32
+ ---
33
+
34
+ ## Critical Rules
12
35
 
13
36
  **Single unified runner.** Use `integrationTestRunner` from `@acmekit/test-utils` with a `mode` parameter. The old names (`pluginIntegrationTestRunner`, `moduleIntegrationTestRunner`) are deprecated aliases — NEVER use them.
14
37
 
@@ -26,6 +49,8 @@ paths:
26
49
 
27
50
  **Always add `jest.clearAllMocks()` in `beforeEach`.** Mock state leaks between test blocks. Without explicit cleanup, assertions on mock call counts fail from prior-test contamination.
28
51
 
52
+ **`pluginPath` is MANDATORY in plugin mode.** Always use `pluginPath: process.cwd()`. Omitting it causes `Cannot read properties of undefined` during ConfigStage.
53
+
29
54
  ---
30
55
 
31
56
  ## Test Runner Selection
@@ -492,34 +517,27 @@ integrationTestRunner({
492
517
 
493
518
  ## Asserting Domain Events
494
519
 
495
- Plugin mode (without HTTP) injects `MockEventBusService`. Spy on the **prototype**, not an instance.
520
+ `MockEventBusService` accumulates all emitted events. Use `.getEmittedEvents()` no spy needed.
496
521
 
497
522
  ```typescript
498
- import { MockEventBusService } from "@acmekit/test-utils"
523
+ it("should emit greeting.created event", async () => {
524
+ const service = container.resolve(GREETING_MODULE)
525
+ await service.createGreetings([{ message: "Hello" }])
499
526
 
500
- it("should capture events", async () => {
501
- const spy = jest.spyOn(MockEventBusService.prototype, "emit")
502
527
  const eventBus = container.resolve(Modules.EVENT_BUS)
503
-
504
- await eventBus.emit([
505
- { name: "greeting.created", data: { id: "g1", message: "Hello" } },
506
- ])
507
-
508
- expect(spy).toHaveBeenCalledTimes(1)
509
- expect(spy.mock.calls[0][0]).toEqual(
528
+ const events = eventBus.getEmittedEvents()
529
+ expect(events).toEqual(
510
530
  expect.arrayContaining([
511
531
  expect.objectContaining({
512
- name: "greeting.created",
513
- data: expect.objectContaining({ id: "g1" }),
532
+ name: "greeting.created", // NOTE: property is "name", NOT "eventName"
533
+ data: expect.objectContaining({ id: expect.any(String) }),
514
534
  }),
515
535
  ])
516
536
  )
517
-
518
- spy.mockRestore()
519
537
  })
520
538
  ```
521
539
 
522
- **Note:** MockEventBusService `emit` takes an **array** of events. Real event bus `emit` takes a single `{ name, data }` object.
540
+ > `emitEventStep` maps `eventName` to `name` internally always assert on `event.name`.
523
541
 
524
542
  ---
525
543
 
@@ -765,7 +783,9 @@ integrationTestRunner({
765
783
 
766
784
  ## Configuring Providers in Plugin Tests
767
785
 
768
- Use `pluginModuleOptions` to pass per-module options (e.g., provider configuration) keyed by module name:
786
+ Providers CANNOT be mocked with `jest.mock({ virtual: true })`. The module loader uses `require.resolve()` which bypasses jest. Providers must be **real files on disk** exporting `ModuleProvider()`.
787
+
788
+ Use `pluginModuleOptions` to register providers keyed by module name. Provider `resolve` paths must be **absolute** (the loader resolves from `modules-sdk` dist, not your test file):
769
789
 
770
790
  ```typescript
771
791
  integrationTestRunner({
@@ -775,8 +795,8 @@ integrationTestRunner({
775
795
  tron: {
776
796
  providers: [
777
797
  {
778
- resolve: "./src/providers/energy/own-pool",
779
- id: "own-pool",
798
+ resolve: process.cwd() + "/integration-tests/helpers/mock-energy-provider",
799
+ id: "mock-energy",
780
800
  options: { apiKey: "test-key" },
781
801
  },
782
802
  ],
@@ -786,10 +806,135 @@ integrationTestRunner({
786
806
  })
787
807
  ```
788
808
 
809
+ The helper file must be a real module:
810
+
811
+ ```typescript
812
+ // integration-tests/helpers/mock-energy-provider.ts
813
+ import { ModuleProvider } from "@acmekit/framework/utils"
814
+
815
+ class MockEnergyService {
816
+ static identifier = "mock-energy"
817
+ // implement provider interface methods
818
+ }
819
+
820
+ export default ModuleProvider("tron", { services: [MockEnergyService] })
821
+ ```
822
+
789
823
  `pluginModuleOptions` is merged into the discovered module config AFTER `pluginOptions`, so module-specific options override plugin-level ones.
790
824
 
791
825
  ---
792
826
 
827
+ ## Auto-Injected Infrastructure Mocks
828
+
829
+ The test runner automatically injects mock implementations for infrastructure modules in plugin and module modes. You do NOT need to manually inject these:
830
+
831
+ | Module | Mock class | Behavior |
832
+ |---|---|---|
833
+ | `Modules.EVENT_BUS` | `MockEventBusService` | Accumulates events — call `.getEmittedEvents()` to inspect |
834
+ | `Modules.LOCKING` | `MockLockingService` | In-memory locking — `execute()` runs job immediately, acquire/release tracked |
835
+ | `Modules.SECRETS` | `MockSecretsService` | In-memory store — pre-populate with `.setSecret(id, value)` |
836
+
837
+ Override any mock via `injectedDependencies` if needed — user overrides take precedence.
838
+
839
+ ### Asserting emitted events (no spy needed)
840
+
841
+ ```typescript
842
+ // Resolve the mock event bus from the container
843
+ const eventBus = container.resolve(Modules.EVENT_BUS)
844
+
845
+ // Run your workflow/operation that emits events
846
+ await createGreetingsWorkflow(container).run({ input: { greetings: [{ message: "Hi" }] } })
847
+
848
+ // Inspect emitted events directly — no jest.spyOn needed
849
+ const events = eventBus.getEmittedEvents()
850
+ const greetingEvent = events.find((e: any) => e.name === "greeting.created")
851
+ expect(greetingEvent).toBeDefined()
852
+ expect(greetingEvent.data).toEqual(expect.objectContaining({ id: expect.any(String) }))
853
+ ```
854
+
855
+ > Event objects have `{ name, data, metadata?, options? }`. The property is `name`, NOT `eventName` — `emitEventStep` maps `eventName` to `name` internally.
856
+
857
+ ### Pre-populating secrets for tests
858
+
859
+ ```typescript
860
+ integrationTestRunner({
861
+ mode: "plugin",
862
+ pluginPath: process.cwd(),
863
+ testSuite: ({ container }) => {
864
+ beforeEach(() => {
865
+ const secrets = container.resolve(Modules.SECRETS)
866
+ secrets.setSecret("prod/wallet", { address: "0xtest123" })
867
+ })
868
+
869
+ it("resolves wallet address from secrets", async () => {
870
+ const addr = await container.resolve(Modules.SECRETS).getKey("prod/wallet", "address")
871
+ expect(addr).toBe("0xtest123")
872
+ })
873
+ },
874
+ })
875
+ ```
876
+
877
+ ---
878
+
879
+ ## injectedDependencies vs pluginModuleOptions
880
+
881
+ | | `injectedDependencies` | `pluginModuleOptions` |
882
+ |---|---|---|
883
+ | **Injects into** | Root container | Per-module config |
884
+ | **Use for** | Infrastructure mocks (locking, secrets, custom services) | Provider registration, module-specific config |
885
+ | **Shape** | `{ [Modules.X]: mockInstance }` | `{ moduleName: { providers: [...], options } }` |
886
+ | **Path resolution** | N/A | Must be **absolute** (`process.cwd() + "/path"`) |
887
+
888
+ ---
889
+
890
+ ## Client Routes Require API Key
891
+
892
+ Routes under `/client/*` use `AcmeKitTypedRequest` (non-authenticated), but they are NOT fully public. AcmeKit middleware enforces a client API key header on all `/client/*` routes.
893
+
894
+ ```typescript
895
+ // WRONG — 401 "Client API key required"
896
+ const response = await api.get("/client/plugin/greetings") // ❌
897
+
898
+ // RIGHT — create client API key and pass header
899
+ const apiKey = await apiKeyModule.createApiKeys({
900
+ title: "Test Client Key",
901
+ type: ApiKeyType.CLIENT,
902
+ created_by: "test",
903
+ })
904
+ const response = await api.get("/client/plugin/greetings", {
905
+ headers: { [CLIENT_API_KEY_HEADER]: apiKey.token },
906
+ })
907
+ ```
908
+
909
+ ---
910
+
911
+ ## Known Environment Issues
912
+
913
+ ### Stale `.acmekit/server/` Build Output
914
+
915
+ **Symptom:** `__joinerConfig is not a function`, missing module services, `Cannot find module` errors when tests previously worked.
916
+
917
+ **Cause:** Plugin tests load compiled JS from `.acmekit/server/src/`, not TypeScript source. If you edited source files but didn't rebuild, the compiled output is stale.
918
+
919
+ **Fix:** Run `pnpm build` (one-time) or keep `pnpm dev` running (watch mode). After build, re-run the failing test.
920
+
921
+ ### Database Teardown Permission Errors
922
+
923
+ **Symptom:** `permission denied for schema public` or `cannot drop schema` during test teardown (afterAll).
924
+
925
+ **Cause:** PostgreSQL user lacks `CREATE`/`DROP` privileges on the `public` schema, or concurrent test processes hold locks.
926
+
927
+ **Fix:**
928
+ ```bash
929
+ # Grant full privileges to the test user (run once)
930
+ psql -U postgres -c "GRANT ALL ON SCHEMA public TO <your_user>;"
931
+ psql -U postgres -c "ALTER USER <your_user> CREATEDB;"
932
+ ```
933
+
934
+ This is an environment setup issue, not a test code issue. If teardown fails but all tests passed, the test results are still valid.
935
+
936
+ ---
937
+
793
938
  ## Anti-Patterns — NEVER Do These
794
939
 
795
940
  ```typescript
@@ -915,4 +1060,26 @@ const re = new RegExp("^(\\*|[0-9]+)(\\/[0-9]+)?$")
915
1060
  const result = await provider.process(bad)
916
1061
  expect(result.success).toBe(false) // ❌ actually throws
917
1062
  // RIGHT — read implementation first to check if it throws or returns
1063
+
1064
+ // WRONG — jest virtual mock for providers (require.resolve bypasses jest)
1065
+ jest.mock("../../src/providers/mock-provider", () => ({ ... }), { virtual: true }) // ❌
1066
+ // RIGHT — create a real file and use absolute path
1067
+ // integration-tests/helpers/mock-provider.ts (real file with ModuleProvider export)
1068
+ pluginModuleOptions: { mod: { providers: [{ resolve: process.cwd() + "/integration-tests/helpers/mock-provider" }] } }
1069
+
1070
+ // WRONG — relative provider resolve path (resolved from modules-sdk dist, not test file)
1071
+ { resolve: "../../src/providers/my-provider" } // ❌
1072
+ // RIGHT
1073
+ { resolve: process.cwd() + "/src/providers/my-provider" }
1074
+
1075
+ // WRONG — using jest.spyOn to inspect emitted events
1076
+ jest.spyOn(MockEventBusService.prototype, "emit") // ❌ fragile, complex extraction
1077
+ // RIGHT — use built-in event accumulation
1078
+ const eventBus = container.resolve(Modules.EVENT_BUS)
1079
+ const events = eventBus.getEmittedEvents()
1080
+
1081
+ // WRONG — checking event.eventName (emitEventStep maps eventName → name internally)
1082
+ expect(event.eventName).toBe("greeting.created") // ❌ property doesn't exist
1083
+ // RIGHT
1084
+ expect(event.name).toBe("greeting.created")
918
1085
  ```
@@ -502,6 +502,8 @@ dismissRemoteLinkStep([{
502
502
 
503
503
  Always resolve services fresh from `container`. Never capture from closure in compensation.
504
504
 
505
+ **Workflow steps have the full application container** — they can resolve ANY module. Module providers and services are scoped to their parent module and cannot resolve other modules. When a provider needs cross-module dependencies, the step resolves them and passes them through the service to the provider as method parameters (see `modules.md` → Provider Container Scoping).
506
+
505
507
  ```typescript
506
508
  // Typed resolution (preferred)
507
509
  const service = container.resolve<IBlogModuleService>(BLOG_MODULE)
@@ -376,6 +376,16 @@ Each widget file exports exactly one default component and one `config`.
376
376
 
377
377
  Widgets receive no props. Fetch all data internally using hooks.
378
378
 
379
+ ### Admin Dependencies
380
+
381
+ Form-related packages are provided by acmekit — do NOT install them separately:
382
+
383
+ - `react-hook-form` — import `useForm` from `"react-hook-form"` (add to `peerDependencies` + `devDependencies`)
384
+ - `@hookform/resolvers` — import `zodResolver` from `"@hookform/resolvers/zod"` (add to `peerDependencies` + `devDependencies`)
385
+ - `zod` — import as `import * as zod from "@acmekit/deps/zod"` (NOT `from "zod"`)
386
+
387
+ **Plugin dependency placement:** These packages must be in `peerDependencies` (host provides them), mirrored in `devDependencies` (for local dev). NEVER put them in only `devDependencies`.
388
+
379
389
  ### Prefer Shared SDK Over Raw Fetch
380
390
 
381
391
  Use `src/admin/lib/sdk.ts` for backend calls so auth, path prefixes, and route typings stay consistent:
@@ -15,6 +15,13 @@ Generate tests for AcmeKit plugins using `integrationTestRunner` with the correc
15
15
  - Plugin integration tests: !`ls integration-tests/plugin/*.spec.ts 2>/dev/null || echo "(none)"`
16
16
  - HTTP integration tests: !`ls integration-tests/http/*.spec.ts 2>/dev/null || echo "(none)"`
17
17
 
18
+ ## Pre-Flight Checklist (MANDATORY — do these BEFORE writing any test)
19
+
20
+ 1. **Build the plugin.** Run `pnpm build` (or verify `pnpm dev` is running). Stale `.acmekit/server/` causes `__joinerConfig is not a function` and missing services.
21
+ 2. **Verify jest config buckets.** Read `jest.config.js` — confirm the bucket you need exists (`integration:plugin`, `integration:http`, `integration:modules`, `unit`). If missing, add the entry and create the directory first.
22
+ 3. **Read source code under test.** Understand method signatures, return types, error handling.
23
+ 4. **Read mock interfaces.** If using auto-injected mocks, read their source to verify method names. Don't guess from real service interfaces.
24
+
18
25
  ## Critical Gotchas — Every Test Must Get These Right
19
26
 
20
27
  1. **Unified runner only.** `import { integrationTestRunner } from "@acmekit/test-utils"`. NEVER use `pluginIntegrationTestRunner` or `moduleIntegrationTestRunner` — those are deprecated.
@@ -26,6 +33,7 @@ Generate tests for AcmeKit plugins using `integrationTestRunner` with the correc
26
33
  7. **HTTP mode requires auth.** `mode: "plugin"` + `http: true` boots the full framework — inline JWT + client API key setup in `beforeEach`.
27
34
  8. **Axios throws on 4xx/5xx.** Use `.catch((e: any) => e)` for error assertions in HTTP tests.
28
35
  9. **MockEventBusService emit takes arrays.** In plugin container mode, `emit` receives `[{ name, data }]` (array). Real event bus uses `{ name, data }` (single object).
36
+ 10. **`pluginPath` is MANDATORY.** Always use `pluginPath: process.cwd()`. Omitting it causes `Cannot read properties of undefined`.
29
37
 
30
38
  ## Instructions
31
39
 
@@ -8,7 +8,8 @@ You are a senior AcmeKit plugin developer. Build distributable plugins that add
8
8
 
9
9
  - Edit files in `src/types/generated/` — overwritten by `npx acmekit generate types`
10
10
  - Import from `.acmekit/types/` or `src/types/generated/` directly — use `InferTypeOf` or barrel exports
11
- - Import `zod`, `awilix`, or `pg` directly — use `@acmekit/framework/zod`, `@acmekit/framework/awilix`, `@acmekit/framework/pg`
11
+ - Import `zod`, `awilix`, or `pg` directly — backend: use `@acmekit/framework/zod`, `@acmekit/framework/awilix`, `@acmekit/framework/pg`; admin UI: use `@acmekit/deps/zod`
12
+ - Add `react-hook-form` or `@hookform/resolvers` to only `devDependencies` — they are provided by acmekit, must be in `peerDependencies` (+ `devDependencies` mirror)
12
13
  - Use string literals for container keys — use `ContainerRegistrationKeys.*` and `Modules.*`
13
14
  - Call services directly for mutations in routes — use workflows
14
15
  - Put business logic in workflow steps — steps are thin wrappers; service methods own orchestration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acmekit/acmekit",
3
- "version": "2.13.86",
3
+ "version": "2.13.88",
4
4
  "description": "Generic application bootstrap and loaders for the AcmeKit framework",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -49,45 +49,45 @@
49
49
  "test:integration": "../../node_modules/.bin/jest --passWithNoTests --forceExit --testPathPattern=\"src/.*/integration-tests/__tests__/.*\\.ts\""
50
50
  },
51
51
  "devDependencies": {
52
- "@acmekit/framework": "2.13.86"
52
+ "@acmekit/framework": "2.13.88"
53
53
  },
54
54
  "dependencies": {
55
- "@acmekit/admin-bundler": "2.13.86",
56
- "@acmekit/analytics": "2.13.86",
57
- "@acmekit/analytics-local": "2.13.86",
58
- "@acmekit/analytics-posthog": "2.13.86",
59
- "@acmekit/api-key": "2.13.86",
60
- "@acmekit/auth": "2.13.86",
61
- "@acmekit/auth-emailpass": "2.13.86",
62
- "@acmekit/auth-github": "2.13.86",
63
- "@acmekit/auth-google": "2.13.86",
64
- "@acmekit/cache-inmemory": "2.13.86",
65
- "@acmekit/cache-redis": "2.13.86",
66
- "@acmekit/caching": "2.13.86",
67
- "@acmekit/caching-redis": "2.13.86",
68
- "@acmekit/core-flows": "2.13.86",
69
- "@acmekit/event-bus-local": "2.13.86",
70
- "@acmekit/event-bus-redis": "2.13.86",
71
- "@acmekit/file": "2.13.86",
72
- "@acmekit/file-local": "2.13.86",
73
- "@acmekit/file-s3": "2.13.86",
74
- "@acmekit/index": "2.13.86",
75
- "@acmekit/link-modules": "2.13.86",
76
- "@acmekit/locking": "2.13.86",
77
- "@acmekit/locking-postgres": "2.13.86",
78
- "@acmekit/locking-redis": "2.13.86",
79
- "@acmekit/notification": "2.13.86",
80
- "@acmekit/notification-local": "2.13.86",
81
- "@acmekit/notification-sendgrid": "2.13.86",
82
- "@acmekit/rbac": "2.13.86",
83
- "@acmekit/secrets-aws": "2.13.86",
84
- "@acmekit/secrets-local": "2.13.86",
85
- "@acmekit/settings": "2.13.86",
86
- "@acmekit/telemetry": "2.13.86",
87
- "@acmekit/translation": "2.13.86",
88
- "@acmekit/user": "2.13.86",
89
- "@acmekit/workflow-engine-inmemory": "2.13.86",
90
- "@acmekit/workflow-engine-redis": "2.13.86",
55
+ "@acmekit/admin-bundler": "2.13.88",
56
+ "@acmekit/analytics": "2.13.88",
57
+ "@acmekit/analytics-local": "2.13.88",
58
+ "@acmekit/analytics-posthog": "2.13.88",
59
+ "@acmekit/api-key": "2.13.88",
60
+ "@acmekit/auth": "2.13.88",
61
+ "@acmekit/auth-emailpass": "2.13.88",
62
+ "@acmekit/auth-github": "2.13.88",
63
+ "@acmekit/auth-google": "2.13.88",
64
+ "@acmekit/cache-inmemory": "2.13.88",
65
+ "@acmekit/cache-redis": "2.13.88",
66
+ "@acmekit/caching": "2.13.88",
67
+ "@acmekit/caching-redis": "2.13.88",
68
+ "@acmekit/core-flows": "2.13.88",
69
+ "@acmekit/event-bus-local": "2.13.88",
70
+ "@acmekit/event-bus-redis": "2.13.88",
71
+ "@acmekit/file": "2.13.88",
72
+ "@acmekit/file-local": "2.13.88",
73
+ "@acmekit/file-s3": "2.13.88",
74
+ "@acmekit/index": "2.13.88",
75
+ "@acmekit/link-modules": "2.13.88",
76
+ "@acmekit/locking": "2.13.88",
77
+ "@acmekit/locking-postgres": "2.13.88",
78
+ "@acmekit/locking-redis": "2.13.88",
79
+ "@acmekit/notification": "2.13.88",
80
+ "@acmekit/notification-local": "2.13.88",
81
+ "@acmekit/notification-sendgrid": "2.13.88",
82
+ "@acmekit/rbac": "2.13.88",
83
+ "@acmekit/secrets-aws": "2.13.88",
84
+ "@acmekit/secrets-local": "2.13.88",
85
+ "@acmekit/settings": "2.13.88",
86
+ "@acmekit/telemetry": "2.13.88",
87
+ "@acmekit/translation": "2.13.88",
88
+ "@acmekit/user": "2.13.88",
89
+ "@acmekit/workflow-engine-inmemory": "2.13.88",
90
+ "@acmekit/workflow-engine-redis": "2.13.88",
91
91
  "@inquirer/checkbox": "^2.3.11",
92
92
  "@inquirer/input": "^2.2.9",
93
93
  "boxen": "^5.0.1",
@@ -106,7 +106,7 @@
106
106
  },
107
107
  "peerDependencies": {
108
108
  "@acmekit/docs-bundler": "^2.13.42",
109
- "@acmekit/framework": "2.13.86",
109
+ "@acmekit/framework": "2.13.88",
110
110
  "@jimsheen/yalc": "^1.2.2",
111
111
  "@swc/core": "^1.7.28",
112
112
  "posthog-node": "^5.11.0",