@polishd/next 0.1.0 → 0.2.1-dev.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 (95) hide show
  1. package/AGENTS.md +161 -46
  2. package/CHANGELOG.md +163 -0
  3. package/README.md +275 -49
  4. package/bin/doctor.mjs +374 -0
  5. package/bin/init.mjs +215 -188
  6. package/bin/polishd.mjs +85 -0
  7. package/bin/shared.mjs +114 -0
  8. package/dist/ai/actions.d.ts +13 -2
  9. package/dist/ai/actions.d.ts.map +1 -1
  10. package/dist/ai/actions.js +19 -1
  11. package/dist/ai/actions.js.map +1 -1
  12. package/dist/ai/design.d.ts +28 -0
  13. package/dist/ai/design.d.ts.map +1 -0
  14. package/dist/ai/design.js +245 -0
  15. package/dist/ai/design.js.map +1 -0
  16. package/dist/ai/guard.d.ts +44 -3
  17. package/dist/ai/guard.d.ts.map +1 -1
  18. package/dist/ai/guard.js +13 -2
  19. package/dist/ai/guard.js.map +1 -1
  20. package/dist/ai/providers.d.ts +2 -0
  21. package/dist/ai/providers.d.ts.map +1 -1
  22. package/dist/ai/providers.js +70 -0
  23. package/dist/ai/providers.js.map +1 -1
  24. package/dist/ai/settings.d.ts +15 -1
  25. package/dist/ai/settings.d.ts.map +1 -1
  26. package/dist/ai/settings.js +38 -1
  27. package/dist/ai/settings.js.map +1 -1
  28. package/dist/ai/types.d.ts +57 -0
  29. package/dist/ai/types.d.ts.map +1 -1
  30. package/dist/client/design-scan.d.ts +37 -0
  31. package/dist/client/design-scan.d.ts.map +1 -0
  32. package/dist/client/design-scan.js +319 -0
  33. package/dist/client/design-scan.js.map +1 -0
  34. package/dist/client/init.d.ts.map +1 -1
  35. package/dist/client/init.js +67 -2
  36. package/dist/client/init.js.map +1 -1
  37. package/dist/config.d.ts +57 -3
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/config.js +62 -3
  40. package/dist/config.js.map +1 -1
  41. package/dist/dashboard/design-review-card.d.ts +15 -0
  42. package/dist/dashboard/design-review-card.d.ts.map +1 -0
  43. package/dist/dashboard/design-review-card.js +82 -0
  44. package/dist/dashboard/design-review-card.js.map +1 -0
  45. package/dist/dashboard/design.d.ts +10 -0
  46. package/dist/dashboard/design.d.ts.map +1 -0
  47. package/dist/dashboard/design.js +83 -0
  48. package/dist/dashboard/design.js.map +1 -0
  49. package/dist/dashboard/index.d.ts +35 -5
  50. package/dist/dashboard/index.d.ts.map +1 -1
  51. package/dist/dashboard/index.js +176 -18
  52. package/dist/dashboard/index.js.map +1 -1
  53. package/dist/dashboard/journeys.d.ts.map +1 -1
  54. package/dist/dashboard/journeys.js +1 -0
  55. package/dist/dashboard/journeys.js.map +1 -1
  56. package/dist/dashboard/summary.d.ts.map +1 -1
  57. package/dist/dashboard/summary.js +59 -5
  58. package/dist/dashboard/summary.js.map +1 -1
  59. package/dist/dashboard/token-auth.d.ts +56 -0
  60. package/dist/dashboard/token-auth.d.ts.map +1 -0
  61. package/dist/dashboard/token-auth.js +158 -0
  62. package/dist/dashboard/token-auth.js.map +1 -0
  63. package/dist/dashboard/unlock.d.ts +2 -0
  64. package/dist/dashboard/unlock.d.ts.map +1 -0
  65. package/dist/dashboard/unlock.js +54 -0
  66. package/dist/dashboard/unlock.js.map +1 -0
  67. package/dist/dashboard.css +2740 -0
  68. package/dist/proxy.d.ts +53 -5
  69. package/dist/proxy.d.ts.map +1 -1
  70. package/dist/proxy.js +73 -3
  71. package/dist/proxy.js.map +1 -1
  72. package/dist/route.d.ts.map +1 -1
  73. package/dist/route.js +11 -3
  74. package/dist/route.js.map +1 -1
  75. package/dist/server/design.d.ts +93 -0
  76. package/dist/server/design.d.ts.map +1 -0
  77. package/dist/server/design.js +430 -0
  78. package/dist/server/design.js.map +1 -0
  79. package/dist/server/ingest.d.ts +33 -2
  80. package/dist/server/ingest.d.ts.map +1 -1
  81. package/dist/server/ingest.js +70 -8
  82. package/dist/server/ingest.js.map +1 -1
  83. package/dist/server/queries.d.ts +16 -0
  84. package/dist/server/queries.d.ts.map +1 -1
  85. package/dist/server/queries.js +80 -16
  86. package/dist/server/queries.js.map +1 -1
  87. package/dist/session.d.ts.map +1 -1
  88. package/dist/session.js +4 -2
  89. package/dist/session.js.map +1 -1
  90. package/dist/shared/types.d.ts +31 -1
  91. package/dist/shared/types.d.ts.map +1 -1
  92. package/dist/shared/types.js +1 -0
  93. package/dist/shared/types.js.map +1 -1
  94. package/docs/SETUP.md +308 -51
  95. package/package.json +14 -7
package/bin/init.mjs CHANGED
@@ -1,224 +1,251 @@
1
- #!/usr/bin/env node
2
1
  /**
3
2
  * `npx @polishd/next init` — scaffold the glue files a Next.js app needs to wire
4
3
  * up Polishd. Node builtins only; zero runtime dependencies.
5
4
  *
6
5
  * Flags:
7
- * --force overwrite existing files
6
+ * --force regenerate files this CLI wrote (backed up first). Files it did
7
+ * not write are never overwritten, with or without this flag.
8
8
  * --dry-run print what would happen, write nothing
9
9
  * --js emit .js/.jsx instead of TypeScript
10
10
  * --config also write a starter polishd.config.ts
11
11
  */
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
12
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
13
13
  import { dirname, join } from "node:path";
14
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.",
15
+ import {
16
+ c,
17
+ detectLayout,
18
+ detectNextMajor,
19
+ nodeVersionOk,
20
+ proxyShape,
21
+ readText,
22
+ } from "./shared.mjs";
23
+
24
+ // Every generated file carries this marker. It is what lets `--force` tell "a
25
+ // file I wrote and may regenerate" apart from "the user's own code", which is
26
+ // the difference between a convenience flag and a data-loss bug.
27
+ const SENTINEL = "@polishd/next init";
28
+ const STAMP =
29
+ `// Generated by \`npx ${SENTINEL}\`. Edit freely — \`--force\` backs up\n` +
30
+ `// this file before regenerating it.\n`;
31
+
32
+ export function runInit(argv, cwd) {
33
+ const args = new Set(argv);
34
+ const FORCE = args.has("--force");
35
+ const DRY = args.has("--dry-run");
36
+ const JS = args.has("--js");
37
+ const WANT_CONFIG = args.has("--config");
38
+ const codeExt = JS ? "js" : "ts";
39
+ const pageExt = JS ? "jsx" : "tsx";
40
+
41
+ // ── Detect project layout ──────────────────────────────────────────────────
42
+ const layout = detectLayout(cwd);
43
+ if (!layout.found) {
44
+ console.error(
45
+ c.warn("✗ Could not find an `app/` or `src/app/` directory.\n") +
46
+ " Run this from the root of a Next.js App Router project.",
47
+ );
48
+ return 1;
49
+ }
50
+ const { base, appDir } = layout;
51
+ console.log(
52
+ c.ok(`✔ Detected Next.js App Router`) +
53
+ c.dim(` (${layout.hasSrcApp ? "src/ layout" : "root layout"})`),
36
54
  );
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
55
 
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) {
56
+ // The dev store uses `node:sqlite`, which landed in 22.5.0. Checked here
57
+ // rather than left to a confusing "cannot find module" at first request.
58
+ if (!nodeVersionOk()) {
119
59
  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`,
60
+ c.warn(`⚠ Node ${process.versions.node}`) +
61
+ c.dim(" — Polishd needs >= 22.5.0 (the dev store uses node:sqlite)."),
125
62
  );
126
- return;
127
- }
128
- if (DRY) {
129
- console.log(c.dim(`• would write ${file.path}`));
130
- return;
131
63
  }
132
- mkdirSync(dirname(abs), { recursive: true });
133
- writeFileSync(abs, file.body);
134
- console.log(c.ok(`✔ Created ${file.path}`));
135
- }
136
64
 
137
- for (const f of files) write(f);
65
+ // ── Detect the installed Next major ────────────────────────────────────────
66
+ const next = detectNextMajor(cwd);
67
+ const shape = proxyShape(next, base, codeExt);
138
68
 
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) {
69
+ if (next.major === null) {
70
+ // An unannounced guess here is exactly the silent failure this detection
71
+ // exists to prevent, so say so.
72
+ console.log(
73
+ c.warn("⚠ Could not determine the installed Next.js version") +
74
+ c.dim(`\n Assuming 16+ and writing ${shape.path}.`) +
75
+ c.dim(`\n On Next 15 rename it to ${shape.otherPath} and rename the exported`) +
76
+ c.dim(`\n function to \`${shape.other}\` — Next 15 does not load ${shape.path}.`),
77
+ );
78
+ } else {
163
79
  console.log(
164
- c.warn(`⚠ Tailwind v3 config detected`) +
165
- c.dim(" — add this to its `content` array:\n") +
166
- ` "./${DIST_GLOB}/**/*.js"`,
80
+ c.ok(`✔ Detected Next.js ${next.version}`) +
81
+ c.dim(` — using ${shape.path}`) +
82
+ (next.source === "package.json" ? c.dim(" (from package.json range)") : ""),
167
83
  );
168
- return;
169
84
  }
170
85
 
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"),
86
+ // ── File templates ─────────────────────────────────────────────────────────
87
+ const mergeHint =
88
+ c.dim(` Add Polishd to your existing handler instead:\n`) +
89
+ ` import { composePolishd } from "@polishd/next/proxy";\n` +
90
+ ` export const ${shape.name} = composePolishd(myHandler, { matcher: "/your/scope" });\n` +
91
+ c.dim(` // Then widen your inline config.matcher to the union of both.\n`) +
92
+ c.dim(` // Declaring your original scope keeps your handler running only where\n`) +
93
+ c.dim(` // it ran before, even though the matcher now feeds Polishd everything.\n`) +
94
+ c.dim(` // See README → "Composing with an existing proxy".`);
95
+
96
+ const files = [
97
+ {
98
+ path: shape.path,
99
+ // The other spelling of this same file. A host on Next 16 that still has
100
+ // a middleware.ts (or vice versa) has a real collision worth flagging.
101
+ counterpart: shape.otherPath,
102
+ mergeHint,
103
+ body:
104
+ (JS ? "" : `import type { NextRequest } from "next/server";\n`) +
105
+ `import { withPolishdSession } from "@polishd/next/proxy";\n\n` +
106
+ `export function ${shape.name}(request${JS ? "" : ": NextRequest"}) {\n` +
107
+ ` return withPolishdSession(request);\n` +
108
+ `}\n\n` +
109
+ `// Next statically parses config.matcher, so it must be an inline literal\n` +
110
+ `// here. Excluding all of /api is required — under Next 16 + Turbopack a\n` +
111
+ `// matcher that touches /api breaks the whole /api segment.\n` +
112
+ `export const config = {\n` +
113
+ ` matcher: ["/((?!api|_next/static|_next/image|favicon|assets).*)"],\n` +
114
+ `};\n`,
115
+ },
116
+ {
117
+ path: join(base, `instrumentation-client.${codeExt}`),
118
+ body: `import { initPolishd } from "@polishd/next/client";\n\ninitPolishd();\n`,
119
+ },
120
+ {
121
+ path: join(appDir, "api", "polishd", `route.${codeExt}`),
122
+ // runtime/dynamic must be declared here, not re-exported: Next statically
123
+ // parses route segment config and rejects `export { runtime } from ...`
124
+ // ("Next.js can't recognize the exported `runtime` field in route").
125
+ body:
126
+ `import { createPolishdRoute } from "@polishd/next/route";\n\n` +
127
+ `// node:sqlite / pg need the Node runtime — never the Edge runtime.\n` +
128
+ `export const runtime = "nodejs";\n` +
129
+ `// This route mutates per-request; it must never be statically cached.\n` +
130
+ `export const dynamic = "force-dynamic";\n\n` +
131
+ `export const POST = createPolishdRoute();\n`,
132
+ },
133
+ {
134
+ path: join(appDir, "polishd", `page.${pageExt}`),
135
+ body:
136
+ `// The dashboard's own stylesheet. Self-contained and scoped to\n` +
137
+ `// .polishd-root, so it needs no Tailwind in this app and cannot\n` +
138
+ `// affect anything outside the dashboard.\n` +
139
+ `import "@polishd/next/dashboard.css";\n` +
140
+ `import { createPolishdPage } from "@polishd/next/dashboard";\n\n` +
141
+ `// Next requires these to be declared inline in the page module.\n` +
142
+ `export const runtime = "nodejs";\n` +
143
+ `export const dynamic = "force-dynamic";\n\n` +
144
+ `// In production the dashboard refuses to render until you either set\n` +
145
+ `// POLISHD_DASHBOARD_TOKEN, pass your own \`authenticate\`, or opt out\n` +
146
+ `// explicitly with POLISHD_DASHBOARD_PUBLIC=true. It can change your AI\n` +
147
+ `// settings and spend your model tokens, so it is not public by default.\n` +
148
+ `export default createPolishdPage();\n`,
149
+ },
178
150
  ];
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`));
151
+
152
+ if (WANT_CONFIG) {
153
+ files.push({
154
+ path: `polishd.config.${codeExt}`,
155
+ body:
156
+ `import { definePolishdConfig } from "@polishd/next";\n\n` +
157
+ `// NOTE: nothing imports this for you. Pass it to initPolishd(),\n` +
158
+ `// createPolishdRoute() and your proxy explicitly, or the layers will\n` +
159
+ `// disagree. For the session cookie name prefer POLISHD_SESSION_COOKIE,\n` +
160
+ `// which every runtime can read.\n` +
161
+ `export default definePolishdConfig({\n` +
162
+ ` // sampleRate: 1,\n` +
163
+ ` // rageClick: { count: 3, windowMs: 500 },\n` +
164
+ `});\n`,
165
+ });
166
+ }
167
+
168
+ // ── Write ──────────────────────────────────────────────────────────────────
169
+ /** Did this CLI write the file currently at `abs`? */
170
+ const isOurs = (abs) => (readText(abs) ?? "").includes(SENTINEL);
171
+
172
+ function write(file) {
173
+ const abs = join(cwd, file.path);
174
+ const body = STAMP + file.body;
175
+
176
+ // A proxy under the *other* name is a collision even when our target is
177
+ // free: the host already has a handler, and Next loads only one of these.
178
+ const counterpartAbs = file.counterpart ? join(cwd, file.counterpart) : null;
179
+ if (counterpartAbs && existsSync(counterpartAbs) && !isOurs(counterpartAbs)) {
180
+ console.log(
181
+ c.warn(`⚠ ${file.counterpart} already exists`) +
182
+ c.dim(` — Next loads only one proxy module, and on this version it is ${file.path}.\n`) +
183
+ mergeHint,
184
+ );
185
+ return;
186
+ }
187
+
188
+ if (existsSync(abs)) {
189
+ // Never overwrite code this CLI did not author — not even with --force.
190
+ // Losing a hand-written proxy (auth redirects, i18n, rate limiting) to a
191
+ // flag someone passed for an unrelated skipped file is not a tradeoff.
192
+ if (!isOurs(abs)) {
193
+ console.log(
194
+ c.warn(`⚠ ${file.path} exists and was not written by Polishd`) +
195
+ c.dim(" — left untouched.\n") +
196
+ (file.mergeHint ?? c.dim(` Rename or delete it to have a fresh copy scaffolded.`)),
197
+ );
198
+ return;
199
+ }
200
+ if (!FORCE) {
201
+ console.log(
202
+ c.dim(`• ${file.path} already scaffolded — skipped (use --force to regenerate)`),
203
+ );
204
+ return;
205
+ }
206
+ if (DRY) {
207
+ console.log(c.dim(`• would back up ${file.path} → ${file.path}.bak and regenerate`));
208
+ return;
209
+ }
210
+ copyFileSync(abs, `${abs}.bak`);
211
+ writeFileSync(abs, body);
212
+ console.log(
213
+ c.ok(`✔ Regenerated ${file.path}`) + c.dim(` (previous copy saved as ${file.path}.bak)`),
214
+ );
186
215
  return;
187
216
  }
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}`;
217
+
191
218
  if (DRY) {
192
- console.log(c.dim(`• would add @source "${source}" to ${rel}`));
219
+ console.log(c.dim(`• would write ${file.path}`));
193
220
  return;
194
221
  }
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;
222
+ mkdirSync(dirname(abs), { recursive: true });
223
+ writeFileSync(abs, body);
224
+ console.log(c.ok(`✔ Created ${file.path}`));
204
225
  }
205
226
 
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
- }
227
+ for (const f of files) write(f);
215
228
 
216
- wireTailwind();
229
+ // ── .gitignore ─────────────────────────────────────────────────────────────
230
+ const giPath = join(cwd, ".gitignore");
231
+ const gi = readText(giPath) ?? "";
232
+ if (!gi.includes(".polishd")) {
233
+ if (DRY) {
234
+ console.log(c.dim("• would add .polishd/ to .gitignore"));
235
+ } else {
236
+ appendFileSync(giPath, `${gi.endsWith("\n") || gi === "" ? "" : "\n"}.polishd/\n`);
237
+ console.log(c.ok("✔ Added .polishd/ to .gitignore"));
238
+ }
239
+ }
217
240
 
218
- // ── Next steps ───────────────────────────────────────────────────────────────
219
- console.log(`
241
+ console.log(`
220
242
  ${c.bold("Next steps:")}
221
243
  1. Local dev needs nothing — events write to ${c.dim(".polishd/analytics.db")} (SQLite).
222
244
  2. For production, set ${c.dim("POLISHD_DATABASE_URL")} (pooled Postgres). See DATABASE.md.
223
- 3. Visit ${c.bold("/polishd")} to see the dashboard.
245
+ 3. Set ${c.dim("POLISHD_DASHBOARD_TOKEN")} to protect ${c.bold("/polishd")} in production.
246
+ 4. Visit ${c.bold("/polishd")} to see the dashboard.
247
+ 5. Check the wiring with ${c.bold("npx @polishd/next doctor")}.
224
248
  ${DRY ? c.warn("\n(dry run — nothing was written)") : ""}`);
249
+
250
+ return 0;
251
+ }
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The `polishd` CLI.
4
+ *
5
+ * npx @polishd/next init scaffold the glue files
6
+ * npx @polishd/next doctor check that an install is actually wired up
7
+ *
8
+ * `init` is the default so the historical `npx polishd` keeps working.
9
+ * Node builtins only; zero runtime dependencies.
10
+ */
11
+ import { c } from "./shared.mjs";
12
+
13
+ const argv = process.argv.slice(2);
14
+ const cwd = process.cwd();
15
+ const command = argv[0] && !argv[0].startsWith("-") ? argv[0] : "init";
16
+ const rest = argv.filter((a) => a !== command);
17
+
18
+ if (argv.includes("--help") || argv.includes("-h")) {
19
+ console.log(`
20
+ ${c.bold("polishd")} — drop-in product analytics for Next.js
21
+
22
+ ${c.bold("Usage")}
23
+ npx @polishd/next init [options] scaffold the files a Next.js app needs
24
+ npx @polishd/next doctor [options] check an existing install
25
+
26
+ ${c.bold("init options")}
27
+ --force regenerate files this CLI wrote (backed up to .bak first).
28
+ Files it did not write are never overwritten.
29
+ --dry-run print what would happen, write nothing
30
+ --js emit .js/.jsx instead of TypeScript
31
+ --config also write a starter polishd.config.ts
32
+
33
+ ${c.bold("doctor options")}
34
+ --url <origin> also run live checks against a running server
35
+ --js inspect .js/.jsx files instead of TypeScript
36
+
37
+ ${c.bold("Installing with a coding agent")}
38
+ Polishd ships a machine-readable install procedure. Paste this to Claude Code,
39
+ Cursor, Copilot, or any other coding agent and let it do the whole install:
40
+
41
+ ${c.dim(" ───────────────────────────────────────────────────────────────────────")}
42
+ Install @polishd/next into this Next.js app.
43
+
44
+ Follow the procedure in AGENTS.md from the package exactly — after
45
+ installing, read node_modules/@polishd/next/AGENTS.md. If the package
46
+ isn't installed yet, read it at
47
+ https://github.com/muskanvaswan/polishd/blob/main/AGENTS.md
48
+
49
+ Work through its phases in order and do not skip Phase 7 (verification).
50
+ Several of its constraints fail silently at runtime rather than at build
51
+ time, so a passing build does not mean the install worked.
52
+
53
+ Finish by running \`npx @polishd/next doctor\` and fixing everything it
54
+ reports.
55
+ ${c.dim(" ───────────────────────────────────────────────────────────────────────")}
56
+
57
+ ${c.bold("Docs")}
58
+ AGENTS.md the install procedure (agent-oriented, and the most precise)
59
+ README.md overview, protecting the dashboard, configuration
60
+ docs/SETUP.md full walkthrough, every variant, troubleshooting
61
+ DATABASE.md production database setup, schema, retention
62
+ `);
63
+ process.exit(0);
64
+ }
65
+
66
+ let code = 0;
67
+ switch (command) {
68
+ case "init": {
69
+ const { runInit } = await import("./init.mjs");
70
+ code = runInit(rest, cwd);
71
+ break;
72
+ }
73
+ case "doctor": {
74
+ const { runDoctor } = await import("./doctor.mjs");
75
+ code = await runDoctor(rest, cwd);
76
+ break;
77
+ }
78
+ default:
79
+ console.error(
80
+ c.err(`✗ Unknown command "${command}"`) + c.dim("\n Try `init` or `doctor`, or --help."),
81
+ );
82
+ code = 1;
83
+ }
84
+
85
+ process.exit(code);
package/bin/shared.mjs ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Shared helpers for the `polishd` CLI. Node builtins only.
3
+ *
4
+ * `init` and `doctor` have to agree exactly about where things live and which
5
+ * Next major is installed — a doctor that inspected different paths than init
6
+ * wrote would be worse than no doctor at all.
7
+ */
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { createRequire } from "node:module";
10
+ import { join } from "node:path";
11
+
12
+ export const c = {
13
+ ok: (s) => `\x1b[32m${s}\x1b[0m`,
14
+ warn: (s) => `\x1b[33m${s}\x1b[0m`,
15
+ err: (s) => `\x1b[31m${s}\x1b[0m`,
16
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
17
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
18
+ };
19
+
20
+ export function readJSON(path) {
21
+ try {
22
+ return JSON.parse(readFileSync(path, "utf8"));
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export function readText(path) {
29
+ try {
30
+ return readFileSync(path, "utf8");
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /** Where the app's code lives: `src/app` layout or root `app`. */
37
+ export function detectLayout(cwd) {
38
+ const hasSrcApp = existsSync(join(cwd, "src", "app"));
39
+ const hasRootApp = existsSync(join(cwd, "app"));
40
+ return {
41
+ found: hasSrcApp || hasRootApp,
42
+ hasSrcApp,
43
+ base: hasSrcApp ? "src" : "",
44
+ appDir: hasSrcApp ? join("src", "app") : "app",
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Which Next major is actually installed.
50
+ *
51
+ * Load-bearing: 16+ loads `proxy.ts`, 15 loads `middleware.ts`, and writing
52
+ * the wrong one means the file never runs — no session cookie, every beacon
53
+ * still 200, nothing stored, and no error to follow.
54
+ */
55
+ export function detectNextMajor(cwd) {
56
+ // Prefer the resolved install. A declared range ("^16", "latest", "canary")
57
+ // does not tell you what is actually in node_modules.
58
+ const direct = readJSON(join(cwd, "node_modules", "next", "package.json"));
59
+ if (direct?.version) {
60
+ const major = Number.parseInt(direct.version, 10);
61
+ if (Number.isFinite(major)) {
62
+ return { major, version: direct.version, source: "node_modules" };
63
+ }
64
+ }
65
+ // Hoisted / pnpm / workspace installs won't be at that path.
66
+ try {
67
+ const require = createRequire(join(cwd, "package.json"));
68
+ const resolved = readJSON(require.resolve("next/package.json"));
69
+ if (resolved?.version) {
70
+ const major = Number.parseInt(resolved.version, 10);
71
+ if (Number.isFinite(major)) {
72
+ return { major, version: resolved.version, source: "node_modules" };
73
+ }
74
+ }
75
+ } catch {
76
+ // `next` not installed yet, or its exports map hides package.json.
77
+ }
78
+ // Last resort: the declared range. Better than guessing blind.
79
+ const pkg = readJSON(join(cwd, "package.json"));
80
+ const range =
81
+ pkg?.dependencies?.next ?? pkg?.devDependencies?.next ?? pkg?.peerDependencies?.next;
82
+ if (typeof range === "string") {
83
+ const major = Number.parseInt(range.replace(/^[^\d]*/, ""), 10);
84
+ if (Number.isFinite(major)) return { major, version: range, source: "package.json" };
85
+ }
86
+ return { major: null, version: null, source: null };
87
+ }
88
+
89
+ /**
90
+ * The proxy file this app should have, given its Next major.
91
+ * An undetermined version is treated as modern, which is also what init writes.
92
+ */
93
+ export function proxyShape(next, base, codeExt) {
94
+ const useMiddleware = next.major !== null && next.major <= 15;
95
+ const name = useMiddleware ? "middleware" : "proxy";
96
+ const other = useMiddleware ? "proxy" : "middleware";
97
+ return {
98
+ useMiddleware,
99
+ name,
100
+ other,
101
+ path: join(base, `${name}.${codeExt}`),
102
+ otherPath: join(base, `${other}.${codeExt}`),
103
+ };
104
+ }
105
+
106
+ export const MIN_NODE = { major: 22, minor: 5 };
107
+
108
+ export function nodeVersionOk() {
109
+ const [major, minor] = process.versions.node.split(".").map(Number);
110
+ return major > MIN_NODE.major || (major === MIN_NODE.major && minor >= MIN_NODE.minor);
111
+ }
112
+
113
+ /** The dist path Tailwind must be told to scan (v4 `@source`, v3 `content`). */
114
+ export const DIST_GLOB = "node_modules/@polishd/next/dist";