@remcostoeten/create-analytics 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.
- package/README.md +35 -0
- package/dist/cli.js +526 -0
- package/dist/cli.js.map +1 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# create-analytics
|
|
2
|
+
|
|
3
|
+
Scaffold [Remco Analytics](https://github.com/remcostoeten/analytics) wiring for Next.js.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx create-analytics my-app
|
|
9
|
+
npx create-analytics my-app --tier separate --yes
|
|
10
|
+
npx create-analytics my-app --tier colocated
|
|
11
|
+
npx create-analytics my-app --tier sdk-only
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Tiers
|
|
15
|
+
|
|
16
|
+
| Tier | Flag | What you get |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| **1 — Recommended** | `separate` | `apps/web` (SDK only) + `apps/analytics-api` (ingestion) |
|
|
19
|
+
| **2 — Co-located** | `colocated` | One Next.js app with SDK + API route (larger server bundle) |
|
|
20
|
+
| **3 — Existing URL** | `sdk-only` | Next.js app with SDK only |
|
|
21
|
+
|
|
22
|
+
Default is Tier 1 — keeps ingestion out of your main app deploy.
|
|
23
|
+
|
|
24
|
+
## Output
|
|
25
|
+
|
|
26
|
+
Tier 1 example:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
my-app/
|
|
30
|
+
README.md
|
|
31
|
+
apps/web/ @remcostoeten/analytics
|
|
32
|
+
apps/analytics-api/ @remcostoeten/ingestion
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
See generated `README.md` for Neon, migrations, and Vercel steps.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { parseArgs } from "util";
|
|
5
|
+
import { resolve } from "path";
|
|
6
|
+
import { access } from "fs/promises";
|
|
7
|
+
|
|
8
|
+
// src/scaffold.ts
|
|
9
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
10
|
+
import { dirname, join } from "path";
|
|
11
|
+
|
|
12
|
+
// src/templates.ts
|
|
13
|
+
function webLayout(projectId) {
|
|
14
|
+
return `import { Analytics } from "@remcostoeten/analytics";
|
|
15
|
+
|
|
16
|
+
export default function RootLayout({
|
|
17
|
+
children,
|
|
18
|
+
}: Readonly<{
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
}>) {
|
|
21
|
+
return (
|
|
22
|
+
<html lang="en">
|
|
23
|
+
<body>
|
|
24
|
+
{children}
|
|
25
|
+
<Analytics projectId="${projectId}" />
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
31
|
+
}
|
|
32
|
+
function webPage() {
|
|
33
|
+
return `export default function Home() {
|
|
34
|
+
return (
|
|
35
|
+
<main>
|
|
36
|
+
<h1>Analytics wired</h1>
|
|
37
|
+
<p>Pageviews track automatically via the Analytics component in layout.tsx.</p>
|
|
38
|
+
</main>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
`;
|
|
42
|
+
}
|
|
43
|
+
function webEnvExample(ingestUrl) {
|
|
44
|
+
return `# Browser SDK (public)
|
|
45
|
+
NEXT_PUBLIC_ANALYTICS_URL=${ingestUrl}
|
|
46
|
+
|
|
47
|
+
# Server tracking (private \u2014 never use NEXT_PUBLIC_)
|
|
48
|
+
ANALYTICS_URL=${ingestUrl}
|
|
49
|
+
INGEST_SECRET=replace-with-a-long-random-secret
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
function webPackageJson() {
|
|
53
|
+
return JSON.stringify(
|
|
54
|
+
{
|
|
55
|
+
name: "web",
|
|
56
|
+
private: true,
|
|
57
|
+
scripts: {
|
|
58
|
+
dev: "next dev",
|
|
59
|
+
build: "next build",
|
|
60
|
+
start: "next start"
|
|
61
|
+
},
|
|
62
|
+
dependencies: {
|
|
63
|
+
"@remcostoeten/analytics": "^1.5.0",
|
|
64
|
+
next: "^15.0.0",
|
|
65
|
+
react: "^19.0.0",
|
|
66
|
+
"react-dom": "^19.0.0"
|
|
67
|
+
},
|
|
68
|
+
devDependencies: {
|
|
69
|
+
"@types/node": "^20.0.0",
|
|
70
|
+
"@types/react": "^19.0.0",
|
|
71
|
+
typescript: "^5.6.0"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
null,
|
|
75
|
+
" "
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
function webTsConfig() {
|
|
79
|
+
return JSON.stringify(
|
|
80
|
+
{
|
|
81
|
+
compilerOptions: {
|
|
82
|
+
target: "ES2022",
|
|
83
|
+
lib: ["dom", "dom.iterable", "esnext"],
|
|
84
|
+
allowJs: true,
|
|
85
|
+
skipLibCheck: true,
|
|
86
|
+
strict: true,
|
|
87
|
+
noEmit: true,
|
|
88
|
+
module: "esnext",
|
|
89
|
+
moduleResolution: "bundler",
|
|
90
|
+
isolatedModules: true,
|
|
91
|
+
jsx: "preserve",
|
|
92
|
+
incremental: true,
|
|
93
|
+
plugins: [{ name: "next" }],
|
|
94
|
+
paths: { "@/*": ["./*"] }
|
|
95
|
+
},
|
|
96
|
+
include: ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
|
97
|
+
exclude: ["node_modules"]
|
|
98
|
+
},
|
|
99
|
+
null,
|
|
100
|
+
" "
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
function apiPackageJson() {
|
|
104
|
+
return JSON.stringify(
|
|
105
|
+
{
|
|
106
|
+
name: "analytics-api",
|
|
107
|
+
private: true,
|
|
108
|
+
type: "module",
|
|
109
|
+
dependencies: {
|
|
110
|
+
"@remcostoeten/ingestion": "^0.1.0",
|
|
111
|
+
"@neondatabase/serverless": "^0.10.0",
|
|
112
|
+
"drizzle-orm": "^0.36.0",
|
|
113
|
+
hono: "^4.6.0",
|
|
114
|
+
"ua-parser-js": "^2.0.0",
|
|
115
|
+
zod: "^3.22.0"
|
|
116
|
+
},
|
|
117
|
+
devDependencies: {
|
|
118
|
+
"drizzle-kit": "^0.31.0",
|
|
119
|
+
typescript: "^5.6.0"
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
null,
|
|
123
|
+
" "
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
function apiEnvExample() {
|
|
127
|
+
return `DATABASE_URL=postgres://user:password@host/db
|
|
128
|
+
IP_HASH_SECRET=replace-with-at-least-32-characters
|
|
129
|
+
ORIGIN_ALLOWLIST=https://your-app.vercel.app
|
|
130
|
+
INGEST_SECRET=replace-with-a-long-random-secret
|
|
131
|
+
`;
|
|
132
|
+
}
|
|
133
|
+
function apiHandler() {
|
|
134
|
+
return `export { default } from "@remcostoeten/ingestion/vercel";
|
|
135
|
+
`;
|
|
136
|
+
}
|
|
137
|
+
function apiVercelJson() {
|
|
138
|
+
return JSON.stringify(
|
|
139
|
+
{
|
|
140
|
+
rewrites: [{ source: "/(.*)", destination: "/api" }]
|
|
141
|
+
},
|
|
142
|
+
null,
|
|
143
|
+
" "
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
function ingestRoute() {
|
|
147
|
+
return `import { app } from "@remcostoeten/ingestion";
|
|
148
|
+
|
|
149
|
+
async function handle(request: Request) {
|
|
150
|
+
return app.fetch(request);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const GET = handle;
|
|
154
|
+
export const POST = handle;
|
|
155
|
+
`;
|
|
156
|
+
}
|
|
157
|
+
function serverTrackingExample(projectId) {
|
|
158
|
+
return `import { trackServerEvent } from "@remcostoeten/analytics/server";
|
|
159
|
+
|
|
160
|
+
export async function POST() {
|
|
161
|
+
// your server logic here
|
|
162
|
+
|
|
163
|
+
await trackServerEvent("example_action", { projectId: "${projectId}", path: "/api/example" });
|
|
164
|
+
|
|
165
|
+
return Response.json({ ok: true });
|
|
166
|
+
}
|
|
167
|
+
`;
|
|
168
|
+
}
|
|
169
|
+
function colocatedPackageJson(projectName) {
|
|
170
|
+
return JSON.stringify(
|
|
171
|
+
{
|
|
172
|
+
name: projectName,
|
|
173
|
+
private: true,
|
|
174
|
+
scripts: {
|
|
175
|
+
dev: "next dev",
|
|
176
|
+
build: "next build",
|
|
177
|
+
start: "next start"
|
|
178
|
+
},
|
|
179
|
+
dependencies: {
|
|
180
|
+
"@remcostoeten/analytics": "^1.5.0",
|
|
181
|
+
"@remcostoeten/ingestion": "^0.1.0",
|
|
182
|
+
"@neondatabase/serverless": "^0.10.0",
|
|
183
|
+
"drizzle-orm": "^0.36.0",
|
|
184
|
+
hono: "^4.6.0",
|
|
185
|
+
next: "^15.0.0",
|
|
186
|
+
react: "^19.0.0",
|
|
187
|
+
"react-dom": "^19.0.0",
|
|
188
|
+
"ua-parser-js": "^2.0.0",
|
|
189
|
+
zod: "^3.22.0"
|
|
190
|
+
},
|
|
191
|
+
devDependencies: {
|
|
192
|
+
"@types/node": "^20.0.0",
|
|
193
|
+
"@types/react": "^19.0.0",
|
|
194
|
+
"drizzle-kit": "^0.31.0",
|
|
195
|
+
typescript: "^5.6.0"
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
null,
|
|
199
|
+
" "
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
function colocatedEnvExample() {
|
|
203
|
+
return `NEXT_PUBLIC_ANALYTICS_URL=http://localhost:3000
|
|
204
|
+
ANALYTICS_URL=http://localhost:3000
|
|
205
|
+
INGEST_SECRET=replace-with-a-long-random-secret
|
|
206
|
+
ORIGIN_ALLOWLIST=http://localhost:3000
|
|
207
|
+
DATABASE_URL=postgres://user:password@host/db
|
|
208
|
+
IP_HASH_SECRET=replace-with-at-least-32-characters
|
|
209
|
+
`;
|
|
210
|
+
}
|
|
211
|
+
function readmeSeparate(projectName, projectId) {
|
|
212
|
+
return `# ${projectName}
|
|
213
|
+
|
|
214
|
+
Tier 1 setup: SDK in \`apps/web\`, ingestion in \`apps/analytics-api\` (separate deploy).
|
|
215
|
+
|
|
216
|
+
## 1. Web app
|
|
217
|
+
|
|
218
|
+
\`\`\`bash
|
|
219
|
+
cd apps/web
|
|
220
|
+
npm install
|
|
221
|
+
cp .env.example .env.local
|
|
222
|
+
npm run dev
|
|
223
|
+
\`\`\`
|
|
224
|
+
|
|
225
|
+
## 2. Analytics API
|
|
226
|
+
|
|
227
|
+
\`\`\`bash
|
|
228
|
+
cd apps/analytics-api
|
|
229
|
+
npm install
|
|
230
|
+
cp .env.example .env
|
|
231
|
+
\`\`\`
|
|
232
|
+
|
|
233
|
+
Create a Neon database, set \`DATABASE_URL\` and \`IP_HASH_SECRET\` (min 32 chars).
|
|
234
|
+
|
|
235
|
+
Run migrations:
|
|
236
|
+
|
|
237
|
+
\`\`\`bash
|
|
238
|
+
npx drizzle-kit up:pg --config node_modules/@remcostoeten/ingestion/drizzle.config.ts
|
|
239
|
+
\`\`\`
|
|
240
|
+
|
|
241
|
+
Deploy \`apps/analytics-api\` to Vercel, then set \`NEXT_PUBLIC_ANALYTICS_URL\` in the web app to that URL.
|
|
242
|
+
|
|
243
|
+
## Server-side tracking
|
|
244
|
+
|
|
245
|
+
For events that happen on your backend (API routes, webhooks, cron jobs), use the server entry:
|
|
246
|
+
|
|
247
|
+
\`\`\`typescript
|
|
248
|
+
// apps/web/app/api/example/route.ts
|
|
249
|
+
import { trackServerEvent } from "@remcostoeten/analytics/server";
|
|
250
|
+
|
|
251
|
+
export async function POST() {
|
|
252
|
+
await trackServerEvent("signup_completed", { projectId: "${projectId}", path: "/api/signup" });
|
|
253
|
+
return Response.json({ ok: true });
|
|
254
|
+
}
|
|
255
|
+
\`\`\`
|
|
256
|
+
|
|
257
|
+
Set \`ANALYTICS_URL\` and \`INGEST_SECRET\` in \`apps/web/.env.local\` (server-only \u2014 never \`NEXT_PUBLIC_\`).
|
|
258
|
+
|
|
259
|
+
On the ingestion side, set the same \`INGEST_SECRET\` so it can authenticate server requests.
|
|
260
|
+
|
|
261
|
+
## Project ID
|
|
262
|
+
|
|
263
|
+
Events use \`projectId="${projectId}"\`. Change in \`apps/web/app/layout.tsx\` if needed.
|
|
264
|
+
`;
|
|
265
|
+
}
|
|
266
|
+
function readmeColocated(projectName, projectId) {
|
|
267
|
+
return `# ${projectName}
|
|
268
|
+
|
|
269
|
+
Tier 2 setup: SDK and ingestion in one Next.js app.
|
|
270
|
+
|
|
271
|
+
Warning: this adds server-side ingestion dependencies to your app deploy (larger serverless bundle).
|
|
272
|
+
|
|
273
|
+
Ingestion routes: \`app/e/route.ts\` and \`app/ingest/route.ts\` (SDK posts to \`/e\`).
|
|
274
|
+
|
|
275
|
+
## Setup
|
|
276
|
+
|
|
277
|
+
\`\`\`bash
|
|
278
|
+
npm install
|
|
279
|
+
cp .env.example .env.local
|
|
280
|
+
\`\`\`
|
|
281
|
+
|
|
282
|
+
Set \`DATABASE_URL\`, \`IP_HASH_SECRET\`, and \`NEXT_PUBLIC_ANALYTICS_URL\` (your app URL in production).
|
|
283
|
+
|
|
284
|
+
Run migrations:
|
|
285
|
+
|
|
286
|
+
\`\`\`bash
|
|
287
|
+
npx drizzle-kit up:pg --config node_modules/@remcostoeten/ingestion/drizzle.config.ts
|
|
288
|
+
\`\`\`
|
|
289
|
+
|
|
290
|
+
\`\`\`bash
|
|
291
|
+
npm run dev
|
|
292
|
+
\`\`\`
|
|
293
|
+
|
|
294
|
+
## Server-side tracking
|
|
295
|
+
|
|
296
|
+
For events that happen in API routes or server actions, use the server entry. See \`app/api/example/route.ts\`:
|
|
297
|
+
|
|
298
|
+
\`\`\`typescript
|
|
299
|
+
import { trackServerEvent } from "@remcostoeten/analytics/server";
|
|
300
|
+
|
|
301
|
+
export async function POST() {
|
|
302
|
+
await trackServerEvent("signup_completed", { projectId: "${projectId}", path: "/api/signup" });
|
|
303
|
+
return Response.json({ ok: true });
|
|
304
|
+
}
|
|
305
|
+
\`\`\`
|
|
306
|
+
|
|
307
|
+
\`ANALYTICS_URL\` and \`INGEST_SECRET\` are already in \`.env.example\`. Never use \`NEXT_PUBLIC_\` for these.
|
|
308
|
+
|
|
309
|
+
## Project ID
|
|
310
|
+
|
|
311
|
+
Events use \`projectId="${projectId}"\`. Change in \`app/layout.tsx\` if needed.
|
|
312
|
+
`;
|
|
313
|
+
}
|
|
314
|
+
function readmeSdkOnly(projectName, projectId) {
|
|
315
|
+
return `# ${projectName}
|
|
316
|
+
|
|
317
|
+
Tier 3 setup: SDK only. Point at an existing ingestion URL.
|
|
318
|
+
|
|
319
|
+
## Setup
|
|
320
|
+
|
|
321
|
+
\`\`\`bash
|
|
322
|
+
npm install
|
|
323
|
+
cp .env.example .env.local
|
|
324
|
+
\`\`\`
|
|
325
|
+
|
|
326
|
+
Set \`NEXT_PUBLIC_ANALYTICS_URL\` to your ingestion base URL (posts to \`/e\`).
|
|
327
|
+
|
|
328
|
+
\`\`\`bash
|
|
329
|
+
npm run dev
|
|
330
|
+
\`\`\`
|
|
331
|
+
|
|
332
|
+
## Project ID
|
|
333
|
+
|
|
334
|
+
Events use \`projectId="${projectId}"\`. Change in \`app/layout.tsx\` if needed.
|
|
335
|
+
`;
|
|
336
|
+
}
|
|
337
|
+
function buildFiles(options) {
|
|
338
|
+
const ingestPlaceholder = "https://your-analytics-api.vercel.app";
|
|
339
|
+
if (options.tier === "separate") {
|
|
340
|
+
return [
|
|
341
|
+
{ path: "README.md", content: readmeSeparate(options.projectName, options.projectId) },
|
|
342
|
+
{ path: "apps/web/package.json", content: webPackageJson() },
|
|
343
|
+
{ path: "apps/web/tsconfig.json", content: webTsConfig() },
|
|
344
|
+
{ path: "apps/web/next.config.mjs", content: "export default {};\n" },
|
|
345
|
+
{ path: "apps/web/.env.example", content: webEnvExample(ingestPlaceholder) },
|
|
346
|
+
{ path: "apps/web/app/layout.tsx", content: webLayout(options.projectId) },
|
|
347
|
+
{ path: "apps/web/app/page.tsx", content: webPage() },
|
|
348
|
+
{ path: "apps/web/app/api/example/route.ts", content: serverTrackingExample(options.projectId) },
|
|
349
|
+
{ path: "apps/analytics-api/package.json", content: apiPackageJson() },
|
|
350
|
+
{ path: "apps/analytics-api/api/index.ts", content: apiHandler() },
|
|
351
|
+
{ path: "apps/analytics-api/vercel.json", content: apiVercelJson() },
|
|
352
|
+
{ path: "apps/analytics-api/.env.example", content: apiEnvExample() }
|
|
353
|
+
];
|
|
354
|
+
}
|
|
355
|
+
if (options.tier === "colocated") {
|
|
356
|
+
return [
|
|
357
|
+
{ path: "README.md", content: readmeColocated(options.projectName, options.projectId) },
|
|
358
|
+
{ path: "package.json", content: colocatedPackageJson(options.projectName) },
|
|
359
|
+
{ path: "tsconfig.json", content: webTsConfig() },
|
|
360
|
+
{ path: "next.config.mjs", content: "export default {};\n" },
|
|
361
|
+
{ path: ".env.example", content: colocatedEnvExample() },
|
|
362
|
+
{ path: "app/layout.tsx", content: webLayout(options.projectId) },
|
|
363
|
+
{ path: "app/page.tsx", content: webPage() },
|
|
364
|
+
{ path: "app/e/route.ts", content: ingestRoute() },
|
|
365
|
+
{ path: "app/ingest/route.ts", content: ingestRoute() },
|
|
366
|
+
{ path: "app/api/example/route.ts", content: serverTrackingExample(options.projectId) }
|
|
367
|
+
];
|
|
368
|
+
}
|
|
369
|
+
return [
|
|
370
|
+
{ path: "README.md", content: readmeSdkOnly(options.projectName, options.projectId) },
|
|
371
|
+
{ path: "package.json", content: webPackageJson().replace('"name": "web"', `"name": "${options.projectName}"`) },
|
|
372
|
+
{ path: "tsconfig.json", content: webTsConfig() },
|
|
373
|
+
{ path: "next.config.mjs", content: "export default {};\n" },
|
|
374
|
+
{ path: ".env.example", content: webEnvExample(ingestPlaceholder) },
|
|
375
|
+
{ path: "app/layout.tsx", content: webLayout(options.projectId) },
|
|
376
|
+
{ path: "app/page.tsx", content: webPage() }
|
|
377
|
+
];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// src/scaffold.ts
|
|
381
|
+
async function scaffoldProject(options) {
|
|
382
|
+
const files = buildFiles(options);
|
|
383
|
+
const written = [];
|
|
384
|
+
for (const file of files) {
|
|
385
|
+
const fullPath = join(options.targetDir, file.path);
|
|
386
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
387
|
+
await writeFile(fullPath, file.content, "utf8");
|
|
388
|
+
written.push(file.path);
|
|
389
|
+
}
|
|
390
|
+
return written;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// src/prompt.ts
|
|
394
|
+
import { createInterface } from "readline/promises";
|
|
395
|
+
import { stdin as input, stdout as output } from "process";
|
|
396
|
+
var tierLabels = {
|
|
397
|
+
separate: "Separate analytics-api project (recommended)",
|
|
398
|
+
colocated: "API route in this app (larger server bundle)",
|
|
399
|
+
"sdk-only": "SDK only \u2014 I already have an ingestion URL"
|
|
400
|
+
};
|
|
401
|
+
async function promptTier() {
|
|
402
|
+
const rl = createInterface({ input, output });
|
|
403
|
+
console.log("\nIntegration tier:\n");
|
|
404
|
+
const tiers = ["separate", "colocated", "sdk-only"];
|
|
405
|
+
for (let i = 0; i < tiers.length; i++) {
|
|
406
|
+
const marker = i === 0 ? "\u2192" : " ";
|
|
407
|
+
console.log(` ${marker} ${i + 1}. ${tierLabels[tiers[i]]}`);
|
|
408
|
+
}
|
|
409
|
+
const answer = await rl.question("\nChoose [1]: ");
|
|
410
|
+
rl.close();
|
|
411
|
+
const index = answer.trim() === "" ? 0 : Number.parseInt(answer, 10) - 1;
|
|
412
|
+
if (index >= 0 && index < tiers.length) return tiers[index];
|
|
413
|
+
return "separate";
|
|
414
|
+
}
|
|
415
|
+
async function promptProjectName(defaultName) {
|
|
416
|
+
const rl = createInterface({ input, output });
|
|
417
|
+
const answer = await rl.question(`Project name [${defaultName}]: `);
|
|
418
|
+
rl.close();
|
|
419
|
+
const name = answer.trim() || defaultName;
|
|
420
|
+
return name.replace(/[^a-zA-Z0-9-_]/g, "-").toLowerCase();
|
|
421
|
+
}
|
|
422
|
+
function parseTier(value) {
|
|
423
|
+
if (value === "separate" || value === "1") return "separate";
|
|
424
|
+
if (value === "colocated" || value === "2") return "colocated";
|
|
425
|
+
if (value === "sdk-only" || value === "sdk" || value === "3") return "sdk-only";
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/cli.ts
|
|
430
|
+
function printUsage() {
|
|
431
|
+
console.log(`
|
|
432
|
+
create-analytics \u2014 scaffold Remco Analytics
|
|
433
|
+
|
|
434
|
+
Usage:
|
|
435
|
+
npx create-analytics [project-name] [options]
|
|
436
|
+
|
|
437
|
+
Options:
|
|
438
|
+
--tier <separate|colocated|sdk-only> Integration tier (default: separate)
|
|
439
|
+
--yes Skip prompts when tier is set
|
|
440
|
+
-h, --help Show help
|
|
441
|
+
|
|
442
|
+
Tiers:
|
|
443
|
+
separate SDK in apps/web, ingestion in apps/analytics-api (recommended)
|
|
444
|
+
colocated SDK + ingestion API route in one Next.js app
|
|
445
|
+
sdk-only SDK only, existing ingestion URL
|
|
446
|
+
`);
|
|
447
|
+
}
|
|
448
|
+
async function directoryEmpty(dir) {
|
|
449
|
+
try {
|
|
450
|
+
await access(dir);
|
|
451
|
+
return false;
|
|
452
|
+
} catch {
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
async function resolveOptions(args, positionalName) {
|
|
457
|
+
const defaultName = positionalName?.trim() || "my-analytics-app";
|
|
458
|
+
let tier = parseTier(args.tier);
|
|
459
|
+
let projectName = defaultName;
|
|
460
|
+
if (!tier && !args.yes) {
|
|
461
|
+
tier = await promptTier();
|
|
462
|
+
projectName = await promptProjectName(defaultName);
|
|
463
|
+
} else {
|
|
464
|
+
tier = tier ?? "separate";
|
|
465
|
+
if (!args.yes && !args.tier) {
|
|
466
|
+
projectName = await promptProjectName(defaultName);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const targetDir = resolve(process.cwd(), projectName);
|
|
470
|
+
if (!await directoryEmpty(targetDir)) {
|
|
471
|
+
console.error(`Error: "${projectName}" already exists. Choose a different name or empty the folder.`);
|
|
472
|
+
process.exit(1);
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
projectName,
|
|
476
|
+
projectId: projectName,
|
|
477
|
+
tier,
|
|
478
|
+
targetDir
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
async function main() {
|
|
482
|
+
const { values, positionals } = parseArgs({
|
|
483
|
+
args: process.argv.slice(2),
|
|
484
|
+
options: {
|
|
485
|
+
tier: { type: "string" },
|
|
486
|
+
yes: { type: "boolean", short: "y" },
|
|
487
|
+
help: { type: "boolean", short: "h" }
|
|
488
|
+
},
|
|
489
|
+
allowPositionals: true
|
|
490
|
+
});
|
|
491
|
+
if (values.help) {
|
|
492
|
+
printUsage();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const options = await resolveOptions(
|
|
496
|
+
{
|
|
497
|
+
tier: values.tier,
|
|
498
|
+
yes: values.yes
|
|
499
|
+
},
|
|
500
|
+
positionals[0]
|
|
501
|
+
);
|
|
502
|
+
console.log(`
|
|
503
|
+
Scaffolding ${options.projectName} (${options.tier})...
|
|
504
|
+
`);
|
|
505
|
+
const written = await scaffoldProject(options);
|
|
506
|
+
for (const file of written) {
|
|
507
|
+
console.log(` created ${file}`);
|
|
508
|
+
}
|
|
509
|
+
console.log(`
|
|
510
|
+
Done. Next steps:
|
|
511
|
+
|
|
512
|
+
cd ${options.projectName}
|
|
513
|
+
cat README.md
|
|
514
|
+
`);
|
|
515
|
+
if (options.tier === "separate") {
|
|
516
|
+
console.log(` Tier 1: deploy apps/analytics-api separately, then set NEXT_PUBLIC_ANALYTICS_URL in apps/web`);
|
|
517
|
+
}
|
|
518
|
+
if (options.tier === "colocated") {
|
|
519
|
+
console.log(` Tier 2: adds ingestion deps to this deploy \u2014 see README for bundle tradeoff`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
main().catch(function(error) {
|
|
523
|
+
console.error(error instanceof Error ? error.message : error);
|
|
524
|
+
process.exit(1);
|
|
525
|
+
});
|
|
526
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cli.ts","../src/scaffold.ts","../src/templates.ts","../src/prompt.ts"],"sourcesContent":["import { parseArgs } from \"node:util\";\nimport { resolve } from \"node:path\";\nimport { access } from \"node:fs/promises\";\nimport { scaffoldProject } from \"./scaffold\";\nimport { parseTier, promptProjectName, promptTier } from \"./prompt\";\nimport { type Tier } from \"./types\";\n\ntype CliArgs = {\n\tprojectName?: string;\n\ttier?: string;\n\tyes?: boolean;\n};\n\nfunction printUsage(): void {\n\tconsole.log(`\ncreate-analytics — scaffold Remco Analytics\n\nUsage:\n npx create-analytics [project-name] [options]\n\nOptions:\n --tier <separate|colocated|sdk-only> Integration tier (default: separate)\n --yes Skip prompts when tier is set\n -h, --help Show help\n\nTiers:\n separate SDK in apps/web, ingestion in apps/analytics-api (recommended)\n colocated SDK + ingestion API route in one Next.js app\n sdk-only SDK only, existing ingestion URL\n`);\n}\n\nasync function directoryEmpty(dir: string): Promise<boolean> {\n\ttry {\n\t\tawait access(dir);\n\t\treturn false;\n\t} catch {\n\t\treturn true;\n\t}\n}\n\nasync function resolveOptions(args: CliArgs, positionalName?: string): Promise<{\n\tprojectName: string;\n\tprojectId: string;\n\ttier: Tier;\n\ttargetDir: string;\n}> {\n\tconst defaultName = positionalName?.trim() || \"my-analytics-app\";\n\tlet tier = parseTier(args.tier);\n\tlet projectName = defaultName;\n\n\tif (!tier && !args.yes) {\n\t\ttier = await promptTier();\n\t\tprojectName = await promptProjectName(defaultName);\n\t} else {\n\t\ttier = tier ?? \"separate\";\n\t\tif (!args.yes && !args.tier) {\n\t\t\tprojectName = await promptProjectName(defaultName);\n\t\t}\n\t}\n\n\tconst targetDir = resolve(process.cwd(), projectName);\n\n\tif (!(await directoryEmpty(targetDir))) {\n\t\tconsole.error(`Error: \"${projectName}\" already exists. Choose a different name or empty the folder.`);\n\t\tprocess.exit(1);\n\t}\n\n\treturn {\n\t\tprojectName,\n\t\tprojectId: projectName,\n\t\ttier,\n\t\ttargetDir,\n\t};\n}\n\nasync function main(): Promise<void> {\n\tconst { values, positionals } = parseArgs({\n\t\targs: process.argv.slice(2),\n\t\toptions: {\n\t\t\ttier: { type: \"string\" },\n\t\t\tyes: { type: \"boolean\", short: \"y\" },\n\t\t\thelp: { type: \"boolean\", short: \"h\" },\n\t\t},\n\t\tallowPositionals: true,\n\t});\n\n\tif (values.help) {\n\t\tprintUsage();\n\t\treturn;\n\t}\n\n\tconst options = await resolveOptions(\n\t\t{\n\t\t\ttier: values.tier,\n\t\t\tyes: values.yes,\n\t\t},\n\t\tpositionals[0],\n\t);\n\n\tconsole.log(`\\nScaffolding ${options.projectName} (${options.tier})...\\n`);\n\n\tconst written = await scaffoldProject(options);\n\n\tfor (const file of written) {\n\t\tconsole.log(` created ${file}`);\n\t}\n\n\tconsole.log(`\nDone. Next steps:\n\n cd ${options.projectName}\n cat README.md\n`);\n\n\tif (options.tier === \"separate\") {\n\t\tconsole.log(` Tier 1: deploy apps/analytics-api separately, then set NEXT_PUBLIC_ANALYTICS_URL in apps/web`);\n\t}\n\n\tif (options.tier === \"colocated\") {\n\t\tconsole.log(` Tier 2: adds ingestion deps to this deploy — see README for bundle tradeoff`);\n\t}\n}\n\nmain().catch(function (error) {\n\tconsole.error(error instanceof Error ? error.message : error);\n\tprocess.exit(1);\n});\n","import { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport { buildFiles } from \"./templates\";\nimport { type ScaffoldOptions } from \"./types\";\n\nexport async function scaffoldProject(options: ScaffoldOptions): Promise<string[]> {\n\tconst files = buildFiles(options);\n\tconst written: string[] = [];\n\n\tfor (const file of files) {\n\t\tconst fullPath = join(options.targetDir, file.path);\n\t\tawait mkdir(dirname(fullPath), { recursive: true });\n\t\tawait writeFile(fullPath, file.content, \"utf8\");\n\t\twritten.push(file.path);\n\t}\n\n\treturn written;\n}\n","import { type ScaffoldFile, type ScaffoldOptions } from \"./types\";\n\nfunction webLayout(projectId: string): string {\n\treturn `import { Analytics } from \"@remcostoeten/analytics\";\n\nexport default function RootLayout({\n\tchildren,\n}: Readonly<{\n\tchildren: React.ReactNode;\n}>) {\n\treturn (\n\t\t<html lang=\"en\">\n\t\t\t<body>\n\t\t\t\t{children}\n\t\t\t\t<Analytics projectId=\"${projectId}\" />\n\t\t\t</body>\n\t\t</html>\n\t);\n}\n`;\n}\n\nfunction webPage(): string {\n\treturn `export default function Home() {\n\treturn (\n\t\t<main>\n\t\t\t<h1>Analytics wired</h1>\n\t\t\t<p>Pageviews track automatically via the Analytics component in layout.tsx.</p>\n\t\t</main>\n\t);\n}\n`;\n}\n\nfunction webEnvExample(ingestUrl: string): string {\n\treturn `# Browser SDK (public)\nNEXT_PUBLIC_ANALYTICS_URL=${ingestUrl}\n\n# Server tracking (private — never use NEXT_PUBLIC_)\nANALYTICS_URL=${ingestUrl}\nINGEST_SECRET=replace-with-a-long-random-secret\n`;\n}\n\nfunction webPackageJson(): string {\n\treturn JSON.stringify(\n\t\t{\n\t\t\tname: \"web\",\n\t\t\tprivate: true,\n\t\t\tscripts: {\n\t\t\t\tdev: \"next dev\",\n\t\t\t\tbuild: \"next build\",\n\t\t\t\tstart: \"next start\",\n\t\t\t},\n\t\t\tdependencies: {\n\t\t\t\t\"@remcostoeten/analytics\": \"^1.5.0\",\n\t\t\t\tnext: \"^15.0.0\",\n\t\t\t\treact: \"^19.0.0\",\n\t\t\t\t\"react-dom\": \"^19.0.0\",\n\t\t\t},\n\t\t\tdevDependencies: {\n\t\t\t\t\"@types/node\": \"^20.0.0\",\n\t\t\t\t\"@types/react\": \"^19.0.0\",\n\t\t\t\ttypescript: \"^5.6.0\",\n\t\t\t},\n\t\t},\n\t\tnull,\n\t\t\"\\t\",\n\t);\n}\n\nfunction webTsConfig(): string {\n\treturn JSON.stringify(\n\t\t{\n\t\t\tcompilerOptions: {\n\t\t\t\ttarget: \"ES2022\",\n\t\t\t\tlib: [\"dom\", \"dom.iterable\", \"esnext\"],\n\t\t\t\tallowJs: true,\n\t\t\t\tskipLibCheck: true,\n\t\t\t\tstrict: true,\n\t\t\t\tnoEmit: true,\n\t\t\t\tmodule: \"esnext\",\n\t\t\t\tmoduleResolution: \"bundler\",\n\t\t\t\tisolatedModules: true,\n\t\t\t\tjsx: \"preserve\",\n\t\t\t\tincremental: true,\n\t\t\t\tplugins: [{ name: \"next\" }],\n\t\t\t\tpaths: { \"@/*\": [\"./*\"] },\n\t\t\t},\n\t\t\tinclude: [\"next-env.d.ts\", \"**/*.ts\", \"**/*.tsx\"],\n\t\t\texclude: [\"node_modules\"],\n\t\t},\n\t\tnull,\n\t\t\"\\t\",\n\t);\n}\n\nfunction apiPackageJson(): string {\n\treturn JSON.stringify(\n\t\t{\n\t\t\tname: \"analytics-api\",\n\t\t\tprivate: true,\n\t\t\ttype: \"module\",\n\t\t\tdependencies: {\n\t\t\t\t\"@remcostoeten/ingestion\": \"^0.1.0\",\n\t\t\t\t\"@neondatabase/serverless\": \"^0.10.0\",\n\t\t\t\t\"drizzle-orm\": \"^0.36.0\",\n\t\t\t\thono: \"^4.6.0\",\n\t\t\t\t\"ua-parser-js\": \"^2.0.0\",\n\t\t\t\tzod: \"^3.22.0\",\n\t\t\t},\n\t\t\tdevDependencies: {\n\t\t\t\t\"drizzle-kit\": \"^0.31.0\",\n\t\t\t\ttypescript: \"^5.6.0\",\n\t\t\t},\n\t\t},\n\t\tnull,\n\t\t\"\\t\",\n\t);\n}\n\nfunction apiEnvExample(): string {\n\treturn `DATABASE_URL=postgres://user:password@host/db\nIP_HASH_SECRET=replace-with-at-least-32-characters\nORIGIN_ALLOWLIST=https://your-app.vercel.app\nINGEST_SECRET=replace-with-a-long-random-secret\n`;\n}\n\nfunction apiHandler(): string {\n\treturn `export { default } from \"@remcostoeten/ingestion/vercel\";\n`;\n}\n\nfunction apiVercelJson(): string {\n\treturn JSON.stringify(\n\t\t{\n\t\t\trewrites: [{ source: \"/(.*)\", destination: \"/api\" }],\n\t\t},\n\t\tnull,\n\t\t\"\\t\",\n\t);\n}\n\nfunction ingestRoute(): string {\n\treturn `import { app } from \"@remcostoeten/ingestion\";\n\nasync function handle(request: Request) {\n\treturn app.fetch(request);\n}\n\nexport const GET = handle;\nexport const POST = handle;\n`;\n}\n\nfunction serverTrackingExample(projectId: string): string {\n\treturn `import { trackServerEvent } from \"@remcostoeten/analytics/server\";\n\nexport async function POST() {\n\t// your server logic here\n\n\tawait trackServerEvent(\"example_action\", { projectId: \"${projectId}\", path: \"/api/example\" });\n\n\treturn Response.json({ ok: true });\n}\n`;\n}\n\nfunction colocatedPackageJson(projectName: string): string {\n\treturn JSON.stringify(\n\t\t{\n\t\t\tname: projectName,\n\t\t\tprivate: true,\n\t\t\tscripts: {\n\t\t\t\tdev: \"next dev\",\n\t\t\t\tbuild: \"next build\",\n\t\t\t\tstart: \"next start\",\n\t\t\t},\n\t\t\tdependencies: {\n\t\t\t\t\"@remcostoeten/analytics\": \"^1.5.0\",\n\t\t\t\t\"@remcostoeten/ingestion\": \"^0.1.0\",\n\t\t\t\t\"@neondatabase/serverless\": \"^0.10.0\",\n\t\t\t\t\"drizzle-orm\": \"^0.36.0\",\n\t\t\t\thono: \"^4.6.0\",\n\t\t\t\tnext: \"^15.0.0\",\n\t\t\t\treact: \"^19.0.0\",\n\t\t\t\t\"react-dom\": \"^19.0.0\",\n\t\t\t\t\"ua-parser-js\": \"^2.0.0\",\n\t\t\t\tzod: \"^3.22.0\",\n\t\t\t},\n\t\t\tdevDependencies: {\n\t\t\t\t\"@types/node\": \"^20.0.0\",\n\t\t\t\t\"@types/react\": \"^19.0.0\",\n\t\t\t\t\"drizzle-kit\": \"^0.31.0\",\n\t\t\t\ttypescript: \"^5.6.0\",\n\t\t\t},\n\t\t},\n\t\tnull,\n\t\t\"\\t\",\n\t);\n}\n\nfunction colocatedEnvExample(): string {\n\treturn `NEXT_PUBLIC_ANALYTICS_URL=http://localhost:3000\nANALYTICS_URL=http://localhost:3000\nINGEST_SECRET=replace-with-a-long-random-secret\nORIGIN_ALLOWLIST=http://localhost:3000\nDATABASE_URL=postgres://user:password@host/db\nIP_HASH_SECRET=replace-with-at-least-32-characters\n`;\n}\n\nfunction readmeSeparate(projectName: string, projectId: string): string {\n\treturn `# ${projectName}\n\nTier 1 setup: SDK in \\`apps/web\\`, ingestion in \\`apps/analytics-api\\` (separate deploy).\n\n## 1. Web app\n\n\\`\\`\\`bash\ncd apps/web\nnpm install\ncp .env.example .env.local\nnpm run dev\n\\`\\`\\`\n\n## 2. Analytics API\n\n\\`\\`\\`bash\ncd apps/analytics-api\nnpm install\ncp .env.example .env\n\\`\\`\\`\n\nCreate a Neon database, set \\`DATABASE_URL\\` and \\`IP_HASH_SECRET\\` (min 32 chars).\n\nRun migrations:\n\n\\`\\`\\`bash\nnpx drizzle-kit up:pg --config node_modules/@remcostoeten/ingestion/drizzle.config.ts\n\\`\\`\\`\n\nDeploy \\`apps/analytics-api\\` to Vercel, then set \\`NEXT_PUBLIC_ANALYTICS_URL\\` in the web app to that URL.\n\n## Server-side tracking\n\nFor events that happen on your backend (API routes, webhooks, cron jobs), use the server entry:\n\n\\`\\`\\`typescript\n// apps/web/app/api/example/route.ts\nimport { trackServerEvent } from \"@remcostoeten/analytics/server\";\n\nexport async function POST() {\n await trackServerEvent(\"signup_completed\", { projectId: \"${projectId}\", path: \"/api/signup\" });\n return Response.json({ ok: true });\n}\n\\`\\`\\`\n\nSet \\`ANALYTICS_URL\\` and \\`INGEST_SECRET\\` in \\`apps/web/.env.local\\` (server-only — never \\`NEXT_PUBLIC_\\`).\n\nOn the ingestion side, set the same \\`INGEST_SECRET\\` so it can authenticate server requests.\n\n## Project ID\n\nEvents use \\`projectId=\"${projectId}\"\\`. Change in \\`apps/web/app/layout.tsx\\` if needed.\n`;\n}\n\nfunction readmeColocated(projectName: string, projectId: string): string {\n\treturn `# ${projectName}\n\nTier 2 setup: SDK and ingestion in one Next.js app.\n\nWarning: this adds server-side ingestion dependencies to your app deploy (larger serverless bundle).\n\nIngestion routes: \\`app/e/route.ts\\` and \\`app/ingest/route.ts\\` (SDK posts to \\`/e\\`).\n\n## Setup\n\n\\`\\`\\`bash\nnpm install\ncp .env.example .env.local\n\\`\\`\\`\n\nSet \\`DATABASE_URL\\`, \\`IP_HASH_SECRET\\`, and \\`NEXT_PUBLIC_ANALYTICS_URL\\` (your app URL in production).\n\nRun migrations:\n\n\\`\\`\\`bash\nnpx drizzle-kit up:pg --config node_modules/@remcostoeten/ingestion/drizzle.config.ts\n\\`\\`\\`\n\n\\`\\`\\`bash\nnpm run dev\n\\`\\`\\`\n\n## Server-side tracking\n\nFor events that happen in API routes or server actions, use the server entry. See \\`app/api/example/route.ts\\`:\n\n\\`\\`\\`typescript\nimport { trackServerEvent } from \"@remcostoeten/analytics/server\";\n\nexport async function POST() {\n await trackServerEvent(\"signup_completed\", { projectId: \"${projectId}\", path: \"/api/signup\" });\n return Response.json({ ok: true });\n}\n\\`\\`\\`\n\n\\`ANALYTICS_URL\\` and \\`INGEST_SECRET\\` are already in \\`.env.example\\`. Never use \\`NEXT_PUBLIC_\\` for these.\n\n## Project ID\n\nEvents use \\`projectId=\"${projectId}\"\\`. Change in \\`app/layout.tsx\\` if needed.\n`;\n}\n\nfunction readmeSdkOnly(projectName: string, projectId: string): string {\n\treturn `# ${projectName}\n\nTier 3 setup: SDK only. Point at an existing ingestion URL.\n\n## Setup\n\n\\`\\`\\`bash\nnpm install\ncp .env.example .env.local\n\\`\\`\\`\n\nSet \\`NEXT_PUBLIC_ANALYTICS_URL\\` to your ingestion base URL (posts to \\`/e\\`).\n\n\\`\\`\\`bash\nnpm run dev\n\\`\\`\\`\n\n## Project ID\n\nEvents use \\`projectId=\"${projectId}\"\\`. Change in \\`app/layout.tsx\\` if needed.\n`;\n}\n\nexport function buildFiles(options: ScaffoldOptions): ScaffoldFile[] {\n\tconst ingestPlaceholder = \"https://your-analytics-api.vercel.app\";\n\n\tif (options.tier === \"separate\") {\n\t\treturn [\n\t\t\t{ path: \"README.md\", content: readmeSeparate(options.projectName, options.projectId) },\n\t\t\t{ path: \"apps/web/package.json\", content: webPackageJson() },\n\t\t\t{ path: \"apps/web/tsconfig.json\", content: webTsConfig() },\n\t\t\t{ path: \"apps/web/next.config.mjs\", content: \"export default {};\\n\" },\n\t\t\t{ path: \"apps/web/.env.example\", content: webEnvExample(ingestPlaceholder) },\n\t\t\t{ path: \"apps/web/app/layout.tsx\", content: webLayout(options.projectId) },\n\t\t\t{ path: \"apps/web/app/page.tsx\", content: webPage() },\n\t\t\t{ path: \"apps/web/app/api/example/route.ts\", content: serverTrackingExample(options.projectId) },\n\t\t\t{ path: \"apps/analytics-api/package.json\", content: apiPackageJson() },\n\t\t\t{ path: \"apps/analytics-api/api/index.ts\", content: apiHandler() },\n\t\t\t{ path: \"apps/analytics-api/vercel.json\", content: apiVercelJson() },\n\t\t\t{ path: \"apps/analytics-api/.env.example\", content: apiEnvExample() },\n\t\t];\n\t}\n\n\tif (options.tier === \"colocated\") {\n\t\treturn [\n\t\t\t{ path: \"README.md\", content: readmeColocated(options.projectName, options.projectId) },\n\t\t\t{ path: \"package.json\", content: colocatedPackageJson(options.projectName) },\n\t\t\t{ path: \"tsconfig.json\", content: webTsConfig() },\n\t\t\t{ path: \"next.config.mjs\", content: \"export default {};\\n\" },\n\t\t\t{ path: \".env.example\", content: colocatedEnvExample() },\n\t\t\t{ path: \"app/layout.tsx\", content: webLayout(options.projectId) },\n\t\t\t{ path: \"app/page.tsx\", content: webPage() },\n\t\t\t{ path: \"app/e/route.ts\", content: ingestRoute() },\n\t\t\t{ path: \"app/ingest/route.ts\", content: ingestRoute() },\n\t\t\t{ path: \"app/api/example/route.ts\", content: serverTrackingExample(options.projectId) },\n\t\t];\n\t}\n\n\treturn [\n\t\t{ path: \"README.md\", content: readmeSdkOnly(options.projectName, options.projectId) },\n\t\t{ path: \"package.json\", content: webPackageJson().replace('\"name\": \"web\"', `\"name\": \"${options.projectName}\"`) },\n\t\t{ path: \"tsconfig.json\", content: webTsConfig() },\n\t\t{ path: \"next.config.mjs\", content: \"export default {};\\n\" },\n\t\t{ path: \".env.example\", content: webEnvExample(ingestPlaceholder) },\n\t\t{ path: \"app/layout.tsx\", content: webLayout(options.projectId) },\n\t\t{ path: \"app/page.tsx\", content: webPage() },\n\t];\n}\n","import { createInterface } from \"node:readline/promises\";\nimport { stdin as input, stdout as output } from \"node:process\";\nimport { type Tier } from \"./types\";\n\nconst tierLabels: Record<Tier, string> = {\n\tseparate: \"Separate analytics-api project (recommended)\",\n\tcolocated: \"API route in this app (larger server bundle)\",\n\t\"sdk-only\": \"SDK only — I already have an ingestion URL\",\n};\n\nexport async function promptTier(): Promise<Tier> {\n\tconst rl = createInterface({ input, output });\n\n\tconsole.log(\"\\nIntegration tier:\\n\");\n\tconst tiers: Tier[] = [\"separate\", \"colocated\", \"sdk-only\"];\n\tfor (let i = 0; i < tiers.length; i++) {\n\t\tconst marker = i === 0 ? \"→\" : \" \";\n\t\tconsole.log(` ${marker} ${i + 1}. ${tierLabels[tiers[i]]}`);\n\t}\n\n\tconst answer = await rl.question(\"\\nChoose [1]: \");\n\trl.close();\n\n\tconst index = answer.trim() === \"\" ? 0 : Number.parseInt(answer, 10) - 1;\n\tif (index >= 0 && index < tiers.length) return tiers[index];\n\n\treturn \"separate\";\n}\n\nexport async function promptProjectName(defaultName: string): Promise<string> {\n\tconst rl = createInterface({ input, output });\n\tconst answer = await rl.question(`Project name [${defaultName}]: `);\n\trl.close();\n\n\tconst name = answer.trim() || defaultName;\n\treturn name.replace(/[^a-zA-Z0-9-_]/g, \"-\").toLowerCase();\n}\n\nexport function parseTier(value: string | undefined): Tier | null {\n\tif (value === \"separate\" || value === \"1\") return \"separate\";\n\tif (value === \"colocated\" || value === \"2\") return \"colocated\";\n\tif (value === \"sdk-only\" || value === \"sdk\" || value === \"3\") return \"sdk-only\";\n\treturn null;\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@remcostoeten/create-analytics",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold Remco Analytics SDK and ingestion wiring",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Remco Stoeten",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/remcostoeten/analytics",
|
|
10
|
+
"directory": "packages/create-analytics"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"files": ["dist", "README.md"],
|
|
14
|
+
"bin": {
|
|
15
|
+
"create-analytics": "./dist/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsup",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"test": "bun test",
|
|
24
|
+
"prepublishOnly": "bun run build"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^20.10.0",
|
|
28
|
+
"bun-types": "^1.3.9",
|
|
29
|
+
"tsup": "^8.5.1",
|
|
30
|
+
"typescript": "5.6.3"
|
|
31
|
+
}
|
|
32
|
+
}
|