@brendanbank/create-atrium-host 0.15.0

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 (40) hide show
  1. package/LICENCE.md +25 -0
  2. package/README.md +94 -0
  3. package/bin/index.js +8 -0
  4. package/package.json +40 -0
  5. package/src/cli.js +279 -0
  6. package/src/git.js +48 -0
  7. package/src/names.js +91 -0
  8. package/src/render.js +90 -0
  9. package/template/.env.example +33 -0
  10. package/template/.github/dependabot.yml +56 -0
  11. package/template/.github/workflows/ci.yml +103 -0
  12. package/template/Dockerfile +46 -0
  13. package/template/Makefile +67 -0
  14. package/template/README.md +124 -0
  15. package/template/backend/alembic/env.py +116 -0
  16. package/template/backend/alembic/script.py.mako +27 -0
  17. package/template/backend/alembic/versions/0001_init.py +92 -0
  18. package/template/backend/alembic.ini +50 -0
  19. package/template/backend/pyproject.toml +17 -0
  20. package/template/backend/src/__HOST_PKG__/__init__.py +0 -0
  21. package/template/backend/src/__HOST_PKG__/bootstrap.py +52 -0
  22. package/template/backend/src/__HOST_PKG__/models.py +51 -0
  23. package/template/backend/src/__HOST_PKG__/router.py +73 -0
  24. package/template/backend/src/__HOST_PKG__/scripts/__init__.py +0 -0
  25. package/template/backend/src/__HOST_PKG__/scripts/seed_host_bundle.py +48 -0
  26. package/template/backend/tests/test_smoke.py +47 -0
  27. package/template/compose.yaml +96 -0
  28. package/template/frontend/package.json +36 -0
  29. package/template/frontend/src/__BRAND_PASCAL__AdminTab.tsx +32 -0
  30. package/template/frontend/src/__BRAND_PASCAL__Page.tsx +34 -0
  31. package/template/frontend/src/__BRAND_PASCAL__ProfileItem.tsx +38 -0
  32. package/template/frontend/src/__BRAND_PASCAL__Widget.tsx +117 -0
  33. package/template/frontend/src/api.ts +38 -0
  34. package/template/frontend/src/main.tsx +55 -0
  35. package/template/frontend/src/queryClient.ts +11 -0
  36. package/template/frontend/src/test/__BRAND_PASCAL__Widget.test.tsx +102 -0
  37. package/template/frontend/src/test/setup.ts +21 -0
  38. package/template/frontend/tsconfig.json +21 -0
  39. package/template/frontend/vite.config.ts +7 -0
  40. package/template/frontend/vitest.config.ts +15 -0
@@ -0,0 +1,117 @@
1
+ /** Home-widget rendering of the demo state.
2
+ *
3
+ * Wraps itself in MantineProvider + QueryClientProvider + AtriumProvider
4
+ * so the host bundle's Mantine instance, TanStack cache, and atrium
5
+ * user-context query stay isolated from atrium's own copies. Two
6
+ * MantineProviders nested in the DOM is supported by Mantine.
7
+ *
8
+ * Permission gating uses `usePerm()` from
9
+ * `@brendanbank/atrium-host-bundle-utils/react` — a single TanStack
10
+ * Query subscription against atrium's `/users/me/context`, shared
11
+ * across this widget, the dedicated page, and the admin tab.
12
+ */
13
+ import {
14
+ Badge,
15
+ Button,
16
+ Card,
17
+ Group,
18
+ Loader,
19
+ MantineProvider,
20
+ Stack,
21
+ Text,
22
+ Title,
23
+ } from '@mantine/core';
24
+ import {
25
+ useMutation,
26
+ useQuery,
27
+ useQueryClient,
28
+ QueryClientProvider,
29
+ } from '@tanstack/react-query';
30
+ import {
31
+ AtriumProvider,
32
+ usePerm,
33
+ } from '@brendanbank/atrium-host-bundle-utils/react';
34
+
35
+ import {
36
+ bump__BRAND_PASCAL__,
37
+ get__BRAND_PASCAL__State,
38
+ type __BRAND_PASCAL__State,
39
+ } from './api';
40
+ import { queryClient } from './queryClient';
41
+
42
+ const STATE_KEY = ['__HOST_PKG__', 'state'] as const;
43
+
44
+ function __BRAND_PASCAL__WidgetInner() {
45
+ const qc = useQueryClient();
46
+ const { data, isLoading, error } = useQuery({
47
+ queryKey: STATE_KEY,
48
+ queryFn: get__BRAND_PASCAL__State,
49
+ });
50
+ const hasPerm = usePerm();
51
+ const canBump = hasPerm('__HOST_PKG__.write');
52
+ const bumpMutation = useMutation({
53
+ mutationFn: bump__BRAND_PASCAL__,
54
+ onSuccess: (next: __BRAND_PASCAL__State) => {
55
+ qc.setQueryData(STATE_KEY, next);
56
+ },
57
+ });
58
+
59
+ return (
60
+ <Card withBorder padding="lg" radius="md" data-testid="__HOST_NAME__-card">
61
+ <Stack gap="sm">
62
+ <Group justify="space-between" align="center">
63
+ <Title order={4}>__BRAND_NAME__</Title>
64
+ <Badge color={canBump ? 'teal' : 'gray'} variant="light">
65
+ {canBump ? 'editor' : 'viewer'}
66
+ </Badge>
67
+ </Group>
68
+ {isLoading && (
69
+ <Group gap="xs">
70
+ <Loader size="xs" />
71
+ <Text size="sm" c="dimmed">Loading…</Text>
72
+ </Group>
73
+ )}
74
+ {error && (
75
+ <Text c="red" size="sm">
76
+ Error: {(error as Error).message}
77
+ </Text>
78
+ )}
79
+ {data && (
80
+ <>
81
+ <Text size="lg" fw={500} data-testid="__HOST_NAME__-message">
82
+ {data.message}
83
+ </Text>
84
+ <Text
85
+ ff="monospace"
86
+ size="xl"
87
+ fw={700}
88
+ data-testid="__HOST_NAME__-counter"
89
+ >
90
+ {data.counter}
91
+ </Text>
92
+ <Button
93
+ onClick={() => bumpMutation.mutate()}
94
+ disabled={!canBump || bumpMutation.isPending}
95
+ data-testid="__HOST_NAME__-bump"
96
+ fullWidth
97
+ >
98
+ {canBump ? 'Bump counter' : 'Bump (admin only)'}
99
+ </Button>
100
+ </>
101
+ )}
102
+ </Stack>
103
+ </Card>
104
+ );
105
+ }
106
+
107
+ export function __BRAND_PASCAL__Widget() {
108
+ return (
109
+ <MantineProvider>
110
+ <QueryClientProvider client={queryClient}>
111
+ <AtriumProvider>
112
+ <__BRAND_PASCAL__WidgetInner />
113
+ </AtriumProvider>
114
+ </QueryClientProvider>
115
+ </MantineProvider>
116
+ );
117
+ }
@@ -0,0 +1,38 @@
1
+ /** Plain fetch — no axios. `credentials: 'include'` carries atrium's
2
+ * auth cookie so authenticated routes work the same as from atrium's
3
+ * own SPA. Build with `VITE_API_BASE_URL=""` (the default in the
4
+ * Dockerfile) so the bundle calls relative paths.
5
+ */
6
+ const apiBase =
7
+ (import.meta as { env?: { VITE_API_BASE_URL?: string } }).env?.VITE_API_BASE_URL ??
8
+ '';
9
+
10
+ async function jsonOrThrow<T>(res: Response): Promise<T> {
11
+ if (!res.ok) {
12
+ const body = await res.text().catch(() => '');
13
+ throw new Error(`api ${res.status}: ${body}`);
14
+ }
15
+ return (await res.json()) as T;
16
+ }
17
+
18
+ export interface __BRAND_PASCAL__State {
19
+ message: string;
20
+ counter: number;
21
+ }
22
+
23
+ export async function get__BRAND_PASCAL__State(): Promise<__BRAND_PASCAL__State> {
24
+ const res = await fetch(`${apiBase}/__HOST_PKG__/state`, {
25
+ credentials: 'include',
26
+ headers: { Accept: 'application/json' },
27
+ });
28
+ return jsonOrThrow<__BRAND_PASCAL__State>(res);
29
+ }
30
+
31
+ export async function bump__BRAND_PASCAL__(): Promise<__BRAND_PASCAL__State> {
32
+ const res = await fetch(`${apiBase}/__HOST_PKG__/bump`, {
33
+ method: 'POST',
34
+ credentials: 'include',
35
+ headers: { Accept: 'application/json' },
36
+ });
37
+ return jsonOrThrow<__BRAND_PASCAL__State>(res);
38
+ }
@@ -0,0 +1,55 @@
1
+ /** Host bundle entry.
2
+ *
3
+ * Atrium loads this module via `import(system.host_bundle_url)` after
4
+ * the SPA boots. The import-time side-effects below populate the
5
+ * registry. The dual-tree mount pattern (atrium-React owns the
6
+ * wrapper, the host's React owns the subtree) is encapsulated in
7
+ * `makeWrapperElement` from `@brendanbank/atrium-host-bundle-utils`.
8
+ */
9
+ import { IconHandStop } from '@tabler/icons-react';
10
+ import {
11
+ type AtriumRegistry,
12
+ makeWrapperElement,
13
+ } from '@brendanbank/atrium-host-bundle-utils';
14
+
15
+ import { __BRAND_PASCAL__AdminTab } from './__BRAND_PASCAL__AdminTab';
16
+ import { __BRAND_PASCAL__Page } from './__BRAND_PASCAL__Page';
17
+ import { __BRAND_PASCAL__ProfileItem } from './__BRAND_PASCAL__ProfileItem';
18
+ import { __BRAND_PASCAL__Widget } from './__BRAND_PASCAL__Widget';
19
+
20
+ const reg = window.__ATRIUM_REGISTRY__ as AtriumRegistry | undefined;
21
+ const AtriumReact = window.React;
22
+
23
+ if (!reg || !AtriumReact) {
24
+ console.error(
25
+ '[__HOST_NAME__] window.__ATRIUM_REGISTRY__ or window.React missing — atrium SPA must mount before the host bundle loads',
26
+ );
27
+ } else {
28
+ reg.registerHomeWidget({
29
+ key: '__HOST_NAME__-widget',
30
+ render: () => makeWrapperElement(<__BRAND_PASCAL__Widget />),
31
+ });
32
+ reg.registerRoute({
33
+ key: '__HOST_NAME__-page',
34
+ path: '/__HOST_NAME__',
35
+ render: () => makeWrapperElement(<__BRAND_PASCAL__Page />),
36
+ });
37
+ reg.registerNavItem({
38
+ key: '__HOST_NAME__-nav',
39
+ label: '__BRAND_NAME__',
40
+ to: '/__HOST_NAME__',
41
+ icon: AtriumReact.createElement(IconHandStop, { size: 18 }),
42
+ });
43
+ reg.registerAdminTab({
44
+ key: '__HOST_NAME__',
45
+ label: '__BRAND_NAME__',
46
+ icon: AtriumReact.createElement(IconHandStop, { size: 14 }),
47
+ perm: '__HOST_PKG__.write',
48
+ render: () => makeWrapperElement(<__BRAND_PASCAL__AdminTab />),
49
+ });
50
+ reg.registerProfileItem({
51
+ key: '__HOST_NAME__-profile',
52
+ slot: 'after-roles',
53
+ render: () => makeWrapperElement(<__BRAND_PASCAL__ProfileItem />),
54
+ });
55
+ }
@@ -0,0 +1,11 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+
3
+ /** Single QueryClient shared across every component this bundle
4
+ * registers — they all wrap their tree in a QueryClientProvider that
5
+ * points at this client so they share cache for the same query keys.
6
+ */
7
+ export const queryClient = new QueryClient({
8
+ defaultOptions: {
9
+ queries: { staleTime: 2_000, retry: 1 },
10
+ },
11
+ });
@@ -0,0 +1,102 @@
1
+ /** Worked example of testing a host bundle component with
2
+ * `@brendanbank/atrium-test-utils`.
3
+ *
4
+ * The widget calls `usePerm()` to gate the bump button on the
5
+ * `__HOST_PKG__.write` permission. The test installs the fakes once
6
+ * via `mockAtriumRegistry` and asserts the gating without standing up
7
+ * the SPA.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
10
+ import { cleanup, screen, waitFor } from '@testing-library/react';
11
+
12
+ import {
13
+ mockAtriumRegistry,
14
+ renderWithAtrium,
15
+ type MockAtriumHandles,
16
+ type UserContext,
17
+ } from '@brendanbank/atrium-test-utils';
18
+
19
+ import { __BRAND_PASCAL__Widget } from '../__BRAND_PASCAL__Widget';
20
+ import { queryClient } from '../queryClient';
21
+
22
+ const EDITOR: UserContext = {
23
+ id: 1,
24
+ email: 'admin@example.com',
25
+ full_name: 'Admin',
26
+ is_active: true,
27
+ roles: ['admin'],
28
+ permissions: ['__HOST_PKG__.write'],
29
+ impersonating_from: null,
30
+ };
31
+
32
+ const VIEWER: UserContext = {
33
+ id: 2,
34
+ email: 'viewer@example.com',
35
+ full_name: 'Viewer',
36
+ is_active: true,
37
+ roles: [],
38
+ permissions: [],
39
+ impersonating_from: null,
40
+ };
41
+
42
+ let handles: MockAtriumHandles;
43
+ let currentMe: UserContext | null = null;
44
+
45
+ function stubFetch() {
46
+ vi.stubGlobal(
47
+ 'fetch',
48
+ vi.fn(async (url: string) => {
49
+ if (url.endsWith('/users/me/context')) {
50
+ if (!currentMe) return new Response(null, { status: 401 });
51
+ return new Response(JSON.stringify(currentMe), {
52
+ status: 200,
53
+ headers: { 'Content-Type': 'application/json' },
54
+ });
55
+ }
56
+ if (url.endsWith('/__HOST_PKG__/state')) {
57
+ return new Response(
58
+ JSON.stringify({ message: 'Hello from __BRAND_NAME__', counter: 7 }),
59
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
60
+ );
61
+ }
62
+ return new Response('{}', { status: 200 });
63
+ }),
64
+ );
65
+ }
66
+
67
+ beforeEach(() => {
68
+ stubFetch();
69
+ });
70
+
71
+ afterEach(() => {
72
+ cleanup();
73
+ handles?.cleanup();
74
+ // The widget self-wraps in a module-singleton QueryClient so clear
75
+ // it between tests, otherwise the previous test's `me` leaks via
76
+ // the cache.
77
+ queryClient.clear();
78
+ currentMe = null;
79
+ vi.unstubAllGlobals();
80
+ });
81
+
82
+ describe('__BRAND_PASCAL__Widget', () => {
83
+ test('users with __HOST_PKG__.write can press the bump button', async () => {
84
+ currentMe = EDITOR;
85
+ handles = mockAtriumRegistry({ me: EDITOR });
86
+ renderWithAtrium(<__BRAND_PASCAL__Widget />);
87
+ const button = (await screen.findByTestId('__HOST_NAME__-bump')) as HTMLButtonElement;
88
+ await waitFor(() => expect(button).not.toBeDisabled());
89
+ expect(screen.getByText('Bump counter')).toBeInTheDocument();
90
+ });
91
+
92
+ test('users without __HOST_PKG__.write see the disabled label', async () => {
93
+ currentMe = VIEWER;
94
+ handles = mockAtriumRegistry({ me: VIEWER });
95
+ renderWithAtrium(<__BRAND_PASCAL__Widget />);
96
+ const button = (await screen.findByTestId('__HOST_NAME__-bump')) as HTMLButtonElement;
97
+ await waitFor(() =>
98
+ expect(screen.getByText(/admin only/)).toBeInTheDocument(),
99
+ );
100
+ expect(button).toBeDisabled();
101
+ });
102
+ });
@@ -0,0 +1,21 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+
3
+ // jsdom doesn't ship window.matchMedia; Mantine's MantineProvider
4
+ // reads it on mount to decide the initial colour scheme. Stub a
5
+ // "no preference" response so component tests can render without a
6
+ // runtime error.
7
+ if (typeof window !== 'undefined' && !window.matchMedia) {
8
+ Object.defineProperty(window, 'matchMedia', {
9
+ writable: true,
10
+ value: (query: string) => ({
11
+ matches: false,
12
+ media: query,
13
+ onchange: null,
14
+ addListener: () => {},
15
+ removeListener: () => {},
16
+ addEventListener: () => {},
17
+ removeEventListener: () => {},
18
+ dispatchEvent: () => false,
19
+ }),
20
+ });
21
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "moduleDetection": "force",
13
+ "noEmit": true,
14
+ "jsx": "react-jsx",
15
+ "strict": true,
16
+ "noUnusedLocals": true,
17
+ "noUnusedParameters": true,
18
+ "noFallthroughCasesInSwitch": true
19
+ },
20
+ "include": ["src"]
21
+ }
@@ -0,0 +1,7 @@
1
+ import { hostBundleConfig } from '@brendanbank/atrium-host-bundle-utils/vite';
2
+
3
+ // One function call: lib-mode build emitting `dist/main.js`, CSS
4
+ // inlined via runtime <style> tags, defines for the externalised
5
+ // React + TanStack Query references. See @brendanbank/atrium-host-bundle-utils
6
+ // for the rationale on each default.
7
+ export default hostBundleConfig({ entry: 'src/main.tsx' });
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ // Vite's built-in esbuild loader handles tsx via the project's
4
+ // tsconfig (`jsx: react-jsx`). No `@vitejs/plugin-react` needed for
5
+ // unit tests — the package isn't listed as a peer dep so we'd be
6
+ // adding one for tests alone, which the host-bundle pattern keeps
7
+ // out by design.
8
+ export default defineConfig({
9
+ test: {
10
+ environment: 'jsdom',
11
+ globals: false,
12
+ setupFiles: ['./src/test/setup.ts'],
13
+ include: ['src/**/*.test.{ts,tsx}'],
14
+ },
15
+ });