@polishd/next 0.1.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 (127) hide show
  1. package/AGENTS.md +262 -0
  2. package/DATABASE.md +147 -0
  3. package/LICENSE +21 -0
  4. package/README.md +310 -0
  5. package/bin/init.mjs +224 -0
  6. package/dist/ai/actions.d.ts +23 -0
  7. package/dist/ai/actions.d.ts.map +1 -0
  8. package/dist/ai/actions.js +58 -0
  9. package/dist/ai/actions.js.map +1 -0
  10. package/dist/ai/digest.d.ts +9 -0
  11. package/dist/ai/digest.d.ts.map +1 -0
  12. package/dist/ai/digest.js +108 -0
  13. package/dist/ai/digest.js.map +1 -0
  14. package/dist/ai/github.d.ts +51 -0
  15. package/dist/ai/github.d.ts.map +1 -0
  16. package/dist/ai/github.js +175 -0
  17. package/dist/ai/github.js.map +1 -0
  18. package/dist/ai/guard.d.ts +43 -0
  19. package/dist/ai/guard.d.ts.map +1 -0
  20. package/dist/ai/guard.js +57 -0
  21. package/dist/ai/guard.js.map +1 -0
  22. package/dist/ai/issues.d.ts +21 -0
  23. package/dist/ai/issues.d.ts.map +1 -0
  24. package/dist/ai/issues.js +291 -0
  25. package/dist/ai/issues.js.map +1 -0
  26. package/dist/ai/profile.d.ts +26 -0
  27. package/dist/ai/profile.d.ts.map +1 -0
  28. package/dist/ai/profile.js +160 -0
  29. package/dist/ai/profile.js.map +1 -0
  30. package/dist/ai/providers.d.ts +40 -0
  31. package/dist/ai/providers.d.ts.map +1 -0
  32. package/dist/ai/providers.js +142 -0
  33. package/dist/ai/providers.js.map +1 -0
  34. package/dist/ai/scan.d.ts +38 -0
  35. package/dist/ai/scan.d.ts.map +1 -0
  36. package/dist/ai/scan.js +274 -0
  37. package/dist/ai/scan.js.map +1 -0
  38. package/dist/ai/settings.d.ts +31 -0
  39. package/dist/ai/settings.d.ts.map +1 -0
  40. package/dist/ai/settings.js +182 -0
  41. package/dist/ai/settings.js.map +1 -0
  42. package/dist/ai/summary.d.ts +26 -0
  43. package/dist/ai/summary.d.ts.map +1 -0
  44. package/dist/ai/summary.js +260 -0
  45. package/dist/ai/summary.js.map +1 -0
  46. package/dist/ai/types.d.ts +188 -0
  47. package/dist/ai/types.d.ts.map +1 -0
  48. package/dist/ai/types.js +13 -0
  49. package/dist/ai/types.js.map +1 -0
  50. package/dist/client/init.d.ts +14 -0
  51. package/dist/client/init.d.ts.map +1 -0
  52. package/dist/client/init.js +274 -0
  53. package/dist/client/init.js.map +1 -0
  54. package/dist/client/monitor.d.ts +23 -0
  55. package/dist/client/monitor.d.ts.map +1 -0
  56. package/dist/client/monitor.js +176 -0
  57. package/dist/client/monitor.js.map +1 -0
  58. package/dist/client.d.ts +11 -0
  59. package/dist/client.d.ts.map +1 -0
  60. package/dist/client.js +10 -0
  61. package/dist/client.js.map +1 -0
  62. package/dist/config.d.ts +36 -0
  63. package/dist/config.d.ts.map +1 -0
  64. package/dist/config.js +26 -0
  65. package/dist/config.js.map +1 -0
  66. package/dist/dashboard/elements.d.ts +6 -0
  67. package/dist/dashboard/elements.d.ts.map +1 -0
  68. package/dist/dashboard/elements.js +35 -0
  69. package/dist/dashboard/elements.js.map +1 -0
  70. package/dist/dashboard/features.d.ts +6 -0
  71. package/dist/dashboard/features.d.ts.map +1 -0
  72. package/dist/dashboard/features.js +28 -0
  73. package/dist/dashboard/features.js.map +1 -0
  74. package/dist/dashboard/index.d.ts +54 -0
  75. package/dist/dashboard/index.d.ts.map +1 -0
  76. package/dist/dashboard/index.js +140 -0
  77. package/dist/dashboard/index.js.map +1 -0
  78. package/dist/dashboard/journeys.d.ts +5 -0
  79. package/dist/dashboard/journeys.d.ts.map +1 -0
  80. package/dist/dashboard/journeys.js +158 -0
  81. package/dist/dashboard/journeys.js.map +1 -0
  82. package/dist/dashboard/pages.d.ts +5 -0
  83. package/dist/dashboard/pages.d.ts.map +1 -0
  84. package/dist/dashboard/pages.js +104 -0
  85. package/dist/dashboard/pages.js.map +1 -0
  86. package/dist/dashboard/summary.d.ts +13 -0
  87. package/dist/dashboard/summary.d.ts.map +1 -0
  88. package/dist/dashboard/summary.js +333 -0
  89. package/dist/dashboard/summary.js.map +1 -0
  90. package/dist/index.d.ts +10 -0
  91. package/dist/index.d.ts.map +1 -0
  92. package/dist/index.js +10 -0
  93. package/dist/index.js.map +1 -0
  94. package/dist/proxy.d.ts +40 -0
  95. package/dist/proxy.d.ts.map +1 -0
  96. package/dist/proxy.js +29 -0
  97. package/dist/proxy.js.map +1 -0
  98. package/dist/route.d.ts +24 -0
  99. package/dist/route.d.ts.map +1 -0
  100. package/dist/route.js +37 -0
  101. package/dist/route.js.map +1 -0
  102. package/dist/server/ingest.d.ts +14 -0
  103. package/dist/server/ingest.d.ts.map +1 -0
  104. package/dist/server/ingest.js +73 -0
  105. package/dist/server/ingest.js.map +1 -0
  106. package/dist/server/queries.d.ts +245 -0
  107. package/dist/server/queries.d.ts.map +1 -0
  108. package/dist/server/queries.js +547 -0
  109. package/dist/server/queries.js.map +1 -0
  110. package/dist/server/store.d.ts +29 -0
  111. package/dist/server/store.d.ts.map +1 -0
  112. package/dist/server/store.js +359 -0
  113. package/dist/server/store.js.map +1 -0
  114. package/dist/server.d.ts +15 -0
  115. package/dist/server.d.ts.map +1 -0
  116. package/dist/server.js +15 -0
  117. package/dist/server.js.map +1 -0
  118. package/dist/session.d.ts +16 -0
  119. package/dist/session.d.ts.map +1 -0
  120. package/dist/session.js +29 -0
  121. package/dist/session.js.map +1 -0
  122. package/dist/shared/types.d.ts +56 -0
  123. package/dist/shared/types.d.ts.map +1 -0
  124. package/dist/shared/types.js +27 -0
  125. package/dist/shared/types.js.map +1 -0
  126. package/docs/SETUP.md +315 -0
  127. package/package.json +92 -0
package/README.md ADDED
@@ -0,0 +1,310 @@
1
+ # @polishd/next
2
+
3
+ > **What gets measured gets improved.**
4
+
5
+ Drop-in **product analytics** for Next.js (App Router). Capture real user
6
+ behavioral signals — rage clicks, dead clicks, scroll depth, JS errors, web
7
+ vitals, component engagement — and explore them on a built-in, Vercel-style
8
+ dashboard at `/polishd`.
9
+
10
+ No third-party service. Events go to your own database: **SQLite in dev**
11
+ (zero-config, built into Node), **Postgres in production**. Anonymous by design —
12
+ one httpOnly UUID cookie per session, no fingerprinting, no PII.
13
+
14
+ ---
15
+
16
+ ## Documentation
17
+
18
+ | | |
19
+ |---|---|
20
+ | **[docs/SETUP.md](./docs/SETUP.md)** | Full walkthrough — every variant, verification, troubleshooting |
21
+ | **[AGENTS.md](./AGENTS.md)** | Install procedure written for AI coding agents |
22
+ | **[DATABASE.md](./DATABASE.md)** | Production database setup, schema, retention |
23
+
24
+ ## Requirements
25
+
26
+ - Next.js **15+**, App Router (no Pages Router support)
27
+ - Node **22.5+** (the dev store uses the built-in `node:sqlite`)
28
+ - Tailwind CSS v3 or v4, if you want the dashboard styled — see [Styling](#styling-required)
29
+ - Postgres in production; nothing extra for local development
30
+
31
+ ## Quickstart
32
+
33
+ ```bash
34
+ npm install @polishd/next
35
+ npx polishd init
36
+ npm run dev # then visit /polishd
37
+ ```
38
+
39
+ That's it locally — events write to `.polishd/analytics.db` with no
40
+ configuration. `init` detects your layout (`src/` or root), writes the four
41
+ files below, adds `.polishd/` to `.gitignore`, wires Tailwind, and skips
42
+ anything that already exists (`--force` to overwrite, `--dry-run` to preview,
43
+ `--js` for JavaScript, `--config` to also emit a `polishd.config.ts`).
44
+
45
+ `pg` installs automatically as an optional dependency and is loaded only when
46
+ you point Polishd at a Postgres URL.
47
+
48
+ ## What gets wired up
49
+
50
+ ```ts
51
+ // src/proxy.ts (middleware.ts on Next 15) — sets the session cookie
52
+ import type { NextRequest } from "next/server";
53
+ import { withPolishdSession } from "@polishd/next/proxy";
54
+
55
+ export function proxy(request: NextRequest) {
56
+ return withPolishdSession(request);
57
+ }
58
+ // Must be an inline literal — Next can't import config.matcher.
59
+ export const config = {
60
+ matcher: ["/((?!api|_next/static|_next/image|favicon|assets).*)"],
61
+ };
62
+
63
+ // src/instrumentation-client.ts — starts capture before hydration
64
+ import { initPolishd } from "@polishd/next/client";
65
+ initPolishd();
66
+
67
+ // src/app/api/polishd/route.ts — the ingest endpoint
68
+ import { createPolishdRoute } from "@polishd/next/route";
69
+ export const runtime = "nodejs"; // must be declared here, not re-exported
70
+ export const dynamic = "force-dynamic";
71
+ export const POST = createPolishdRoute();
72
+
73
+ // src/app/polishd/page.tsx — the dashboard (unguarded by default)
74
+ import { createPolishdPage } from "@polishd/next/dashboard";
75
+ export const runtime = "nodejs"; // Next requires these inline in the page
76
+ export const dynamic = "force-dynamic";
77
+ export default createPolishdPage();
78
+ ```
79
+
80
+ > **Route segment config can't be re-exported.** Next statically parses
81
+ > `runtime` and `dynamic`, so `export { POST, runtime, dynamic } from
82
+ > "@polishd/next/route"` fails the build — declare them inline as above. The same
83
+ > applies to the dashboard page.
84
+
85
+ > **Get the matcher right.** Next statically parses `config.matcher`, so it must
86
+ > be an inline literal in your proxy file — it can't be imported. `npx @polishd/next
87
+ > init` writes the correct one for you. Excluding all of `/api` is load-bearing:
88
+ > under Next 16 + Turbopack, a proxy matcher that touches any `/api/*` route
89
+ > breaks resolution for the entire `/api` segment.
90
+
91
+ ## Styling (required)
92
+
93
+ The dashboard is built from Tailwind utility classes and ships **no stylesheet
94
+ of its own**, so Tailwind has to scan the package's `dist` — it won't find it by
95
+ default. `init` wires this up for you; if you're doing it by hand:
96
+
97
+ ```css
98
+ /* Tailwind v4 — in the CSS file that imports tailwind */
99
+ @import "tailwindcss";
100
+ @source "../../node_modules/@polishd/next/dist"; /* relative to this file */
101
+ ```
102
+
103
+ ```ts
104
+ /* Tailwind v3 — in tailwind.config.ts */
105
+ content: [
106
+ "./src/**/*.{ts,tsx}",
107
+ "./node_modules/@polishd/next/dist/**/*.js",
108
+ ],
109
+ ```
110
+
111
+ Skip this and `/polishd` renders as unstyled HTML. Tailwind v4's automatic content
112
+ detection deliberately ignores `node_modules`, so the `@source` line is not
113
+ optional.
114
+
115
+ ## Protecting the dashboard
116
+
117
+ By default `/polishd` is **public** (a dev-only console warning reminds you).
118
+ Gate it with an `authenticate` callback, and optionally render your own sign-in
119
+ UI when it fails:
120
+
121
+ ```tsx
122
+ import { createPolishdPage } from "@polishd/next/dashboard";
123
+ import { isAuthenticated } from "@/lib/auth";
124
+ import { MyLogin } from "./login";
125
+
126
+ export const runtime = "nodejs";
127
+ export const dynamic = "force-dynamic";
128
+
129
+ export default createPolishdPage({
130
+ authenticate: isAuthenticated, // () => boolean | Promise<boolean>
131
+ unauthorized: <MyLogin />, // optional; a minimal screen otherwise
132
+ });
133
+ ```
134
+
135
+ The same callback also guards the AI **server actions** (generate summary, save
136
+ settings, scan codebase, file GitHub issues). That matters: a server action is
137
+ an addressable POST endpoint whose id ships in the public client bundle, so
138
+ gating only the page would leave those actions callable by anyone. They re-run
139
+ `authenticate` against the caller's cookies on every call, and deny when no
140
+ dashboard page has registered a policy.
141
+
142
+ If you pass no `authenticate`, the dashboard **and** its actions are public —
143
+ anyone who finds the URL can read your analytics, change the configured model
144
+ and API base URL, and spend your model tokens. Always gate it in production.
145
+
146
+ ## AI summary
147
+
148
+ The dashboard's top card turns the captured signals into a plain-English story
149
+ of how people are actually using your site, followed by a **wins** list (what's
150
+ working) and a **losses** list (specific problems). Losses are held to a higher
151
+ bar: each must cite the exact page, selector, or component from the data —
152
+ citations that don't appear in the analytics are discarded as hallucinations,
153
+ and the rest are matched to your source files so every loss shows the file it
154
+ lives in (or an honest "not matched to source" tag for dynamic content). It's
155
+ **bring-your-own-key**: on first visit the card walks you through a four-step
156
+ setup — connect a model (Anthropic, OpenAI, any OpenAI-compatible endpoint, or
157
+ Google), describe your site, scan your codebase, and pick a refresh cadence.
158
+ After that the card is just the story: the summary, when it was generated, a
159
+ **refresh** icon to force a regenerate, and a **gear** that opens the full
160
+ settings when you need them. Nothing is sent to a model until you ask.
161
+
162
+ It's built to spend as few tokens as possible:
163
+
164
+ - The model never sees raw events — only a compact, server-side **digest** of the
165
+ already-aggregated numbers (a few hundred tokens for an entire site).
166
+ - The digest is **fingerprinted** and the summary is **cached**. Re-opening the
167
+ dashboard costs nothing, and a regenerate with no new data returns the cached
168
+ text without a model call.
169
+ - The output is capped to a single tight paragraph.
170
+
171
+ **Auto-refresh.** Pick a cadence (manual / daily / weekly) during setup or in
172
+ Settings. When you open the dashboard past the cadence and the data has
173
+ actually changed, the summary regenerates in the background after the page is
174
+ served — no external cron, serverless-friendly. Unchanged data never triggers
175
+ a model call, whatever the cadence.
176
+
177
+ The API key is stored server-side (in your database) and is **never** sent back
178
+ to the browser — the UI only ever shows whether one is set. You can also preset
179
+ everything from the environment (handy for CI / shared deploys); dashboard
180
+ settings take precedence.
181
+
182
+ **Where to get a key:**
183
+
184
+ | Provider | Get a key | Notes |
185
+ |---|---|---|
186
+ | Anthropic (Claude) | [console.anthropic.com/settings/keys](https://console.anthropic.com/settings/keys) | default model `claude-opus-4-8` |
187
+ | OpenAI | [platform.openai.com/api-keys](https://platform.openai.com/api-keys) | default model `gpt-4o-mini` |
188
+ | OpenAI-compatible | e.g. [openrouter.ai/keys](https://openrouter.ai/keys), [console.groq.com/keys](https://console.groq.com/keys) | also set **Base URL** |
189
+ | Google (Gemini) | [aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey) | default model `gemini-1.5-flash` |
190
+
191
+ ### Project profile — the one-time setup scan
192
+
193
+ To make the summary understand your *code* — not just the numbers — run the
194
+ one-time **Scan codebase** step from the card's Project profile strip. Polishd
195
+ reads your app's source from disk (pages and layouts first, then component
196
+ files, under a hard token budget), and asks the model to write a compact
197
+ profile: what the site is for, a map of its routes, and every interactive
198
+ component by its exact identifier. Optionally tell it your **target audience**
199
+ and **ideology / values** in Settings so the analysis judges the site by your
200
+ goals.
201
+
202
+ The profile is cached in your database and injected into every summary as
203
+ authoritative context, so:
204
+
205
+ - **Summaries never re-read source.** One scan, then pure digest + profile.
206
+ - **The codebase is only touched again** when analytics mention a component the
207
+ profile doesn't cover — a tiny targeted read of just the files naming it —
208
+ or when you explicitly re-scan (the strip shows a hint when new components
209
+ appear).
210
+ - On serverless hosts where source isn't on disk, scan in local dev; the saved
211
+ profile keeps serving in production.
212
+
213
+ > **What the model sees.** A compact, server-side digest of the aggregated
214
+ > analytics (page paths, element selectors, `data-component` names, sample
215
+ > text, error messages), the cached project profile, and the description you
216
+ > provide. Your source is read only during an explicit scan (or a targeted
217
+ > gap-fill), and always under strict size budgets.
218
+
219
+ ## Component-level tracking
220
+
221
+ Wrap any element to track it explicitly. Interactive controls get hover + click
222
+ attribution; pass `content` for articles/regions to also measure viewport time,
223
+ scroll depth, and rendered size.
224
+
225
+ ```tsx
226
+ import { PolishdMonitor } from "@polishd/next/client";
227
+
228
+ <PolishdMonitor name="listen-button">
229
+ <ListenButton />
230
+ </PolishdMonitor>
231
+
232
+ <PolishdMonitor name={slug} content className="block">
233
+ <Article />
234
+ </PolishdMonitor>
235
+ ```
236
+
237
+ ## Configuration
238
+
239
+ Defaults suit a low-traffic site. Override via `polishd.config.ts` and pass it to
240
+ `initPolishd`:
241
+
242
+ ```ts
243
+ // polishd.config.ts
244
+ import { definePolishdConfig } from "@polishd/next";
245
+ export default definePolishdConfig({
246
+ sampleRate: 1,
247
+ rageClick: { count: 3, windowMs: 500 },
248
+ });
249
+ ```
250
+
251
+ ```ts
252
+ // src/instrumentation-client.ts
253
+ import { initPolishd } from "@polishd/next/client";
254
+ import config from "../polishd.config";
255
+ initPolishd(config);
256
+ ```
257
+
258
+ ## Environment variables
259
+
260
+ | Var | When | Purpose |
261
+ |---|---|---|
262
+ | `POLISHD_DATABASE_URL` | production | Pooled Postgres connection string — enables capture |
263
+ | `POLISHD_DB_PATH` | dev (optional) | Custom SQLite path (default `.polishd/analytics.db`) |
264
+ | `POLISHD_AI_PROVIDER` | AI (optional) | `anthropic` \| `openai` \| `openai-compatible` \| `google` |
265
+ | `POLISHD_AI_MODEL` | AI (optional) | Model id (defaults per provider, e.g. `claude-opus-4-8`) |
266
+ | `POLISHD_AI_API_KEY` | AI (optional) | Model API key — preset instead of using the dashboard |
267
+ | `POLISHD_AI_BASE_URL` | AI (optional) | Base URL for `openai-compatible` providers |
268
+ | `POLISHD_AI_INSTRUCTIONS` / `POLISHD_AI_CONTEXT` | AI (optional) | Default instructions / site description |
269
+ | `POLISHD_AI_AUDIENCE` / `POLISHD_AI_IDEOLOGY` | AI (optional) | Target audience / product values for the profile scan |
270
+ | `POLISHD_AI_SOURCE_DIRS` | AI (optional) | Comma-separated folders to scan (default `src`/`app`/`components`/`pages`/`lib`) |
271
+ | `POLISHD_AI_REFRESH_CADENCE` | AI (optional) | `manual` \| `daily` \| `weekly` — summary auto-refresh cadence |
272
+ | `POLISHD_GITHUB_REPO` | GitHub (optional) | Repository as `owner/repo` — enables filing bugs from AI-found losses |
273
+ | `POLISHD_GITHUB_TOKEN` | GitHub (optional) | Fine-grained PAT: Contents (read), Issues & Pull requests (write) |
274
+ | `POLISHD_GITHUB_AUTO_ISSUES` | GitHub (optional) | `true` — file a GitHub issue automatically for each new problem a summary finds |
275
+
276
+ AI settings are optional — the dashboard's Settings panel configures the same
277
+ fields, and what you save there overrides these env defaults.
278
+
279
+ Local dev needs nothing. If no writable store is available the package degrades
280
+ to a safe no-op (a console warning, dashboard notice) — your app never breaks.
281
+ See [DATABASE.md](./DATABASE.md) for production setup.
282
+
283
+ ## Entry points
284
+
285
+ | Import | Contents |
286
+ |---|---|
287
+ | `@polishd/next` | config, `definePolishdConfig`, event types (isomorphic) |
288
+ | `@polishd/next/client` | `initPolishd`, `PolishdMonitor` |
289
+ | `@polishd/next/server` | store, ingest, queries, `withPolishdSession` (Node only) |
290
+ | `@polishd/next/route` | `POST`, `createPolishdRoute` |
291
+ | `@polishd/next/proxy` | `proxy`, `config`, `withPolishdSession`, `polishdMatcher` |
292
+ | `@polishd/next/dashboard` | `createPolishdPage`, `PolishdDashboard`, `loadPolishdDashboardData` |
293
+
294
+ ## Development
295
+
296
+ This repository contains the package only. To work on it:
297
+
298
+ ```bash
299
+ npm install
300
+ npm run build # tsc -> dist (ESM + .d.ts)
301
+ npm run typecheck
302
+ ```
303
+
304
+ To test a change against a real app, `npm pack` and install the tarball into a
305
+ scratch Next.js project — that exercises the published `exports` map and the
306
+ "use client"/"use server" boundaries the way a consumer will.
307
+
308
+ ## License
309
+
310
+ MIT — see [LICENSE](./LICENSE).
package/bin/init.mjs ADDED
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx @polishd/next init` — scaffold the glue files a Next.js app needs to wire
4
+ * up Polishd. Node builtins only; zero runtime dependencies.
5
+ *
6
+ * Flags:
7
+ * --force overwrite existing files
8
+ * --dry-run print what would happen, write nothing
9
+ * --js emit .js/.jsx instead of TypeScript
10
+ * --config also write a starter polishd.config.ts
11
+ */
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+
15
+ const args = new Set(process.argv.slice(2));
16
+ const FORCE = args.has("--force");
17
+ const DRY = args.has("--dry-run");
18
+ const JS = args.has("--js");
19
+ const WANT_CONFIG = args.has("--config");
20
+ const cwd = process.cwd();
21
+
22
+ const c = {
23
+ ok: (s) => `\x1b[32m${s}\x1b[0m`,
24
+ warn: (s) => `\x1b[33m${s}\x1b[0m`,
25
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
26
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
27
+ };
28
+
29
+ // ── Detect project layout ────────────────────────────────────────────────────
30
+ const hasSrcApp = existsSync(join(cwd, "src", "app"));
31
+ const hasRootApp = existsSync(join(cwd, "app"));
32
+ if (!hasSrcApp && !hasRootApp) {
33
+ console.error(
34
+ c.warn("✗ Could not find an `app/` or `src/app/` directory.\n") +
35
+ " Run this from the root of a Next.js App Router project.",
36
+ );
37
+ process.exit(1);
38
+ }
39
+ const base = hasSrcApp ? "src" : ""; // where proxy/instrumentation live
40
+ const appDir = hasSrcApp ? join("src", "app") : "app";
41
+ const ext = JS ? "tsx" : "tsx"; // page is always JSX
42
+ const codeExt = JS ? "js" : "ts";
43
+ const pageExt = JS ? "jsx" : "tsx";
44
+
45
+ console.log(
46
+ c.ok(`✔ Detected Next.js App Router`) +
47
+ c.dim(` (${hasSrcApp ? "src/ layout" : "root layout"})`),
48
+ );
49
+
50
+ // ── File templates ───────────────────────────────────────────────────────────
51
+ const files = [
52
+ {
53
+ path: join(base, `proxy.${codeExt}`),
54
+ merge: join(base, `middleware.${codeExt}`),
55
+ body:
56
+ (JS ? "" : `import type { NextRequest } from "next/server";\n`) +
57
+ `import { withPolishdSession } from "@polishd/next/proxy";\n\n` +
58
+ `export function proxy(request${JS ? "" : ": NextRequest"}) {\n` +
59
+ ` return withPolishdSession(request);\n` +
60
+ `}\n\n` +
61
+ `// Next statically parses config.matcher, so it must be an inline literal\n` +
62
+ `// here. Excluding all of /api is required — under Next 16 + Turbopack a\n` +
63
+ `// matcher that touches /api breaks the whole /api segment.\n` +
64
+ `export const config = {\n` +
65
+ ` matcher: ["/((?!api|_next/static|_next/image|favicon|assets).*)"],\n` +
66
+ `};\n`,
67
+ },
68
+ {
69
+ path: join(base, `instrumentation-client.${codeExt}`),
70
+ body: `import { initPolishd } from "@polishd/next/client";\n\ninitPolishd();\n`,
71
+ },
72
+ {
73
+ path: join(appDir, "api", "polishd", `route.${codeExt}`),
74
+ // runtime/dynamic must be declared here, not re-exported: Next statically
75
+ // parses route segment config and rejects `export { runtime } from ...`
76
+ // ("Next.js can't recognize the exported `runtime` field in route").
77
+ body:
78
+ `import { createPolishdRoute } from "@polishd/next/route";\n\n` +
79
+ `// node:sqlite / pg need the Node runtime — never the Edge runtime.\n` +
80
+ `export const runtime = "nodejs";\n` +
81
+ `// This route mutates per-request; it must never be statically cached.\n` +
82
+ `export const dynamic = "force-dynamic";\n\n` +
83
+ `export const POST = createPolishdRoute();\n`,
84
+ },
85
+ {
86
+ path: join(appDir, "polishd", `page.${pageExt}`),
87
+ body:
88
+ `import { createPolishdPage } from "@polishd/next/dashboard";\n\n` +
89
+ `// Next requires these to be declared inline in the page module.\n` +
90
+ `export const runtime = "nodejs";\n` +
91
+ `export const dynamic = "force-dynamic";\n\n` +
92
+ `// Unguarded by default. To protect it, pass an authenticate callback:\n` +
93
+ `// export default createPolishdPage({ authenticate: isAuthenticated });\n` +
94
+ `export default createPolishdPage();\n`,
95
+ },
96
+ ];
97
+
98
+ if (WANT_CONFIG) {
99
+ files.push({
100
+ path: `polishd.config.${codeExt}`,
101
+ body:
102
+ `import { definePolishdConfig } from "@polishd/next";\n\n` +
103
+ `export default definePolishdConfig({\n` +
104
+ ` // sampleRate: 1,\n` +
105
+ ` // rageClick: { count: 3, windowMs: 500 },\n` +
106
+ `});\n`,
107
+ });
108
+ }
109
+
110
+ // ── Write ────────────────────────────────────────────────────────────────────
111
+ function write(file) {
112
+ const abs = join(cwd, file.path);
113
+ if (existsSync(abs) && !FORCE) {
114
+ console.log(c.warn(`⚠ ${file.path} exists`) + c.dim(" — skipped (use --force)"));
115
+ return;
116
+ }
117
+ // If a middleware.ts already exists for the proxy file, print a merge snippet.
118
+ if (file.merge && existsSync(join(cwd, file.merge)) && !FORCE) {
119
+ console.log(
120
+ c.warn(`⚠ ${file.merge} exists`) +
121
+ c.dim(" — add this to it (and keep the matcher):\n") +
122
+ ` import { withPolishdSession, polishdMatcher } from "@polishd/next/proxy";\n` +
123
+ ` // call withPolishdSession(request) in your handler;\n` +
124
+ ` // and merge polishdMatcher into your config.matcher`,
125
+ );
126
+ return;
127
+ }
128
+ if (DRY) {
129
+ console.log(c.dim(`• would write ${file.path}`));
130
+ return;
131
+ }
132
+ mkdirSync(dirname(abs), { recursive: true });
133
+ writeFileSync(abs, file.body);
134
+ console.log(c.ok(`✔ Created ${file.path}`));
135
+ }
136
+
137
+ for (const f of files) write(f);
138
+
139
+ // ── .gitignore ───────────────────────────────────────────────────────────────
140
+ const giPath = join(cwd, ".gitignore");
141
+ if (!DRY) {
142
+ const gi = existsSync(giPath) ? readFileSync(giPath, "utf8") : "";
143
+ if (!gi.includes(".polishd")) {
144
+ appendFileSync(giPath, `${gi.endsWith("\n") || gi === "" ? "" : "\n"}.polishd/\n`);
145
+ console.log(c.ok("✔ Added .polishd/ to .gitignore"));
146
+ }
147
+ }
148
+
149
+ // ── Tailwind ─────────────────────────────────────────────────────────────────
150
+ // The dashboard is styled entirely with Tailwind utility classes and ships no
151
+ // stylesheet of its own, so Tailwind has to be told to scan the package's dist.
152
+ // Neither version finds it by default: v4's automatic content detection skips
153
+ // node_modules, and v3 only scans the globs it is given. Without this the
154
+ // dashboard renders completely unstyled.
155
+ const DIST_GLOB = "node_modules/@polishd/next/dist";
156
+
157
+ function wireTailwind() {
158
+ const v3Config = ["tailwind.config.ts", "tailwind.config.js", "tailwind.config.mjs"]
159
+ .map((f) => join(cwd, f))
160
+ .find((f) => existsSync(f));
161
+
162
+ if (v3Config) {
163
+ console.log(
164
+ c.warn(`⚠ Tailwind v3 config detected`) +
165
+ c.dim(" — add this to its `content` array:\n") +
166
+ ` "./${DIST_GLOB}/**/*.js"`,
167
+ );
168
+ return;
169
+ }
170
+
171
+ // Tailwind v4: find the entry stylesheet that imports tailwind.
172
+ const cssCandidates = [
173
+ join("src", "app", "globals.css"),
174
+ join("app", "globals.css"),
175
+ join("src", "styles", "globals.css"),
176
+ join("styles", "globals.css"),
177
+ join("src", "app", "global.css"),
178
+ ];
179
+ for (const rel of cssCandidates) {
180
+ const abs = join(cwd, rel);
181
+ if (!existsSync(abs)) continue;
182
+ const css = readFileSync(abs, "utf8");
183
+ if (!/@import\s+["']tailwindcss["']/.test(css)) continue;
184
+ if (css.includes("@polishd/next")) {
185
+ console.log(c.dim(`• ${rel} already sources Polishd's styles`));
186
+ return;
187
+ }
188
+ // `@source` is resolved relative to the stylesheet, so walk back up to root.
189
+ const depth = dirname(rel).split(/[\\/]/).filter(Boolean).length;
190
+ const source = `${"../".repeat(depth)}${DIST_GLOB}`;
191
+ if (DRY) {
192
+ console.log(c.dim(`• would add @source "${source}" to ${rel}`));
193
+ return;
194
+ }
195
+ writeFileSync(
196
+ abs,
197
+ css.replace(
198
+ /(@import\s+["']tailwindcss["'];?)/,
199
+ `$1\n/* Generate the Polishd dashboard's utility classes (Tailwind skips node_modules). */\n@source "${source}";`,
200
+ ),
201
+ );
202
+ console.log(c.ok(`✔ Added @source "${source}" to ${rel}`));
203
+ return;
204
+ }
205
+
206
+ console.log(
207
+ c.warn("⚠ No Tailwind entry stylesheet found") +
208
+ c.dim(" — the dashboard needs Tailwind to render styled.\n") +
209
+ c.dim(" v4: add to your CSS → ") +
210
+ `@source "<path-to>/${DIST_GLOB}";\n` +
211
+ c.dim(" v3: add to content → ") +
212
+ `"./${DIST_GLOB}/**/*.js"`,
213
+ );
214
+ }
215
+
216
+ wireTailwind();
217
+
218
+ // ── Next steps ───────────────────────────────────────────────────────────────
219
+ console.log(`
220
+ ${c.bold("Next steps:")}
221
+ 1. Local dev needs nothing — events write to ${c.dim(".polishd/analytics.db")} (SQLite).
222
+ 2. For production, set ${c.dim("POLISHD_DATABASE_URL")} (pooled Postgres). See DATABASE.md.
223
+ 3. Visit ${c.bold("/polishd")} to see the dashboard.
224
+ ${DRY ? c.warn("\n(dry run — nothing was written)") : ""}`);
@@ -0,0 +1,23 @@
1
+ import { type SaveAISettingsInput } from "./settings";
2
+ import type { PolishdAISettingsPublic, PolishdLossItem, CreateIssueResult, GenerateProfileResult, GenerateSummaryResult, VerifyGithubResult } from "./types";
3
+ /** Generate or refresh the narrative. `force` re-asks the model even if data is unchanged. */
4
+ export declare function generateSummaryAction(force?: boolean): Promise<GenerateSummaryResult>;
5
+ /** Persist provider/model/key/instructions/context; returns the no-secret view. */
6
+ export declare function saveAISettingsAction(input: SaveAISettingsInput): Promise<PolishdAISettingsPublic>;
7
+ /**
8
+ * Scan the codebase and (re)build the project profile — the explicit setup
9
+ * step. Passes current analytics so known component names steer the scan.
10
+ */
11
+ export declare function generateProfileAction(): Promise<GenerateProfileResult>;
12
+ /**
13
+ * Check the saved GitHub repo + token live against the API — called by the
14
+ * onboarding step right after saving, so a bad token fails at setup.
15
+ */
16
+ export declare function verifyGithubAction(): Promise<VerifyGithubResult>;
17
+ /**
18
+ * Verify one loss against the repository's source and — when it holds up —
19
+ * file a GitHub issue with the technical analysis and fix suggestions. A
20
+ * disproved report returns `not-a-bug` with the reasoning instead of filing.
21
+ */
22
+ export declare function createIssueFromLossAction(loss: Pick<PolishdLossItem, "issue" | "evidence" | "location">): Promise<CreateIssueResult>;
23
+ //# sourceMappingURL=actions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../../src/ai/actions.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAkB,KAAK,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAEtE,OAAO,KAAK,EACV,uBAAuB,EACvB,eAAe,EACf,iBAAiB,EACjB,qBAAqB,EACrB,qBAAqB,EACrB,kBAAkB,EACnB,MAAM,SAAS,CAAC;AAEjB,8FAA8F;AAC9F,wBAAsB,qBAAqB,CAAC,KAAK,UAAQ,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAGzF;AAED,mFAAmF;AACnF,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,mBAAmB,GACzB,OAAO,CAAC,uBAAuB,CAAC,CAGlC;AAED;;;GAGG;AACH,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,qBAAqB,CAAC,CAI5E;AAED;;;GAGG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,kBAAkB,CAAC,CAGtE;AAED;;;;GAIG;AACH,wBAAsB,yBAAyB,CAC7C,IAAI,EAAE,IAAI,CAAC,eAAe,EAAE,OAAO,GAAG,UAAU,GAAG,UAAU,CAAC,GAC7D,OAAO,CAAC,iBAAiB,CAAC,CAG5B"}
@@ -0,0 +1,58 @@
1
+ "use server";
2
+ /**
3
+ * Polishd — server actions for the AI summary card.
4
+ *
5
+ * These are the only AI entry points the client touches. They run on the
6
+ * server (Node runtime), so the API key never crosses to the browser, and they
7
+ * return only serializable, non-secret data.
8
+ *
9
+ * Every one of them gates on `requirePolishdAuth()` first. A server action is an
10
+ * addressable POST endpoint whose id ships in the public client bundle, so it
11
+ * is reachable directly regardless of whether the dashboard page rendered —
12
+ * the page's `authenticate` gate protects the render, not these. The guard
13
+ * re-runs that same callback against the caller's cookies on every call.
14
+ */
15
+ import { requirePolishdAuth } from "./guard";
16
+ import { verifyGithubConnection } from "./github";
17
+ import { createIssueFromLoss } from "./issues";
18
+ import { generateProjectProfile } from "./profile";
19
+ import { loadPolishdDashboardData } from "../server/queries";
20
+ import { saveAISettings } from "./settings";
21
+ import { generateSummary } from "./summary";
22
+ /** Generate or refresh the narrative. `force` re-asks the model even if data is unchanged. */
23
+ export async function generateSummaryAction(force = false) {
24
+ await requirePolishdAuth();
25
+ return generateSummary({ force });
26
+ }
27
+ /** Persist provider/model/key/instructions/context; returns the no-secret view. */
28
+ export async function saveAISettingsAction(input) {
29
+ await requirePolishdAuth();
30
+ return saveAISettings(input);
31
+ }
32
+ /**
33
+ * Scan the codebase and (re)build the project profile — the explicit setup
34
+ * step. Passes current analytics so known component names steer the scan.
35
+ */
36
+ export async function generateProfileAction() {
37
+ await requirePolishdAuth();
38
+ const data = await loadPolishdDashboardData();
39
+ return generateProjectProfile(data);
40
+ }
41
+ /**
42
+ * Check the saved GitHub repo + token live against the API — called by the
43
+ * onboarding step right after saving, so a bad token fails at setup.
44
+ */
45
+ export async function verifyGithubAction() {
46
+ await requirePolishdAuth();
47
+ return verifyGithubConnection();
48
+ }
49
+ /**
50
+ * Verify one loss against the repository's source and — when it holds up —
51
+ * file a GitHub issue with the technical analysis and fix suggestions. A
52
+ * disproved report returns `not-a-bug` with the reasoning instead of filing.
53
+ */
54
+ export async function createIssueFromLossAction(loss) {
55
+ await requirePolishdAuth();
56
+ return createIssueFromLoss(loss);
57
+ }
58
+ //# sourceMappingURL=actions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"actions.js","sourceRoot":"","sources":["../../src/ai/actions.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAC7C,OAAO,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAC/C,OAAO,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AACnD,OAAO,EAAE,wBAAwB,EAAE,MAAM,mBAAmB,CAAC;AAC7D,OAAO,EAAE,cAAc,EAA4B,MAAM,YAAY,CAAC;AACtE,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAU5C,8FAA8F;AAC9F,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAAC,KAAK,GAAG,KAAK;IACvD,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,eAAe,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;AACpC,CAAC;AAED,mFAAmF;AACnF,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,KAA0B;IAE1B,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,cAAc,CAAC,KAAK,CAAC,CAAC;AAC/B,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB;IACzC,MAAM,kBAAkB,EAAE,CAAC;IAC3B,MAAM,IAAI,GAAG,MAAM,wBAAwB,EAAE,CAAC;IAC9C,OAAO,sBAAsB,CAAC,IAAI,CAAC,CAAC;AACtC,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB;IACtC,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,sBAAsB,EAAE,CAAC;AAClC,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,IAA8D;IAE9D,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,mBAAmB,CAAC,IAAI,CAAC,CAAC;AACnC,CAAC"}
@@ -0,0 +1,9 @@
1
+ import type { PolishdDashboardData } from "../server/queries";
2
+ /**
3
+ * Render the captured analytics as a compact, model-readable brief. Sections
4
+ * are omitted when empty so the prompt only ever contains real signal.
5
+ */
6
+ export declare function buildDigest(data: PolishdDashboardData, context?: string): string;
7
+ /** Stable fingerprint of a digest — drives the "data unchanged" short-circuit. */
8
+ export declare function fingerprintDigest(digest: string): string;
9
+ //# sourceMappingURL=digest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"digest.d.ts","sourceRoot":"","sources":["../../src/ai/digest.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAS9D;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,oBAAoB,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CA4FhF;AAED,kFAAkF;AAClF,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAExD"}