tempest-react-sdk 0.7.0 → 0.8.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.
package/README.md CHANGED
@@ -7,7 +7,15 @@
7
7
  [![TypeScript](https://img.shields.io/badge/types-TypeScript-3178c6.svg?logo=typescript)](https://www.typescriptlang.org/)
8
8
  [![Bundle size](https://img.shields.io/bundlephobia/minzip/tempest-react-sdk?label=gzip)](https://bundlephobia.com/package/tempest-react-sdk)
9
9
 
10
- > 📚 **Documentation (bilingual / bilíngue):** **[Português (BR)](https://mauriciobenjamin700.github.io/tempest-react-sdk/)** · **[English (US)](https://mauriciobenjamin700.github.io/tempest-react-sdk/en/)** — full docs site (MkDocs Material on GitHub Pages) with a PT-BR / EN-US language switcher in the header. The site is the navigable, per-module source of truth; this README stays the npm/GitHub landing page.
10
+ [**📖 Documentação completa (PT-BR) →**](https://mauriciobenjamin700.github.io/tempest-react-sdk/) · [**📖 Full documentation (EN-US) →**](https://mauriciobenjamin700.github.io/tempest-react-sdk/en/)
11
+
12
+ > O site MkDocs é bilíngue (**PT-BR** padrão · **EN-US**) com seletor de idioma 🇧🇷/🇺🇸 no cabeçalho. — The MkDocs site is bilingual (**PT-BR** default · **EN-US**) with a 🇧🇷/🇺🇸 language switcher in the header. The site is the navigable, per-module source of truth; this README stays the npm/GitHub landing page.
13
+
14
+ - **PT-BR:** [Scaffold](https://mauriciobenjamin700.github.io/tempest-react-sdk/scaffold/) · [Estrutura de app](https://mauriciobenjamin700.github.io/tempest-react-sdk/architecture/) · [Componentes](https://mauriciobenjamin700.github.io/tempest-react-sdk/components/) · [Hooks](https://mauriciobenjamin700.github.io/tempest-react-sdk/hooks/) · [Utilitários](https://mauriciobenjamin700.github.io/tempest-react-sdk/utilities/)
15
+ - **EN-US:** [Scaffold](https://mauriciobenjamin700.github.io/tempest-react-sdk/en/scaffold/) · [App foundation](https://mauriciobenjamin700.github.io/tempest-react-sdk/en/architecture/) · [Components](https://mauriciobenjamin700.github.io/tempest-react-sdk/en/components/) · [Hooks](https://mauriciobenjamin700.github.io/tempest-react-sdk/en/hooks/) · [Utilities](https://mauriciobenjamin700.github.io/tempest-react-sdk/en/utilities/)
16
+ - **🤖 For LLMs:** [llms.txt](https://mauriciobenjamin700.github.io/tempest-react-sdk/llms.txt) (curated index) · [llms-full.txt](https://mauriciobenjamin700.github.io/tempest-react-sdk/llms-full.txt) (full docs in one block) — [llmstxt.org](https://llmstxt.org) convention.
17
+
18
+ > 💡 `pip install -r docs/requirements.txt && mkdocs serve` é só para preview local — em produção use as URLs do GitHub Pages acima. / For local preview only — in production use the GitHub Pages URLs above.
11
19
 
12
20
  Shared React/TypeScript building blocks used across Tempest frontends: UI components, hooks, HTTP client, auth store, query keys, forms (zod), real-time transports (SSE / WebSocket / Web Push / Service Worker), theme, i18n, telemetry, feature flags, offline storage, error boundary, and a curated set of utilities (`cn`, `formatCurrency`, `formatCPF`, etc.).
13
21
 
@@ -87,8 +95,13 @@ npx -p tempest-react-sdk create-tempest-app my-app
87
95
  cd my-app
88
96
  npm install
89
97
  npm run dev
98
+
99
+ # want it installable + web-push ready? add --pwa
100
+ npx -p tempest-react-sdk create-tempest-app my-app --pwa
90
101
  ```
91
102
 
103
+ The `--pwa` flag overlays a manifest, a service worker built from `tempest-react-sdk/sw`, and push wiring (`usePushSubscription` + `useBeforeInstallPrompt`) on top of the base app — no `vite-plugin-pwa`. See [Scaffold › PWA mode](https://mauriciobenjamin700.github.io/tempest-react-sdk/scaffold/#modo-pwa-pwa).
104
+
92
105
  Already have a project? Install the SDK, then scaffold `src/` + configs into it:
93
106
 
94
107
  ```bash
@@ -180,33 +193,33 @@ The styles ship hashed under the `tempest_` namespace — they do **not** collid
180
193
 
181
194
  Every module is re-exported from the package root — `import { Button, useDebounce, createApiClient } from "tempest-react-sdk"` always works.
182
195
 
183
- | Module | Exports |
184
- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
185
- | `components` | `Avatar`, `Badge`, `Breadcrumbs`, `Button`, `Card`, `Checkbox`, `ChipInput`, `ConfirmDialog`, `Container`, `DatePicker`, `Drawer`, `EmptyState`, `ErrorState`, `FileUpload`, `Form` (`FormSection`, `FormRow`, `FormActions`), `Grid`, `Input`, `Modal`, `Pagination`, `Progress`, `Radio`, `RadioGroup`, `SearchBar`, `Select`, `Skeleton`, `Spinner`, `Stack`, `Stepper`, `Switch`, `Table`, `Tabs`, `Textarea`, `Toast` (`ToastProvider`, `useToast`), `Tooltip`, `VirtualList` |
186
- | `hooks` | `useDebounce`, `usePagination`, `useClientFilter`, `useMediaQuery`, `useOnline`, `useDocumentVisibility`, `useIntersectionObserver`, `useResizeObserver`, `useClipboard`, `useKeyboardShortcut`, `useBeforeInstallPrompt`, `useIdle`, `useGeolocation`, `useScrollLock`, `useFocusTrap`, `useStableCallback`, `useDeepMemo` |
187
- | `http` | `createApiClient`, `parseResponse`, `uploadWithProgress`, `retry`, `generateIdempotencyKey`, `usePoll`, types: `ApiClient`, `ApiClientConfig`, `ApiError`, `RequestOptions`, `RetryOptions`, `UploadProgressEvent`, `UploadWithProgressOptions`, `UsePollOptions`, `UsePollResult` |
188
- | `auth` _(peer: `zustand`)_ | `createAuthStore`, `AuthGuard`, `decodeJWT`, `isJWTExpired`, `lazyWithRetry`, `createRefreshQueue`, types: `AuthState`, `CreateAuthStoreOptions`, `AuthGuardProps`, `DecodedJWT`, `LazyWithRetryOptions` |
189
- | `query` _(peer: `@tanstack/react-query`)_ | `QueryProvider`, `createQueryKeys`, `STALE_TIME`, `CACHE_TIME`, `REFETCH_TIME` |
190
- | `router` _(dep: `react-router-dom`)_ | `defineRoutes`, `AppRouter`, `RouteGuard`, + re-exports (`Link`, `NavLink`, `Outlet`, `Navigate`, `useNavigate`, `useParams`, `useSearchParams`, `useLocation`, `useMatch`, `useRouteError`, `redirect`, `BrowserRouter`/`HashRouter`/`MemoryRouter`/`Routes`/`Route`), types: `TempestRouteObject`, `RouterKind`, `AppRouterProps`, `RouteGuardProps` |
191
- | `store` _(dep: `zustand`)_ | `createStore`, `createSelectors`, types: `CreateStoreOptions`, `CreateStorePersistOptions`, `WithSelectors` |
192
- | `app` | `AppProviders` (composes `ErrorBoundary` → `QueryProvider` → `ThemeProvider` → `I18nProvider`), type: `AppProvidersProps` |
193
- | `vite` _(subpath `tempest-react-sdk/vite`)_ | `createViteConfig`, types: `CreateViteConfigOptions`, `ProxyEntry`, `TempestViteConfig` |
194
- | `forms` _(peer: `zod`, `react-hook-form`)_ | `validateForm`, `zodResolver`, `useZodForm`, `validateCPF`, `validateCNPJ`, `formatCEP`, `formatCNPJ`, `unmask`, `CPFInput`, `CNPJInput`, `PhoneInput`, `CEPInput`, `MoneyInput`, `useViaCEP` |
195
- | `sse` | `createEventStream`, `useEventStream` |
196
- | `ws` | `createWebSocket`, `useWebSocket` |
197
- | `push` | `WebPushClient`, `WebPushUnsupportedError`, `WebPushPermissionDeniedError`, `usePushSubscription`, `urlBase64ToUint8Array`, `isPushSupported` |
198
- | `sw` | `registerServiceWorker`, `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener` |
199
- | `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio` |
200
- | `offline` _(peer: `dexie`)_ | `createOfflineStore`, types: `OfflineStore`, `OfflineStoreConfig`, `ListOptions` |
201
- | `error-boundary` | `ErrorBoundary`, `useErrorHandler`, types: `ErrorBoundaryProps`, `ErrorBoundaryRenderProps` |
202
- | `theme` | `ThemeProvider`, `useTheme`, `getInitialTheme`, `themeInitScript`, types: `ThemeMode`, `ResolvedTheme` |
203
- | `i18n` | `createI18n`, `I18nProvider`, `useI18n`, `useTranslate`, types: `Catalog`, `Messages`, `I18n`, `InterpolationValues` |
204
- | `logger` | `createLogger`, `consoleSink`, types: `Logger`, `LogEntry`, `LogLevel`, `LoggerSink` |
205
- | `telemetry` | `TelemetryProvider`, `useTelemetry`, `consoleTelemetryAdapter`, `createSentryTelemetryAdapter`, `createPostHogTelemetryAdapter`, types: `TelemetryAdapter`, `TelemetryEvent`, `TelemetryUser`, `CreateSentryTelemetryAdapterOptions`, `SentryLike`, `CreatePostHogTelemetryAdapterOptions`, `PostHogLike` |
206
- | `feature-flags` | `FeatureFlagsProvider`, `useFeatureFlag`, `useFlagValue`, `createInMemoryFlags`, `createGrowthBookFeatureFlagsAdapter`, `createLaunchDarklyFeatureFlagsAdapter`, types: `FeatureFlagsAdapter`, `FlagValue`, `GrowthBookLike`, `LDClientLike` |
207
- | `share` | `share`, `isShareSupported`, types: `SharePayload`, `ShareResult` |
208
- | `utils` | `cn`, format BR (`formatCurrency`, `formatDate`, `formatDateTime`, `formatPhone`, `formatCPF`, `formatPercent`), `storage`, strings (`slugify`, `truncate`, `capitalize`, `camelCase`, `kebabCase`, `pluralize`), numbers (`clamp`, `formatBytes`, `formatCompactNumber`), arrays (`groupBy`, `uniqueBy`, `chunk`, `range`), objects (`pick`, `omit`, `deepMerge`, `isEmpty`), guards (`isDefined`, `isString`, `isNumber`, `isPlainObject`, `assertNever`), functions (`debounce`, `throttle`, `once`, `memoizeOne`), promises (`sleep`, `withTimeout`), `randomId`, `relativeTime` |
209
- | generic components | display (`CopyButton`, `RelativeTime`, `Money`, `TruncateText`, `VisuallyHidden`), headless (`Portal`, `ClickOutside`, `ConditionalWrapper`, `For`, `ErrorText`), media/content (`Image`, `DataList`, `DescriptionList`) |
196
+ | Module | Exports |
197
+ | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
198
+ | `components` | `Avatar`, `Badge`, `Breadcrumbs`, `Button`, `Card`, `Checkbox`, `ChipInput`, `ConfirmDialog`, `Container`, `DatePicker`, `Drawer`, `EmptyState`, `ErrorState`, `FileUpload`, `Form` (`FormSection`, `FormRow`, `FormActions`), `Grid`, `Input`, `Modal`, `Pagination`, `Progress`, `Radio`, `RadioGroup`, `SearchBar`, `Select`, `Skeleton`, `Spinner`, `Stack`, `Stepper`, `Switch`, `Table`, `Tabs`, `Textarea`, `Toast` (`ToastProvider`, `useToast`), `Tooltip`, `VirtualList` |
199
+ | `hooks` | `useDebounce`, `usePagination`, `useClientFilter`, `useMediaQuery`, `useOnline`, `useDocumentVisibility`, `useIntersectionObserver`, `useResizeObserver`, `useClipboard`, `useKeyboardShortcut`, `useBeforeInstallPrompt`, `useIdle`, `useGeolocation`, `useScrollLock`, `useFocusTrap`, `useStableCallback`, `useDeepMemo` |
200
+ | `http` | `createApiClient`, `parseResponse`, `uploadWithProgress`, `retry`, `generateIdempotencyKey`, `usePoll`, types: `ApiClient`, `ApiClientConfig`, `ApiError`, `RequestOptions`, `RetryOptions`, `UploadProgressEvent`, `UploadWithProgressOptions`, `UsePollOptions`, `UsePollResult` |
201
+ | `auth` _(peer: `zustand`)_ | `createAuthStore`, `AuthGuard`, `decodeJWT`, `isJWTExpired`, `lazyWithRetry`, `createRefreshQueue`, types: `AuthState`, `CreateAuthStoreOptions`, `AuthGuardProps`, `DecodedJWT`, `LazyWithRetryOptions` |
202
+ | `query` _(peer: `@tanstack/react-query`)_ | `QueryProvider`, `createQueryKeys`, `STALE_TIME`, `CACHE_TIME`, `REFETCH_TIME` |
203
+ | `router` _(dep: `react-router-dom`)_ | `defineRoutes`, `AppRouter`, `RouteGuard`, + re-exports (`Link`, `NavLink`, `Outlet`, `Navigate`, `useNavigate`, `useParams`, `useSearchParams`, `useLocation`, `useMatch`, `useRouteError`, `redirect`, `BrowserRouter`/`HashRouter`/`MemoryRouter`/`Routes`/`Route`), types: `TempestRouteObject`, `RouterKind`, `AppRouterProps`, `RouteGuardProps` |
204
+ | `store` _(dep: `zustand`)_ | `createStore`, `createSelectors`, types: `CreateStoreOptions`, `CreateStorePersistOptions`, `WithSelectors` |
205
+ | `app` | `AppProviders` (composes `ErrorBoundary` → `QueryProvider` → `ThemeProvider` → `I18nProvider`), type: `AppProvidersProps` |
206
+ | `vite` _(subpath `tempest-react-sdk/vite`)_ | `createViteConfig`, types: `CreateViteConfigOptions`, `ProxyEntry`, `TempestViteConfig` |
207
+ | `forms` _(peer: `zod`, `react-hook-form`)_ | `validateForm`, `zodResolver`, `useZodForm`, `validateCPF`, `validateCNPJ`, `formatCEP`, `formatCNPJ`, `unmask`, `CPFInput`, `CNPJInput`, `PhoneInput`, `CEPInput`, `MoneyInput`, `useViaCEP` |
208
+ | `sse` | `createEventStream`, `useEventStream` |
209
+ | `ws` | `createWebSocket`, `useWebSocket` |
210
+ | `push` | `WebPushClient`, `WebPushUnsupportedError`, `WebPushPermissionDeniedError`, `usePushSubscription`, `urlBase64ToUint8Array`, `isPushSupported` |
211
+ | `sw` _(also subpath `tempest-react-sdk/sw`)_ | `registerServiceWorker`, `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener` — the React-free `tempest-react-sdk/sw` subpath is ideal for bundling into your own `sw.ts` |
212
+ | `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio` |
213
+ | `offline` _(peer: `dexie`)_ | `createOfflineStore`, types: `OfflineStore`, `OfflineStoreConfig`, `ListOptions` |
214
+ | `error-boundary` | `ErrorBoundary`, `useErrorHandler`, types: `ErrorBoundaryProps`, `ErrorBoundaryRenderProps` |
215
+ | `theme` | `ThemeProvider`, `useTheme`, `getInitialTheme`, `themeInitScript`, types: `ThemeMode`, `ResolvedTheme` |
216
+ | `i18n` | `createI18n`, `I18nProvider`, `useI18n`, `useTranslate`, types: `Catalog`, `Messages`, `I18n`, `InterpolationValues` |
217
+ | `logger` | `createLogger`, `consoleSink`, types: `Logger`, `LogEntry`, `LogLevel`, `LoggerSink` |
218
+ | `telemetry` | `TelemetryProvider`, `useTelemetry`, `consoleTelemetryAdapter`, `createSentryTelemetryAdapter`, `createPostHogTelemetryAdapter`, types: `TelemetryAdapter`, `TelemetryEvent`, `TelemetryUser`, `CreateSentryTelemetryAdapterOptions`, `SentryLike`, `CreatePostHogTelemetryAdapterOptions`, `PostHogLike` |
219
+ | `feature-flags` | `FeatureFlagsProvider`, `useFeatureFlag`, `useFlagValue`, `createInMemoryFlags`, `createGrowthBookFeatureFlagsAdapter`, `createLaunchDarklyFeatureFlagsAdapter`, types: `FeatureFlagsAdapter`, `FlagValue`, `GrowthBookLike`, `LDClientLike` |
220
+ | `share` | `share`, `isShareSupported`, types: `SharePayload`, `ShareResult` |
221
+ | `utils` | `cn`, format BR (`formatCurrency`, `formatDate`, `formatDateTime`, `formatPhone`, `formatCPF`, `formatPercent`), `storage`, strings (`slugify`, `truncate`, `capitalize`, `camelCase`, `kebabCase`, `pluralize`), numbers (`clamp`, `formatBytes`, `formatCompactNumber`), arrays (`groupBy`, `uniqueBy`, `chunk`, `range`), objects (`pick`, `omit`, `deepMerge`, `isEmpty`), guards (`isDefined`, `isString`, `isNumber`, `isPlainObject`, `assertNever`), functions (`debounce`, `throttle`, `once`, `memoizeOne`), promises (`sleep`, `withTimeout`), `randomId`, `relativeTime` |
222
+ | generic components | display (`CopyButton`, `RelativeTime`, `Money`, `TruncateText`, `VisuallyHidden`), headless (`Portal`, `ClickOutside`, `ConditionalWrapper`, `For`, `ErrorText`), media/content (`Image`, `DataList`, `DescriptionList`) |
210
223
 
211
224
  Full per-module docs are published as a bilingual MkDocs site on GitHub Pages — **[Português (BR)](https://mauriciobenjamin700.github.io/tempest-react-sdk/)** / **[English (US)](https://mauriciobenjamin700.github.io/tempest-react-sdk/en/)** (one page per module + draw.io diagrams in [`docs/diagrams/`](./docs/diagrams)). The source markdown lives in [`docs/`](./docs) (PT-BR base files + `.en.md` translations).
212
225
 
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  // create-tempest-app — ships inside tempest-react-sdk.
3
3
  //
4
- // npx create-tempest-app my-app → scaffold a brand-new project folder
5
- // npx create-tempest-app . → scaffold into the current directory
6
- // npx create-tempest-app → same as "." (merge into the current dir)
4
+ // npx create-tempest-app my-app → scaffold a brand-new project folder
5
+ // npx create-tempest-app . → scaffold into the current directory
6
+ // npx create-tempest-app → same as "." (merge into the current dir)
7
+ // npx create-tempest-app my-app --pwa → also wire installability + web-push
7
8
  //
8
- // In merge mode, existing files are left untouched and an existing package.json
9
- // has the Tempest scripts/deps merged in (your name/version are preserved).
9
+ // In merge mode, files the user already has are left untouched and an existing
10
+ // package.json has the Tempest scripts/deps merged in (your name/version are
11
+ // preserved). The `--pwa` flag overlays the PWA template (manifest, service
12
+ // worker, push-subscribe wiring) on top of the base.
10
13
  import { cp, mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
11
14
  import { existsSync } from "node:fs";
12
15
  import { dirname, join, resolve } from "node:path";
@@ -15,9 +18,14 @@ import { fileURLToPath } from "node:url";
15
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
19
  const PKG_ROOT = resolve(__dirname, "..");
17
20
  const TEMPLATE_DIR = join(PKG_ROOT, "template");
21
+ const TEMPLATE_PWA_DIR = join(PKG_ROOT, "template-pwa");
18
22
 
19
23
  /** Files renamed on copy so they ship inside the npm tarball. */
20
- const RENAME_ON_COPY = { _gitignore: ".gitignore", "_env.example": ".env.example" };
24
+ const RENAME_ON_COPY = {
25
+ _gitignore: ".gitignore",
26
+ "_env.example": ".env.example",
27
+ "_prettierrc.json": ".prettierrc.json",
28
+ };
21
29
 
22
30
  const c = {
23
31
  reset: "\x1b[0m",
@@ -33,6 +41,26 @@ function isValidName(name) {
33
41
  return /^[a-z0-9._-]+$/i.test(name) && !name.startsWith(".");
34
42
  }
35
43
 
44
+ /** Final on-disk name for a template entry (dotfiles are unprefixed on copy). */
45
+ function finalName(entryName) {
46
+ return RENAME_ON_COPY[entryName] ?? entryName;
47
+ }
48
+
49
+ /** Recursively collect existing file paths under `dir`, relative to it. */
50
+ async function listFiles(dir, relBase = "", out = new Set()) {
51
+ if (!existsSync(dir)) return out;
52
+ const entries = await readdir(dir, { withFileTypes: true });
53
+ for (const entry of entries) {
54
+ const rel = join(relBase, entry.name);
55
+ if (entry.isDirectory()) {
56
+ await listFiles(join(dir, entry.name), rel, out);
57
+ } else {
58
+ out.add(rel);
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
36
64
  async function isEmptyDir(dir) {
37
65
  if (!existsSync(dir)) return true;
38
66
  const entries = await readdir(dir);
@@ -49,6 +77,31 @@ async function readSdkVersion() {
49
77
  }
50
78
  }
51
79
 
80
+ /**
81
+ * Copy a template tree into `destDir`. `package.json` is handled separately;
82
+ * any path already present in `protect` (the user's own files) is skipped and
83
+ * recorded. Everything else is overwritten, so a later overlay wins over the
84
+ * base it sits on.
85
+ */
86
+ async function copyTree(srcDir, destDir, protect, skipped, relBase = "") {
87
+ const entries = await readdir(srcDir, { withFileTypes: true });
88
+ for (const entry of entries) {
89
+ const rel = join(relBase, finalName(entry.name));
90
+ const from = join(srcDir, entry.name);
91
+ const to = join(destDir, finalName(entry.name));
92
+ if (entry.isDirectory()) {
93
+ await mkdir(to, { recursive: true });
94
+ await copyTree(from, to, protect, skipped, rel);
95
+ } else if (entry.name === "package.json") {
96
+ // handled separately by writeFreshPackageJson / mergePackageJson
97
+ } else if (protect.has(rel)) {
98
+ skipped.add(rel);
99
+ } else {
100
+ await cp(from, to);
101
+ }
102
+ }
103
+ }
104
+
52
105
  /** Recursively rename underscore-prefixed dotfiles after a directory copy. */
53
106
  async function fixDotfiles(dir) {
54
107
  const entries = await readdir(dir, { withFileTypes: true });
@@ -62,26 +115,6 @@ async function fixDotfiles(dir) {
62
115
  }
63
116
  }
64
117
 
65
- /** Recursively copy template files, skipping any that already exist in dest. */
66
- async function mergeCopy(srcDir, destDir, skipped, relBase = "") {
67
- const entries = await readdir(srcDir, { withFileTypes: true });
68
- for (const entry of entries) {
69
- const rel = join(relBase, RENAME_ON_COPY[entry.name] ?? entry.name);
70
- const from = join(srcDir, entry.name);
71
- const to = join(destDir, RENAME_ON_COPY[entry.name] ?? entry.name);
72
- if (entry.isDirectory()) {
73
- await mkdir(to, { recursive: true });
74
- await mergeCopy(from, to, skipped, rel);
75
- } else if (entry.name === "package.json") {
76
- // handled separately by mergePackageJson
77
- } else if (existsSync(to)) {
78
- skipped.push(rel);
79
- } else {
80
- await cp(from, to);
81
- }
82
- }
83
- }
84
-
85
118
  /** Stamp the SDK version into a fresh package.json. */
86
119
  async function writeFreshPackageJson(destDir, name, sdkVersion) {
87
120
  const pkg = JSON.parse(await readFile(join(TEMPLATE_DIR, "package.json"), "utf8"));
@@ -104,6 +137,31 @@ async function mergePackageJson(destDir, sdkVersion) {
104
137
  await writeFile(join(destDir, "package.json"), JSON.stringify(target, null, 2) + "\n");
105
138
  }
106
139
 
140
+ /**
141
+ * Fold the PWA package.json patch into the just-written one: PWA scripts win
142
+ * (e.g. `build` also bundles the service worker), deps/devDeps are added.
143
+ */
144
+ async function applyPwaPackageJson(destDir) {
145
+ const patch = JSON.parse(await readFile(join(TEMPLATE_PWA_DIR, "package.json"), "utf8"));
146
+ const target = JSON.parse(await readFile(join(destDir, "package.json"), "utf8"));
147
+
148
+ target.scripts = { ...(target.scripts ?? {}), ...(patch.scripts ?? {}) };
149
+ target.dependencies = { ...(target.dependencies ?? {}), ...(patch.dependencies ?? {}) };
150
+ target.devDependencies = {
151
+ ...(target.devDependencies ?? {}),
152
+ ...(patch.devDependencies ?? {}),
153
+ };
154
+
155
+ await writeFile(join(destDir, "package.json"), JSON.stringify(target, null, 2) + "\n");
156
+ }
157
+
158
+ function parseArgs(argv) {
159
+ const rest = argv.slice(2);
160
+ const flags = new Set(rest.filter((a) => a.startsWith("--")));
161
+ const positionals = rest.filter((a) => !a.startsWith("--"));
162
+ return { name: positionals[0], pwa: flags.has("--pwa") };
163
+ }
164
+
107
165
  async function main() {
108
166
  console.log(`\n${c.bold}${c.cyan}create-tempest-app${c.reset}\n`);
109
167
 
@@ -112,63 +170,76 @@ async function main() {
112
170
  process.exit(1);
113
171
  }
114
172
 
115
- const sdkVersion = await readSdkVersion();
116
- const arg = process.argv[2];
173
+ const { name: arg, pwa } = parseArgs(process.argv);
117
174
 
118
- // Merge mode: "." or no arg → scaffold into the current directory.
119
- if (arg === "." || arg === undefined) {
120
- const destDir = process.cwd();
121
- const hasPkg = existsSync(join(destDir, "package.json"));
122
- console.log(`${c.dim}Scaffolding into the current directory…${c.reset}`);
175
+ if (pwa && !existsSync(TEMPLATE_PWA_DIR)) {
176
+ console.error(`${c.red}✗ PWA template not found at ${TEMPLATE_PWA_DIR}${c.reset}`);
177
+ process.exit(1);
178
+ }
123
179
 
124
- const skipped = [];
125
- await mergeCopy(TEMPLATE_DIR, destDir, skipped);
126
- await fixDotfiles(destDir);
180
+ const sdkVersion = await readSdkVersion();
181
+ const mergeMode = arg === "." || arg === undefined;
127
182
 
128
- if (hasPkg) {
129
- await mergePackageJson(destDir, sdkVersion);
130
- console.log(`${c.dim}Merged scripts + deps into existing package.json.${c.reset}`);
131
- } else {
132
- const name = (destDir.split("/").pop() || "tempest-app").toLowerCase();
133
- await writeFreshPackageJson(destDir, name, sdkVersion);
183
+ // Resolve destination + validate.
184
+ let destDir;
185
+ if (mergeMode) {
186
+ destDir = process.cwd();
187
+ console.log(`${c.dim}Scaffolding into the current directory…${c.reset}`);
188
+ } else {
189
+ if (!isValidName(arg)) {
190
+ console.error(`${c.red}✗ Invalid project name: "${arg}"${c.reset}`);
191
+ process.exit(1);
134
192
  }
135
-
136
- if (skipped.length) {
137
- console.log(`\n${c.yellow}Skipped ${skipped.length} existing file(s):${c.reset}`);
138
- for (const f of skipped) console.log(` ${c.dim}· ${f}${c.reset}`);
193
+ destDir = resolve(process.cwd(), arg);
194
+ if (!(await isEmptyDir(destDir))) {
195
+ console.error(`${c.red} Directory "${arg}" exists and is not empty.${c.reset}`);
196
+ console.error(
197
+ `${c.dim} Use "create-tempest-app ." to merge into the current directory.${c.reset}`,
198
+ );
199
+ process.exit(1);
139
200
  }
140
-
141
- console.log(`\n${c.green} Done!${c.reset} Next steps:\n`);
142
- console.log(` ${c.bold}npm install${c.reset}`);
143
- console.log(` ${c.bold}npm run dev${c.reset}\n`);
144
- return;
201
+ await mkdir(destDir, { recursive: true });
202
+ console.log(`${c.dim}Scaffolding into ${destDir}…${c.reset}`);
145
203
  }
204
+ if (pwa) console.log(`${c.dim}PWA mode: installability + web-push wiring.${c.reset}`);
146
205
 
147
- // New-project mode: scaffold a fresh folder from the given name.
148
- if (!isValidName(arg)) {
149
- console.error(`${c.red}✗ Invalid project name: "${arg}"${c.reset}`);
150
- process.exit(1);
151
- }
206
+ // Snapshot the user's own files so neither copy pass clobbers them.
207
+ const protect = await listFiles(destDir);
208
+ const hadPkg = protect.has("package.json");
209
+ const skipped = new Set();
152
210
 
153
- const destDir = resolve(process.cwd(), arg);
154
- if (!(await isEmptyDir(destDir))) {
155
- console.error(`${c.red}✗ Directory "${arg}" exists and is not empty.${c.reset}`);
156
- console.error(
157
- `${c.dim} Use "create-tempest-app ." to merge into the current directory.${c.reset}`,
158
- );
159
- process.exit(1);
211
+ await copyTree(TEMPLATE_DIR, destDir, protect, skipped);
212
+ if (pwa) await copyTree(TEMPLATE_PWA_DIR, destDir, protect, skipped);
213
+ await fixDotfiles(destDir);
214
+
215
+ // package.json: merge into the user's if present, otherwise write fresh.
216
+ if (hadPkg) {
217
+ await mergePackageJson(destDir, sdkVersion);
218
+ console.log(`${c.dim}Merged scripts + deps into existing package.json.${c.reset}`);
219
+ } else {
220
+ const name = mergeMode ? (destDir.split("/").pop() || "tempest-app").toLowerCase() : arg;
221
+ await writeFreshPackageJson(destDir, name, sdkVersion);
160
222
  }
223
+ if (pwa) await applyPwaPackageJson(destDir);
161
224
 
162
- console.log(`${c.dim}Scaffolding into ${destDir}…${c.reset}`);
163
- await mkdir(destDir, { recursive: true });
164
- await cp(TEMPLATE_DIR, destDir, { recursive: true });
165
- await fixDotfiles(destDir);
166
- await writeFreshPackageJson(destDir, arg, sdkVersion);
225
+ if (skipped.size) {
226
+ console.log(`\n${c.yellow}Skipped ${skipped.size} existing file(s):${c.reset}`);
227
+ for (const f of skipped) console.log(` ${c.dim}· ${f}${c.reset}`);
228
+ }
167
229
 
168
230
  console.log(`\n${c.green}✓ Done!${c.reset} Next steps:\n`);
169
- console.log(` ${c.bold}cd ${arg}${c.reset}`);
231
+ if (!mergeMode) console.log(` ${c.bold}cd ${arg}${c.reset}`);
170
232
  console.log(` ${c.bold}npm install${c.reset}`);
171
- console.log(` ${c.bold}npm run dev${c.reset}\n`);
233
+ console.log(` ${c.bold}npm run dev${c.reset}`);
234
+ if (pwa) {
235
+ console.log(
236
+ `\n${c.dim}PWA: set VITE_VAPID_PUBLIC_KEY in .env, then test installability${c.reset}`,
237
+ );
238
+ console.log(
239
+ `${c.dim}and push with a production build: ${c.reset}${c.bold}npm run build && npm run preview${c.reset}`,
240
+ );
241
+ }
242
+ console.log();
172
243
  }
173
244
 
174
245
  main().catch((err) => {