@fixprompt/cli 0.4.0 → 0.6.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/cli.js +645 -50
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -87,9 +87,362 @@ function detectContext() {
87
87
  }
88
88
 
89
89
  // src/connect.ts
90
- import { appendFileSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
91
- import { join } from "path";
90
+ import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync2, readdirSync, statSync, writeFileSync as writeFileSync2 } from "fs";
91
+ import { join as join2 } from "path";
92
+ import { spawnSync as spawnSync2 } from "child_process";
93
+
94
+ // src/install-sdk.ts
95
+ import { existsSync, readFileSync, writeFileSync } from "fs";
96
+ import { dirname, join } from "path";
92
97
  import { spawnSync } from "child_process";
98
+ function readPackageJson(cwd) {
99
+ const p = join(cwd, "package.json");
100
+ if (!existsSync(p)) return null;
101
+ try {
102
+ return JSON.parse(readFileSync(p, "utf8"));
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+ function firstExisting(cwd, candidates) {
108
+ for (const c of candidates) {
109
+ const full = join(cwd, c);
110
+ if (existsSync(full)) return full;
111
+ }
112
+ return null;
113
+ }
114
+ function detectPackageManager(cwd) {
115
+ if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
116
+ if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
117
+ if (existsSync(join(cwd, "bun.lockb"))) return "bun";
118
+ return "npm";
119
+ }
120
+ function detectStack(cwd) {
121
+ const pj = readPackageJson(cwd);
122
+ const deps = { ...pj?.dependencies ?? {}, ...pj?.devDependencies ?? {} };
123
+ const has = (n) => Object.prototype.hasOwnProperty.call(deps, n);
124
+ const packageManager = detectPackageManager(cwd);
125
+ if (has("react-native") || has("expo")) {
126
+ return {
127
+ runtime: "react-native",
128
+ packageManager,
129
+ sdkPackage: "@fixprompt/react-native",
130
+ envVarName: "EXPO_PUBLIC_FIXPROMPT_KEY",
131
+ entryFile: firstExisting(cwd, [
132
+ "App.tsx",
133
+ "App.jsx",
134
+ "App.ts",
135
+ "App.js",
136
+ "src/App.tsx",
137
+ "src/App.jsx",
138
+ "src/App.ts",
139
+ "src/App.js",
140
+ "index.tsx",
141
+ "index.jsx",
142
+ "index.ts",
143
+ "index.js"
144
+ ])
145
+ };
146
+ }
147
+ if (has("next")) {
148
+ const appLayout = firstExisting(cwd, [
149
+ "app/layout.tsx",
150
+ "app/layout.jsx",
151
+ "app/layout.ts",
152
+ "app/layout.js",
153
+ "src/app/layout.tsx",
154
+ "src/app/layout.jsx",
155
+ "src/app/layout.ts",
156
+ "src/app/layout.js"
157
+ ]);
158
+ if (appLayout) {
159
+ return {
160
+ runtime: "next-app",
161
+ packageManager,
162
+ sdkPackage: "@fixprompt/browser",
163
+ envVarName: "NEXT_PUBLIC_FIXPROMPT_KEY",
164
+ entryFile: appLayout
165
+ };
166
+ }
167
+ const pagesApp = firstExisting(cwd, [
168
+ "pages/_app.tsx",
169
+ "pages/_app.jsx",
170
+ "pages/_app.ts",
171
+ "pages/_app.js",
172
+ "src/pages/_app.tsx",
173
+ "src/pages/_app.jsx",
174
+ "src/pages/_app.ts",
175
+ "src/pages/_app.js"
176
+ ]);
177
+ return {
178
+ runtime: "next-pages",
179
+ packageManager,
180
+ sdkPackage: "@fixprompt/browser",
181
+ envVarName: "NEXT_PUBLIC_FIXPROMPT_KEY",
182
+ entryFile: pagesApp
183
+ };
184
+ }
185
+ if (has("vite")) {
186
+ return {
187
+ runtime: "vite",
188
+ packageManager,
189
+ sdkPackage: "@fixprompt/browser",
190
+ envVarName: "VITE_FIXPROMPT_KEY",
191
+ entryFile: firstExisting(cwd, [
192
+ "src/main.tsx",
193
+ "src/main.jsx",
194
+ "src/main.ts",
195
+ "src/main.js"
196
+ ])
197
+ };
198
+ }
199
+ if (has("react-scripts")) {
200
+ return {
201
+ runtime: "cra",
202
+ packageManager,
203
+ sdkPackage: "@fixprompt/browser",
204
+ envVarName: "REACT_APP_FIXPROMPT_KEY",
205
+ entryFile: firstExisting(cwd, [
206
+ "src/index.tsx",
207
+ "src/index.jsx",
208
+ "src/index.ts",
209
+ "src/index.js"
210
+ ])
211
+ };
212
+ }
213
+ if (has("react") || has("@vitejs/plugin-react")) {
214
+ return {
215
+ runtime: "browser",
216
+ packageManager,
217
+ sdkPackage: "@fixprompt/browser",
218
+ envVarName: "VITE_FIXPROMPT_KEY",
219
+ entryFile: firstExisting(cwd, [
220
+ "src/main.tsx",
221
+ "src/main.jsx",
222
+ "src/index.tsx",
223
+ "src/index.jsx"
224
+ ])
225
+ };
226
+ }
227
+ if (has("express") || has("@nestjs/core") || has("fastify") || pj?.main) {
228
+ return {
229
+ runtime: "node",
230
+ packageManager,
231
+ // @fixprompt/node isn't published yet — fall back to the legacy
232
+ // GitHub install which is still what every existing backend uses.
233
+ sdkPackage: "@geos/loghub-client@github:goscha01/geos-loghub-client",
234
+ envVarName: "LOGHUB_KEY",
235
+ entryFile: null
236
+ // legacy client has no init() to inject
237
+ };
238
+ }
239
+ return {
240
+ runtime: null,
241
+ packageManager,
242
+ sdkPackage: "",
243
+ envVarName: "",
244
+ entryFile: null
245
+ };
246
+ }
247
+ function installSdk(cwd, stack) {
248
+ if (!stack.sdkPackage) return { skipped: true, reason: "no SDK for this stack" };
249
+ const pj = readPackageJson(cwd);
250
+ const deps = { ...pj?.dependencies ?? {}, ...pj?.devDependencies ?? {} };
251
+ const bareName = stack.sdkPackage.split("@github:")[0].replace(/@\^?[\d.]+$/, "");
252
+ if (Object.prototype.hasOwnProperty.call(deps, bareName)) {
253
+ return { skipped: true, reason: `${bareName} already in package.json` };
254
+ }
255
+ const useShell = process.platform === "win32";
256
+ const pm = stack.packageManager;
257
+ const args = pm === "yarn" ? ["add", stack.sdkPackage] : pm === "pnpm" ? ["add", stack.sdkPackage] : pm === "bun" ? ["add", stack.sdkPackage] : ["install", stack.sdkPackage];
258
+ console.log(` \xB7 running ${pm} ${args.join(" ")}\u2026`);
259
+ const r = spawnSync(
260
+ pm,
261
+ useShell ? args.map((a) => `"${a.replace(/"/g, '\\"')}"`) : args,
262
+ { cwd, stdio: "inherit", shell: useShell }
263
+ );
264
+ if (r.status !== 0) {
265
+ throw new Error(`${pm} install exited ${r.status}`);
266
+ }
267
+ return { skipped: false };
268
+ }
269
+ function alreadyHasFixPromptInit(src) {
270
+ return /initFixPrompt\s*\(/.test(src) || /from\s+['"]@fixprompt\//.test(src);
271
+ }
272
+ function buildBrowserInitSnippet(slug, envAccess, envCheck) {
273
+ return `
274
+ import { initFixPrompt } from '@fixprompt/browser';
275
+ const __fpKey = ${envAccess};
276
+ if (__fpKey) {
277
+ initFixPrompt({
278
+ projectKey: __fpKey,
279
+ source: '${slug}-prod',
280
+ service: '${slug}',
281
+ env: ${envCheck} ? 'prod' : 'dev',
282
+ });
283
+ }
284
+ `;
285
+ }
286
+ function buildRnInitSnippet(slug) {
287
+ return `
288
+ import { initFixPrompt } from '@fixprompt/react-native';
289
+ const __fpKey = process.env.EXPO_PUBLIC_FIXPROMPT_KEY;
290
+ if (__fpKey) {
291
+ initFixPrompt({
292
+ projectKey: __fpKey,
293
+ source: '${slug}-prod',
294
+ service: '${slug}',
295
+ env: __DEV__ ? 'dev' : 'prod',
296
+ });
297
+ }
298
+ `;
299
+ }
300
+ function injectAfterImports(src, snippet) {
301
+ const lines = src.split("\n");
302
+ let lastImportIdx = -1;
303
+ for (let i = 0; i < lines.length; i++) {
304
+ const l = lines[i].trim();
305
+ if (l.startsWith("import ")) lastImportIdx = i;
306
+ if (lastImportIdx !== -1 && l && !l.startsWith("import ") && !l.startsWith("//") && !l.startsWith("/*")) {
307
+ break;
308
+ }
309
+ }
310
+ if (lastImportIdx === -1) return snippet.replace(/^\n/, "") + src;
311
+ lines.splice(lastImportIdx + 1, 0, snippet.replace(/^\n/, "").replace(/\n$/, ""));
312
+ return lines.join("\n");
313
+ }
314
+ function injectVite(opts) {
315
+ const entry = opts.stack.entryFile;
316
+ if (!entry) return { applied: false, reason: "no Vite entry file (src/main.*) found" };
317
+ const src = readFileSync(entry, "utf8");
318
+ if (alreadyHasFixPromptInit(src)) return { applied: false, file: entry, reason: "already wired" };
319
+ const snippet = buildBrowserInitSnippet(
320
+ opts.slug,
321
+ "import.meta.env.VITE_FIXPROMPT_KEY",
322
+ "import.meta.env.PROD"
323
+ );
324
+ writeFileSync(entry, injectAfterImports(src, snippet));
325
+ return { applied: true, file: entry };
326
+ }
327
+ function injectCra(opts) {
328
+ const entry = opts.stack.entryFile;
329
+ if (!entry) return { applied: false, reason: "no CRA entry file (src/index.*) found" };
330
+ const src = readFileSync(entry, "utf8");
331
+ if (alreadyHasFixPromptInit(src)) return { applied: false, file: entry, reason: "already wired" };
332
+ const snippet = buildBrowserInitSnippet(
333
+ opts.slug,
334
+ "process.env.REACT_APP_FIXPROMPT_KEY",
335
+ "process.env.NODE_ENV === 'production'"
336
+ );
337
+ writeFileSync(entry, injectAfterImports(src, snippet));
338
+ return { applied: true, file: entry };
339
+ }
340
+ function injectRn(opts) {
341
+ const entry = opts.stack.entryFile;
342
+ if (!entry) return { applied: false, reason: "no React Native entry (App.* / index.*) found" };
343
+ const src = readFileSync(entry, "utf8");
344
+ if (alreadyHasFixPromptInit(src)) return { applied: false, file: entry, reason: "already wired" };
345
+ const snippet = buildRnInitSnippet(opts.slug);
346
+ writeFileSync(entry, injectAfterImports(src, snippet));
347
+ return { applied: true, file: entry };
348
+ }
349
+ function injectNextApp(opts) {
350
+ const layoutPath = opts.stack.entryFile;
351
+ if (!layoutPath) return { applied: false, reason: "no Next.js layout file found" };
352
+ const layoutDir = dirname(layoutPath);
353
+ const initPath = join(layoutDir, "fixprompt-init.tsx");
354
+ if (!existsSync(initPath)) {
355
+ const initContent = `'use client';
356
+ import { useEffect } from 'react';
357
+ import { initFixPrompt } from '@fixprompt/browser';
358
+
359
+ let __fpDone = false;
360
+
361
+ export function FixPromptInit() {
362
+ useEffect(() => {
363
+ if (__fpDone) return;
364
+ const key = process.env.NEXT_PUBLIC_FIXPROMPT_KEY;
365
+ if (!key) return;
366
+ initFixPrompt({
367
+ projectKey: key,
368
+ source: '${opts.slug}-prod',
369
+ service: '${opts.slug}',
370
+ env: process.env.NODE_ENV === 'production' ? 'prod' : 'dev',
371
+ });
372
+ __fpDone = true;
373
+ }, []);
374
+ return null;
375
+ }
376
+ `;
377
+ writeFileSync(initPath, initContent);
378
+ }
379
+ const layoutSrc = readFileSync(layoutPath, "utf8");
380
+ if (alreadyHasFixPromptInit(layoutSrc)) {
381
+ return { applied: false, file: layoutPath, reason: "layout already imports FixPromptInit" };
382
+ }
383
+ let next = layoutSrc;
384
+ const importLine = `import { FixPromptInit } from './fixprompt-init';
385
+ `;
386
+ const lines = next.split("\n");
387
+ let lastImportIdx = -1;
388
+ for (let i = 0; i < lines.length; i++) {
389
+ if (lines[i].trim().startsWith("import ")) lastImportIdx = i;
390
+ }
391
+ if (lastImportIdx >= 0) {
392
+ lines.splice(lastImportIdx + 1, 0, importLine.trim());
393
+ } else {
394
+ lines.unshift(importLine.trim());
395
+ }
396
+ next = lines.join("\n");
397
+ const bodyOpen = /<body([^>]*)>/;
398
+ if (!bodyOpen.test(next)) {
399
+ return {
400
+ applied: false,
401
+ file: layoutPath,
402
+ reason: "layout has no <body> tag \u2014 paste <FixPromptInit /> manually"
403
+ };
404
+ }
405
+ next = next.replace(bodyOpen, (m) => `${m}
406
+ <FixPromptInit />`);
407
+ writeFileSync(layoutPath, next);
408
+ return { applied: true, file: layoutPath };
409
+ }
410
+ function injectNextPages(opts) {
411
+ const entry = opts.stack.entryFile;
412
+ if (!entry) return { applied: false, reason: "no pages/_app.* found" };
413
+ const src = readFileSync(entry, "utf8");
414
+ if (alreadyHasFixPromptInit(src)) return { applied: false, file: entry, reason: "already wired" };
415
+ const snippet = buildBrowserInitSnippet(
416
+ opts.slug,
417
+ "process.env.NEXT_PUBLIC_FIXPROMPT_KEY",
418
+ "process.env.NODE_ENV === 'production'"
419
+ );
420
+ writeFileSync(entry, injectAfterImports(src, snippet));
421
+ return { applied: true, file: entry };
422
+ }
423
+ function injectInit(opts) {
424
+ switch (opts.stack.runtime) {
425
+ case "vite":
426
+ return injectVite(opts);
427
+ case "cra":
428
+ return injectCra(opts);
429
+ case "browser":
430
+ return injectVite(opts);
431
+ // close enough — uses VITE_ env, edits the same kind of file
432
+ case "react-native":
433
+ return injectRn(opts);
434
+ case "next-app":
435
+ return injectNextApp(opts);
436
+ case "next-pages":
437
+ return injectNextPages(opts);
438
+ case "node":
439
+ return { applied: false, reason: "Node SDK (@geos/loghub-client) has no init() to inject" };
440
+ default:
441
+ return { applied: false, reason: "unknown stack \u2014 no injection performed" };
442
+ }
443
+ }
444
+
445
+ // src/connect.ts
93
446
  var DEFAULT_ENDPOINT = "https://geosloghub-production.up.railway.app";
94
447
  var ENV_VAR_BY_SCOPE = {
95
448
  deploy: "FIXPROMPT_DEPLOY_TOKEN",
@@ -114,13 +467,13 @@ function dirExists(p) {
114
467
  }
115
468
  }
116
469
  function detectProvider(cwd) {
117
- if (fileExists(join(cwd, "app.json")) || fileExists(join(cwd, "app.config.js")) || fileExists(join(cwd, "app.config.ts")) || fileExists(join(cwd, "eas.json"))) {
470
+ if (fileExists(join2(cwd, "app.json")) || fileExists(join2(cwd, "app.config.js")) || fileExists(join2(cwd, "app.config.ts")) || fileExists(join2(cwd, "eas.json"))) {
118
471
  return "eas";
119
472
  }
120
- if (fileExists(join(cwd, "vercel.json")) || fileExists(join(cwd, ".vercel", "project.json"))) {
473
+ if (fileExists(join2(cwd, "vercel.json")) || fileExists(join2(cwd, ".vercel", "project.json"))) {
121
474
  return "vercel";
122
475
  }
123
- const wfDir = join(cwd, ".github", "workflows");
476
+ const wfDir = join2(cwd, ".github", "workflows");
124
477
  if (dirExists(wfDir)) {
125
478
  try {
126
479
  const files = readdirSync(wfDir);
@@ -167,7 +520,7 @@ function writeEas(envVar, token, env, force) {
167
520
  "--non-interactive"
168
521
  ];
169
522
  if (force) args.push("--force");
170
- const r = spawnSync("npx", useShell ? args.map((a) => `"${a.replace(/"/g, '\\"')}"`) : args, {
523
+ const r = spawnSync2("npx", useShell ? args.map((a) => `"${a.replace(/"/g, '\\"')}"`) : args, {
171
524
  stdio: "inherit",
172
525
  shell: useShell
173
526
  });
@@ -184,7 +537,7 @@ function writeGitHubActions(envVar, token, cwd) {
184
537
  console.log(` \xB7 writing ${envVar} to GitHub Actions repo secrets\u2026`);
185
538
  const useShell = process.platform === "win32";
186
539
  const args = ["secret", "set", envVar, "--body", "-"];
187
- const r = spawnSync("gh", useShell ? args.map((a) => `"${a.replace(/"/g, '\\"')}"`) : args, {
540
+ const r = spawnSync2("gh", useShell ? args.map((a) => `"${a.replace(/"/g, '\\"')}"`) : args, {
188
541
  input: token,
189
542
  cwd,
190
543
  stdio: ["pipe", "inherit", "inherit"],
@@ -209,7 +562,7 @@ then re-run with that token exported.`
209
562
  }
210
563
  let projectId = target.vercelProjectId;
211
564
  if (!projectId) {
212
- const pj = join(process.cwd(), ".vercel", "project.json");
565
+ const pj = join2(process.cwd(), ".vercel", "project.json");
213
566
  if (!fileExists(pj)) {
214
567
  throw new Error(
215
568
  `Could not resolve Vercel project id. Either run from a 'vercel link'-ed
@@ -217,7 +570,7 @@ directory or pass --vercel-project-id <prj_\u2026>.`
217
570
  );
218
571
  }
219
572
  try {
220
- const parsed = JSON.parse(readFileSync(pj, "utf8"));
573
+ const parsed = JSON.parse(readFileSync2(pj, "utf8"));
221
574
  projectId = parsed.projectId;
222
575
  } catch (e) {
223
576
  throw new Error(`failed to read .vercel/project.json: ${e.message}`);
@@ -256,20 +609,20 @@ function writeToProvider(envVar, token, provider, target, force, cwd) {
256
609
  }
257
610
  }
258
611
  function ensureGitignoreHas(cwd, pattern) {
259
- const gi = join(cwd, ".gitignore");
612
+ const gi = join2(cwd, ".gitignore");
260
613
  let s = "";
261
614
  try {
262
- s = readFileSync(gi, "utf8");
615
+ s = readFileSync2(gi, "utf8");
263
616
  } catch {
264
617
  }
265
618
  const lines = s.split(/\r?\n/);
266
619
  if (lines.some((l) => l.trim() === pattern)) return false;
267
620
  const sep = s.length === 0 || s.endsWith("\n") ? "" : "\n";
268
- writeFileSync(gi, s + sep + "\n# FixLoop\n" + pattern + "\n");
621
+ writeFileSync2(gi, s + sep + "\n# FixLoop\n" + pattern + "\n");
269
622
  return true;
270
623
  }
271
624
  function appendEnvLocal(cwd, token, projectId, projectSlug, endpoint) {
272
- const envPath = join(cwd, ".env.local");
625
+ const envPath = join2(cwd, ".env.local");
273
626
  const lines = [
274
627
  "",
275
628
  "# FixLoop \u2014 read token for the local coding agent. Gitignored.",
@@ -282,44 +635,147 @@ function appendEnvLocal(cwd, token, projectId, projectSlug, endpoint) {
282
635
  lines.push(`FIXPROMPT_BROKER_URL=${endpoint}`);
283
636
  lines.push("");
284
637
  const block = lines.join("\n");
285
- if (existsSync(envPath)) {
638
+ if (existsSync2(envPath)) {
286
639
  appendFileSync(envPath, block);
287
640
  } else {
288
- writeFileSync(envPath, block.replace(/^\n/, ""));
641
+ writeFileSync2(envPath, block.replace(/^\n/, ""));
289
642
  }
290
643
  }
291
- function appendClaudeMdGuidance(cwd, projectId, projectSlug) {
292
- const claudePath = join(cwd, "CLAUDE.md");
293
- const section = [
644
+ function detectRuntime(cwd) {
645
+ const pj = join2(cwd, "package.json");
646
+ if (!existsSync2(pj)) return "unknown";
647
+ try {
648
+ const j = JSON.parse(readFileSync2(pj, "utf8"));
649
+ const deps = { ...j.dependencies || {}, ...j.devDependencies || {} };
650
+ if (deps["react-native"] || deps["expo"]) return "react-native";
651
+ if (deps["next"]) return "next";
652
+ if (deps["react"] || deps["vite"] || deps["react-scripts"]) return "browser";
653
+ if (deps["@nestjs/core"] || deps["express"] || deps["fastify"]) return "node";
654
+ } catch {
655
+ }
656
+ return "unknown";
657
+ }
658
+ var FIXLOOP_SECTION_MARKER = "<!-- fixloop:claude-section -->";
659
+ function buildClaudeMdSection(projectId, projectSlug, runtime) {
660
+ const sdkRows = {
661
+ "react-native": {
662
+ pkg: "@fixprompt/react-native",
663
+ entry: "App.tsx / index.js \u2014 call before the first render",
664
+ example: "import { initFixPrompt } from '@fixprompt/react-native';\ninitFixPrompt({\n projectKey: process.env.EXPO_PUBLIC_FIXPROMPT_KEY!,\n source: '<slug>-prod',\n service: '<slug>',\n env: 'prod',\n});"
665
+ },
666
+ next: {
667
+ pkg: "@fixprompt/browser (client) + @fixprompt/node (server, github:goscha01/geos-loghub-client until @fixprompt/node ships)",
668
+ entry: "src/app/providers.tsx for client (useEffect-init), instrumentation.ts or server-start for Node",
669
+ example: `// client (in a "use client" component, useEffect):
670
+ import { initFixPrompt } from '@fixprompt/browser';
671
+ initFixPrompt({ projectKey: process.env.NEXT_PUBLIC_FIXPROMPT_KEY!, source: '<slug>-prod', service: '<slug>', env: 'prod' });`
672
+ },
673
+ browser: {
674
+ pkg: "@fixprompt/browser",
675
+ entry: "src/main.{ts,tsx} / index.{js,tsx} \u2014 top of file, before render",
676
+ example: "import { initFixPrompt } from '@fixprompt/browser';\ninitFixPrompt({\n projectKey: import.meta.env.VITE_FIXPROMPT_KEY, // or REACT_APP_FIXPROMPT_KEY for CRA\n source: '<slug>-prod',\n service: '<slug>',\n env: import.meta.env.PROD ? 'prod' : 'dev',\n});"
677
+ },
678
+ node: {
679
+ pkg: "@geos/loghub-client (npm install @geos/loghub-client@github:goscha01/geos-loghub-client \u2014 @fixprompt/node not yet on npm)",
680
+ entry: "src/index.{ts,js} / src/main.{ts,js} \u2014 at server start, before route registration",
681
+ example: "const { loghubLog } = require('@geos/loghub-client');\n// per-request middleware:\napp.use((req, res, next) => {\n res.on('finish', () => loghubLog({ service: '<slug>', app: '<slug>', env: 'prod', level: res.statusCode >= 500 ? 'error' : 'info', message: 'http_request', attrs: { method: req.method, path: req.originalUrl, status: res.statusCode } }));\n next();\n});"
682
+ }
683
+ };
684
+ const sdk = runtime === "unknown" ? {
685
+ pkg: "@fixprompt/browser | @fixprompt/react-native | @geos/loghub-client (Node) \u2014 pick by stack",
686
+ entry: "(read package.json to determine runtime)",
687
+ example: "// see https://fixloop-dashboard.vercel.app/onboarding for runtime-specific snippets"
688
+ } : sdkRows[runtime];
689
+ const slugLine = projectSlug ? `\`${projectSlug}\`` : "(see .env.local)";
690
+ const sdkExampleWithSlug = sdk.example.replace(/<slug>/g, projectSlug ?? "<slug>");
691
+ return [
692
+ "",
693
+ FIXLOOP_SECTION_MARKER,
694
+ "## FixLoop \u2014 production error logs (auto-fetch on bug reports + dev loop)",
294
695
  "",
295
- "## FixLoop \u2014 production error logs (auto-fetch on bug reports)",
696
+ `This project is wired to FixLoop. Two behaviors expected of the coding agent reading this file:`,
296
697
  "",
297
- 'This project ships errors to FixLoop. When the user reports a bug, crash, error, "X is broken", or pastes an error stack:',
698
+ "### 1. Install the SDK on first use (skip if already in `package.json`)",
298
699
  "",
299
- "1. Read the FixLoop env vars from `.env.local` (gitignored \u2014 never commit).",
300
- "2. `curl` the broker for recent error lines.",
301
- "3. Parse the response `entries[]` \u2014 each has `ts`, `level`, `message`, optional `attrs` + `stack`. Match against the user's symptom.",
302
- "4. Propose a fix grounded in what the logs show. **Do not ask the user to paste log lines \u2014 fetch them yourself.**",
700
+ `Detected runtime: **${runtime}**. Recommended SDK: \`${sdk.pkg}\``,
303
701
  "",
304
- "### Fetch logs",
702
+ `- Entry point: ${sdk.entry}`,
703
+ `- Init snippet:`,
305
704
  "",
306
- "```sh",
307
- 'curl -sH "x-fixprompt-read-token: $FIXPROMPT_READ_TOKEN" \\',
308
- ' "$FIXPROMPT_BROKER_URL/projects/$FIXPROMPT_PROJECT_ID/logs?since=24h&level=error,warn,fatal&limit=200"',
705
+ "```ts",
706
+ sdkExampleWithSlug,
309
707
  "```",
310
708
  "",
311
- "Query params: `since` (15m..7d), `level` (csv: debug,info,warn,error,fatal), `q` (substring), `limit` (1..500).",
709
+ "After install, the SDK ships every uncaught error to the broker \u2192 Grafana Loki under `service_name=" + (projectSlug ?? "<slug>") + "`. Subsequent debug runs read these errors back via the curl in step 2.",
710
+ "",
711
+ "### 2. Debug loop on bug reports",
712
+ "",
713
+ `Trigger phrases: "X is broken", "doesn't work", "crashes", "is failing", pasted error stacks, "users are reporting Y", TestFlight/App Store bug reports.`,
714
+ "",
715
+ "Loop until the log query returns no new matches:",
716
+ "",
717
+ "1. **Fetch recent errors** \u2014 env vars come from `.env.local` (gitignored \u2014 never commit):",
718
+ " ```sh",
719
+ ' curl -sH "x-fixprompt-read-token: $FIXPROMPT_READ_TOKEN" \\',
720
+ ' "$FIXPROMPT_BROKER_URL/projects/$FIXPROMPT_PROJECT_ID/logs?since=24h&level=error,warn,fatal&limit=200"',
721
+ " ```",
722
+ " Query params: `since` (15m..7d), `level` (csv), `q` (substring), `limit` (1..500).",
723
+ "",
724
+ '2. **Parse `entries[]`** \u2014 each has `ts`, `level`, `message`, optional `attrs` + `stack`. Find the entries matching the user\'s symptom. Cite specific lines in your response (e.g. *"the `Cannot read property foo of null` at -200ms vs `[Storage] read failed` at -240ms suggests AsyncStorage is racing the migration"*).',
725
+ "",
726
+ "3. **Apply a fix** grounded in what the logs show. **Do not ask the user to paste log lines \u2014 fetch them yourself.**",
727
+ "",
728
+ "4. **Verify locally** \u2014 run the build / dev server / tests so the fix compiles and the affected path no longer throws.",
729
+ "",
730
+ "5. **Re-fetch with `since=5m`** after deploy. If the same error still appears OR new errors appear, **loop back to step 2** with the latest entries as context. Stop when the query returns no new matches against the user's symptom.",
312
731
  "",
313
- projectSlug ? `**Project**: \`${projectSlug}\`.` : "",
314
- `Manage / revoke: https://fixloop-dashboard.vercel.app/integrations/${projectId}`,
732
+ "### Project",
733
+ "",
734
+ `- Slug: ${slugLine}`,
735
+ `- Dashboard: https://fixloop-dashboard.vercel.app/issues/${projectId}`,
736
+ `- Manage / revoke the read token: https://fixloop-dashboard.vercel.app/integrations/${projectId}`,
315
737
  ""
316
- ].filter(Boolean).join("\n");
317
- if (existsSync(claudePath)) {
318
- const existing = readFileSync(claudePath, "utf8");
319
- if (existing.includes("## FixLoop \u2014")) return;
320
- appendFileSync(claudePath, section);
738
+ ].join("\n");
739
+ }
740
+ function stripFixloopSection(body) {
741
+ const lines = body.split("\n");
742
+ let start = -1;
743
+ for (let i = 0; i < lines.length; i++) {
744
+ if (lines[i].trim() === FIXLOOP_SECTION_MARKER) {
745
+ start = i;
746
+ break;
747
+ }
748
+ if (lines[i].startsWith("## FixLoop \u2014")) {
749
+ start = i;
750
+ break;
751
+ }
752
+ }
753
+ if (start === -1) return body;
754
+ const realStart = start > 0 && lines[start - 1] === "" ? start - 1 : start;
755
+ let end = lines.length;
756
+ for (let i = start + 1; i < lines.length; i++) {
757
+ if (lines[i].startsWith("## ") && !lines[i].startsWith("## FixLoop \u2014")) {
758
+ end = i;
759
+ break;
760
+ }
761
+ }
762
+ const trimmedTail = lines.slice(end);
763
+ while (trimmedTail.length > 0 && realStart > 0 && lines[realStart - 1] === "" && trimmedTail[0] === "") {
764
+ trimmedTail.shift();
765
+ }
766
+ return [...lines.slice(0, realStart), ...trimmedTail].join("\n");
767
+ }
768
+ function appendClaudeMdGuidance(cwd, projectId, projectSlug) {
769
+ const claudePath = join2(cwd, "CLAUDE.md");
770
+ const runtime = detectRuntime(cwd);
771
+ const section = buildClaudeMdSection(projectId, projectSlug, runtime);
772
+ if (existsSync2(claudePath)) {
773
+ const existing = readFileSync2(claudePath, "utf8");
774
+ const stripped = stripFixloopSection(existing);
775
+ const next = stripped.endsWith("\n") ? stripped + section.replace(/^\n/, "") : stripped + section;
776
+ writeFileSync2(claudePath, next);
321
777
  } else {
322
- writeFileSync(claudePath, "# Project\n" + section);
778
+ writeFileSync2(claudePath, "# Project\n" + section);
323
779
  }
324
780
  }
325
781
  function writeLocalDiscovery(opts) {
@@ -329,6 +785,61 @@ function writeLocalDiscovery(opts) {
329
785
  appendEnvLocal(opts.cwd, opts.token, opts.projectId, opts.projectSlug, opts.endpoint);
330
786
  appendClaudeMdGuidance(opts.cwd, opts.projectId, opts.projectSlug);
331
787
  }
788
+ function deriveSlug(name) {
789
+ const stripped = name.replace(/^@[^/]+\//, "");
790
+ const slug = stripped.toLowerCase().replace(/[_.@]/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
791
+ if (slug.length < 2 || slug.length > 32) return null;
792
+ if (!/^[a-z0-9]/.test(slug) || !/[a-z0-9]$/.test(slug)) return null;
793
+ return slug;
794
+ }
795
+ function deriveProjectType(runtime) {
796
+ switch (runtime) {
797
+ case "react-native":
798
+ return "mobile";
799
+ case "next-app":
800
+ case "next-pages":
801
+ case "vite":
802
+ case "cra":
803
+ case "browser":
804
+ return "web";
805
+ case "node":
806
+ return "api";
807
+ default:
808
+ return "other";
809
+ }
810
+ }
811
+ async function createProjectViaAdmin(opts) {
812
+ const res = await fetch(`${opts.endpoint}/admin/projects`, {
813
+ method: "POST",
814
+ headers: {
815
+ "x-internal-admin-token": opts.adminToken,
816
+ "Content-Type": "application/json"
817
+ },
818
+ body: JSON.stringify({
819
+ name: opts.name,
820
+ slug: opts.slug,
821
+ project_type: opts.projectType
822
+ })
823
+ });
824
+ const text = await res.text();
825
+ if (!res.ok) {
826
+ throw new Error(`broker /admin/projects responded ${res.status}: ${text.slice(0, 200)}`);
827
+ }
828
+ return JSON.parse(text);
829
+ }
830
+ async function writeRuntimeKey(key, envVar, provider, target, force, cwd) {
831
+ if (!provider) return { wrote: false, reason: "no provider detected" };
832
+ switch (provider) {
833
+ case "eas":
834
+ writeEas(envVar, key, target.easEnv, force);
835
+ return { wrote: true };
836
+ case "vercel":
837
+ await writeVercel(envVar, key, target);
838
+ return { wrote: true };
839
+ case "github-actions":
840
+ return { wrote: false, reason: "github-actions has no runtime bundle \u2014 runtime key not stored" };
841
+ }
842
+ }
332
843
  async function connect(args) {
333
844
  const cwd = process.cwd();
334
845
  const scopeRaw = (args.flags.scope ?? "deploy").toLowerCase();
@@ -345,25 +856,67 @@ async function connect(args) {
345
856
  process.exit(1);
346
857
  }
347
858
  const endpoint = (args.flags.endpoint ?? process.env.FIXPROMPT_ENDPOINT ?? DEFAULT_ENDPOINT).replace(/\/$/, "");
859
+ const stack = detectStack(cwd);
860
+ const shouldInstall = args.flags["no-install"] !== true;
348
861
  let projectId = args.flags["project-id"] ?? process.env.FIXPROMPT_PROJECT_ID ?? null;
349
862
  let projectSlug = args.flags["project-slug"] ?? null;
863
+ let freshRuntimeKey = null;
350
864
  if (!projectId && projectSlug) {
351
865
  const res = await fetch(`${endpoint}/admin/projects/by-slug/${encodeURIComponent(projectSlug)}`, {
352
866
  headers: { "x-internal-admin-token": adminToken }
353
867
  });
354
- const text = await res.text();
355
- if (!res.ok) {
868
+ if (res.ok) {
869
+ const parsed = await res.json();
870
+ projectId = parsed.id;
871
+ projectSlug = parsed.slug;
872
+ console.log(` resolved --project-slug=${projectSlug} \u2192 ${parsed.id} (${parsed.name})`);
873
+ } else if (res.status !== 404) {
874
+ const text = await res.text();
356
875
  console.error(`Project lookup failed (${res.status}): ${text.slice(0, 200)}`);
357
876
  process.exit(1);
358
877
  }
359
- const parsed = JSON.parse(text);
360
- projectId = parsed.id;
361
- projectSlug = parsed.slug;
362
- console.log(` resolved --project-slug=${projectSlug} \u2192 ${parsed.id} (${parsed.name})`);
878
+ }
879
+ const wantCreate = args.flags.create === true || !projectId && !projectSlug;
880
+ if (!projectId && wantCreate) {
881
+ let slugForCreate = projectSlug;
882
+ let nameForCreate = args.flags["project-name"] ?? null;
883
+ if (!slugForCreate || !nameForCreate) {
884
+ const pj = (() => {
885
+ try {
886
+ return JSON.parse(readFileSync2(join2(cwd, "package.json"), "utf8"));
887
+ } catch {
888
+ return null;
889
+ }
890
+ })();
891
+ const pkgName = pj?.name;
892
+ if (pkgName) {
893
+ slugForCreate = slugForCreate ?? deriveSlug(pkgName);
894
+ nameForCreate = nameForCreate ?? pkgName;
895
+ }
896
+ }
897
+ if (!slugForCreate) {
898
+ console.error(
899
+ "Could not derive a project slug.\n Pass --project-slug <slug> or set a clean package.json#name.\n Slug rules: 2-32 chars, lowercase a-z 0-9 dashes, starts/ends with alphanum."
900
+ );
901
+ process.exit(1);
902
+ }
903
+ console.log(`
904
+ \u2022 Creating FixLoop project '${slugForCreate}' (no existing match)\u2026`);
905
+ const created = await createProjectViaAdmin({
906
+ endpoint,
907
+ adminToken,
908
+ name: nameForCreate ?? slugForCreate,
909
+ slug: slugForCreate,
910
+ projectType: deriveProjectType(stack.runtime)
911
+ });
912
+ projectId = created.project_id;
913
+ projectSlug = slugForCreate;
914
+ freshRuntimeKey = created.key;
915
+ console.log(` \u2713 project_id ${created.project_id}, source ${created.source}`);
363
916
  }
364
917
  if (!projectId) {
365
918
  console.error(
366
- "Need --project-id or --project-slug (or $FIXPROMPT_PROJECT_ID).\n --project-slug is easiest: it matches the value you see in the dashboard."
919
+ "Need --project-id, --project-slug, or --create (or $FIXPROMPT_PROJECT_ID).\n --create derives slug + name from package.json#name automatically."
367
920
  );
368
921
  process.exit(1);
369
922
  }
@@ -397,6 +950,18 @@ async function connect(args) {
397
950
  const force = args.flags.force === true;
398
951
  const scopes = scope === "both" ? ["deploy", "read"] : [scope];
399
952
  const mintedTokens = [];
953
+ if (freshRuntimeKey && stack.envVarName) {
954
+ console.log(`
955
+ \u2022 Writing runtime key (${stack.envVarName}) to ${provider ?? "no provider"}\u2026`);
956
+ try {
957
+ const r = await writeRuntimeKey(freshRuntimeKey, stack.envVarName, provider, target, force, cwd);
958
+ if (r.wrote) console.log(" \u2713 runtime key stored on CI provider");
959
+ else console.log(` \xB7 skipped (${r.reason}) \u2014 set ${stack.envVarName} manually if needed`);
960
+ } catch (e) {
961
+ console.warn(` ! runtime key write failed: ${e.message}`);
962
+ console.warn(` Manually set ${stack.envVarName}=${freshRuntimeKey.slice(0, 8)}\u2026 in your CI provider.`);
963
+ }
964
+ }
400
965
  for (const s of scopes) {
401
966
  const envVar = ENV_VAR_BY_SCOPE[s];
402
967
  const label = scope === "both" ? `${labelBase}-${s}` : labelBase;
@@ -418,6 +983,27 @@ async function connect(args) {
418
983
  mintedTokens.push({ scope: s, token_id: minted.token_id, envVar });
419
984
  }
420
985
  const readMinted = mintedTokens.some((m) => m.scope === "read");
986
+ if (readMinted && shouldInstall && stack.runtime) {
987
+ console.log(`
988
+ \u2022 Installing SDK + injecting init for runtime '${stack.runtime}'\u2026`);
989
+ try {
990
+ const inst = installSdk(cwd, stack);
991
+ if (inst.skipped) console.log(` \xB7 ${inst.reason}`);
992
+ else console.log(` \u2713 ${stack.sdkPackage} installed`);
993
+ } catch (e) {
994
+ console.warn(` ! install failed: ${e.message}`);
995
+ console.warn(` Continuing \u2014 set up the SDK manually with: ${stack.packageManager} add ${stack.sdkPackage}`);
996
+ }
997
+ if (projectSlug) {
998
+ const inj = injectInit({ cwd, stack, slug: projectSlug });
999
+ if (inj.applied) console.log(` \u2713 init code injected into ${inj.file}`);
1000
+ else console.log(` \xB7 injection skipped: ${inj.reason}${inj.file ? ` (${inj.file})` : ""}`);
1001
+ }
1002
+ } else if (readMinted && !stack.runtime) {
1003
+ console.log(`
1004
+ \u2022 Skipped SDK install \u2014 couldn't detect a known runtime in ${cwd}.`);
1005
+ console.log(` Manually install the right @fixprompt/* package and call initFixPrompt({...}).`);
1006
+ }
421
1007
  const deployMinted = mintedTokens.some((m) => m.scope === "deploy");
422
1008
  console.log("");
423
1009
  if (deployMinted && provider) {
@@ -444,7 +1030,7 @@ async function connect(args) {
444
1030
 
445
1031
  // src/version.ts
446
1032
  var CLI_NAME = "@fixprompt/cli";
447
- var CLI_VERSION = "0.4.0";
1033
+ var CLI_VERSION = "0.6.0";
448
1034
 
449
1035
  // src/cli.ts
450
1036
  var DEFAULT_ENDPOINT2 = "https://geosloghub-production.up.railway.app";
@@ -484,13 +1070,19 @@ Usage:
484
1070
 
485
1071
  connect options:
486
1072
  --scope <s> deploy | read | both (default: deploy)
487
- deploy \u2192 mints fpd_\u2026, writes FIXPROMPT_DEPLOY_TOKEN
488
- read \u2192 mints fpr_\u2026, writes FIXPROMPT_READ_TOKEN
489
- (lets a customer's coding agent fetch
490
- production logs from FixLoop directly)
491
- both \u2192 does both in one pass
1073
+ \u25B8 For the zero-paste new-project flow, use --scope both.
1074
+ deploy \u2192 mints fpd_\u2026, writes FIXPROMPT_DEPLOY_TOKEN to CI
1075
+ read \u2192 mints fpr_\u2026, writes FIXPROMPT_READ_TOKEN locally,
1076
+ installs the SDK, injects initFixPrompt({\u2026})
1077
+ into the entry file, drops a CLAUDE.md section
1078
+ both \u2192 all of the above in one pass
1079
+ --create Auto-create the FixLoop project if --project-slug doesn't
1080
+ exist (or if no slug given, derived from package.json#name).
1081
+ Writes the new runtime k_ key into the CI provider env.
1082
+ --no-install Skip SDK install + init code injection (read scope only).
492
1083
  --project-id <uuid> FixLoop project id (or $FIXPROMPT_PROJECT_ID)
493
1084
  --project-slug <slug> Look up project_id by slug (no UUID needed)
1085
+ --project-name <text> Display name for --create (default: package.json#name)
494
1086
  --admin-token <fpa_\u2026> Broker admin token (or $FIXPROMPT_ADMIN_TOKEN)
495
1087
  --provider <name> eas | vercel | github-actions (auto-detected from cwd)
496
1088
  --eas-env <env> EAS env to write to (default: production)
@@ -499,6 +1091,9 @@ connect options:
499
1091
  --label <text> Token label (default: <provider>-<timestamp>)
500
1092
  --force Overwrite an existing env var value
501
1093
 
1094
+ Zero-paste flow (in your app repo root):
1095
+ FIXPROMPT_ADMIN_TOKEN=fpa_\u2026 npx @fixprompt/cli connect --scope both --create
1096
+
502
1097
  deploy-start auth (pick one):
503
1098
  --deploy-token <fpd_...> Deploy-only token from dashboard (recommended for CI)
504
1099
  (or $FIXPROMPT_DEPLOY_TOKEN env)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fixprompt/cli",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "FixPrompt CLI — annotate deployments and ship them to the broker so the dashboard knows what changed.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {