userdispatch 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 (2) hide show
  1. package/dist/index.js +713 -0
  2. package/package.json +30 -0
package/dist/index.js ADDED
@@ -0,0 +1,713 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { intro as intro2, outro as outro2 } from "@clack/prompts";
5
+
6
+ // src/commands/init.ts
7
+ import { intro, log as log7 } from "@clack/prompts";
8
+
9
+ // src/steps/auth.ts
10
+ import { text, spinner, log, isCancel } from "@clack/prompts";
11
+ import { exec } from "child_process";
12
+ import { platform } from "os";
13
+
14
+ // src/lib/config.ts
15
+ var PROD_URL = process.env.USERDISPATCH_API_URL || "https://userdispatch.com";
16
+ var API_BASE = `${PROD_URL}/api`;
17
+
18
+ // src/lib/api.ts
19
+ var ApiError = class extends Error {
20
+ constructor(status, body) {
21
+ super(body.error || `HTTP ${status}`);
22
+ this.status = status;
23
+ this.body = body;
24
+ this.name = "ApiError";
25
+ }
26
+ };
27
+ async function request(path, options) {
28
+ const url = `${API_BASE}${path}`;
29
+ const res = await fetch(url, options);
30
+ const body = await res.json().catch(() => ({ error: "Invalid JSON response" }));
31
+ if (!res.ok) {
32
+ throw new ApiError(res.status, body);
33
+ }
34
+ return body;
35
+ }
36
+ async function apiGet(path, token) {
37
+ return request(path, {
38
+ method: "GET",
39
+ headers: {
40
+ Authorization: `Bearer ${token}`,
41
+ Accept: "application/json"
42
+ }
43
+ });
44
+ }
45
+ async function apiPost(path, body, token) {
46
+ return request(path, {
47
+ method: "POST",
48
+ headers: {
49
+ Authorization: `Bearer ${token}`,
50
+ "Content-Type": "application/json",
51
+ Accept: "application/json"
52
+ },
53
+ body: JSON.stringify(body)
54
+ });
55
+ }
56
+ async function apiSubmission(path, body, apiKey) {
57
+ return request(path, {
58
+ method: "POST",
59
+ headers: {
60
+ "X-API-Key": apiKey,
61
+ "Content-Type": "application/json",
62
+ Accept: "application/json"
63
+ },
64
+ body: JSON.stringify(body)
65
+ });
66
+ }
67
+
68
+ // src/steps/auth.ts
69
+ function openBrowser(url) {
70
+ const os = platform();
71
+ const cmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
72
+ exec(`${cmd} "${url}"`, (err) => {
73
+ if (err) {
74
+ log.warn(`Could not open browser. Please visit:
75
+ ${url}`);
76
+ }
77
+ });
78
+ }
79
+ async function validateToken(token) {
80
+ try {
81
+ const res = await apiGet("/cli/auth", token);
82
+ return { email: res.data.email || "", org: res.data.org };
83
+ } catch (err) {
84
+ if (err instanceof ApiError && err.status === 401) {
85
+ throw new Error("Invalid token. Please try again.");
86
+ }
87
+ throw err;
88
+ }
89
+ }
90
+ async function authStep(flags2) {
91
+ const s = spinner();
92
+ let token = flags2.token;
93
+ if (!token) {
94
+ const authUrl = `${PROD_URL}/cli/auth`;
95
+ log.step("Step 1 of 6 \u2014 Authenticate");
96
+ log.info("Opening your browser to sign in with Google.");
97
+ log.info("After signing in, copy the token shown on screen and paste it here.");
98
+ openBrowser(authUrl);
99
+ const result = await text({
100
+ message: "Paste your token here (starts with ud_):",
101
+ placeholder: "ud_...",
102
+ validate(value) {
103
+ if (!value.trim()) return "Token is required.";
104
+ if (!value.trim().startsWith("ud_")) return 'Token should start with "ud_".';
105
+ }
106
+ });
107
+ if (isCancel(result)) {
108
+ log.warn("Setup cancelled.");
109
+ process.exit(0);
110
+ }
111
+ token = result.trim();
112
+ }
113
+ s.start("Validating token...");
114
+ try {
115
+ const { email, org } = await validateToken(token);
116
+ s.stop("Token validated.");
117
+ if (email) {
118
+ log.info(`Signed in as ${email}`);
119
+ }
120
+ if (org) {
121
+ log.info(`Organization: ${org.name} (${org.slug})`);
122
+ } else {
123
+ log.info("No organization found \u2014 we'll create one next.");
124
+ }
125
+ return { token, email, org };
126
+ } catch (err) {
127
+ s.stop("Token validation failed.");
128
+ if (err instanceof Error) {
129
+ log.error(err.message);
130
+ }
131
+ process.exit(1);
132
+ }
133
+ }
134
+
135
+ // src/steps/setup.ts
136
+ import { text as text2, spinner as spinner2, log as log2, isCancel as isCancel2 } from "@clack/prompts";
137
+ import { basename } from "path";
138
+ function slugify(str) {
139
+ return str.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
140
+ }
141
+ async function promptForValue(message, defaultValue, placeholder) {
142
+ const result = await text2({
143
+ message,
144
+ defaultValue,
145
+ placeholder: placeholder || defaultValue,
146
+ validate(value) {
147
+ if (!value.trim()) return "This field is required.";
148
+ }
149
+ });
150
+ if (isCancel2(result)) {
151
+ log2.warn("Setup cancelled.");
152
+ process.exit(0);
153
+ }
154
+ return result.trim();
155
+ }
156
+ async function setupStep(auth, flags2 = {}) {
157
+ const s = spinner2();
158
+ log2.step("Step 2 of 6 \u2014 Set up your organization and app");
159
+ let orgName;
160
+ let orgSlug;
161
+ if (!auth.org) {
162
+ if (flags2.ci && flags2.org) {
163
+ orgName = flags2.org;
164
+ orgSlug = slugify(flags2.org);
165
+ } else {
166
+ orgName = await promptForValue("Organization name:", "My Organization");
167
+ orgSlug = await promptForValue("Organization slug:", slugify(orgName));
168
+ }
169
+ } else {
170
+ log2.info(`Using organization: ${auth.org.name}`);
171
+ }
172
+ const defaultAppName = basename(process.cwd());
173
+ let appName;
174
+ let appSlug;
175
+ if (flags2.ci && flags2.app) {
176
+ appName = flags2.app;
177
+ appSlug = slugify(flags2.app);
178
+ } else {
179
+ appName = await promptForValue("App name:", defaultAppName);
180
+ appSlug = await promptForValue("App slug:", slugify(appName));
181
+ }
182
+ s.start("Creating your setup...");
183
+ try {
184
+ const body = {
185
+ appName,
186
+ appSlug
187
+ };
188
+ if (orgName) body.orgName = orgName;
189
+ if (orgSlug) body.orgSlug = orgSlug;
190
+ const res = await apiPost("/cli/setup", body, auth.token);
191
+ s.stop("Setup complete.");
192
+ const { org, app, isNewOrg } = res.data;
193
+ if (isNewOrg) {
194
+ log2.info(`Created organization: ${org.name} (${org.slug})`);
195
+ }
196
+ log2.info(`Created app: ${app.name} (${app.slug})`);
197
+ return { org, app, isNewOrg };
198
+ } catch (err) {
199
+ s.stop("Setup failed.");
200
+ if (err instanceof ApiError) {
201
+ if (err.status === 409) {
202
+ log2.error(`Conflict: ${err.message}. Try a different name or slug.`);
203
+ } else {
204
+ log2.error(err.message);
205
+ }
206
+ } else if (err instanceof Error) {
207
+ log2.error(err.message);
208
+ }
209
+ process.exit(1);
210
+ }
211
+ }
212
+
213
+ // src/steps/widget.ts
214
+ import { spinner as spinner3, log as log3, confirm, isCancel as isCancel3 } from "@clack/prompts";
215
+ import { execSync } from "child_process";
216
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
217
+ import { join as join2 } from "path";
218
+
219
+ // src/lib/detect.ts
220
+ import { existsSync, readFileSync } from "fs";
221
+ import { join } from "path";
222
+ import { homedir } from "os";
223
+ function detectPackageManager(cwd = process.cwd()) {
224
+ if (existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock"))) return "bun";
225
+ if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
226
+ if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
227
+ return "npm";
228
+ }
229
+ function detectFramework(cwd = process.cwd()) {
230
+ const pkgPath = join(cwd, "package.json");
231
+ let deps = {};
232
+ if (existsSync(pkgPath)) {
233
+ try {
234
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
235
+ deps = { ...pkg.dependencies, ...pkg.devDependencies };
236
+ } catch {
237
+ }
238
+ }
239
+ if (deps["next"]) {
240
+ if (existsSync(join(cwd, "app/layout.tsx")) || existsSync(join(cwd, "app/layout.jsx"))) {
241
+ return { framework: "next-app", label: "Next.js (App Router)", layoutFile: join(cwd, "app/layout.tsx") };
242
+ }
243
+ if (existsSync(join(cwd, "src/app/layout.tsx")) || existsSync(join(cwd, "src/app/layout.jsx"))) {
244
+ return { framework: "next-app", label: "Next.js (App Router)", layoutFile: join(cwd, "src/app/layout.tsx") };
245
+ }
246
+ if (existsSync(join(cwd, "pages/_document.tsx")) || existsSync(join(cwd, "pages/_document.jsx"))) {
247
+ return { framework: "next-pages", label: "Next.js (Pages Router)", layoutFile: join(cwd, "pages/_document.tsx") };
248
+ }
249
+ return { framework: "next-app", label: "Next.js", layoutFile: void 0 };
250
+ }
251
+ if (deps["vite"]) {
252
+ return { framework: "vite", label: "Vite", layoutFile: join(cwd, "index.html") };
253
+ }
254
+ if (deps["react-scripts"]) {
255
+ return { framework: "cra", label: "Create React App", layoutFile: join(cwd, "public/index.html") };
256
+ }
257
+ if (deps["nuxt"]) {
258
+ return { framework: "nuxt", label: "Nuxt", layoutFile: join(cwd, "nuxt.config.ts") };
259
+ }
260
+ if (deps["@sveltejs/kit"]) {
261
+ return { framework: "sveltekit", label: "SvelteKit", layoutFile: join(cwd, "src/app.html") };
262
+ }
263
+ if (deps["astro"]) {
264
+ return { framework: "astro", label: "Astro", layoutFile: void 0 };
265
+ }
266
+ if (existsSync(join(cwd, "index.html"))) {
267
+ return { framework: "static", label: "Static HTML", layoutFile: join(cwd, "index.html") };
268
+ }
269
+ return { framework: "unknown", label: "Unknown", layoutFile: void 0 };
270
+ }
271
+ function detectAgents(cwd = process.cwd()) {
272
+ const home = homedir();
273
+ const agents = [
274
+ {
275
+ name: "Claude Code",
276
+ id: "claude-code",
277
+ configPath: join(cwd, ".mcp.json"),
278
+ detected: true
279
+ // always offer Claude Code
280
+ },
281
+ {
282
+ name: "Cursor",
283
+ id: "cursor",
284
+ configPath: join(cwd, ".cursor/mcp.json"),
285
+ detected: existsSync(join(cwd, ".cursor"))
286
+ },
287
+ {
288
+ name: "Windsurf",
289
+ id: "windsurf",
290
+ configPath: join(home, ".codeium/windsurf/mcp_config.json"),
291
+ detected: existsSync(join(home, ".codeium/windsurf"))
292
+ },
293
+ {
294
+ name: "Claude Desktop",
295
+ id: "claude-desktop",
296
+ configPath: join(home, "Library/Application Support/Claude/claude_desktop_config.json"),
297
+ detected: existsSync(join(home, "Library/Application Support/Claude"))
298
+ },
299
+ {
300
+ name: "VS Code Copilot",
301
+ id: "vscode",
302
+ configPath: join(cwd, ".vscode/mcp.json"),
303
+ detected: existsSync(join(cwd, ".vscode"))
304
+ }
305
+ ];
306
+ return agents;
307
+ }
308
+
309
+ // src/steps/widget.ts
310
+ var WIDGET_URL = `${PROD_URL}/widget.js`;
311
+ function installCommand(pm, pkg) {
312
+ switch (pm) {
313
+ case "bun":
314
+ return `bun add ${pkg}`;
315
+ case "pnpm":
316
+ return `pnpm add ${pkg}`;
317
+ case "yarn":
318
+ return `yarn add ${pkg}`;
319
+ default:
320
+ return `npm install ${pkg}`;
321
+ }
322
+ }
323
+ function isAlreadyInstalled(cwd) {
324
+ const pkgPath = join2(cwd, "package.json");
325
+ if (!existsSync2(pkgPath)) return false;
326
+ try {
327
+ const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
328
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
329
+ return !!allDeps["@userdispatch/sdk"];
330
+ } catch {
331
+ return false;
332
+ }
333
+ }
334
+ function fileContainsWidget(filePath) {
335
+ if (!existsSync2(filePath)) return false;
336
+ const content = readFileSync2(filePath, "utf-8");
337
+ return content.includes("userdispatch.com/widget.js") || content.includes("userdispatch/widget");
338
+ }
339
+ function injectNextAppRouter(filePath, apiKey) {
340
+ if (!existsSync2(filePath)) return false;
341
+ let content = readFileSync2(filePath, "utf-8");
342
+ if (fileContainsWidget(filePath)) {
343
+ log3.info("Widget already present in layout file \u2014 skipping injection.");
344
+ return false;
345
+ }
346
+ if (!content.includes("from 'next/script'") && !content.includes('from "next/script"')) {
347
+ const lastImportIndex = content.lastIndexOf("import ");
348
+ if (lastImportIndex !== -1) {
349
+ const endOfImport = content.indexOf("\n", lastImportIndex);
350
+ const importLine = `
351
+ import Script from "next/script";`;
352
+ content = content.slice(0, endOfImport + 1) + importLine + content.slice(endOfImport + 1);
353
+ }
354
+ }
355
+ const scriptTag = ` <Script src="${WIDGET_URL}" data-api-key="${apiKey}" strategy="afterInteractive" />`;
356
+ const bodyCloseIndex = content.lastIndexOf("</body>");
357
+ if (bodyCloseIndex === -1) {
358
+ log3.warn("Could not find </body> tag in layout file.");
359
+ return false;
360
+ }
361
+ content = content.slice(0, bodyCloseIndex) + scriptTag + "\n" + content.slice(bodyCloseIndex);
362
+ writeFileSync(filePath, content, "utf-8");
363
+ return true;
364
+ }
365
+ function injectHtmlFile(filePath, apiKey) {
366
+ if (!existsSync2(filePath)) return false;
367
+ let content = readFileSync2(filePath, "utf-8");
368
+ if (fileContainsWidget(filePath)) {
369
+ log3.info("Widget already present \u2014 skipping injection.");
370
+ return false;
371
+ }
372
+ const scriptTag = ` <script src="${WIDGET_URL}" data-api-key="${apiKey}" defer></script>`;
373
+ const bodyCloseIndex = content.lastIndexOf("</body>");
374
+ if (bodyCloseIndex === -1) {
375
+ log3.warn("Could not find </body> tag in file.");
376
+ return false;
377
+ }
378
+ content = content.slice(0, bodyCloseIndex) + scriptTag + "\n" + content.slice(bodyCloseIndex);
379
+ writeFileSync(filePath, content, "utf-8");
380
+ return true;
381
+ }
382
+ async function widgetStep(apiKey, flags2 = {}) {
383
+ const cwd = process.cwd();
384
+ const s = spinner3();
385
+ const pm = detectPackageManager(cwd);
386
+ const detection = detectFramework(cwd);
387
+ log3.step("Step 3 of 6 \u2014 Install widget");
388
+ log3.info(`Detected framework: ${detection.label}`);
389
+ let installed = false;
390
+ if (!isAlreadyInstalled(cwd)) {
391
+ let shouldInstall;
392
+ if (flags2.ci) {
393
+ shouldInstall = true;
394
+ } else {
395
+ shouldInstall = await confirm({
396
+ message: "Install @userdispatch/sdk? (TypeScript SDK for sending feedback from your code)",
397
+ initialValue: true
398
+ });
399
+ if (isCancel3(shouldInstall)) {
400
+ log3.warn("Setup cancelled.");
401
+ process.exit(0);
402
+ }
403
+ }
404
+ if (shouldInstall) {
405
+ s.start(`Installing @userdispatch/sdk via ${pm}...`);
406
+ try {
407
+ execSync(installCommand(pm, "@userdispatch/sdk"), {
408
+ cwd,
409
+ stdio: "pipe"
410
+ });
411
+ s.stop("SDK installed.");
412
+ installed = true;
413
+ } catch {
414
+ s.stop("SDK installation failed \u2014 you can install it manually later.");
415
+ }
416
+ }
417
+ } else {
418
+ log3.info("@userdispatch/sdk already installed.");
419
+ installed = true;
420
+ }
421
+ let injected = false;
422
+ const { framework, layoutFile } = detection;
423
+ if (!layoutFile || !existsSync2(layoutFile)) {
424
+ if (framework === "unknown") {
425
+ log3.info("Could not detect framework. Add the widget manually:");
426
+ log3.info(` <script src="${WIDGET_URL}" data-api-key="${apiKey}" defer></script>`);
427
+ } else {
428
+ log3.info(`No layout file found for ${detection.label}. Add the widget manually.`);
429
+ }
430
+ return { installed, injected: false, framework, packageManager: pm };
431
+ }
432
+ if (framework === "next-app") {
433
+ injected = injectNextAppRouter(layoutFile, apiKey);
434
+ } else if (["vite", "cra", "sveltekit", "static"].includes(framework)) {
435
+ injected = injectHtmlFile(layoutFile, apiKey);
436
+ } else if (framework === "nuxt") {
437
+ log3.info("For Nuxt, add the widget script to your nuxt.config.ts:");
438
+ log3.info(` app: { head: { script: [{ src: "${WIDGET_URL}", "data-api-key": "${apiKey}", defer: true }] } }`);
439
+ } else if (framework === "next-pages") {
440
+ injected = injectHtmlFile(layoutFile, apiKey);
441
+ } else {
442
+ log3.info("Add the widget manually:");
443
+ log3.info(` <script src="${WIDGET_URL}" data-api-key="${apiKey}" defer></script>`);
444
+ }
445
+ if (injected) {
446
+ log3.success(`Widget injected into ${layoutFile}`);
447
+ }
448
+ return { installed, injected, framework, file: injected ? layoutFile : void 0, packageManager: pm };
449
+ }
450
+
451
+ // src/steps/mcp.ts
452
+ import { multiselect, spinner as spinner4, log as log4, isCancel as isCancel4 } from "@clack/prompts";
453
+ import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync } from "fs";
454
+ import { dirname, relative } from "path";
455
+ var MCP_URL = `${PROD_URL}/api/mcp`;
456
+ function readJsonFile(filePath) {
457
+ if (!existsSync3(filePath)) return {};
458
+ try {
459
+ return JSON.parse(readFileSync3(filePath, "utf-8"));
460
+ } catch {
461
+ return {};
462
+ }
463
+ }
464
+ function writeJsonFile(filePath, data) {
465
+ const dir = dirname(filePath);
466
+ if (!existsSync3(dir)) {
467
+ mkdirSync(dir, { recursive: true });
468
+ }
469
+ writeFileSync2(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
470
+ }
471
+ function addToGitignore(cwd, entry) {
472
+ const gitignorePath = `${cwd}/.gitignore`;
473
+ if (!existsSync3(gitignorePath)) return;
474
+ const content = readFileSync3(gitignorePath, "utf-8");
475
+ if (content.includes(entry)) return;
476
+ const newline = content.endsWith("\n") ? "" : "\n";
477
+ writeFileSync2(gitignorePath, content + newline + entry + "\n", "utf-8");
478
+ }
479
+ function configureMcp(agent, token, cwd) {
480
+ const config = readJsonFile(agent.configPath);
481
+ const mcpServers = config.mcpServers ?? {};
482
+ mcpServers["userdispatch"] = {
483
+ url: MCP_URL,
484
+ headers: {
485
+ Authorization: `Bearer ${token}`
486
+ }
487
+ };
488
+ config.mcpServers = mcpServers;
489
+ writeJsonFile(agent.configPath, config);
490
+ const relPath = relative(cwd, agent.configPath);
491
+ if (!relPath.startsWith("..") && !relPath.startsWith("/")) {
492
+ addToGitignore(cwd, relPath);
493
+ }
494
+ }
495
+ async function mcpStep(token, flags2 = {}) {
496
+ const cwd = process.cwd();
497
+ const agents = detectAgents(cwd);
498
+ const detected = agents.filter((a) => a.detected);
499
+ log4.step("Step 4 of 6 \u2014 Configure MCP for your coding agent");
500
+ if (detected.length === 0) {
501
+ log4.info("No coding agents detected. You can configure MCP manually later.");
502
+ return { configured: [], skipped: true };
503
+ }
504
+ let selected;
505
+ if (flags2.ci) {
506
+ selected = detected;
507
+ log4.info(`Configuring MCP for ${detected.map((a) => a.name).join(", ")}...`);
508
+ } else if (detected.length === 1) {
509
+ selected = detected;
510
+ log4.info(`Configuring MCP for ${detected[0].name}...`);
511
+ } else {
512
+ const result = await multiselect({
513
+ message: "Which coding agents should we configure?",
514
+ options: detected.map((a) => ({
515
+ value: a.id,
516
+ label: a.name,
517
+ hint: a.configPath
518
+ })),
519
+ initialValues: detected.map((a) => a.id)
520
+ });
521
+ if (isCancel4(result)) {
522
+ log4.warn("Setup cancelled.");
523
+ process.exit(0);
524
+ }
525
+ selected = detected.filter((a) => result.includes(a.id));
526
+ }
527
+ if (selected.length === 0) {
528
+ log4.info("Skipped MCP configuration.");
529
+ return { configured: [], skipped: true };
530
+ }
531
+ const s = spinner4();
532
+ s.start("Configuring MCP servers...");
533
+ for (const agent of selected) {
534
+ configureMcp(agent, token, cwd);
535
+ }
536
+ s.stop("MCP servers configured.");
537
+ for (const agent of selected) {
538
+ log4.info(` ${agent.name}: ${agent.configPath}`);
539
+ }
540
+ return { configured: selected, skipped: false };
541
+ }
542
+
543
+ // src/steps/verify.ts
544
+ import { spinner as spinner5, log as log5 } from "@clack/prompts";
545
+ async function verifyStep(apiKey) {
546
+ const s = spinner5();
547
+ log5.step("Step 5 of 6 \u2014 Verify connection");
548
+ s.start("Sending a test submission to your dashboard...");
549
+ try {
550
+ await apiSubmission(
551
+ "/v1/submissions",
552
+ {
553
+ type: "feedback",
554
+ subject: "Setup complete",
555
+ message: "Test submission from npx userdispatch init.",
556
+ metadata: { source: "cli-init", test: true }
557
+ },
558
+ apiKey
559
+ );
560
+ s.stop("Test submission sent \u2014 check your dashboard to see it.");
561
+ return true;
562
+ } catch (err) {
563
+ s.stop("Test submission failed.");
564
+ if (err instanceof ApiError) {
565
+ log5.warn(`Could not send test submission: ${err.message}`);
566
+ } else if (err instanceof Error) {
567
+ log5.warn(`Could not send test submission: ${err.message}`);
568
+ }
569
+ return false;
570
+ }
571
+ }
572
+
573
+ // src/steps/summary.ts
574
+ import { note, outro, log as log6 } from "@clack/prompts";
575
+ function summaryStep(result) {
576
+ const { setup, widget, mcp, verified, modifiedFiles } = result;
577
+ log6.step("Step 6 of 6 \u2014 Summary");
578
+ const maskedKey = setup.app.apiKey.slice(0, 7) + "..." + setup.app.apiKey.slice(-4);
579
+ const lines = [];
580
+ lines.push(`Organization: ${setup.org.name} (${setup.org.slug})`);
581
+ lines.push(`App: ${setup.app.name} (${setup.app.slug})`);
582
+ lines.push(`API Key: ${maskedKey}`);
583
+ lines.push("");
584
+ if (widget.installed) {
585
+ lines.push(`SDK: installed via ${widget.packageManager}`);
586
+ }
587
+ if (widget.injected && widget.file) {
588
+ lines.push(`Widget: injected into ${widget.file}`);
589
+ } else if (!widget.injected) {
590
+ lines.push(`Widget: manual setup needed`);
591
+ }
592
+ if (mcp.configured.length > 0) {
593
+ const names = mcp.configured.map((a) => a.name).join(", ");
594
+ lines.push(`MCP: configured for ${names}`);
595
+ }
596
+ lines.push(`Test: ${verified ? "passed" : "skipped (check dashboard)"}`);
597
+ if (modifiedFiles.length > 0) {
598
+ lines.push("");
599
+ lines.push("Files modified:");
600
+ for (const f of modifiedFiles) {
601
+ lines.push(` ${f}`);
602
+ }
603
+ }
604
+ note(lines.join("\n"), "Setup Summary");
605
+ const nextSteps = [
606
+ `Dashboard: ${PROD_URL}/org/${setup.org.slug}/dashboard`,
607
+ `Submissions: ${PROD_URL}/org/${setup.org.slug}/submissions`,
608
+ "",
609
+ "Next steps:",
610
+ " 1. Start your dev server \u2014 the widget appears bottom-right",
611
+ " 2. Submit feedback through the widget",
612
+ ' 3. Open Claude Code and try: "Show me my recent submissions"'
613
+ ].join("\n");
614
+ note(nextSteps, "What's Next");
615
+ outro("You're all set! Happy building.");
616
+ }
617
+
618
+ // src/commands/init.ts
619
+ async function initCommand(flags2) {
620
+ intro("UserDispatch Setup");
621
+ const auth = await authStep(flags2);
622
+ const setup = await setupStep(auth, flags2);
623
+ const modifiedFiles = [];
624
+ let widget;
625
+ try {
626
+ widget = await widgetStep(setup.app.apiKey, flags2);
627
+ if (widget.file) modifiedFiles.push(widget.file);
628
+ } catch (err) {
629
+ log7.warn(`Widget setup encountered an issue: ${err instanceof Error ? err.message : String(err)}`);
630
+ widget = {
631
+ installed: false,
632
+ injected: false,
633
+ framework: "unknown",
634
+ packageManager: "npm"
635
+ };
636
+ }
637
+ let mcp;
638
+ try {
639
+ mcp = await mcpStep(auth.token, flags2);
640
+ for (const agent of mcp.configured) {
641
+ modifiedFiles.push(agent.configPath);
642
+ }
643
+ } catch (err) {
644
+ log7.warn(`MCP setup encountered an issue: ${err instanceof Error ? err.message : String(err)}`);
645
+ mcp = { configured: [], skipped: true };
646
+ }
647
+ let verified = false;
648
+ try {
649
+ verified = await verifyStep(setup.app.apiKey);
650
+ } catch (err) {
651
+ log7.warn(`Verification encountered an issue: ${err instanceof Error ? err.message : String(err)}`);
652
+ }
653
+ summaryStep({
654
+ auth,
655
+ setup,
656
+ widget,
657
+ mcp,
658
+ verified,
659
+ modifiedFiles
660
+ });
661
+ }
662
+
663
+ // src/lib/args.ts
664
+ function parseArgs(argv) {
665
+ const flags2 = {};
666
+ for (let i = 0; i < argv.length; i++) {
667
+ const arg = argv[i];
668
+ if (arg === "--ci") {
669
+ flags2.ci = true;
670
+ continue;
671
+ }
672
+ const next = argv[i + 1];
673
+ if (arg === "--token" && next) {
674
+ flags2.token = next;
675
+ i++;
676
+ } else if (arg === "--org" && next) {
677
+ flags2.org = next;
678
+ i++;
679
+ } else if (arg === "--app" && next) {
680
+ flags2.app = next;
681
+ i++;
682
+ } else if (arg === "--framework" && next) {
683
+ flags2.framework = next;
684
+ i++;
685
+ } else if (arg === "--agent" && next) {
686
+ flags2.agent = next;
687
+ i++;
688
+ } else if (arg.startsWith("--token=")) {
689
+ flags2.token = arg.split("=")[1];
690
+ } else if (arg.startsWith("--org=")) {
691
+ flags2.org = arg.split("=")[1];
692
+ } else if (arg.startsWith("--app=")) {
693
+ flags2.app = arg.split("=")[1];
694
+ } else if (arg.startsWith("--framework=")) {
695
+ flags2.framework = arg.split("=")[1];
696
+ } else if (arg.startsWith("--agent=")) {
697
+ flags2.agent = arg.split("=")[1];
698
+ }
699
+ }
700
+ return flags2;
701
+ }
702
+
703
+ // src/index.ts
704
+ var args = process.argv.slice(2);
705
+ var command = args.find((a) => !a.startsWith("--"));
706
+ var flags = parseArgs(args);
707
+ if (command === "init" || !command) {
708
+ await initCommand(flags);
709
+ } else {
710
+ intro2("UserDispatch");
711
+ outro2(`Unknown command: ${command}. Use "userdispatch init" to get started.`);
712
+ process.exit(1);
713
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "userdispatch",
3
+ "version": "0.1.0",
4
+ "description": "CLI installer for UserDispatch feedback widget + MCP server",
5
+ "bin": {
6
+ "userdispatch": "dist/index.js"
7
+ },
8
+ "type": "module",
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsup src/index.ts --format esm --target node18 --clean",
17
+ "dev": "tsx src/index.ts",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest"
20
+ },
21
+ "dependencies": {
22
+ "@clack/prompts": "^0.9.0"
23
+ },
24
+ "devDependencies": {
25
+ "tsup": "^8.0.0",
26
+ "tsx": "^4.0.0",
27
+ "typescript": "^5.7.0",
28
+ "vitest": "^3.0.0"
29
+ }
30
+ }