@nuniapp/cli 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 (3) hide show
  1. package/README.md +41 -0
  2. package/dist/index.mjs +393 -0
  3. package/package.json +44 -0
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # @nuniapp/cli
2
+
3
+ Sets up [Nuni](https://nuni.praveenjuge.com) in any project.
4
+
5
+ ```sh
6
+ npx @nuniapp/cli init # new project ID + install steps for your framework
7
+ npx @nuniapp/cli init --json # the same, machine-readable (for agents)
8
+ npx @nuniapp/cli prompt # the ready-made prompt for your coding agent
9
+ npx @nuniapp/cli id # just a new project ID
10
+ ```
11
+
12
+ `init` does not edit your files. It detects your framework (Next.js, React, Vue, Nuxt, SvelteKit, Astro, Angular, plain HTML and more) and package manager, and prints exactly what to add and where.
13
+
14
+ ## Agent prompt
15
+
16
+ <!-- prompt:start -->
17
+
18
+ ```text
19
+ Add Nuni (pinned comments on the live site) to this project.
20
+
21
+ Get a project ID by running: npx @nuniapp/cli@latest init (it prints the ID and the exact snippet for this framework).
22
+
23
+ Steps:
24
+ 1. Detect the framework and package manager.
25
+ 2. If the app uses React (Next.js, Vite, Remix, React Router, TanStack Start, etc.):
26
+ - Install @nuniapp/react.
27
+ - Render <Nuni project="PROJECT_ID" /> once, globally, in the root layout or app entry (for Next.js App Router: app/layout.tsx, inside <body>). It is already a client component.
28
+ 3. Otherwise, if there is a bundler (Vue, Svelte, Astro, Angular, etc.):
29
+ - Install @nuniapp/widget.
30
+ - Call init({ project: "PROJECT_ID" }) once on the client, in the app entry or root layout. import { init } from "@nuniapp/widget".
31
+ 4. If there is no bundler (plain HTML, WordPress, Webflow): add this before </body> on every page:
32
+ <script src="https://cdn.jsdelivr.net/npm/@nuniapp/widget@0/dist/nuni.global.js" data-project="PROJECT_ID" defer></script>
33
+ 5. Commit the project ID directly in the code. It is public, not a secret. Do not put it in an env var.
34
+ 6. Do not add Nuni to server-only code, and do not load it more than once.
35
+ 7. If the site sends a Content-Security-Policy, allow connect-src https://*.convex.cloud wss://*.convex.cloud https://*.convex.site (and script-src cdn.jsdelivr.net for the script tag).
36
+ 8. Start the dev server, open the site, and confirm the Nuni button appears in the bottom-right corner.
37
+
38
+ Docs: https://nuni.praveenjuge.com/quickstart
39
+ ```
40
+
41
+ <!-- prompt:end -->
package/dist/index.mjs ADDED
@@ -0,0 +1,393 @@
1
+ #!/usr/bin/env node
2
+ import { join, resolve } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
5
+ //#region ../shared/src/id.ts
6
+ const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
7
+ const ID_LENGTH = 22;
8
+ const PREFIX = "nuni_";
9
+ const PROJECT_ID_PATTERN = new RegExp(`^${PREFIX}[${ALPHABET}]{${ID_LENGTH}}$`);
10
+ /** Random base58 string using the platform CSPRNG (browser, Node, Convex). */
11
+ function randomBase58(length) {
12
+ const bytes = new Uint8Array(length);
13
+ crypto.getRandomValues(bytes);
14
+ let out = "";
15
+ for (const byte of bytes) {
16
+ let b = byte;
17
+ while (b >= 232) {
18
+ const retry = /* @__PURE__ */ new Uint8Array(1);
19
+ crypto.getRandomValues(retry);
20
+ b = retry[0];
21
+ }
22
+ out += ALPHABET[b % 58];
23
+ }
24
+ return out;
25
+ }
26
+ /** Public project identifier, safe to commit into source code. */
27
+ function generateProjectId() {
28
+ return PREFIX + randomBase58(ID_LENGTH);
29
+ }
30
+ function isProjectId(value) {
31
+ return typeof value === "string" && PROJECT_ID_PATTERN.test(value);
32
+ }
33
+ //#endregion
34
+ //#region ../shared/src/prompt.ts
35
+ const PACKAGES = {
36
+ widget: "@nuniapp/widget",
37
+ react: "@nuniapp/react",
38
+ cli: "@nuniapp/cli"
39
+ };
40
+ const CDN_URL = "https://cdn.jsdelivr.net/npm/@nuniapp/widget@0/dist/nuni.global.js";
41
+ const DOCS_URL = "https://nuni.praveenjuge.com";
42
+ /**
43
+ * The prompt a developer pastes into Codex, Claude Code, Cursor, etc.
44
+ * Single source of truth for the docs site, CLI and READMEs.
45
+ */
46
+ function buildAgentPrompt(options = {}) {
47
+ return `Add Nuni (pinned comments on the live site) to this project.
48
+
49
+ ${options.projectId ? `Use this Nuni project ID: ${options.projectId}` : `Get a project ID by running: npx ${PACKAGES.cli}@latest init (it prints the ID and the exact snippet for this framework).`}
50
+
51
+ Steps:
52
+ 1. Detect the framework and package manager.
53
+ 2. If the app uses React (Next.js, Vite, Remix, React Router, TanStack Start, etc.):
54
+ - Install ${PACKAGES.react}.
55
+ - Render <Nuni project="PROJECT_ID" /> once, globally, in the root layout or app entry (for Next.js App Router: app/layout.tsx, inside <body>). It is already a client component.
56
+ 3. Otherwise, if there is a bundler (Vue, Svelte, Astro, Angular, etc.):
57
+ - Install ${PACKAGES.widget}.
58
+ - Call init({ project: "PROJECT_ID" }) once on the client, in the app entry or root layout. import { init } from "${PACKAGES.widget}".
59
+ 4. If there is no bundler (plain HTML, WordPress, Webflow): add this before </body> on every page:
60
+ <script src="${CDN_URL}" data-project="PROJECT_ID" defer><\/script>
61
+ 5. Commit the project ID directly in the code. It is public, not a secret. Do not put it in an env var.
62
+ 6. Do not add Nuni to server-only code, and do not load it more than once.
63
+ 7. If the site sends a Content-Security-Policy, allow connect-src https://*.convex.cloud wss://*.convex.cloud https://*.convex.site (and script-src cdn.jsdelivr.net for the script tag).
64
+ 8. Start the dev server, open the site, and confirm the Nuni button appears in the bottom-right corner.
65
+
66
+ Docs: ${DOCS_URL}/quickstart`;
67
+ }
68
+ //#endregion
69
+ //#region src/detect.ts
70
+ function readPackage(cwd) {
71
+ try {
72
+ return JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+ function first(cwd, candidates, fallback) {
78
+ return candidates.find((c) => existsSync(join(cwd, c))) ?? fallback;
79
+ }
80
+ function detectPackageManager(cwd) {
81
+ if (existsSync(join(cwd, "bun.lock")) || existsSync(join(cwd, "bun.lockb"))) return "bun";
82
+ if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
83
+ if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
84
+ return "npm";
85
+ }
86
+ function installCommand(pm, pkg) {
87
+ switch (pm) {
88
+ case "bun": return `bun add ${pkg}`;
89
+ case "pnpm": return `pnpm add ${pkg}`;
90
+ case "yarn": return `yarn add ${pkg}`;
91
+ default: return `npm install ${pkg}`;
92
+ }
93
+ }
94
+ function detectFramework(cwd) {
95
+ const pkg = readPackage(cwd);
96
+ if (!pkg) return {
97
+ id: "html",
98
+ name: "Static HTML",
99
+ kind: "script",
100
+ file: existsSync(join(cwd, "index.html")) ? "index.html" : readdirSync(cwd).find((f) => f.endsWith(".html")) ?? "index.html"
101
+ };
102
+ const deps = {
103
+ ...pkg.dependencies,
104
+ ...pkg.devDependencies
105
+ };
106
+ const has = (name) => name in deps;
107
+ if (has("next")) {
108
+ const app = first(cwd, [
109
+ "app/layout.tsx",
110
+ "src/app/layout.tsx",
111
+ "app/layout.jsx",
112
+ "src/app/layout.jsx",
113
+ "app/layout.js",
114
+ "src/app/layout.js"
115
+ ], "");
116
+ if (app) return {
117
+ id: "next-app",
118
+ name: "Next.js (App Router)",
119
+ kind: "react",
120
+ file: app
121
+ };
122
+ const pages = first(cwd, [
123
+ "pages/_app.tsx",
124
+ "src/pages/_app.tsx",
125
+ "pages/_app.jsx",
126
+ "src/pages/_app.jsx",
127
+ "pages/_app.js"
128
+ ], "");
129
+ if (pages) return {
130
+ id: "next-pages",
131
+ name: "Next.js (Pages Router)",
132
+ kind: "react",
133
+ file: pages
134
+ };
135
+ return {
136
+ id: "next-app",
137
+ name: "Next.js",
138
+ kind: "react",
139
+ file: "app/layout.tsx"
140
+ };
141
+ }
142
+ if (has("@tanstack/react-start")) return {
143
+ id: "tanstack-start",
144
+ name: "TanStack Start",
145
+ kind: "react",
146
+ file: first(cwd, ["src/routes/__root.tsx", "app/routes/__root.tsx"], "src/routes/__root.tsx")
147
+ };
148
+ if (has("@remix-run/react") || has("react-router") || has("@react-router/dev")) return {
149
+ id: "react-router",
150
+ name: "React Router / Remix",
151
+ kind: "react",
152
+ file: first(cwd, ["app/root.tsx", "app/root.jsx"], "app/root.tsx")
153
+ };
154
+ if (has("gatsby")) return {
155
+ id: "gatsby",
156
+ name: "Gatsby",
157
+ kind: "react",
158
+ file: first(cwd, ["gatsby-browser.tsx", "gatsby-browser.js"], "gatsby-browser.tsx")
159
+ };
160
+ if (has("nuxt")) return {
161
+ id: "nuxt",
162
+ name: "Nuxt",
163
+ kind: "vanilla",
164
+ file: "plugins/nuni.client.ts"
165
+ };
166
+ if (has("@sveltejs/kit")) return {
167
+ id: "sveltekit",
168
+ name: "SvelteKit",
169
+ kind: "vanilla",
170
+ file: "src/routes/+layout.svelte"
171
+ };
172
+ if (has("astro")) return {
173
+ id: "astro",
174
+ name: "Astro",
175
+ kind: "vanilla",
176
+ file: first(cwd, ["src/layouts/Layout.astro", "src/layouts/BaseLayout.astro"], "src/layouts/Layout.astro")
177
+ };
178
+ if (has("@angular/core")) return {
179
+ id: "angular",
180
+ name: "Angular",
181
+ kind: "vanilla",
182
+ file: "src/main.ts"
183
+ };
184
+ if (has("vue")) return {
185
+ id: "vue",
186
+ name: "Vue",
187
+ kind: "vanilla",
188
+ file: first(cwd, ["src/main.ts", "src/main.js"], "src/main.ts")
189
+ };
190
+ if (has("svelte")) return {
191
+ id: "svelte",
192
+ name: "Svelte",
193
+ kind: "vanilla",
194
+ file: first(cwd, ["src/main.ts", "src/main.js"], "src/main.ts")
195
+ };
196
+ if (has("react")) return {
197
+ id: "react",
198
+ name: "React",
199
+ kind: "react",
200
+ file: first(cwd, [
201
+ "src/main.tsx",
202
+ "src/main.jsx",
203
+ "src/index.tsx",
204
+ "src/index.jsx",
205
+ "src/App.tsx"
206
+ ], "src/main.tsx")
207
+ };
208
+ return {
209
+ id: "vanilla",
210
+ name: "JavaScript app",
211
+ kind: "vanilla",
212
+ file: first(cwd, [
213
+ "src/main.ts",
214
+ "src/main.js",
215
+ "src/index.ts",
216
+ "src/index.js"
217
+ ], "src/main.ts")
218
+ };
219
+ }
220
+ //#endregion
221
+ //#region src/snippets.ts
222
+ function packageFor(framework) {
223
+ if (framework.kind === "react") return PACKAGES.react;
224
+ if (framework.kind === "vanilla") return PACKAGES.widget;
225
+ return null;
226
+ }
227
+ function snippetFor(framework, projectId) {
228
+ const react = `import { Nuni } from "${PACKAGES.react}"`;
229
+ switch (framework.id) {
230
+ case "next-app": return `${react}
231
+
232
+ // Inside <body>, once:
233
+ <Nuni project="${projectId}" />`;
234
+ case "next-pages": return `${react}
235
+
236
+ export default function App({ Component, pageProps }) {
237
+ return (
238
+ <>
239
+ <Component {...pageProps} />
240
+ <Nuni project="${projectId}" />
241
+ </>
242
+ )
243
+ }`;
244
+ case "gatsby": return `${react}
245
+
246
+ export const wrapRootElement = ({ element }) => (
247
+ <>
248
+ {element}
249
+ <Nuni project="${projectId}" />
250
+ </>
251
+ )`;
252
+ case "nuxt": return `import { init } from "${PACKAGES.widget}"
253
+
254
+ export default defineNuxtPlugin(() => {
255
+ init({ project: "${projectId}" })
256
+ })`;
257
+ case "sveltekit": return `<script>
258
+ import { onMount } from "svelte"
259
+ import { init } from "${PACKAGES.widget}"
260
+
261
+ onMount(() => init({ project: "${projectId}" }).destroy)
262
+ <\/script>
263
+
264
+ <slot />`;
265
+ case "astro": return `<!-- In your base layout, before </body> -->
266
+ <script>
267
+ import { init } from "${PACKAGES.widget}"
268
+ init({ project: "${projectId}" })
269
+ <\/script>`;
270
+ case "html": return `<!-- Before </body> on every page -->
271
+ <script src="${CDN_URL}" data-project="${projectId}" defer><\/script>`;
272
+ default:
273
+ if (framework.kind === "react") return `${react}
274
+
275
+ // Render once next to your app's root component:
276
+ <Nuni project="${projectId}" />`;
277
+ return `import { init } from "${PACKAGES.widget}"
278
+
279
+ // Once, on the client:
280
+ init({ project: "${projectId}" })`;
281
+ }
282
+ }
283
+ //#endregion
284
+ //#region src/cli.ts
285
+ const VERSION = "0.1.0";
286
+ const HELP = `nuni ${VERSION}: pinned comments on your live site
287
+
288
+ Usage
289
+ npx @nuniapp/cli init [--id <project-id>] [--cwd <dir>] [--json]
290
+ npx @nuniapp/cli prompt [--id <project-id>]
291
+ npx @nuniapp/cli id
292
+
293
+ Commands
294
+ init Generate a project ID and print the install steps for this project
295
+ prompt Print the ready-made prompt for Codex, Claude Code, Cursor, etc.
296
+ id Print a new project ID
297
+
298
+ Docs: ${DOCS_URL}`;
299
+ const bold = (s) => process.stdout.isTTY ? `\x1b[1m${s}\x1b[22m` : s;
300
+ const dim = (s) => process.stdout.isTTY ? `\x1b[2m${s}\x1b[22m` : s;
301
+ function run(argv) {
302
+ const { values, positionals } = parseArgs({
303
+ args: argv,
304
+ allowPositionals: true,
305
+ options: {
306
+ id: { type: "string" },
307
+ cwd: { type: "string" },
308
+ json: {
309
+ type: "boolean",
310
+ default: false
311
+ },
312
+ help: {
313
+ type: "boolean",
314
+ short: "h",
315
+ default: false
316
+ },
317
+ version: {
318
+ type: "boolean",
319
+ short: "v",
320
+ default: false
321
+ }
322
+ }
323
+ });
324
+ const command = positionals[0] ?? "init";
325
+ if (values.version) {
326
+ console.log(VERSION);
327
+ return 0;
328
+ }
329
+ if (values.help || command === "help") {
330
+ console.log(HELP);
331
+ return 0;
332
+ }
333
+ if (values.id && !isProjectId(values.id)) {
334
+ console.error(`"${values.id}" is not a valid Nuni project ID (expected nuni_ followed by 22 characters).`);
335
+ return 1;
336
+ }
337
+ const projectId = values.id ?? generateProjectId();
338
+ if (command === "id") {
339
+ console.log(projectId);
340
+ return 0;
341
+ }
342
+ if (command === "prompt") {
343
+ console.log(buildAgentPrompt({ projectId }));
344
+ return 0;
345
+ }
346
+ if (command !== "init") {
347
+ console.error(`Unknown command "${command}".\n\n${HELP}`);
348
+ return 1;
349
+ }
350
+ const cwd = resolve(values.cwd ?? process.cwd());
351
+ const framework = detectFramework(cwd);
352
+ const pm = detectPackageManager(cwd);
353
+ const pkg = packageFor(framework);
354
+ const install = pkg ? installCommand(pm, pkg) : null;
355
+ const snippet = snippetFor(framework, projectId);
356
+ if (values.json) {
357
+ console.log(JSON.stringify({
358
+ projectId,
359
+ framework: framework.name,
360
+ frameworkId: framework.id,
361
+ file: framework.file,
362
+ package: pkg,
363
+ install,
364
+ snippet
365
+ }, null, 2));
366
+ return 0;
367
+ }
368
+ console.log(`
369
+ ${bold("Nuni")} ${dim(`v${VERSION}`)}
370
+
371
+ ${bold("Project ID")} ${projectId}
372
+ ${dim("Public, safe to commit. Keep using this same ID on every environment.")}
373
+
374
+ ${bold("Detected")} ${framework.name}${pkg ? ` (${pm})` : ""}
375
+ ${install ? `\n${bold("1. Install")}\n ${install}\n` : ""}
376
+ ${bold(`${install ? "2" : "1"}. Add it once, globally`)} ${dim(`(${framework.file})`)}
377
+
378
+ ${snippet.split("\n").map((line) => ` ${line}`).join("\n")}
379
+
380
+ ${bold(`${install ? "3" : "2"}. Open the site`)}
381
+ Start the dev server. A Nuni button appears in the bottom-right corner.
382
+ Press C, click anything, leave a comment. No account needed.
383
+
384
+ ${bold("Own this site?")} Open the Nuni panel and choose "Claim Nuni" to sign in with GitHub.
385
+ ${dim(`Docs: ${DOCS_URL}`)}
386
+ `);
387
+ return 0;
388
+ }
389
+ //#endregion
390
+ //#region src/index.ts
391
+ process.exitCode = run(process.argv.slice(2));
392
+ //#endregion
393
+ export {};
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@nuniapp/cli",
3
+ "version": "0.1.0",
4
+ "description": "Set up Nuni in any project: generates a project ID and prints the install snippet.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "homepage": "https://nuni.praveenjuge.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/praveenjuge/nuni.git",
11
+ "directory": "packages/cli"
12
+ },
13
+ "bin": {
14
+ "nuni": "./dist/index.mjs"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "scripts": {
28
+ "build": "tsdown",
29
+ "dev": "tsdown --watch",
30
+ "lint": "eslint .",
31
+ "typecheck": "tsc",
32
+ "test": "vitest run"
33
+ },
34
+ "devDependencies": {
35
+ "@nuni/eslint-config": "workspace:*",
36
+ "@nuni/shared": "workspace:*",
37
+ "@nuni/tsconfig": "workspace:*",
38
+ "@types/node": "^22",
39
+ "eslint": "^10.11.0",
40
+ "tsdown": "^0.23.0",
41
+ "typescript": "^5.9.3",
42
+ "vitest": "^5.0.2"
43
+ }
44
+ }