@fixprompt/cli 0.5.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 +528 -36
  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,17 +635,17 @@ 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
644
  function detectRuntime(cwd) {
292
- const pj = join(cwd, "package.json");
293
- if (!existsSync(pj)) return "unknown";
645
+ const pj = join2(cwd, "package.json");
646
+ if (!existsSync2(pj)) return "unknown";
294
647
  try {
295
- const j = JSON.parse(readFileSync(pj, "utf8"));
648
+ const j = JSON.parse(readFileSync2(pj, "utf8"));
296
649
  const deps = { ...j.dependencies || {}, ...j.devDependencies || {} };
297
650
  if (deps["react-native"] || deps["expo"]) return "react-native";
298
651
  if (deps["next"]) return "next";
@@ -413,16 +766,16 @@ function stripFixloopSection(body) {
413
766
  return [...lines.slice(0, realStart), ...trimmedTail].join("\n");
414
767
  }
415
768
  function appendClaudeMdGuidance(cwd, projectId, projectSlug) {
416
- const claudePath = join(cwd, "CLAUDE.md");
769
+ const claudePath = join2(cwd, "CLAUDE.md");
417
770
  const runtime = detectRuntime(cwd);
418
771
  const section = buildClaudeMdSection(projectId, projectSlug, runtime);
419
- if (existsSync(claudePath)) {
420
- const existing = readFileSync(claudePath, "utf8");
772
+ if (existsSync2(claudePath)) {
773
+ const existing = readFileSync2(claudePath, "utf8");
421
774
  const stripped = stripFixloopSection(existing);
422
775
  const next = stripped.endsWith("\n") ? stripped + section.replace(/^\n/, "") : stripped + section;
423
- writeFileSync(claudePath, next);
776
+ writeFileSync2(claudePath, next);
424
777
  } else {
425
- writeFileSync(claudePath, "# Project\n" + section);
778
+ writeFileSync2(claudePath, "# Project\n" + section);
426
779
  }
427
780
  }
428
781
  function writeLocalDiscovery(opts) {
@@ -432,6 +785,61 @@ function writeLocalDiscovery(opts) {
432
785
  appendEnvLocal(opts.cwd, opts.token, opts.projectId, opts.projectSlug, opts.endpoint);
433
786
  appendClaudeMdGuidance(opts.cwd, opts.projectId, opts.projectSlug);
434
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
+ }
435
843
  async function connect(args) {
436
844
  const cwd = process.cwd();
437
845
  const scopeRaw = (args.flags.scope ?? "deploy").toLowerCase();
@@ -448,25 +856,67 @@ async function connect(args) {
448
856
  process.exit(1);
449
857
  }
450
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;
451
861
  let projectId = args.flags["project-id"] ?? process.env.FIXPROMPT_PROJECT_ID ?? null;
452
862
  let projectSlug = args.flags["project-slug"] ?? null;
863
+ let freshRuntimeKey = null;
453
864
  if (!projectId && projectSlug) {
454
865
  const res = await fetch(`${endpoint}/admin/projects/by-slug/${encodeURIComponent(projectSlug)}`, {
455
866
  headers: { "x-internal-admin-token": adminToken }
456
867
  });
457
- const text = await res.text();
458
- 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();
459
875
  console.error(`Project lookup failed (${res.status}): ${text.slice(0, 200)}`);
460
876
  process.exit(1);
461
877
  }
462
- const parsed = JSON.parse(text);
463
- projectId = parsed.id;
464
- projectSlug = parsed.slug;
465
- 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}`);
466
916
  }
467
917
  if (!projectId) {
468
918
  console.error(
469
- "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."
470
920
  );
471
921
  process.exit(1);
472
922
  }
@@ -500,6 +950,18 @@ async function connect(args) {
500
950
  const force = args.flags.force === true;
501
951
  const scopes = scope === "both" ? ["deploy", "read"] : [scope];
502
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
+ }
503
965
  for (const s of scopes) {
504
966
  const envVar = ENV_VAR_BY_SCOPE[s];
505
967
  const label = scope === "both" ? `${labelBase}-${s}` : labelBase;
@@ -521,6 +983,27 @@ async function connect(args) {
521
983
  mintedTokens.push({ scope: s, token_id: minted.token_id, envVar });
522
984
  }
523
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
+ }
524
1007
  const deployMinted = mintedTokens.some((m) => m.scope === "deploy");
525
1008
  console.log("");
526
1009
  if (deployMinted && provider) {
@@ -547,7 +1030,7 @@ async function connect(args) {
547
1030
 
548
1031
  // src/version.ts
549
1032
  var CLI_NAME = "@fixprompt/cli";
550
- var CLI_VERSION = "0.5.0";
1033
+ var CLI_VERSION = "0.6.0";
551
1034
 
552
1035
  // src/cli.ts
553
1036
  var DEFAULT_ENDPOINT2 = "https://geosloghub-production.up.railway.app";
@@ -587,13 +1070,19 @@ Usage:
587
1070
 
588
1071
  connect options:
589
1072
  --scope <s> deploy | read | both (default: deploy)
590
- deploy \u2192 mints fpd_\u2026, writes FIXPROMPT_DEPLOY_TOKEN
591
- read \u2192 mints fpr_\u2026, writes FIXPROMPT_READ_TOKEN
592
- (lets a customer's coding agent fetch
593
- production logs from FixLoop directly)
594
- 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).
595
1083
  --project-id <uuid> FixLoop project id (or $FIXPROMPT_PROJECT_ID)
596
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)
597
1086
  --admin-token <fpa_\u2026> Broker admin token (or $FIXPROMPT_ADMIN_TOKEN)
598
1087
  --provider <name> eas | vercel | github-actions (auto-detected from cwd)
599
1088
  --eas-env <env> EAS env to write to (default: production)
@@ -602,6 +1091,9 @@ connect options:
602
1091
  --label <text> Token label (default: <provider>-<timestamp>)
603
1092
  --force Overwrite an existing env var value
604
1093
 
1094
+ Zero-paste flow (in your app repo root):
1095
+ FIXPROMPT_ADMIN_TOKEN=fpa_\u2026 npx @fixprompt/cli connect --scope both --create
1096
+
605
1097
  deploy-start auth (pick one):
606
1098
  --deploy-token <fpd_...> Deploy-only token from dashboard (recommended for CI)
607
1099
  (or $FIXPROMPT_DEPLOY_TOKEN env)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fixprompt/cli",
3
- "version": "0.5.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": {