march-hare 0.13.0 → 0.13.2

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 (131) hide show
  1. package/README.md +45 -1
  2. package/dist/action/index.d.ts +2 -2
  3. package/dist/action/utils.d.ts +2 -2
  4. package/dist/actions/index.d.ts +2 -2
  5. package/dist/actions/types.d.ts +3 -3
  6. package/dist/actions/utils.d.ts +3 -3
  7. package/dist/app/index.d.ts +7 -7
  8. package/dist/app/types.d.ts +5 -5
  9. package/dist/boundary/components/broadcast/index.d.ts +2 -2
  10. package/dist/boundary/components/broadcast/types.d.ts +1 -1
  11. package/dist/boundary/components/consumer/components/partition/index.d.ts +1 -1
  12. package/dist/boundary/components/consumer/components/partition/types.d.ts +1 -1
  13. package/dist/boundary/components/consumer/index.d.ts +5 -5
  14. package/dist/boundary/components/consumer/types.d.ts +1 -1
  15. package/dist/boundary/components/consumer/utils.d.ts +1 -1
  16. package/dist/boundary/components/env/index.d.ts +3 -27
  17. package/dist/boundary/components/env/types.d.ts +24 -2
  18. package/dist/boundary/components/env/utils.d.ts +1 -1
  19. package/dist/boundary/components/scope/index.d.ts +2 -2
  20. package/dist/boundary/components/scope/types.d.ts +1 -1
  21. package/dist/boundary/components/scope/utils.d.ts +1 -1
  22. package/dist/boundary/components/sharing/index.d.ts +1 -1
  23. package/dist/boundary/components/tap/index.d.ts +3 -3
  24. package/dist/boundary/components/tap/types.d.ts +2 -2
  25. package/dist/boundary/components/tap/utils.d.ts +1 -1
  26. package/dist/boundary/components/tasks/index.d.ts +2 -2
  27. package/dist/boundary/components/tasks/utils.d.ts +1 -1
  28. package/dist/boundary/index.d.ts +1 -1
  29. package/dist/boundary/types.d.ts +2 -2
  30. package/dist/cache/index.d.ts +91 -9
  31. package/dist/cache/types.d.ts +1 -1
  32. package/dist/cli/bin/mh.js +10 -0
  33. package/dist/cli/lib/banner/index.js +14 -0
  34. package/dist/cli/lib/commands/app/index.js +37 -0
  35. package/dist/cli/lib/commands/feature/index.js +55 -0
  36. package/dist/cli/lib/commands/index.js +89 -0
  37. package/dist/cli/lib/commands/init/index.js +29 -0
  38. package/dist/cli/lib/commands/shared/index.js +56 -0
  39. package/dist/cli/lib/index.js +56 -0
  40. package/dist/cli/lib/parser/index.js +24 -0
  41. package/dist/cli/lib/prompt/index.js +61 -0
  42. package/dist/cli/lib/runner/index.js +46 -0
  43. package/dist/cli/lib/runner/types.js +1 -0
  44. package/dist/cli/lib/runner/utils.js +60 -0
  45. package/dist/cli/lib/types.js +1 -0
  46. package/dist/cli/lib/utils.js +20 -0
  47. package/dist/cli/templates/app/action/actions.ts.ejs.t +10 -0
  48. package/dist/cli/templates/app/action/types.ts.ejs.t +7 -0
  49. package/dist/cli/templates/app/integration/index.integration.tsx.ejs.t +13 -0
  50. package/dist/cli/templates/app/page/actions.ts.ejs.t +14 -0
  51. package/dist/cli/templates/app/page/index.tsx.ejs.t +20 -0
  52. package/dist/cli/templates/app/page/styles.ts.ejs.t +35 -0
  53. package/dist/cli/templates/app/page/types.ts.ejs.t +12 -0
  54. package/dist/cli/templates/feature/action/actions.ts.ejs.t +10 -0
  55. package/dist/cli/templates/feature/action/types.ts.ejs.t +7 -0
  56. package/dist/cli/templates/feature/multicast/types.ts.ejs.t +7 -0
  57. package/dist/cli/templates/feature/presentational/index.tsx.ejs.t +14 -0
  58. package/dist/cli/templates/feature/presentational/types.ts.ejs.t +12 -0
  59. package/dist/cli/templates/feature/presentational/utils.ts.ejs.t +8 -0
  60. package/dist/cli/templates/feature/stateful/actions.ts.ejs.t +16 -0
  61. package/dist/cli/templates/feature/stateful/index.tsx.ejs.t +19 -0
  62. package/dist/cli/templates/feature/stateful/types.ts.ejs.t +16 -0
  63. package/dist/cli/templates/feature/stateful/utils.ts.ejs.t +8 -0
  64. package/dist/cli/templates/feature/unit/index.test.tsx.ejs.t +21 -0
  65. package/dist/cli/templates/init/new/README.md.ejs.t +48 -0
  66. package/dist/cli/templates/init/new/eslint.config.js.ejs.t +88 -0
  67. package/dist/cli/templates/init/new/gitignore.ejs.t +9 -0
  68. package/dist/cli/templates/init/new/index.html.ejs.t +18 -0
  69. package/dist/cli/templates/init/new/package.json.ejs.t +54 -0
  70. package/dist/cli/templates/init/new/playwright.config.ts.ejs.t +17 -0
  71. package/dist/cli/templates/init/new/prettierrc.ejs.t +8 -0
  72. package/dist/cli/templates/init/new/src.app.index.tsx.ejs.t +14 -0
  73. package/dist/cli/templates/init/new/src.app.pages.home.actions.ts.ejs.t +16 -0
  74. package/dist/cli/templates/init/new/src.app.pages.home.index.tsx.ejs.t +30 -0
  75. package/dist/cli/templates/init/new/src.app.pages.home.integration.tsx.ejs.t +28 -0
  76. package/dist/cli/templates/init/new/src.app.pages.home.styles.ts.ejs.t +45 -0
  77. package/dist/cli/templates/init/new/src.app.pages.home.types.ts.ejs.t +12 -0
  78. package/dist/cli/templates/init/new/src.app.utils.ts.ejs.t +9 -0
  79. package/dist/cli/templates/init/new/src.features.greet.actions.ts.ejs.t +20 -0
  80. package/dist/cli/templates/init/new/src.features.greet.index.test.tsx.ejs.t +21 -0
  81. package/dist/cli/templates/init/new/src.features.greet.index.tsx.ejs.t +24 -0
  82. package/dist/cli/templates/init/new/src.features.greet.types.ts.ejs.t +18 -0
  83. package/dist/cli/templates/init/new/src.features.greet.utils.ts.ejs.t +8 -0
  84. package/dist/cli/templates/init/new/src.index.tsx.ejs.t +8 -0
  85. package/dist/cli/templates/init/new/src.shared.components.button.index.test.tsx.ejs.t +13 -0
  86. package/dist/cli/templates/init/new/src.shared.components.button.index.tsx.ejs.t +10 -0
  87. package/dist/cli/templates/init/new/src.shared.components.button.types.ts.ejs.t +6 -0
  88. package/dist/cli/templates/init/new/src.shared.resources.index.ts.ejs.t +4 -0
  89. package/dist/cli/templates/init/new/src.shared.theme.index.ts.ejs.t +51 -0
  90. package/dist/cli/templates/init/new/src.shared.types.index.ts.ejs.t +23 -0
  91. package/dist/cli/templates/init/new/src.test-setup.ts.ejs.t +10 -0
  92. package/dist/cli/templates/init/new/src.vite-env.d.ts.ejs.t +4 -0
  93. package/dist/cli/templates/init/new/tests.home.e2e.ts.ejs.t +14 -0
  94. package/dist/cli/templates/init/new/tsconfig.json.ejs.t +29 -0
  95. package/dist/cli/templates/init/new/vite.config.ts.ejs.t +17 -0
  96. package/dist/cli/templates/init/new/vitest.config.ts.ejs.t +24 -0
  97. package/dist/cli/templates/shared/component/index.tsx.ejs.t +9 -0
  98. package/dist/cli/templates/shared/component/types.ts.ejs.t +8 -0
  99. package/dist/cli/templates/shared/resource/index.ts.ejs.t +15 -0
  100. package/dist/cli/templates/shared/resource/types.ts.ejs.t +10 -0
  101. package/dist/cli/templates/shared/type-broadcast/types.ts.ejs.t +7 -0
  102. package/dist/cli/templates/shared/type-payload/types.ts.ejs.t +9 -0
  103. package/dist/cli/templates/shared/unit-component/index.test.tsx.ejs.t +13 -0
  104. package/dist/cli/templates/shared/unit-resource/index.test.ts.ejs.t +15 -0
  105. package/dist/cli/templates/shared/unit-util/index.test.ts.ejs.t +11 -0
  106. package/dist/cli/templates/shared/util/index.ts.ejs.t +6 -0
  107. package/dist/coalesce/index.d.ts +1 -1
  108. package/dist/context/index.d.ts +1 -1
  109. package/dist/error/types.d.ts +1 -1
  110. package/dist/error/utils.d.ts +1 -1
  111. package/dist/index.d.ts +17 -17
  112. package/dist/march-hare.js +9 -8
  113. package/dist/march-hare.js.map +1 -1
  114. package/dist/march-hare.umd.cjs +1 -1
  115. package/dist/march-hare.umd.cjs.map +1 -1
  116. package/dist/resource/index.d.ts +6 -5
  117. package/dist/resource/types.d.ts +3 -3
  118. package/dist/resource/utils.d.ts +12 -5
  119. package/dist/scope/index.d.ts +3 -3
  120. package/dist/scope/types.d.ts +2 -2
  121. package/dist/scope/utils.d.ts +6 -4
  122. package/dist/shared/index.d.ts +4 -4
  123. package/dist/types/index.d.ts +5 -5
  124. package/dist/utils/index.d.ts +3 -3
  125. package/dist/utils/types.d.ts +1 -1
  126. package/dist/utils/utils.d.ts +1 -1
  127. package/dist/with/index.d.ts +3 -3
  128. package/dist/with/types.d.ts +2 -2
  129. package/dist/with/utils.d.ts +3 -3
  130. package/package.json +18 -4
  131. package/src/cli/README.md +314 -0
package/README.md CHANGED
@@ -27,8 +27,9 @@
27
27
  1. [Multicast actions](#multicast-actions)
28
28
  1. [Global data](#global-data)
29
29
  1. [Reusable components](#reusable-components)
30
+ 1. [Scaffolding CLI](#scaffolding-cli)
30
31
 
31
- For advanced topics, see the [recipes directory](./recipes/). For a worked end-to-end example with the FSD layout, see [`src/example/`](./src/example/README.md).
32
+ For advanced topics, see the [recipes directory](./recipes/). For a worked end-to-end example with the FSD layout, see [`src/example/`](./src/example/README.md). To scaffold a new project that mirrors that example, see [`src/cli/`](./src/cli/README.md).
32
33
 
33
34
  ## Benefits
34
35
 
@@ -606,6 +607,26 @@ export default function CatCard(): React.ReactElement {
606
607
 
607
608
  `Cache()` with no adapter is an in-memory scope – useful in tests or when you want a holdable cache without persistence. Per-params keying via `JSON.stringify(params)` is automatic, so `user({ id: 5 })` and `user({ id: 6 })` are distinct slots.
608
609
 
610
+ For multi-tenant apps that share a single backing store, add a `key(context)` callback alongside the adapter methods to derive a per-context prefix from the live `<app.Boundary>` Env. The callback receives the same `{ env }` an `app.Resource` fetcher sees; its return value is prepended to every cache slot, so two users on the same device do not see each other's data:
611
+
612
+ ```ts
613
+ // app.ts
614
+ type AppEnv = { session: { accessToken: string } | null };
615
+
616
+ export const app = App<AppEnv>({
617
+ env: { session: null },
618
+ cache: Cache<AppEnv>({
619
+ get: (key) => localStorage.getItem(key),
620
+ set: (key, value) => localStorage.setItem(key, value),
621
+ remove: (key) => localStorage.removeItem(key),
622
+ clear: () => localStorage.clear(),
623
+ key: ({ env }) => env.session?.accessToken ?? "",
624
+ }),
625
+ });
626
+ ```
627
+
628
+ Successful writes for Alice land under `alice:0:{...}`; Bob's land under `bob:0:{...}`. Return `""`, `null`, or `undefined` to skip prefixing &ndash; useful for the signed-out gap, where the scope is genuinely empty.
629
+
609
630
  The adapter contract is **strictly synchronous** &ndash; `get` / `set` / `remove` / `clear` all return immediately, with no `Promise`. The model-literal read (`{ user: resource.user.get() }`) is evaluated during render and has no place to wait. React Native projects should use [`react-native-mmkv`](https://github.com/mrousavy/react-native-mmkv), which is sync out of the box and drops straight into the contract; `AsyncStorage` is incompatible. Truly async backends (IndexedDB, `chrome.storage.local`) need a sync facade hydrated at app entry &ndash; see the [storage recipe](./recipes/storage.md).
610
631
 
611
632
  See the [storage recipe](./recipes/storage.md) for backend adapters (React Native `react-native-mmkv`, browser `localStorage`, browser extension `chrome.storage`), sign-out purge, and the `unset` sentinel that keeps "nothing stored" distinct from "a legitimately stored null".
@@ -896,3 +917,26 @@ function Where(): React.ReactElement {
896
917
  When a reusable component or resource is genuinely Env-agnostic &mdash; the fetcher never touches `context.env`, the hook never calls `shared.useEnv` &mdash; pass `Envless` as `E` instead of spelling out `Record<never, never>`: `shared.Resource<Envless, T>`, `shared.useContext<Envless, M, A>()`. It's a named alias for the empty-record shape exported from `march-hare`, kept around purely for legibility at the call site.
897
918
 
898
919
  For one-line handler binding &mdash; flipping a boolean, assigning a payload to a leaf, pinning a field to a fixed value &mdash; reach for `context.with.{update,invert,always}`. See the [`With` helpers recipe](./recipes/with-helpers.md) for the full surface.
920
+
921
+ ## Scaffolding CLI
922
+
923
+ A [Hygen](https://github.com/jondot/hygen)-style scaffolder ships under [`src/cli/`](./src/cli/) as the `mh` binary. It mirrors the layout of [`src/example/`](./src/example/) and the FSD layering rules enforced by `eslint-plugin-boundaries` &mdash; imports flow strictly downward (`app → features → shared`).
924
+
925
+ ```bash
926
+ cd src/cli
927
+ npm install
928
+ npm link # creates the global `mh` binary
929
+ ```
930
+
931
+ Run it with no arguments for an interactive menu, or drive any leaf command directly:
932
+
933
+ ```bash
934
+ mh # banner + interactive menu
935
+ mh init my-project # bootstrap a new project
936
+ mh feature new add-cat # add a stateful feature
937
+ mh app new dashboard # add a page
938
+ mh shared component card # add a shared component
939
+ mh feature action counter Reset # inject an Action + handler stub
940
+ ```
941
+
942
+ Every command lives in a tree &mdash; typing `mh feature` opens a sub-menu, typing `mh feature new` prompts for a name, typing `mh feature new add-cat` runs non-interactively. See [`src/cli/README.md`](./src/cli/README.md) for the full command surface, the template format, and instructions for adding your own generators.
@@ -1,5 +1,5 @@
1
- import { HandlerPayload, BroadcastPayload, MulticastPayload, Distribution, Filter } from '../types/index';
2
- export { getActionSymbol, getLifecycleType, isBroadcastAction, isMulticastAction, getName, isChanneledAction, } from './utils';
1
+ import { HandlerPayload, BroadcastPayload, MulticastPayload, Distribution, Filter } from '../types/index.js';
2
+ export { getActionSymbol, getLifecycleType, isBroadcastAction, isMulticastAction, getName, isChanneledAction, } from './utils.js';
3
3
  /**
4
4
  * Interface for the Action factory function.
5
5
  */
@@ -1,5 +1,5 @@
1
- import { ChanneledAction, AnyAction } from '../types/index';
2
- import { ActionId } from '../boundary/components/tasks/types';
1
+ import { ChanneledAction, AnyAction } from '../types/index.js';
2
+ import { ActionId } from '../boundary/components/tasks/types.js';
3
3
  /**
4
4
  * Extracts the underlying symbol from an action or channeled action.
5
5
  * This symbol is used as the event emitter key for dispatching.
@@ -1,5 +1,5 @@
1
- import { Data } from './types';
2
- import { Model, Props, Actions, UseActions } from '../types/index';
1
+ import { Data } from './types.js';
2
+ import { Model, Props, Actions, UseActions } from '../types/index.js';
3
3
  /**
4
4
  * A hook for managing state with actions.
5
5
  *
@@ -1,8 +1,8 @@
1
1
  import { default as EventEmitter } from 'eventemitter3';
2
2
  import { RefObject } from 'react';
3
- import { Model, HandlerContext, Actions, Props, Tasks, ActionId, Phase, Filter } from '../types/index';
4
- import { BroadcastEmitter } from '../boundary/components/broadcast/utils';
5
- import { ScopeContext } from '../boundary/components/scope/types';
3
+ import { Model, HandlerContext, Actions, Props, Tasks, ActionId, Phase, Filter } from '../types/index.js';
4
+ import { BroadcastEmitter } from '../boundary/components/broadcast/utils.js';
5
+ import { ScopeContext } from '../boundary/components/scope/types.js';
6
6
  /**
7
7
  * Function signature for action handlers registered via `useAction`.
8
8
  * Receives the reactive context and payload, returning void or a promise/generator.
@@ -1,8 +1,8 @@
1
1
  import { RefObject } from 'react';
2
- import { Props, Model, Actions, Filter, ActionId, HandlerPayload, ChanneledAction, HandlerContext } from '../types/index';
2
+ import { Props, Model, Actions, Filter, ActionId, HandlerPayload, ChanneledAction, HandlerContext } from '../types/index.js';
3
3
  import { default as EventEmitter } from 'eventemitter3';
4
- import { Dispatchers, LifecycleConfig, Scope } from './types';
5
- import { isChanneledAction, getActionSymbol } from '../action/index';
4
+ import { Dispatchers, LifecycleConfig, Scope } from './types.js';
5
+ import { isChanneledAction, getActionSymbol } from '../action/index.js';
6
6
  import * as React from "react";
7
7
  /**
8
8
  * Creates a new object with getters for each property of the input object.
@@ -1,10 +1,10 @@
1
- import { Env } from '../boundary/components/env/index';
2
- import { Cache } from '../cache/index';
3
- import { Actions, Model, Props } from '../types/index';
4
- import { AppHandle, AppContextHandle } from './types';
5
- import { Tap } from '../boundary/components/tap/types';
6
- export type { AppArgs, AppContextHandle, AppFetcher, AppResource, } from './types';
7
- export type { AppHandle } from './types';
1
+ import { Env } from '../boundary/components/env/types.js';
2
+ import { Cache } from '../cache/index.js';
3
+ import { Actions, Model, Props } from '../types/index.js';
4
+ import { AppHandle, AppContextHandle } from './types.js';
5
+ import { Tap } from '../boundary/components/tap/types.js';
6
+ export type { AppArgs, AppContextHandle, AppFetcher, AppResource, } from './types.js';
7
+ export type { AppHandle } from './types.js';
8
8
  /**
9
9
  * Creates an `App` &mdash; the entrypoint for a typed Env shape `E`,
10
10
  * inferred from `config.env`. `App<E>` exposes `Boundary`, hooks, and
@@ -1,8 +1,8 @@
1
- import { Args, ResourceHandle } from '../resource/types';
2
- import { Actions, Context, Model, Props, UseActions } from '../types/index';
3
- import { Data } from '../actions/types';
4
- import { Env } from '../boundary/components/env/index';
5
- import { WithHandle } from '../with/types';
1
+ import { Args, ResourceHandle } from '../resource/types.js';
2
+ import { Actions, Context, Model, Props, UseActions } from '../types/index.js';
3
+ import { Data } from '../actions/types.js';
4
+ import { Env } from '../boundary/components/env/types.js';
5
+ import { WithHandle } from '../with/types.js';
6
6
  /**
7
7
  * Args object passed to an `app.Resource` fetcher. Same shape as the
8
8
  * base `Resource` fetcher's args but with `env` typed as `E`.
@@ -1,6 +1,6 @@
1
- import { Props } from './types';
1
+ import { Props } from './types.js';
2
2
  import * as React from "react";
3
- export { useBroadcast, BroadcastEmitter } from './utils';
3
+ export { useBroadcast, BroadcastEmitter } from './utils.js';
4
4
  /**
5
5
  * Creates a new broadcast context for distributed actions. Only needed if you
6
6
  * want to isolate a broadcast context, useful for libraries that want to provide
@@ -1,4 +1,4 @@
1
- import { BroadcastEmitter } from './utils';
1
+ import { BroadcastEmitter } from './utils.js';
2
2
  import * as React from "react";
3
3
  /**
4
4
  * The broadcast context is a BroadcastEmitter used for distributed actions across components.
@@ -1,4 +1,4 @@
1
- import { Props } from './types';
1
+ import { Props } from './types.js';
2
2
  import * as React from "react";
3
3
  /**
4
4
  * Renders output for the `stream()` method by subscribing to distributed action events.
@@ -1,4 +1,4 @@
1
- import { ConsumerRenderer } from '../../types';
1
+ import { ConsumerRenderer } from '../../types.js';
2
2
  /**
3
3
  * Props for the Partition component.
4
4
  * @internal
@@ -1,9 +1,9 @@
1
- import { Props } from './types';
1
+ import { Props } from './types.js';
2
2
  import * as React from "react";
3
- export { useConsumer } from './utils';
4
- export { Partition } from './components/partition/index';
5
- export type { Props as PartitionProps } from './components/partition/types';
6
- export type { ConsumerRenderer, Entry, ConsumerContext } from './types';
3
+ export { useConsumer } from './utils.js';
4
+ export { Partition } from './components/partition/index.js';
5
+ export type { Props as PartitionProps } from './components/partition/types.js';
6
+ export type { ConsumerRenderer, Entry, ConsumerContext } from './types.js';
7
7
  /**
8
8
  * Creates a new consumer context for storing distributed action values. Only needed if you
9
9
  * want to isolate a consumer context, useful for libraries that want to provide
@@ -1,5 +1,5 @@
1
1
  import { Inspect, State } from 'immertation';
2
- import { ActionId } from '../tasks/types';
2
+ import { ActionId } from '../tasks/types.js';
3
3
  import * as React from "react";
4
4
  /**
5
5
  * Callback function for the stream() method.
@@ -1,4 +1,4 @@
1
- import { ConsumerContext } from './types';
1
+ import { ConsumerContext } from './types.js';
2
2
  import * as React from "react";
3
3
  /**
4
4
  * React context for the consumer store.
@@ -1,32 +1,8 @@
1
- import { Props } from './types';
1
+ import { Props } from './types.js';
2
2
  import * as React from "react";
3
- export { useEnv } from './utils';
3
+ export { useEnv } from './utils.js';
4
4
  /**
5
- * Loose runtime shape for the per-`<Boundary>` Env. Each {@link App}
6
- * narrows this to its own typed env via `App<E>({ env })`; the
7
- * loose type exists so the framework's internal plumbing
8
- * (`<Boundary>`, `useEnv`, handler `context.env`, Resource
9
- * fetcher `context.env`) does not need to be parametric over E.
10
- *
11
- * Consumers should declare their Env shape inline via `App({ env })`
12
- * &mdash; the inferred `E` is what flows through `app.useContext`,
13
- * `app.useEnv`, and `app.Resource`. Module augmentation of `Env`
14
- * is no longer required.
15
- */
16
- export type Env = Record<string, unknown>;
17
- /**
18
- * `E` generic for `shared.X<E, ...>` factories whose callers don't read
19
- * anything off the Env. Equivalent to `Record<never, never>` &mdash; the
20
- * named alias keeps consumer sites legible (`shared.Resource<Envless, T>`
21
- * over `shared.Resource<Record<never, never>, T>`) and signals intent.
22
- *
23
- * Reach for `Envless` only when the component or resource is genuinely
24
- * Env-agnostic. Anything that reads `context.env.x` should declare the
25
- * required shape (or a union of host Envs) as `E` instead.
26
- */
27
- export type Envless = Record<never, never>;
28
- /**
29
- * Provides a per-Boundary {@link Env} value to every component inside
5
+ * Provides a per-Boundary {@link EnvType} value to every component inside
30
6
  * the boundary. Usually wired in via the `<Boundary env={initial}>`
31
7
  * prop rather than used directly.
32
8
  *
@@ -1,6 +1,28 @@
1
1
  import { ReactNode } from 'react';
2
- import { Env } from './index';
3
- export type { Env } from './index';
2
+ /**
3
+ * Loose runtime shape for the per-`<Boundary>` Env. Each {@link App}
4
+ * narrows this to its own typed env via `App<E>({ env })`; the
5
+ * loose type exists so the framework's internal plumbing
6
+ * (`<Boundary>`, `useEnv`, handler `context.env`, Resource
7
+ * fetcher `context.env`) does not need to be parametric over E.
8
+ *
9
+ * Consumers should declare their Env shape inline via `App({ env })`
10
+ * &mdash; the inferred `E` is what flows through `app.useContext`,
11
+ * `app.useEnv`, and `app.Resource`. Module augmentation of `Env`
12
+ * is no longer required.
13
+ */
14
+ export type Env = Record<string, unknown>;
15
+ /**
16
+ * `E` generic for `shared.X<E, ...>` factories whose callers don't read
17
+ * anything off the Env. Equivalent to `Record<never, never>` &mdash; the
18
+ * named alias keeps consumer sites legible (`shared.Resource<Envless, T>`
19
+ * over `shared.Resource<Record<never, never>, T>`) and signals intent.
20
+ *
21
+ * Reach for `Envless` only when the component or resource is genuinely
22
+ * Env-agnostic. Anything that reads `context.env.x` should declare the
23
+ * required shape (or a union of host Envs) as `E` instead.
24
+ */
25
+ export type Envless = Record<never, never>;
4
26
  /**
5
27
  * Props for the Env provider component. Accepts the initial Env
6
28
  * value that satisfies the augmented {@link Env} interface.
@@ -1,5 +1,5 @@
1
1
  import { RefObject } from 'react';
2
- import { Env } from './index';
2
+ import { Env } from './types.js';
3
3
  import * as React from "react";
4
4
  /**
5
5
  * React context exposing the per-Boundary Env ref. The ref itself is
@@ -1,2 +1,2 @@
1
- export { Context, useScope, getScope } from './utils';
2
- export type { ScopeEntry, ScopeContext } from './types';
1
+ export { Context, useScope, getScope } from './utils.js';
2
+ export type { ScopeEntry, ScopeContext } from './types.js';
@@ -1,4 +1,4 @@
1
- import { BroadcastEmitter } from '../broadcast/utils';
1
+ import { BroadcastEmitter } from '../broadcast/utils.js';
2
2
  /**
3
3
  * Runtime entry for a single multicast scope opened by an
4
4
  * `<app.Scope().Boundary>`. The `id` uniquely identifies this scope
@@ -1,4 +1,4 @@
1
- import { ScopeContext, ScopeEntry } from './types';
1
+ import { ScopeContext, ScopeEntry } from './types.js';
2
2
  import * as React from "react";
3
3
  /**
4
4
  * React context for the nearest multicast scope. `null` at the root.
@@ -1,4 +1,4 @@
1
- import { Invocation } from '../../../resource/index';
1
+ import { Invocation } from '../../../resource/index.js';
2
2
  import * as React from "react";
3
3
  /**
4
4
  * Per-`<Boundary>` registry for `.coalesce(token)` sharing. Outer map
@@ -1,7 +1,7 @@
1
- import { Props } from './types';
1
+ import { Props } from './types.js';
2
2
  import * as React from "react";
3
- export { useTap } from './utils';
4
- export type { Tap, Taps, Invocation, Failure, Mutations, Snapshot, } from './types';
3
+ export { useTap } from './utils.js';
4
+ export type { Tap, Taps, Invocation, Failure, Mutations, Snapshot, } from './types.js';
5
5
  /**
6
6
  * Internal provider that wires a {@link Tap} observer into the React
7
7
  * context consumed by `useActions` during dispatch. Rendered by the
@@ -1,5 +1,5 @@
1
- import { Reason } from '../../../error/types';
2
- import { Task } from '../tasks/types';
1
+ import { Reason } from '../../../error/types.js';
2
+ import { Task } from '../tasks/types.js';
3
3
  import type * as React from "react";
4
4
  /**
5
5
  * Identity of a handler invocation: the action being handled and the
@@ -1,4 +1,4 @@
1
- import { Tap } from './types';
1
+ import { Tap } from './types.js';
2
2
  import * as React from "react";
3
3
  /**
4
4
  * React context carrying the active {@link Tap} observer for the
@@ -1,6 +1,6 @@
1
- import { Props } from './types';
1
+ import { Props } from './types.js';
2
2
  import * as React from "react";
3
- export type { Task } from './types';
3
+ export type { Task } from './types.js';
4
4
  /**
5
5
  * Creates a new tasks context for action control. Only needed if you
6
6
  * want to isolate a tasks context, useful for libraries that want to provide
@@ -1,4 +1,4 @@
1
- import { Tasks } from './types';
1
+ import { Tasks } from './types.js';
2
2
  import * as React from "react";
3
3
  /**
4
4
  * React context for the shared tasks Set.
@@ -1,4 +1,4 @@
1
- import { Props } from './types';
1
+ import { Props } from './types.js';
2
2
  import * as React from "react";
3
3
  /**
4
4
  * Low-level boundary primitive. Wraps children with the Broadcaster,
@@ -1,5 +1,5 @@
1
- import { Env } from './components/env/types';
2
- import { Tap } from './components/tap/types';
1
+ import { Env } from './components/env/types.js';
2
+ import { Tap } from './components/tap/types.js';
3
3
  import type * as React from "react";
4
4
  /**
5
5
  * Props accepted by the bare `<Boundary>` provider.
@@ -1,5 +1,35 @@
1
- import { Adapter, Stored } from './types';
2
- export type { Adapter, Encoded } from './types';
1
+ import { Adapter, Stored } from './types.js';
2
+ import { Env } from '../boundary/components/env/types.js';
3
+ export type { Adapter, Encoded } from './types.js';
4
+ /**
5
+ * Context passed to {@link CacheConfig.key}. Mirrors the shape an
6
+ * `app.Resource` fetcher receives, restricted to the field the cache
7
+ * needs to scope on: the live per-`<Boundary>` Env. Future-extensible
8
+ * &mdash; new fields can land here without breaking the call shape.
9
+ *
10
+ * @template E The Env shape the cache is parameterised by.
11
+ */
12
+ export type CacheContext<E extends object> = {
13
+ readonly env: E;
14
+ };
15
+ /**
16
+ * Configuration accepted by the {@link Cache} factory. Combines the
17
+ * synchronous {@link Adapter} (`get`/`set`/`remove`/`clear`/`keys?`)
18
+ * with an optional `key(context)` callback in a single flat object,
19
+ * so all the cache's plumbing lives in one literal at the call site.
20
+ *
21
+ * - `key` &mdash; derives a per-context cache scope. Called every time
22
+ * a cache key is assembled with the same `{ env }` shape an
23
+ * `app.Resource` fetcher receives; the returned string is prepended
24
+ * to the per-resource namespace and params so different scopes
25
+ * (e.g. one cache slot per access token, locale, or tenant id) can
26
+ * coexist in the same backing store. Return `""`, `null`, or
27
+ * `undefined` to skip prefixing &mdash; useful for "not signed in"
28
+ * gaps where the scope is genuinely empty.
29
+ */
30
+ export type CacheConfig<E extends object> = Adapter & {
31
+ readonly key?: (context: CacheContext<E>) => string | null | undefined;
32
+ };
3
33
  /**
4
34
  * Persistence-aware cache for a single {@link Resource}. Wraps a
5
35
  * **strictly synchronous** {@link Adapter} (localStorage, MMKV,
@@ -25,6 +55,21 @@ export type { Adapter, Encoded } from './types';
25
55
  * persistent store; when supplied, the adapter is the **only** tier
26
56
  * &mdash; the Cache does not maintain a separate in-memory mirror.
27
57
  *
58
+ * Pass `key(context)` alongside the adapter methods to scope cache
59
+ * slots by the per-`<Boundary>` Env. The returned string is prepended
60
+ * to every cache key the Resource layer assembles, so different
61
+ * tenants / sessions / locales share the adapter without stepping on
62
+ * each other.
63
+ *
64
+ * The `E` generic lives on the {@link Cache} factory and on
65
+ * {@link CacheConfig}: it parameterises the `key(context)` callback
66
+ * at construction time so the caller can read `context.env.X` with
67
+ * full typing. The returned {@link Cache} value is itself
68
+ * env-agnostic &mdash; the runtime `scope(env)` method takes the
69
+ * loose {@link Env} record and narrows internally before invoking
70
+ * the callback &mdash; which keeps it freely assignable across
71
+ * differently-typed Apps without variance gymnastics.
72
+ *
28
73
  * @example
29
74
  * ```ts
30
75
  * // In-memory, scoped to this instance.
@@ -38,6 +83,16 @@ export type { Adapter, Encoded } from './types';
38
83
  * clear: () => localStorage.clear(),
39
84
  * });
40
85
  *
86
+ * // Multi-tenant: writes go under `${accessToken}:…`.
87
+ * type AppEnv = { session: { accessToken: string } | null };
88
+ * const cache = Cache<AppEnv>({
89
+ * get: (key) => localStorage.getItem(key),
90
+ * set: (key, value) => localStorage.setItem(key, value),
91
+ * remove: (key) => localStorage.removeItem(key),
92
+ * clear: () => localStorage.clear(),
93
+ * key: ({ env }) => env.session?.accessToken ?? "",
94
+ * });
95
+ *
41
96
  * // Wire it into a Resource — successful runs write through automatically.
42
97
  * export const cat = Resource({
43
98
  * cache,
@@ -87,14 +142,41 @@ export type Cache = {
87
142
  * stored params satisfy a `where` pattern.
88
143
  */
89
144
  keys(): Iterable<string>;
145
+ /**
146
+ * Returns the per-context prefix derived from the configured
147
+ * `key(context)`. The returned string is appended with `:` by the
148
+ * Resource layer to compose the full cache key. Always `""` when
149
+ * no `key` option was supplied or when the callback returned an
150
+ * empty value &mdash; "no scope" is encoded as the empty string.
151
+ *
152
+ * Takes the loose {@link Env} record at runtime &mdash; the typed
153
+ * `E` lives on the `key(context)` callback registered at
154
+ * construction time, which the cache narrows to `E` internally
155
+ * before invoking.
156
+ *
157
+ * @internal Public surface lives on the Resource layer; consumers
158
+ * should not need to call this directly.
159
+ */
160
+ scope(env: Env | undefined): string;
90
161
  };
91
162
  /**
92
- * Constructs a {@link Cache} backed by `adapter`, or by an in-memory
93
- * `Map` when none is supplied. The returned object is the same shape
94
- * regardless &mdash; only the durability differs.
163
+ * Constructs a {@link Cache} from `config`. The config object carries
164
+ * the synchronous adapter methods (`get`/`set`/`remove`/`clear`/`keys?`)
165
+ * and, optionally, a `key(context)` callback that scopes every cache
166
+ * slot by the live per-`<Boundary>` Env. Omit `config` entirely for an
167
+ * in-memory cache scoped to this instance.
168
+ *
169
+ * When `key` is supplied, it runs each time the Resource layer
170
+ * assembles a cache key, receiving the same `{ env }` shape an
171
+ * `app.Resource` fetcher sees; its return value is prepended
172
+ * (separated by `:`) to the per-resource namespace and params JSON.
95
173
  *
96
- * @param adapter Optional synchronous backing store (localStorage, MMKV,
97
- * or a custom sync facade). Omit for an in-memory cache scoped to
98
- * this instance.
174
+ * @template E The Env shape `config.key` is typed against. Defaults
175
+ * to the loose {@link Env} record so callers that don't scope by
176
+ * env can keep using `Cache({ ...adapter })` without supplying a
177
+ * generic.
178
+ * @param config Optional adapter-plus-options literal. Omit for an
179
+ * in-memory cache; supply adapter methods alone for a persisted
180
+ * cache; add `key` to also scope writes by the live Env.
99
181
  */
100
- export declare function Cache(adapter?: Adapter): Cache;
182
+ export declare function Cache<E extends object = Env>(config?: CacheConfig<E>): Cache;
@@ -1,4 +1,4 @@
1
- export type { Stored } from '../utils/types';
1
+ export type { Stored } from '../utils/types.js';
2
2
  /**
3
3
  * On-disk JSON shape of a `Stored` envelope. The Cache wrapper
4
4
  * encodes a populated Stored as `{ data, at: at.toString() }` so the
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import process from "node:process";
3
+ import { main } from "../lib/index.js";
4
+ main(process.argv.slice(2)).catch((error) => {
5
+ if (error instanceof Error && error.name === "ExitPromptError") {
6
+ process.exit(130);
7
+ }
8
+ console.error(error);
9
+ process.exit(1);
10
+ });
@@ -0,0 +1,14 @@
1
+ import figlet from "figlet";
2
+ import kleur from "kleur";
3
+ import { config } from "../utils.js";
4
+ export function banner() {
5
+ const art = figlet.textSync(config.banner.title, {
6
+ font: config.banner.font,
7
+ horizontalLayout: "default",
8
+ verticalLayout: "default",
9
+ });
10
+ console.log(kleur.magenta(art));
11
+ console.log(kleur.gray(` ${config.banner.tagline} `) +
12
+ kleur.dim(config.banner.subtitle));
13
+ console.log();
14
+ }
@@ -0,0 +1,37 @@
1
+ import path from "node:path";
2
+ import kleur from "kleur";
3
+ import { pascalCase, capitalCase } from "change-case";
4
+ import { scaffold } from "../../runner/index.js";
5
+ import { askName, askDescription, pickDirectory, requireProjectRoot, } from "../../prompt/index.js";
6
+ export async function newPage({ positional, flags, }) {
7
+ const root = requireProjectRoot();
8
+ const name = positional[0] || (await askName("Page name (kebab-case)"));
9
+ const heading = (typeof flags.heading === "string" ? flags.heading : undefined) ||
10
+ capitalCase(name);
11
+ const tagline = (typeof flags.tagline === "string" ? flags.tagline : undefined) ||
12
+ (await askDescription("Page tagline", `Welcome to ${heading}`));
13
+ await scaffold("app", "page", { name, heading, tagline, pascalName: pascalCase(name) }, { cwd: root });
14
+ console.log(kleur.green("\n Page ready."), kleur.dim(`Wire it up in src/app/index.tsx with <${pascalCase(name)}Page />.`));
15
+ }
16
+ export async function integration({ positional }) {
17
+ const root = requireProjectRoot();
18
+ const pagesRoot = path.join(root, "src", "app", "pages");
19
+ const name = positional[0] || (await pickDirectory("page", pagesRoot));
20
+ await scaffold("app", "integration", { name, pascalName: pascalCase(name) }, { cwd: root });
21
+ console.log(kleur.green("\n Integration test added."), kleur.dim(`Run with \`make integration\` or \`npx playwright test\`.`));
22
+ }
23
+ export async function action({ positional, flags, }) {
24
+ const root = requireProjectRoot();
25
+ const pagesRoot = path.join(root, "src", "app", "pages");
26
+ const page = positional[0] || (await pickDirectory("page", pagesRoot));
27
+ const name = positional[1] ||
28
+ (typeof flags.name === "string" ? flags.name : undefined) ||
29
+ (await askName("Action name (PascalCase)"));
30
+ await scaffold("app", "action", {
31
+ page,
32
+ name: pascalCase(name),
33
+ pascalName: pascalCase(name),
34
+ rawName: name,
35
+ }, { cwd: root });
36
+ console.log(kleur.green("\n Action added."), kleur.dim(`Dispatch with actions.dispatch(Actions.${pascalCase(name)}).`));
37
+ }