tempest-react-sdk 0.75.0 → 0.77.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 (147) hide show
  1. package/README.md +4 -2
  2. package/bin/create-tempest-app.e2e.test.mjs +112 -0
  3. package/bin/create-tempest-app.mjs +69 -28
  4. package/bin/lib/component/generate.mjs +191 -0
  5. package/bin/lib/component/generate.test.mjs +336 -0
  6. package/bin/lib/openapi/escape.mjs +210 -0
  7. package/bin/lib/openapi/generate.hostile.test.mjs +592 -0
  8. package/bin/lib/openapi/generate.mjs +93 -17
  9. package/bin/lib/openapi/schema-to-zod.mjs +41 -4
  10. package/bin/tempest.mjs +184 -3
  11. package/dist/auth/create-tempest-auth.cjs +1 -1
  12. package/dist/auth/create-tempest-auth.cjs.map +1 -1
  13. package/dist/auth/create-tempest-auth.js +127 -55
  14. package/dist/auth/create-tempest-auth.js.map +1 -1
  15. package/dist/auth/refresh-queue.cjs +1 -1
  16. package/dist/auth/refresh-queue.cjs.map +1 -1
  17. package/dist/auth/refresh-queue.js +13 -6
  18. package/dist/auth/refresh-queue.js.map +1 -1
  19. package/dist/br/pix.cjs +1 -1
  20. package/dist/br/pix.cjs.map +1 -1
  21. package/dist/br/pix.js +72 -62
  22. package/dist/br/pix.js.map +1 -1
  23. package/dist/br.d.ts +12 -4
  24. package/dist/components/Chat/ChatAttachments.cjs +1 -1
  25. package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
  26. package/dist/components/Chat/ChatAttachments.js +46 -38
  27. package/dist/components/Chat/ChatAttachments.js.map +1 -1
  28. package/dist/components/Markdown/markdown-parse.cjs +7 -5
  29. package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
  30. package/dist/components/Markdown/markdown-parse.js +151 -87
  31. package/dist/components/Markdown/markdown-parse.js.map +1 -1
  32. package/dist/components/Toast/Toast.module.cjs +1 -1
  33. package/dist/components/Toast/Toast.module.cjs.map +1 -1
  34. package/dist/components/Toast/Toast.module.js +7 -3
  35. package/dist/components/Toast/Toast.module.js.map +1 -1
  36. package/dist/components/Toast/ToastProvider.cjs +1 -1
  37. package/dist/components/Toast/ToastProvider.cjs.map +1 -1
  38. package/dist/components/Toast/ToastProvider.js +77 -51
  39. package/dist/components/Toast/ToastProvider.js.map +1 -1
  40. package/dist/data/create-data-provider.cjs +1 -1
  41. package/dist/data/create-data-provider.cjs.map +1 -1
  42. package/dist/data/create-data-provider.js +32 -21
  43. package/dist/data/create-data-provider.js.map +1 -1
  44. package/dist/http/api-client.cjs +1 -1
  45. package/dist/http/api-client.cjs.map +1 -1
  46. package/dist/http/api-client.js +81 -73
  47. package/dist/http/api-client.js.map +1 -1
  48. package/dist/http/errors.cjs +1 -1
  49. package/dist/http/errors.cjs.map +1 -1
  50. package/dist/http/errors.js +39 -35
  51. package/dist/http/errors.js.map +1 -1
  52. package/dist/neural.cjs +2 -0
  53. package/dist/neural.cjs.map +1 -0
  54. package/dist/neural.d.ts +110 -0
  55. package/dist/neural.js +48 -0
  56. package/dist/neural.js.map +1 -0
  57. package/dist/oauth/callback.cjs +2 -0
  58. package/dist/oauth/callback.cjs.map +1 -0
  59. package/dist/oauth/callback.js +42 -0
  60. package/dist/oauth/callback.js.map +1 -0
  61. package/dist/oauth/use-oauth-callback.cjs +1 -1
  62. package/dist/oauth/use-oauth-callback.cjs.map +1 -1
  63. package/dist/oauth/use-oauth-callback.js +13 -20
  64. package/dist/oauth/use-oauth-callback.js.map +1 -1
  65. package/dist/push/use-push-subscription.cjs +1 -1
  66. package/dist/push/use-push-subscription.cjs.map +1 -1
  67. package/dist/push/use-push-subscription.js +42 -42
  68. package/dist/push/use-push-subscription.js.map +1 -1
  69. package/dist/push/web-push-client.cjs +1 -1
  70. package/dist/push/web-push-client.cjs.map +1 -1
  71. package/dist/push/web-push-client.js +23 -3
  72. package/dist/push/web-push-client.js.map +1 -1
  73. package/dist/query/offline-persistence.cjs +1 -1
  74. package/dist/query/offline-persistence.cjs.map +1 -1
  75. package/dist/query/offline-persistence.js +68 -23
  76. package/dist/query/offline-persistence.js.map +1 -1
  77. package/dist/styles/component/Toast.css +6 -1
  78. package/dist/styles/feedback.css +6 -1
  79. package/dist/styles.css +1 -1
  80. package/dist/sw/background-sync.cjs +1 -1
  81. package/dist/sw/background-sync.cjs.map +1 -1
  82. package/dist/sw/background-sync.js +271 -60
  83. package/dist/sw/background-sync.js.map +1 -1
  84. package/dist/sw/cache-inspect.cjs.map +1 -1
  85. package/dist/sw/cache-inspect.js.map +1 -1
  86. package/dist/sw/cache.cjs +1 -1
  87. package/dist/sw/cache.cjs.map +1 -1
  88. package/dist/sw/cache.js +91 -64
  89. package/dist/sw/cache.js.map +1 -1
  90. package/dist/sw/credential-headers.cjs +2 -0
  91. package/dist/sw/credential-headers.cjs.map +1 -0
  92. package/dist/sw/credential-headers.js +17 -0
  93. package/dist/sw/credential-headers.js.map +1 -0
  94. package/dist/sw/session-data.cjs +2 -0
  95. package/dist/sw/session-data.cjs.map +1 -0
  96. package/dist/sw/session-data.js +17 -0
  97. package/dist/sw/session-data.js.map +1 -0
  98. package/dist/sw.cjs +1 -1
  99. package/dist/sw.d.ts +271 -23
  100. package/dist/sw.js +7 -5
  101. package/dist/telemetry/app-error-owner.cjs +2 -0
  102. package/dist/telemetry/app-error-owner.cjs.map +1 -0
  103. package/dist/telemetry/app-error-owner.js +18 -0
  104. package/dist/telemetry/app-error-owner.js.map +1 -0
  105. package/dist/telemetry/app-error-queue.cjs +1 -1
  106. package/dist/telemetry/app-error-queue.cjs.map +1 -1
  107. package/dist/telemetry/app-error-queue.js +19 -14
  108. package/dist/telemetry/app-error-queue.js.map +1 -1
  109. package/dist/telemetry/app-error-reporter.cjs +1 -1
  110. package/dist/telemetry/app-error-reporter.cjs.map +1 -1
  111. package/dist/telemetry/app-error-reporter.js +75 -34
  112. package/dist/telemetry/app-error-reporter.js.map +1 -1
  113. package/dist/telemetry/app-error-transport.cjs +1 -1
  114. package/dist/telemetry/app-error-transport.cjs.map +1 -1
  115. package/dist/telemetry/app-error-transport.js +16 -3
  116. package/dist/telemetry/app-error-transport.js.map +1 -1
  117. package/dist/tempest-react-sdk.cjs +1 -1
  118. package/dist/tempest-react-sdk.d.ts +979 -62
  119. package/dist/tempest-react-sdk.js +160 -156
  120. package/dist/utils/csv.cjs +3 -3
  121. package/dist/utils/csv.cjs.map +1 -1
  122. package/dist/utils/csv.js +16 -13
  123. package/dist/utils/csv.js.map +1 -1
  124. package/dist/utils/safe-url.cjs +2 -0
  125. package/dist/utils/safe-url.cjs.map +1 -0
  126. package/dist/utils/safe-url.js +39 -0
  127. package/dist/utils/safe-url.js.map +1 -0
  128. package/dist/vite/tempest-csp.cjs +2 -2
  129. package/dist/vite/tempest-csp.cjs.map +1 -1
  130. package/dist/vite/tempest-csp.js +118 -63
  131. package/dist/vite/tempest-csp.js.map +1 -1
  132. package/dist/vite.d.ts +21 -5
  133. package/dist/webrtc/peer-link.cjs +1 -1
  134. package/dist/webrtc/peer-link.cjs.map +1 -1
  135. package/dist/webrtc/peer-link.js +2 -10
  136. package/dist/webrtc/peer-link.js.map +1 -1
  137. package/dist/webrtc/peer-mesh.cjs +1 -1
  138. package/dist/webrtc/peer-mesh.cjs.map +1 -1
  139. package/dist/webrtc/peer-mesh.js +110 -72
  140. package/dist/webrtc/peer-mesh.js.map +1 -1
  141. package/package.json +11 -1
  142. package/template-pwa/src/pwa.tsx +22 -0
  143. package/template-pwa/src/sw.ts +27 -14
  144. package/dist/components/Markdown/markdown-url.cjs +0 -2
  145. package/dist/components/Markdown/markdown-url.cjs.map +0 -1
  146. package/dist/components/Markdown/markdown-url.js +0 -28
  147. package/dist/components/Markdown/markdown-url.js.map +0 -1
package/README.md CHANGED
@@ -284,9 +284,10 @@ Every module is re-exported from the package root — `import { Button, useDebou
284
284
  | `geo` _(opt. peer `leaflet`)_ | `haversineKm`, `pathLengthKm`, `bearingDeg`, `estimateTravel`, `createOSRMBackend`, `boundingBox`, `projectMercator`, `fitProjection`, `createPositionTracker`, `usePositionTracker`, `TrajectoryMap` — tile-free SVG map + trajectory tracking (no external/paid API; Leaflet is an opt-in, lazy-loaded tile layer for self-hosted tiles), types: `Coordinate`, `TrackPoint`, `TravelEstimate`, `TravelMode`, `GeoBounds`, `RoutingBackend` |
285
285
  | `br` _(subpath `tempest-react-sdk/br`)_ | `BrazilMap`, `BrazilStateMap`, `BrazilStateCitySelect`, `listStates`, `getState`, `citiesByUf`, `statesByRegion`, `ufChoices`, `cityChoices`, `isValidUf`, `normalizeUf`, `isValidCity`, `loadBrUfGeoJson`, `loadStateMunicipalities`, types `UF`, `BrRegion`, `BrazilState` — clickable 27-UF SVG map + per-state municipality submaps (bundled simplified IBGE GeoJSON, lazy per UF) + states/cities dataset, no external API; mirrors the FastAPI SDK `utils/locations` |
286
286
  | `push` | `WebPushClient`, `WebPushUnsupportedError`, `WebPushPermissionDeniedError`, `usePushSubscription`, `urlBase64ToUint8Array`, `isPushSupported` |
287
- | `sw` _(also subpath `tempest-react-sdk/sw`)_ | `registerServiceWorker` (opt-in `autoUpdate` poll + reload, no `vite-plugin-pwa`), `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener`, `installPrecache` (app-shell offline + Navigation Preload), `installRuntimeCache` (per-route caching, incl. `rangeRequests`), `createPartialResponse` (206 range slicing), `installBackgroundSync` (offline mutation queue + `periodicsync`), `registerPeriodicSync` (main-thread periodic sync), `inspectCaches`/`clearCaches` (cache observability) — the React-free `tempest-react-sdk/sw` subpath is ideal for bundling into your own `sw.ts` |
287
+ | `sw` _(also subpath `tempest-react-sdk/sw`)_ | `registerServiceWorker` (opt-in `autoUpdate` poll + reload, no `vite-plugin-pwa`), `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener`, `installPrecache` (app-shell offline + Navigation Preload), `installRuntimeCache` (per-route caching, incl. `rangeRequests`), `createPartialResponse` (206 range slicing), `installBackgroundSync` (offline mutation queue + `periodicsync`, credentials never stored), `provideBackgroundSyncCredentials`, `registerPeriodicSync` (main-thread periodic sync), `inspectCaches`/`clearCaches` (cache observability), `clearSessionData`/`clearBackgroundSyncQueue` (logout cleanup) — the React-free `tempest-react-sdk/sw` subpath is ideal for bundling into your own `sw.ts` |
288
288
  | `charts` _(subpath `tempest-react-sdk/charts`, opt. peer `recharts`)_ | `AreaChart`, `BarChart`, `LineChart`, `PieChart`, `RadarChart`, `DEFAULT_CHART_COLORS` — themed recharts wrappers (recharts externalized; install it only if you use charts) |
289
289
  | `icons` _(subpath `tempest-react-sdk/icons`)_ | `Icon`, `IconProvider`, `createIconRegistry`, `useIcon`, `preloadIcons`, `iconStatus`, `peekIcon`, `loadIcon`, `resolveIconAlias`, `isIconName`, `iconNames`, `iconAliases`, type `IconName` — render any of lucide's **2065 icons by kebab-case slug**. A literal slug becomes a static import via the `tempestIcons()` Vite plugin (zero extra requests); a runtime slug loads **one of 46 balanced shards** (40 slugs each) instead of the ~2000 chunk boundaries `lucide-react`'s own `DynamicIcon` forces — sharding by range rather than by initial letter took the worst fetch from 19.10 KB to 4.78 KB brotli. Unknown slug renders `fallback` and never throws; lucide's 258 deprecated aliases keep resolving |
290
+ | `neural` _(subpath `tempest-react-sdk/neural`, opt. peer `@sapphi-red/web-noise-suppressor`)_ | `isNeuralSuppressionSupported`, `prepareNeuralSuppressor`, `createNeuralSuppressor`, types `NeuralSuppressorAssets`, `NeuralSuppressor` — RNNoise noise suppression: a recurrent model in an `AudioWorklet` separates speech from non-speech on a 48 kHz context and hands back a denoised `MediaStreamTrack` to publish in place of the capture (peer externalized; install it only if you use neural) |
290
291
  | `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio`, `createAudioBus` / `useAudioBus` (gain above 100%, post-mix limiter, `setSinkId` routing), `DEFAULT_MAX_GAIN` |
291
292
  | `capture` | `BarcodeScanner`, `useBarcodeScanner`, `useTorch`, `isBarcodeDetectionSupported`, `getSupportedBarcodeFormats`, `createBarcodeDetector`, `normalizeBarcode`, `useVideoRecorder`, `createVideoRecorder`, `pickVideoMimeType`, `isVideoRecordingSupported`, `useScreenCapture`, `isScreenCaptureSupported`, `useSpeechRecognition`, `isSpeechRecognitionSupported`, `createMediaRecorder` — barcode/QR reading over the native `BarcodeDetector` (Chromium-only, so inject a polyfill through `detector` or render the `unsupported` fallback), video + screen recording on the same engine as the audio recorder, and Web Speech dictation (**Chromium streams the audio to a Google server**) |
292
293
  | `offline` _(peer: `dexie`)_ | `createOfflineStore`, `createOfflineSync` (outbox + delta-pull + watermark engine, now with `subscribe`/`getState`/`dispose` + cross-tab `crossTab`), `useOfflineSync`/`useSyncStatus` (reactive hooks), `lastWriteWins`/`higherVersionWins` (conflict resolvers), types: `OfflineStore`, `OfflineStoreConfig`, `ListOptions`, `OfflineSync`, `OfflineSyncConfig`, `OutboxEntry`, `PullPage`, `SyncPhase`, `SyncState`, `SyncRunSummary`, `SyncTrigger`, `SyncStatus`, `SyncTone`, `WatermarkStore` |
@@ -320,11 +321,12 @@ Every module is re-exported from the package root — `import { Button, useDebou
320
321
  | `br` _(subpath `tempest-react-sdk/br`)_ | `BrazilMap`, `BrazilStateMap`, `BrazilStateCitySelect`, `listStates`, `getState`, `citiesByUf`, `statesByRegion`, `ufChoices`, `cityChoices`, `isValidUf`, `normalizeUf`, `isValidCity`, `loadBrUfGeoJson`, `loadStateMunicipalities`, types `UF`, `BrRegion`, `BrazilState` — clickable 27-UF SVG map + per-state municipality submaps (bundled simplified IBGE GeoJSON, lazy per UF) + states/cities dataset, no external API; mirrors the FastAPI SDK `utils/locations` |
321
322
  | `icons` _(subpath `tempest-react-sdk/icons`)_ | `Icon`, `IconProvider`, `createIconRegistry`, `useIcon`, `preloadIcons`, `iconStatus`, `peekIcon`, `loadIcon`, `resolveIconAlias`, `isIconName`, `iconNames`, `iconAliases`, type `IconName` — render any of lucide's **2065 icons by kebab-case slug**. A literal slug becomes a static import via the `tempestIcons()` Vite plugin (zero extra requests); a runtime slug loads **one of 46 balanced shards** (40 slugs each) instead of the ~2000 chunk boundaries `lucide-react`'s own `DynamicIcon` forces — sharding by range rather than by initial letter took the worst fetch from 19.10 KB to 4.78 KB brotli. Unknown slug renders `fallback` and never throws; lucide's 258 deprecated aliases keep resolving |
322
323
  | `push` | `WebPushClient`, `WebPushUnsupportedError`, `WebPushPermissionDeniedError`, `usePushSubscription`, `urlBase64ToUint8Array`, `isPushSupported` |
323
- | `sw` _(also subpath `tempest-react-sdk/sw`)_ | `registerServiceWorker` (opt-in `autoUpdate` poll + reload, no `vite-plugin-pwa`), `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener`, `installPrecache` (app-shell offline + Navigation Preload), `installRuntimeCache` (per-route caching, incl. `rangeRequests`), `createPartialResponse` (206 range slicing), `installBackgroundSync` (offline mutation queue + `periodicsync`), `registerPeriodicSync` (main-thread periodic sync), `inspectCaches`/`clearCaches` (cache observability) — the React-free `tempest-react-sdk/sw` subpath is ideal for bundling into your own `sw.ts` |
324
+ | `sw` _(also subpath `tempest-react-sdk/sw`)_ | `registerServiceWorker` (opt-in `autoUpdate` poll + reload, no `vite-plugin-pwa`), `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener`, `installPrecache` (app-shell offline + Navigation Preload), `installRuntimeCache` (per-route caching, incl. `rangeRequests`), `createPartialResponse` (206 range slicing), `installBackgroundSync` (offline mutation queue + `periodicsync`, credentials never stored), `provideBackgroundSyncCredentials`, `registerPeriodicSync` (main-thread periodic sync), `inspectCaches`/`clearCaches` (cache observability), `clearSessionData`/`clearBackgroundSyncQueue` (logout cleanup) — the React-free `tempest-react-sdk/sw` subpath is ideal for bundling into your own `sw.ts` |
324
325
  | `editor` _(subpath `tempest-react-sdk/editor`, opt. peers `@tiptap/react`+`@tiptap/starter-kit`)_ | `RichTextEditor` — themed tiptap v3 rich-text editor with toolbar (tiptap externalized; install it only if you use the editor) |
325
326
  | `vision` _(subpath `tempest-react-sdk/vision`, opt. peer `onnxruntime-web`)_ | `Classifier`, `Detector`, `Segmenter`, `DetectClassify` (fused detect→classify pipeline, optionally with a segmentation stage) + `warmup()`, FP16 models, `cache` (Cache Storage), video/`VideoFrame` inputs, `raiseOnEmpty`/`NoDetectionsError`, `LetterboxPipeline`, `readFusionSpec` and results/labels/preprocess — on-device ONNX vision inference, vendored from `@mauriciobenjamin700/ort-vision-sdk-web` (onnxruntime-web externalized; install it only if you use vision). Plus browser capture primitives: `useCameraStream` (rear-camera `getUserMedia` + classified errors + `retry()`), `computeImageLuminance`/`isLuminanceAcceptable`/`LowLuminanceError`/`LUMINANCE_SAMPLE_MAX_EDGE` (BT.709 brightness), `useLiveLuminance` (live rAF sampling) |
326
327
  | `tabular` _(subpath `tempest-react-sdk/tabular`, opt. peer `onnxruntime-web`)_ | `TabularPredictor`, `useTabularPredictor` — scikit-learn models exported by `tempest-fastapi-sdk`'s `export_sklearn_to_onnx`, running in the browser with no inference server. Offline by design: `fetchModelBytes`/`isModelCached`/`cacheModelBytes`/`clearModelCache` (Cache Storage, cache-first) plus `configureOrtAssets`/`ortAssetUrls`/`ORT_WASM_ASSETS` for the runtime's WebAssembly binaries, which ONNX Runtime Web fetches at session creation. Import the default `onnxruntime-web` entry — the WebGPU build has no `ai.onnx.ml` kernels |
327
328
  | `imaging` _(subpath `tempest-react-sdk/imaging`, no deps)_ | `resizeImage`, `cropImage`, `rotateImage`, `flipImage`, `compressToTarget` (binary-searches quality into a byte budget), `createThumbnails` (several sizes from one decode), `captureFrame` (one frame out of a `<video>`, at the instant you ask for), `decodeImage`/`readImageInfo`, `encodeImage`, `supportsImageType`/`bestSupportedType`, plus `useImagePreview`/`useImageProcessing`. Canvas only — decodes with EXIF orientation applied (phone photos arrive upright), reports the format actually produced (asking for AVIF where there is no encoder silently returns PNG), and re-encoding drops EXIF, which is the privacy step as much as the bandwidth one. `captureFrame` is the one with teeth: `seeked` does not mean the new frame is readable, so the naive seek-then-draw returns the neighbouring frame with no error — and a `MediaRecorder` blob reports no duration until it is probed for one, which it does for you |
329
+ | `neural` _(subpath `tempest-react-sdk/neural`, opt. peer `@sapphi-red/web-noise-suppressor`)_ | `isNeuralSuppressionSupported`, `prepareNeuralSuppressor`, `createNeuralSuppressor`, types `NeuralSuppressorAssets`, `NeuralSuppressor` — RNNoise noise suppression: a recurrent model in an `AudioWorklet` separates speech from non-speech on a 48 kHz context and hands back a denoised `MediaStreamTrack` to publish in place of the capture (peer externalized; install it only if you use neural) |
328
330
  | `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio`, `createAudioBus` / `useAudioBus` (gain above 100%, post-mix limiter, `setSinkId` routing), `DEFAULT_MAX_GAIN` |
329
331
  | `capture` | `BarcodeScanner`, `useBarcodeScanner`, `useTorch`, `isBarcodeDetectionSupported`, `getSupportedBarcodeFormats`, `createBarcodeDetector`, `normalizeBarcode`, `useVideoRecorder`, `createVideoRecorder`, `pickVideoMimeType`, `isVideoRecordingSupported`, `useScreenCapture`, `isScreenCaptureSupported`, `useSpeechRecognition`, `isSpeechRecognitionSupported`, `createMediaRecorder` — barcode/QR reading over the native `BarcodeDetector` (Chromium-only, so inject a polyfill through `detector` or render the `unsupported` fallback), video + screen recording on the same engine as the audio recorder, and Web Speech dictation (**Chromium streams the audio to a Google server**) |
330
332
  | `offline` _(peer: `dexie`)_ | `createOfflineStore`, `createOfflineSync` (outbox + delta-pull + watermark engine, now with `subscribe`/`getState`/`dispose` + cross-tab `crossTab`), `useOfflineSync`/`useSyncStatus` (reactive hooks), `lastWriteWins`/`higherVersionWins` (conflict resolvers), types: `OfflineStore`, `OfflineStoreConfig`, `ListOptions`, `OfflineSync`, `OfflineSyncConfig`, `OutboxEntry`, `PullPage`, `SyncPhase`, `SyncState`, `SyncRunSummary`, `SyncTrigger`, `SyncStatus`, `SyncTone`, `WatermarkStore` |
@@ -0,0 +1,112 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ rmSync,
10
+ symlinkSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
18
+
19
+ const CLI = join(dirname(fileURLToPath(import.meta.url)), "create-tempest-app.mjs");
20
+
21
+ let root;
22
+ let proj;
23
+ let outside;
24
+
25
+ beforeEach(() => {
26
+ root = mkdtempSync(join(tmpdir(), "cta-"));
27
+ proj = join(root, "proj");
28
+ outside = join(root, "outside");
29
+ mkdirSync(proj);
30
+ mkdirSync(outside);
31
+ writeFileSync(join(proj, "package.json"), JSON.stringify({ name: "mine" }));
32
+ });
33
+
34
+ afterEach(() => {
35
+ rmSync(root, { recursive: true, force: true });
36
+ });
37
+
38
+ /**
39
+ * Run `create-tempest-app .` in `proj` (merge mode) and return its output.
40
+ *
41
+ * @returns {{ status: number | null, stdout: string }}
42
+ */
43
+ function scaffoldHere() {
44
+ const result = spawnSync(process.execPath, [CLI, "."], {
45
+ cwd: proj,
46
+ encoding: "utf8",
47
+ env: { ...process.env, NO_COLOR: "1" },
48
+ });
49
+ return { status: result.status, stdout: result.stdout + result.stderr };
50
+ }
51
+
52
+ describe("create-tempest-app merge mode never writes through a symlink (#436)", () => {
53
+ it("a symlinked src/ pointing outside the project is skipped, not written into", () => {
54
+ writeFileSync(join(outside, "main.tsx"), "OUTSIDE-USER-FILE");
55
+ symlinkSync("../outside", join(proj, "src"));
56
+
57
+ const { status, stdout } = scaffoldHere();
58
+
59
+ expect(status).toBe(0);
60
+ expect(readFileSync(join(outside, "main.tsx"), "utf8")).toBe("OUTSIDE-USER-FILE");
61
+ expect(readdirSync(outside)).toEqual(["main.tsx"]);
62
+ expect(stdout).toMatch(/· src\b/);
63
+ expect(lstatSync(join(proj, "src")).isSymbolicLink()).toBe(true);
64
+ });
65
+
66
+ it("a symlinked nested directory is skipped while its siblings are still written", () => {
67
+ mkdirSync(join(proj, "src"));
68
+ symlinkSync("../../outside", join(proj, "src", "pages"));
69
+
70
+ const { status, stdout } = scaffoldHere();
71
+
72
+ expect(status).toBe(0);
73
+ expect(readdirSync(outside)).toEqual([]);
74
+ expect(stdout).toMatch(/· src[/\\]pages\b/);
75
+ expect(existsSync(join(proj, "src", "main.tsx"))).toBe(true);
76
+ });
77
+
78
+ it("a symlinked package.json is not merged through", () => {
79
+ rmSync(join(proj, "package.json"));
80
+ writeFileSync(join(outside, "package.json"), '{"name":"outside"}');
81
+ symlinkSync("../outside/package.json", join(proj, "package.json"));
82
+
83
+ const { status, stdout } = scaffoldHere();
84
+
85
+ expect(status).toBe(0);
86
+ expect(readFileSync(join(outside, "package.json"), "utf8")).toBe('{"name":"outside"}');
87
+ expect(stdout).toMatch(/· package\.json\b/);
88
+ });
89
+
90
+ it("leaves the user's own _gitignore alone instead of renaming it over .gitignore", () => {
91
+ mkdirSync(join(proj, "notes"));
92
+ writeFileSync(join(proj, "notes", "_gitignore"), "user-underscore");
93
+ writeFileSync(join(proj, "notes", ".gitignore"), "user-dot");
94
+
95
+ const { status } = scaffoldHere();
96
+
97
+ expect(status).toBe(0);
98
+ expect(readFileSync(join(proj, "notes", "_gitignore"), "utf8")).toBe("user-underscore");
99
+ expect(readFileSync(join(proj, "notes", ".gitignore"), "utf8")).toBe("user-dot");
100
+ expect(existsSync(join(proj, ".gitignore"))).toBe(true);
101
+ expect(existsSync(join(proj, "_gitignore"))).toBe(false);
102
+ });
103
+
104
+ it("still scaffolds the full template into a plain directory", () => {
105
+ const { status } = scaffoldHere();
106
+
107
+ expect(status).toBe(0);
108
+ expect(existsSync(join(proj, "src", "main.tsx"))).toBe(true);
109
+ expect(existsSync(join(proj, ".env.example"))).toBe(true);
110
+ expect(JSON.parse(readFileSync(join(proj, "package.json"), "utf8")).name).toBe("mine");
111
+ });
112
+ });
@@ -8,11 +8,13 @@
8
8
  //
9
9
  // In merge mode, files the user already has are left untouched and an existing
10
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.
13
- import { cp, mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
11
+ // preserved). A symlink already in the destination is never written through:
12
+ // it is skipped and reported, so a link to a folder outside the project cannot
13
+ // redirect the template there. The `--pwa` flag overlays the PWA template
14
+ // (manifest, service worker, push-subscribe wiring) on top of the base.
15
+ import { cp, lstat, mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
14
16
  import { existsSync } from "node:fs";
15
- import { dirname, join, resolve } from "node:path";
17
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
16
18
  import { fileURLToPath } from "node:url";
17
19
 
18
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -86,23 +88,73 @@ async function readSdkVersion() {
86
88
  }
87
89
  }
88
90
 
91
+ /**
92
+ * Whether `path` exists in the destination as a symbolic link.
93
+ *
94
+ * Checked with `lstat`, which does not follow the link: `mkdir(recursive)` and
95
+ * `cp` both would, and that is how a `src -> ../elsewhere` link turned a merge
96
+ * into writes outside the project (#436).
97
+ *
98
+ * @param {string} path - Absolute destination path.
99
+ * @returns {Promise<boolean>}
100
+ */
101
+ async function isSymlink(path) {
102
+ try {
103
+ return (await lstat(path)).isSymbolicLink();
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Whether the real location of `path`'s parent directory sits inside `root`.
111
+ *
112
+ * Second line behind {@link isSymlink}: it catches a link that appears in an
113
+ * ancestor the copy did not create itself. A folder whose name merely starts
114
+ * with two dots (`..cache`) is inside; only `..` itself or a `../` prefix is not.
115
+ *
116
+ * Both checks run immediately before each `mkdir`/`cp`, but nothing makes the
117
+ * check and the write one atomic step: a process that swaps a folder for a
118
+ * symlink in that window can still redirect the write. Node offers no
119
+ * `openat`/`O_NOFOLLOW` walk to close it, and the attacker would need to be
120
+ * racing the scaffold inside the user's own project folder, so this residual
121
+ * TOCTOU window is an accepted risk.
122
+ *
123
+ * @param {string} realRoot - `realpath` of the destination root.
124
+ * @param {string} path - Absolute destination path about to be written.
125
+ * @returns {Promise<boolean>}
126
+ */
127
+ async function parentInside(realRoot, path) {
128
+ const parent = await realpath(dirname(path));
129
+ const rel = relative(realRoot, parent);
130
+ return !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel));
131
+ }
132
+
89
133
  /**
90
134
  * Copy a template tree into `destDir`. `package.json` is handled separately;
91
135
  * any path already present in `protect` (the user's own files) is skipped and
92
- * recorded. Everything else is overwritten, so a later overlay wins over the
136
+ * recorded, and so is any symlink in the destination, which is never written
137
+ * through. Everything else is overwritten, so a later overlay wins over the
93
138
  * base it sits on.
139
+ *
140
+ * Template entries are written straight under their final name
141
+ * (`_gitignore` → `.gitignore`), so no rename pass over the destination is
142
+ * needed; the one that existed renamed the user's own `_gitignore` files too.
94
143
  */
95
- async function copyTree(srcDir, destDir, protect, skipped, relBase = "") {
144
+ async function copyTree(srcDir, destDir, protect, skipped, realRoot, relBase = "") {
96
145
  const entries = await readdir(srcDir, { withFileTypes: true });
97
146
  for (const entry of entries) {
98
147
  const rel = join(relBase, finalName(entry.name));
99
148
  const from = join(srcDir, entry.name);
100
149
  const to = join(destDir, finalName(entry.name));
150
+ if (entry.name === "package.json" && !entry.isDirectory()) continue;
151
+ if ((await isSymlink(to)) || !(await parentInside(realRoot, to))) {
152
+ skipped.add(rel);
153
+ continue;
154
+ }
101
155
  if (entry.isDirectory()) {
102
156
  await mkdir(to, { recursive: true });
103
- await copyTree(from, to, protect, skipped, rel);
104
- } else if (entry.name === "package.json") {
105
- // handled separately by writeFreshPackageJson / mergePackageJson
157
+ await copyTree(from, to, protect, skipped, realRoot, rel);
106
158
  } else if (protect.has(rel)) {
107
159
  skipped.add(rel);
108
160
  } else {
@@ -111,19 +163,6 @@ async function copyTree(srcDir, destDir, protect, skipped, relBase = "") {
111
163
  }
112
164
  }
113
165
 
114
- /** Recursively rename underscore-prefixed dotfiles after a directory copy. */
115
- async function fixDotfiles(dir) {
116
- const entries = await readdir(dir, { withFileTypes: true });
117
- for (const entry of entries) {
118
- const full = join(dir, entry.name);
119
- if (entry.isDirectory()) {
120
- await fixDotfiles(full);
121
- } else if (RENAME_ON_COPY[entry.name]) {
122
- await rename(full, join(dir, RENAME_ON_COPY[entry.name]));
123
- }
124
- }
125
- }
126
-
127
166
  /** Stamp the SDK version into a fresh package.json. */
128
167
  async function writeFreshPackageJson(destDir, name, sdkVersion) {
129
168
  const pkg = JSON.parse(await readFile(join(TEMPLATE_DIR, "package.json"), "utf8"));
@@ -216,20 +255,22 @@ async function main() {
216
255
  const protect = await listFiles(destDir);
217
256
  const hadPkg = protect.has("package.json");
218
257
  const skipped = new Set();
258
+ const realRoot = await realpath(destDir);
219
259
 
220
- await copyTree(TEMPLATE_DIR, destDir, protect, skipped);
221
- if (pwa) await copyTree(TEMPLATE_PWA_DIR, destDir, protect, skipped);
222
- await fixDotfiles(destDir);
260
+ await copyTree(TEMPLATE_DIR, destDir, protect, skipped, realRoot);
261
+ if (pwa) await copyTree(TEMPLATE_PWA_DIR, destDir, protect, skipped, realRoot);
223
262
 
224
- // package.json: merge into the user's if present, otherwise write fresh.
225
- if (hadPkg) {
263
+ const pkgIsLink = await isSymlink(join(destDir, "package.json"));
264
+ if (pkgIsLink) {
265
+ skipped.add("package.json");
266
+ } else if (hadPkg) {
226
267
  await mergePackageJson(destDir, sdkVersion);
227
268
  console.log(`${c.dim}Merged scripts + deps into existing package.json.${c.reset}`);
228
269
  } else {
229
270
  const name = mergeMode ? (destDir.split("/").pop() || "tempest-app").toLowerCase() : arg;
230
271
  await writeFreshPackageJson(destDir, name, sdkVersion);
231
272
  }
232
- if (pwa) await applyPwaPackageJson(destDir);
273
+ if (pwa && !pkgIsLink) await applyPwaPackageJson(destDir);
233
274
 
234
275
  if (skipped.size) {
235
276
  console.log(`\n${c.yellow}Skipped ${skipped.size} existing file(s):${c.reset}`);
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Scaffold for `tempest gen component <Name>`: one folder per component with
3
+ * `index.tsx`, `styles.module.css`, `constants.ts`, `utils.ts` and `hooks.ts`.
4
+ *
5
+ * Pure: takes a name, returns `{ name, files }`. The CLI does the I/O. The output
6
+ * is written to pass the app template's own gates as generated — `tsc` under its
7
+ * `strict` + `noUnusedLocals` tsconfig and ESLint under its `eslint.config.js`
8
+ * (sorted imports, type-only imports, no unused code) — and follows the
9
+ * template's Prettier config (double quotes, two-space indent, trailing commas).
10
+ * Every file is wired into `index.tsx`, so none starts out as dead code.
11
+ *
12
+ * `hooks.ts` reaches React through `import * as React` rather than a named
13
+ * import, so no generated name can ever redeclare what the file imports. The
14
+ * hook is `use<Name>`, or `use<Name>Component` when that would be a React
15
+ * built-in (see {@link REACT_BUILTIN_HOOKS}).
16
+ */
17
+
18
+ const WORD = /[A-Za-z0-9]+/g;
19
+
20
+ /**
21
+ * Folder names Windows reserves for devices, matched case-insensitively. A
22
+ * folder called `Con` or `Lpt1` cannot be created or cloned there, so a project
23
+ * with one breaks for every Windows teammate.
24
+ */
25
+ /**
26
+ * React's built-in hook names. Ported from the `react` and `react-dom` hook
27
+ * exports (React 19). A component whose `use<Name>` lands on one of them gets
28
+ * `use<Name>Component` instead: the `react-hooks` lint rules match hooks by name,
29
+ * so a custom `useMemo(title)` would be linted as React's and warned about.
30
+ */
31
+ export const REACT_BUILTIN_HOOKS = new Set([
32
+ "use",
33
+ "useActionState",
34
+ "useCallback",
35
+ "useContext",
36
+ "useDebugValue",
37
+ "useDeferredValue",
38
+ "useEffect",
39
+ "useEffectEvent",
40
+ "useFormStatus",
41
+ "useId",
42
+ "useImperativeHandle",
43
+ "useInsertionEffect",
44
+ "useLayoutEffect",
45
+ "useMemo",
46
+ "useOptimistic",
47
+ "useReducer",
48
+ "useRef",
49
+ "useState",
50
+ "useSyncExternalStore",
51
+ "useTransition",
52
+ ]);
53
+
54
+ const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
55
+
56
+ /**
57
+ * Normalize a component name to PascalCase.
58
+ *
59
+ * Words are split on any non-alphanumeric character and on lower→upper case
60
+ * boundaries, so `"user card"`, `"user-card"`, `"user_card"`, `"userCard"` and
61
+ * `"UserCard"` all become `UserCard`. A word already in capitals (`"API"`) keeps
62
+ * them after its first letter: `"api client"` → `ApiClient`, `"APIClient"` →
63
+ * `APIClient`.
64
+ *
65
+ * @param {unknown} raw - The name as typed on the command line.
66
+ * @returns {string} The PascalCase component name.
67
+ * @throws {Error} When the name is empty, has characters other than ASCII
68
+ * letters, digits, spaces, `-` and `_`, does not start with a letter, or is a
69
+ * device name Windows reserves (`Con`, `Nul`, `Com1`…).
70
+ */
71
+ export function normalizeComponentName(raw) {
72
+ const input = typeof raw === "string" ? raw.trim() : "";
73
+ if (!input) {
74
+ throw new Error("Missing component name. Usage: tempest gen component <Name>");
75
+ }
76
+ if (!/^[A-Za-z0-9 _-]+$/.test(input)) {
77
+ throw new Error(
78
+ `Invalid component name ${JSON.stringify(input)}: use ASCII letters, digits, spaces, "-" or "_".`,
79
+ );
80
+ }
81
+ if (!/^[A-Za-z]/.test(input)) {
82
+ throw new Error(
83
+ `Invalid component name ${JSON.stringify(input)}: it must start with a letter.`,
84
+ );
85
+ }
86
+ const words = input.replace(/([a-z0-9])([A-Z])/g, "$1 $2").match(WORD) ?? [];
87
+ const name = words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
88
+ if (WINDOWS_RESERVED.test(name)) {
89
+ throw new Error(
90
+ `Invalid component name ${JSON.stringify(input)}: "${name}" is a reserved device name on Windows and cannot be a folder there.`,
91
+ );
92
+ }
93
+ return name;
94
+ }
95
+
96
+ /**
97
+ * Turn a PascalCase name into `UPPER_SNAKE_CASE` (`UserCard` → `USER_CARD`).
98
+ *
99
+ * @param {string} name - A PascalCase name.
100
+ * @returns {string}
101
+ */
102
+ function upperSnake(name) {
103
+ return name
104
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
105
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
106
+ .toUpperCase();
107
+ }
108
+
109
+ /**
110
+ * Build the five files of a component folder.
111
+ *
112
+ * @param {string} rawName - The name as typed; normalized with
113
+ * {@link normalizeComponentName}.
114
+ * @returns {{ name: string, files: Record<string, string> }} The PascalCase name
115
+ * (also the folder name) and a `{ fileName: contents }` map.
116
+ * @throws {Error} When the name is invalid.
117
+ */
118
+ export function componentFiles(rawName) {
119
+ const name = normalizeComponentName(rawName);
120
+ const constant = `${upperSnake(name)}_DEFAULT_TITLE`;
121
+ const hook = REACT_BUILTIN_HOOKS.has(`use${name}`) ? `use${name}Component` : `use${name}`;
122
+ const props = `${name}Props`;
123
+
124
+ const index = [
125
+ `import { ${hook} } from "./hooks";`,
126
+ `import styles from "./styles.module.css";`,
127
+ `import { joinClassNames } from "./utils";`,
128
+ ``,
129
+ `export interface ${props} {`,
130
+ ` title?: string;`,
131
+ ` className?: string;`,
132
+ `}`,
133
+ ``,
134
+ `export function ${name}({ title, className }: ${props}) {`,
135
+ ` const { heading } = ${hook}(title);`,
136
+ ``,
137
+ ` return (`,
138
+ ` <section className={joinClassNames(styles.root, className)}>`,
139
+ ` <h2 className={styles.title}>{heading}</h2>`,
140
+ ` </section>`,
141
+ ` );`,
142
+ `}`,
143
+ ``,
144
+ ].join("\n");
145
+
146
+ const css = [
147
+ `.root {`,
148
+ ` display: flex;`,
149
+ ` flex-direction: column;`,
150
+ ` gap: var(--tempest-space-2);`,
151
+ `}`,
152
+ ``,
153
+ `.title {`,
154
+ ` margin: 0;`,
155
+ `}`,
156
+ ``,
157
+ ].join("\n");
158
+
159
+ const constants = [`export const ${constant} = ${JSON.stringify(name)};`, ``].join("\n");
160
+
161
+ const utils = [
162
+ `export function joinClassNames(...names: Array<string | false | null | undefined>): string {`,
163
+ ` return names.filter(Boolean).join(" ");`,
164
+ `}`,
165
+ ``,
166
+ ].join("\n");
167
+
168
+ const hooks = [
169
+ `import * as React from "react";`,
170
+ ``,
171
+ `import { ${constant} } from "./constants";`,
172
+ ``,
173
+ `export function ${hook}(title?: string): { heading: string } {`,
174
+ ` const heading = React.useMemo(() => title?.trim() || ${constant}, [title]);`,
175
+ ``,
176
+ ` return { heading };`,
177
+ `}`,
178
+ ``,
179
+ ].join("\n");
180
+
181
+ return {
182
+ name,
183
+ files: {
184
+ "index.tsx": index,
185
+ "styles.module.css": css,
186
+ "constants.ts": constants,
187
+ "utils.ts": utils,
188
+ "hooks.ts": hooks,
189
+ },
190
+ };
191
+ }