@flocon-ai/init 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.mjs CHANGED
@@ -1,41 +1,92 @@
1
1
  #!/usr/bin/env node
2
- import { spawnSync } from "node:child_process"
2
+ import { spawn, spawnSync } from "node:child_process"
3
3
  import fs from "node:fs"
4
4
  import path from "node:path"
5
- import readline from "node:readline/promises"
6
- import { addSitemapAndLlms } from "./sitemap.mjs"
5
+ import { addSitemapAndLlms, ownSitemapFiles } from "./sitemap.mjs"
7
6
  import { TEMPLATES } from "./templates.mjs"
7
+ import { applyUrlRule } from "./url.mjs"
8
8
 
9
9
  const DOCS = "https://flocon-ai.com/docs"
10
10
  const TOKENS_URL = "https://flocon-ai.com/app/tokens"
11
11
 
12
12
  const FRAMEWORKS = {
13
13
  nextjs: { label: "Next.js (App Router)", pkg: "@flocon-ai/next" },
14
- react: { label: "React Router v7 (framework mode)", pkg: "@flocon-ai/react" },
14
+ react: { label: "React Router (framework mode)", pkg: "@flocon-ai/react" },
15
15
  astro: { label: "Astro", pkg: "@flocon-ai/astro" }
16
16
  }
17
17
 
18
- const log = (line = "") => console.log(line)
18
+ // Interactive = a person in a terminal: step-by-step questions (like create-next-app).
19
+ // Not interactive (AI agents, CI, --yes, --path or --domain given): no questions, flags and defaults only, plain output.
20
+ let interactive = false
21
+ // @clack/prompts, loaded only for questions: it needs Node 20.12+ (util.styleText). Older Node crashed on start, even with --yes.
22
+ let p
23
+
24
+ // Node 20.12+ (the questions library needs it).
25
+ function canAsk() {
26
+ const [major, minor] = process.versions.node.split(".").map(Number)
27
+ return major > 20 || (major === 20 && minor >= 12)
28
+ }
29
+
30
+ function log(line = "") {
31
+ if (!interactive) return console.log(line)
32
+ const text = line.replace(/^\n+/, "")
33
+ if (text) p.log.message(text)
34
+ }
19
35
 
20
36
  function fail(lines) {
21
- for (const line of lines) console.error(line)
37
+ if (interactive) p.log.error(lines.join("\n"))
38
+ else for (const line of lines) console.error(line)
22
39
  process.exit(1)
23
40
  }
24
41
 
42
+ // Ctrl+C or Esc at a question.
43
+ function cancelled() {
44
+ p.cancel("Cancelled. Nothing was installed or changed.")
45
+ process.exit(130)
46
+ }
47
+
48
+ async function ask(prompt) {
49
+ const value = await prompt
50
+ if (p.isCancel(value)) cancelled()
51
+ return value
52
+ }
53
+
25
54
  function parseArgs(argv) {
26
- const args = { yes: false, skipInstall: false, sitemap: true, path: undefined }
55
+ const args = { yes: false, skipInstall: false, sitemap: true, path: undefined, domain: undefined }
27
56
  for (let i = 0; i < argv.length; i++) {
28
57
  const a = argv[i]
29
58
  if (a === "--yes") args.yes = true
30
59
  else if (a === "--skip-install") args.skipInstall = true
31
60
  else if (a === "--no-sitemap") args.sitemap = false
32
- else if (a === "--path") args.path = argv[++i] ?? ""
33
- else if (a.startsWith("--path=")) args.path = a.slice("--path=".length)
34
- else fail([`Unknown option: ${a}`, "Options: --path <path> --yes --skip-install --no-sitemap"])
61
+ else if (a === "--path") {
62
+ const value = argv[++i]
63
+ // `--path --yes`: the value is missing. Without this check the path would be "/--yes".
64
+ if (value === undefined || value.startsWith("-")) fail(["--path needs a value, e.g. --path /blog (or --path / for the whole website)."])
65
+ args.path = value
66
+ } else if (a.startsWith("--path=")) args.path = a.slice("--path=".length)
67
+ else if (a === "--domain") {
68
+ const value = argv[++i]
69
+ if (value === undefined || value.startsWith("-")) fail(["--domain needs a value, e.g. --domain https://acme.com"])
70
+ args.domain = value
71
+ } else if (a.startsWith("--domain=")) args.domain = a.slice("--domain=".length)
72
+ else fail([`Unknown option: ${a}`, "Options: --path <path> --domain <url> --yes --skip-install --no-sitemap"])
35
73
  }
36
74
  return args
37
75
  }
38
76
 
77
+ // "acme.com", "https://acme.com/blog" → "https://acme.com". Undefined when it is not a domain.
78
+ function toOrigin(input) {
79
+ const t = String(input ?? "").trim()
80
+ if (!t) return undefined
81
+ try {
82
+ const u = new URL(t.includes("://") ? t : `https://${t}`)
83
+ if (!/^https?:$/.test(u.protocol) || !u.hostname.includes(".")) return undefined
84
+ return u.origin
85
+ } catch {
86
+ return undefined
87
+ }
88
+ }
89
+
39
90
  function readPackageJson(cwd) {
40
91
  try {
41
92
  return JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"))
@@ -57,38 +108,92 @@ function detectFramework(cwd) {
57
108
  const deps = { ...pkg.dependencies, ...pkg.devDependencies }
58
109
  if (deps.next && nextAppDir(cwd)) return "nextjs"
59
110
  if (deps.astro && fs.existsSync(path.join(cwd, "src/pages"))) return "astro"
60
- if (deps["@react-router/dev"] && fs.existsSync(path.join(cwd, "app/routes.ts"))) return "react"
111
+ if (deps["@react-router/dev"] && rrRoutesFile(cwd)) return "react"
61
112
  return undefined
62
113
  }
63
114
 
115
+ // React Router: the app folder (`appDirectory` in react-router.config, default "app").
116
+ function rrAppDir(cwd) {
117
+ const m = /appDirectory\s*:\s*["'`]([^"'`]+)["'`]/.exec(readFirst(cwd, RR_CONFIGS))
118
+ return (m?.[1] ?? "app").replace(/^\.\//, "").replace(/\/+$/, "")
119
+ }
120
+
121
+ // React Router: routes.ts, or routes.js in a JavaScript project. Undefined when there is none.
122
+ function rrRoutesFile(cwd) {
123
+ const app = rrAppDir(cwd)
124
+ return ["routes.ts", "routes.js", "routes.mts", "routes.mjs"].map((f) => `${app}/${f}`).find((f) => fs.existsSync(path.join(cwd, f)))
125
+ }
126
+
127
+ const LOCK_FILES = [
128
+ ["pnpm-lock.yaml", "pnpm"],
129
+ ["yarn.lock", "yarn"],
130
+ ["bun.lock", "bun"],
131
+ ["bun.lockb", "bun"],
132
+ ["package-lock.json", "npm"]
133
+ ]
134
+
135
+ // 1. The lock file, in this folder or a parent one (monorepos keep it at the repo root).
136
+ // 2. No lock file: the tool that ran this command (pnpm dlx, yarn dlx, bunx, npx).
64
137
  function detectPackageManager(cwd) {
65
- const has = (f) => fs.existsSync(path.join(cwd, f))
66
- if (has("pnpm-lock.yaml")) return "pnpm"
67
- if (has("yarn.lock")) return "yarn"
68
- if (has("bun.lock") || has("bun.lockb")) return "bun"
138
+ for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
139
+ for (const [file, pm] of LOCK_FILES) if (fs.existsSync(path.join(dir, file))) return pm
140
+ if (dir === path.dirname(dir)) break
141
+ }
142
+ const agent = process.env.npm_config_user_agent ?? ""
143
+ for (const pm of ["pnpm", "yarn", "bun"]) if (agent.startsWith(pm + "/")) return pm
69
144
  return "npm"
70
145
  }
71
146
 
72
147
  const INSTALL_ARGS = { npm: ["install"], pnpm: ["add"], yarn: ["add"], bun: ["add"] }
73
148
 
74
149
  // "blog" -> "/blog", "/blog/" -> "/blog". Returns { error } when the path is not valid.
150
+ // Next.js route groups are allowed: "/(site)/blog" -> path "/blog" (the URL), dir "/(site)/blog" (the folder).
75
151
  function normalizePath(input) {
76
152
  let p = String(input ?? "").trim()
153
+ // Git Bash on Windows turns "/blog" into "C:/Program Files/Git/blog" before the command sees it.
154
+ if (/^[A-Za-z]:[\\/]/.test(p)) {
155
+ return {
156
+ error:
157
+ 'Git Bash changed the path into a Windows path. Type it without the first slash: --path blog (or --path "(site)/blog"). Whole website: --yes without --path.'
158
+ }
159
+ }
77
160
  if (/\s/.test(p)) return { error: "The path must not contain spaces." }
78
161
  if (p === "") p = "/"
79
162
  if (!p.startsWith("/")) p = "/" + p
80
163
  if (p.length > 1) p = p.replace(/\/+$/, "") || "/"
81
- if (!/^(\/[A-Za-z0-9_-]+)*\/?$/.test(p)) {
82
- return { error: "Use only letters, numbers, - and _ in the path (e.g. /blog or /guides/seo)." }
164
+ if (!/^(\/([A-Za-z0-9_-]+|\([A-Za-z0-9_-]+\)))*\/?$/.test(p)) {
165
+ // Accents (e.g. /actualités): suggest the plain letters.
166
+ const plain = p.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
167
+ const tip = plain !== p && /^(\/([A-Za-z0-9_-]+|\([A-Za-z0-9_-]+\)))*\/?$/.test(plain) ? ` Try ${plain}.` : ""
168
+ return { error: `Use only letters, numbers, - and _ in the path (e.g. /blog or /guides/seo). A Next.js route group is fine too: /(site)/blog.${tip}` }
83
169
  }
84
- return { path: p }
170
+ const url = p.split("/").filter((s) => s && !s.startsWith("("))
171
+ return { path: "/" + url.join("/"), dir: p }
85
172
  }
86
173
 
87
- // Next.js (app/[locale]) or Astro (src/pages/[locale]) with a `[locale]` segment: the route goes under it.
88
- function hasLocaleSegment(cwd, framework) {
89
- if (framework === "nextjs") return fs.existsSync(path.join(cwd, nextAppDir(cwd), "[locale]"))
90
- if (framework === "astro") return fs.existsSync(path.join(cwd, "src/pages/[locale]"))
91
- return false
174
+ const LAYOUTS = ["layout.tsx", "layout.jsx", "layout.js", "layout.ts"]
175
+ const hasLayout = (dir) => LAYOUTS.some((f) => fs.existsSync(path.join(dir, f)))
176
+
177
+ // The language segment the route goes under: "locale" for app/[locale] or src/pages/[locale].
178
+ // Next.js also: no app/layout and the root layout in one top folder like app/[lang] (the Next.js i18n docs pattern).
179
+ // Returns the segment name, or undefined.
180
+ function localeSegment(cwd, framework) {
181
+ if (framework === "astro") return fs.existsSync(path.join(cwd, "src/pages/[locale]")) ? "locale" : undefined
182
+ if (framework !== "nextjs") return undefined
183
+ const appDir = path.join(cwd, nextAppDir(cwd))
184
+ if (fs.existsSync(path.join(appDir, "[locale]"))) return "locale"
185
+ if (hasLayout(appDir)) return undefined
186
+ const withLayout = fs
187
+ .readdirSync(appDir)
188
+ .filter((e) => /^\[[A-Za-z_]\w*\]$/.test(e) && hasLayout(path.join(appDir, e)))
189
+ return withLayout.length === 1 ? withLayout[0].slice(1, -1) : undefined
190
+ }
191
+
192
+ // Next.js needs a root layout above every page. Route groups like (site) do not count: they cover their own folder only.
193
+ function missingRootLayout(cwd, file) {
194
+ const parts = path.dirname(file).split("/")
195
+ for (let i = 1; i <= parts.length; i++) if (hasLayout(path.join(cwd, ...parts.slice(0, i)))) return false
196
+ return true
92
197
  }
93
198
 
94
199
  // Projects we know and do not support: stop with the reason, install nothing.
@@ -114,29 +219,376 @@ function unsupportedReason(cwd) {
114
219
  return undefined
115
220
  }
116
221
 
117
- function routeFile(cwd, framework, urlPath, hasLocale) {
118
- const prefix = urlPath === "/" ? "" : urlPath
222
+ // Removes /* */ and // comments, so a commented-out line (e.g. // output: "export") is not read.
223
+ // Keeps // after ":" or a quote (URLs like https://…).
224
+ function stripComments(code) {
225
+ return code.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`\\])\/\/.*$/gm, "$1")
226
+ }
227
+
228
+ // First config or code file found, without its comments.
229
+ function readFirst(cwd, files) {
230
+ for (const f of files) {
231
+ try {
232
+ return stripComments(fs.readFileSync(path.join(cwd, f), "utf8"))
233
+ } catch {}
234
+ }
235
+ return ""
236
+ }
237
+
238
+ const NEXT_CONFIGS = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs", "next.config.mts"]
239
+ const ASTRO_CONFIGS = ["astro.config.mjs", "astro.config.ts", "astro.config.js", "astro.config.mts"]
240
+ const RR_CONFIGS = ["react-router.config.ts", "react-router.config.js", "react-router.config.mjs"]
241
+
242
+ // Supported framework, but a setup Flocon cannot work with. Stop with the reason, install nothing.
243
+ function blockedReason(cwd, framework) {
244
+ const pkg = readPackageJson(cwd)
245
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies }
246
+ const nothing = "Nothing was installed or changed. Docs: " + DOCS
119
247
  if (framework === "nextjs") {
120
- const locale = hasLocale ? "/[locale]" : ""
121
- return `${nextAppDir(cwd)}${locale}${prefix}/[...slug]/page.tsx`
248
+ const major = Number(/\d+/.exec(String(deps.next))?.[0])
249
+ if (major && major < 15) {
250
+ return [`Flocon AI: this project uses Next.js ${major}. @flocon-ai/next needs Next.js 15 or newer.`, "Upgrade Next.js first, then run this command again.", nothing]
251
+ }
252
+ if (/output\s*:\s*["'`]export["'`]/.test(readFirst(cwd, NEXT_CONFIGS))) {
253
+ return [
254
+ 'Flocon AI: this project uses output: "export" (static export). Flocon pages are read on each request, so they need a server.',
255
+ 'Remove output: "export" from next.config and host the website on a server (Vercel, Netlify, Node…).',
256
+ nothing
257
+ ]
258
+ }
259
+ }
260
+ if (framework === "astro") {
261
+ const config = readFirst(cwd, ASTRO_CONFIGS)
262
+ if (!/output\s*:\s*["'`]server["'`]/.test(config) || !/adapter\s*:/.test(config)) {
263
+ return [
264
+ 'Flocon AI: Astro must run in SSR (output: "server" + an adapter). Pages are read on each request.',
265
+ 'Add an adapter (npx astro add node, or vercel, netlify, cloudflare), set output: "server" in astro.config, then run this command again.',
266
+ nothing
267
+ ]
268
+ }
269
+ if (/\bi18n\s*:/.test(config) && !fs.existsSync(path.join(cwd, "src/pages/[locale]"))) {
270
+ return [
271
+ "Flocon AI: this Astro website uses i18n routing (i18n in astro.config). This command can't set it up yet: Flocon pages in other languages would not show.",
272
+ "Add the Flocon route yourself (see the Astro install page in the docs).",
273
+ nothing
274
+ ]
275
+ }
122
276
  }
277
+ if (framework === "react" && /ssr\s*:\s*false/.test(readFirst(cwd, RR_CONFIGS))) {
278
+ return [
279
+ "Flocon AI: this React Router app runs in SPA mode (ssr: false). It is not supported.",
280
+ "Why: AI bots and search crawlers do not run JavaScript. They would see an empty page.",
281
+ "Set ssr: true in react-router.config, then run this command again.",
282
+ nothing
283
+ ]
284
+ }
285
+ return undefined
286
+ }
287
+
288
+ // React Router with flatRoutes(): the file name is the URL, nothing to add in app/routes.ts.
289
+ function usesFlatRoutes(cwd) {
290
+ return /flatRoutes\s*\(/.test(readFirst(cwd, [rrRoutesFile(cwd)]))
291
+ }
292
+
293
+ // React Router: the route files folder, inside the app folder. flatRoutes({ rootDirectory: "pages" }) reads app/pages.
294
+ function rrRoutesDir(cwd) {
295
+ const m = /flatRoutes\s*\(\s*\{[^}]*rootDirectory\s*:\s*["'`]([^"'`]+)["'`]/.exec(readFirst(cwd, [rrRoutesFile(cwd)]))
296
+ return (m?.[1] ?? "routes").replace(/^\.\//, "").replace(/\/+$/, "")
297
+ }
298
+
299
+ // React Router: every route path in routes.ts, with its prefix() folders.
300
+ // e.g. ...prefix("blog", [route("*", …)]) -> "blog/*".
301
+ function rrRoutePaths(code) {
302
+ const out = []
303
+ const stack = []
304
+ const re = /prefix\(\s*["'`]([^"'`]*)["'`]\s*,\s*\[|route\(\s*["'`]([^"'`]*)["'`]|\[|\]/g
305
+ for (const m of code.matchAll(re)) {
306
+ if (m[0] === "[") stack.push(null)
307
+ else if (m[0] === "]") stack.pop()
308
+ else if (m[1] !== undefined) stack.push(m[1])
309
+ else out.push([...stack.filter((s) => s), m[2]].map((s) => s.replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/"))
310
+ }
311
+ return out
312
+ }
313
+
314
+ // Next.js 16 cacheComponents: `export const dynamic` breaks the build there.
315
+ // React Router 7.0 to 7.7: meta() gets `data` (`loaderData` came in 7.8, v8 only has `loaderData`).
316
+ // Installed version first, else the range in package.json. Unknown → new versions.
317
+ function rrOldMeta(cwd) {
318
+ let version
319
+ try {
320
+ version = JSON.parse(fs.readFileSync(path.join(cwd, "node_modules/react-router/package.json"), "utf8")).version
321
+ } catch {
322
+ const pkg = readPackageJson(cwd)
323
+ version = { ...pkg?.dependencies, ...pkg?.devDependencies }["react-router"]
324
+ }
325
+ const m = /(\d+)\.(\d+)/.exec(String(version ?? ""))
326
+ return Boolean(m) && Number(m[1]) === 7 && Number(m[2]) < 8
327
+ }
328
+
329
+ function usesCacheComponents(cwd) {
330
+ return /cacheComponents\s*:\s*true/.test(readFirst(cwd, NEXT_CONFIGS))
331
+ }
332
+
333
+ // React Router: a splat route for the same URLs is already there (often a 404 page).
334
+ // Two splats for the same URLs: the first one always wins, so Flocon pages would never show.
335
+ // Returns the clashing route, or undefined.
336
+ function reactSplatClash(cwd, urlPath, flat, rr) {
337
+ const segments = urlPath === "/" ? [] : urlPath.slice(1).split("/")
338
+ if (!flat) {
339
+ const target = [...segments, "*"].join("/")
340
+ const hit = rrRoutePaths(readFirst(cwd, [rr.routes])).find((p) => p === target)
341
+ return hit ? `route("${hit}") in ${rr.routes}` : undefined
342
+ }
343
+ // flatRoutes: "$.tsx", "blog.$.tsx", "($locale).$.tsx" (optional and pathless "_x" segments add nothing to the URL).
344
+ let entries = []
345
+ try {
346
+ entries = fs.readdirSync(path.join(cwd, rr.app, rr.dir))
347
+ } catch {}
348
+ for (const e of entries) {
349
+ const parts = e.replace(/\.(tsx|ts|jsx|js)$/, "").split(".")
350
+ if (parts.at(-1) !== "$") continue
351
+ const rest = parts.slice(0, -1).filter((p) => !/^\(.+\)$/.test(p) && !p.startsWith("_"))
352
+ if (rest.join("/") === segments.join("/")) return `${rr.app}/${rr.dir}/${e}`
353
+ }
354
+ return undefined
355
+ }
356
+
357
+ // Next.js: optional catch-all [[...slug]] when nothing serves the folder's own URL: it also serves that URL
358
+ // (e.g. a Flocon page "/" = the home page). When the website has a page there (app/page.tsx, app/blog/page.tsx,
359
+ // app/(site)/page.tsx…), plain [...slug]: Next.js refuses a page and an optional catch-all for the same URL
360
+ // ("You cannot define a route with the same specificity as an optional catch-all route", tested with next build).
361
+ // `dirPath` = the path with its route groups, e.g. "/(site)/blog" (Next.js only).
362
+ function routeFile(cwd, framework, urlPath, dirPath, locale, flat, rr, optional) {
363
+ const prefix = urlPath === "/" ? "" : urlPath
364
+ const hasLocale = Boolean(locale)
365
+ if (framework === "nextjs") return `${nextAppDir(cwd)}${locale ? `/[${locale}]` : ""}${dirPath === "/" ? "" : dirPath}/${optional ? "[[...slug]]" : "[...slug]"}/page.tsx`
123
366
  if (framework === "astro") return `src/pages${hasLocale ? "/[locale]" : ""}${prefix}/[...slug].astro`
124
- return "app/routes/flocon.tsx"
367
+ // flatRoutes: "/" -> $.tsx, "/blog" -> blog.$.tsx, "/guides/seo" -> guides.seo.$.tsx
368
+ if (flat) return `${rr.app}/${rr.dir}/${prefix ? prefix.slice(1).replaceAll("/", ".") + "." : ""}$.tsx`
369
+ return `${rr.app}/routes/flocon.tsx`
370
+ }
371
+
372
+ const PAGES = ["page.tsx", "page.jsx", "page.js", "page.ts"]
373
+
374
+ // Next.js: the website's own page for this URL (in the folder or in a route group with the same URL), or undefined.
375
+ // Pages Router files count too (a project moving to the App Router): pages/index.tsx, pages/blog.tsx, pages/blog/index.tsx.
376
+ function ownPage(cwd, base, urlPath) {
377
+ const segments = urlPath === "/" ? [] : urlPath.slice(1).split("/")
378
+ for (const dir of sameUrlDirs(cwd, base, segments)) {
379
+ const hit = PAGES.find((f) => fs.existsSync(path.join(cwd, dir, f)))
380
+ if (hit) return `${dir}/${hit}`
381
+ }
382
+ for (const pages of nextPagesDirs(cwd)) {
383
+ const stems = [[pages, ...segments, "index"].join("/"), ...(segments.length ? [[pages, ...segments].join("/")] : [])]
384
+ for (const stem of stems) {
385
+ const hit = ["tsx", "ts", "jsx", "js"].map((ext) => `${stem}.${ext}`).find((f) => fs.existsSync(path.join(cwd, f)))
386
+ if (hit) return hit
387
+ }
388
+ }
389
+ return undefined
390
+ }
391
+
392
+ // Next.js Pages Router folders next to the App Router (a project moving to the App Router).
393
+ // A Pages Router catch-all and an App Router catch-all for the same URLs: next build passes, next start crashes.
394
+ function nextPagesDirs(cwd) {
395
+ return ["pages", "src/pages"].filter((d) => fs.existsSync(path.join(cwd, d)))
396
+ }
397
+
398
+ const isGroup = (name) => /^\(.+\)$/.test(name)
399
+
400
+ // Adds the route groups inside these folders, at any depth (they add no URL segment).
401
+ function withGroups(cwd, dirs) {
402
+ const out = [...dirs]
403
+ for (let i = 0; i < out.length; i++) {
404
+ let entries = []
405
+ try {
406
+ entries = fs.readdirSync(path.join(cwd, out[i]))
407
+ } catch {}
408
+ for (const e of entries) if (isGroup(e)) out.push(`${out[i]}/${e}`)
409
+ }
410
+ return out
411
+ }
412
+
413
+ // Next.js: every folder that maps to the same URL as `base` + `segments`, through route groups at any level.
414
+ // e.g. base "app", ["blog"] -> app/blog, app/(marketing)/blog, app/(a)/(b)/blog…
415
+ function sameUrlDirs(cwd, base, segments) {
416
+ let dirs = withGroups(cwd, [base])
417
+ for (const s of segments) dirs = withGroups(cwd, dirs.map((d) => `${d}/${s}`))
418
+ return dirs
419
+ }
420
+
421
+ // Next.js and Astro pick a [slug] or [lang] route before the [...slug] catch-all: Flocon pages would never show.
422
+ // Two catch-alls in one folder break the build. Returns the clashing route, or undefined.
423
+ function clashingRoute(cwd, framework, file, base, urlPath) {
424
+ if (framework === "react") return undefined
425
+ const ours = framework === "nextjs" ? path.basename(path.dirname(file)) : "[...slug].astro"
426
+ const segments = urlPath === "/" ? [] : urlPath.slice(1).split("/")
427
+ // Next.js: route groups like (marketing) add no URL segment, at any level: look inside them too.
428
+ // Pages Router folders too: pages/[...slug].tsx next to app/[[...slug]] makes next start crash.
429
+ const dirs = framework === "nextjs" ? [...sameUrlDirs(cwd, base, segments), ...nextPagesDirs(cwd).map((d) => [d, ...segments].join("/"))] : [path.dirname(file)]
430
+ for (const dir of dirs) {
431
+ let entries = []
432
+ try {
433
+ entries = fs.readdirSync(path.join(cwd, dir))
434
+ } catch {}
435
+ const hit = entries.find((e) => e.startsWith("[") && e !== ours)
436
+ if (hit) return path.join(dir, hit).split(path.sep).join("/")
437
+ }
438
+ return undefined
125
439
  }
126
440
 
127
- function fileIsTaken(cwd, framework, file) {
128
- if (fs.existsSync(path.join(cwd, file))) return true
441
+ // The file already at the route's place (Next.js: page.js, page.jsx… count too), or undefined.
442
+ function takenFile(cwd, framework, file) {
443
+ if (fs.existsSync(path.join(cwd, file))) return file
129
444
  if (framework === "nextjs") {
130
445
  const stem = file.replace(/\.tsx$/, "")
131
- return ["js", "jsx", "ts", "tsx"].some((ext) => fs.existsSync(path.join(cwd, `${stem}.${ext}`)))
446
+ return ["js", "jsx", "ts", "tsx"].map((ext) => `${stem}.${ext}`).find((f) => fs.existsSync(path.join(cwd, f)))
132
447
  }
133
- return false
448
+ return undefined
449
+ }
450
+
451
+ // The Flocon route already in the project, or undefined: a catch-all route file that imports the Flocon lib.
452
+ // Only catch-alls count: the website's own pages can use the lib too (e.g. a list of pages with getAllPages).
453
+ function findFloconRoute(cwd, framework, rr) {
454
+ const root = framework === "nextjs" ? nextAppDir(cwd) : framework === "astro" ? "src/pages" : `${rr.app}/${rr.dir}`
455
+ const lib = FRAMEWORKS[framework].pkg
456
+ const isRoute = (name, dir) =>
457
+ framework === "nextjs"
458
+ ? /^page\.(tsx|ts|jsx|js)$/.test(name) && /^\[\[?\.\.\.[^\]]+\]\]?$/.test(path.basename(dir))
459
+ : framework === "astro"
460
+ ? /^\[\.\.\.[^\]]+\]\.astro$/.test(name)
461
+ : /(^|\.)\$\.(tsx|ts|jsx|js)$/.test(name) || /^flocon\.(tsx|ts|jsx|js)$/.test(name)
462
+ const walk = (dir, depth) => {
463
+ let entries = []
464
+ try {
465
+ entries = fs.readdirSync(path.join(cwd, dir), { withFileTypes: true })
466
+ } catch {}
467
+ for (const e of entries) {
468
+ const rel = `${dir}/${e.name}`
469
+ if (e.isDirectory() && depth < 8 && e.name !== "node_modules" && !e.name.startsWith(".")) {
470
+ const hit = walk(rel, depth + 1)
471
+ if (hit) return hit
472
+ } else if (e.isFile() && isRoute(e.name, dir) && fs.readFileSync(path.join(cwd, rel), "utf8").includes(lib)) return rel
473
+ }
474
+ return undefined
475
+ }
476
+ return walk(root, 0)
477
+ }
478
+
479
+ // Same place = same folder (Next.js: [...slug] from an older version counts as [[...slug]]).
480
+ const routePlace = (file) => file.replace(/\/\[\[?\.\.\.slug\]\]?\/page\.\w+$/, "")
481
+
482
+ // Next.js: route groups that have a layout (often the header + footer), e.g. ["(site)"]. Only used in hints:
483
+ // the user picks the group by typing it in the path (/(site)/blog).
484
+ function groupLayouts(cwd, base) {
485
+ let entries = []
486
+ try {
487
+ entries = fs.readdirSync(path.join(cwd, base))
488
+ } catch {}
489
+ return entries.filter((e) => isGroup(e) && hasLayout(path.join(cwd, base, e)))
490
+ }
491
+
492
+ // Folders that are often behind a login, or not for public content (admin, dashboard, (app)…).
493
+ // Bots would only see the login page: these go last in the suggestions, without the layout hint.
494
+ const PRIVATE_FOLDER =
495
+ /^\(?(app|auth|admin|dashboard|account|accounts|settings|login|signin|sign-in|signup|sign-up|register|logout|onboarding|billing|checkout|portal|platform|workspace|authenticated|protected|private|user|users|profile|me)\)?$/i
496
+
497
+ // Suggestions for the path question: "/", "/blog", "/guides" first, then the website's folders (Next.js: with route groups),
498
+ // private-looking folders last. Returns [{ value, hint }].
499
+ function pathSuggestions(cwd, framework, base) {
500
+ const out = [{ value: "/", hint: "whole website" }]
501
+ const later = []
502
+ const root = framework === "nextjs" ? base : framework === "astro" ? "src/pages" : undefined
503
+ const walk = (dir, rel, depth) => {
504
+ let entries = []
505
+ try {
506
+ entries = fs.readdirSync(path.join(cwd, dir), { withFileTypes: true })
507
+ } catch {}
508
+ for (const e of entries) {
509
+ if (!e.isDirectory()) continue
510
+ const n = e.name
511
+ // Skip dynamic segments, private folders, parallel/intercepting routes and the API.
512
+ if (/^[[_@.]/.test(n) || /^\(\.+\)/.test(n) || n === "api" || n === "node_modules") continue
513
+ if (isGroup(n) && framework !== "nextjs") continue
514
+ const value = `${rel}/${n}`
515
+ const hidden = value.split("/").some((s) => PRIVATE_FOLDER.test(s))
516
+ const group = !hidden && isGroup(n) && hasLayout(path.join(cwd, dir, n))
517
+ const item = { value, hint: group ? `uses the layout of ${n}` : "existing folder" }
518
+ if (hidden) later.push(item)
519
+ else found.push(item)
520
+ if (depth < 2) walk(`${dir}/${n}`, value, depth + 1)
521
+ }
522
+ }
523
+ const found = []
524
+ if (root) walk(root, "", 0)
525
+ for (const value of ["/blog", "/guides"]) {
526
+ const existing = found.find((o) => o.value === value)
527
+ out.push(existing ?? { value, hint: "new folder" })
528
+ }
529
+ return [...out, ...found.filter((o) => !out.includes(o)), ...later]
530
+ }
531
+
532
+ // The website's domain, when the project already has it. Returns { origin, from } or undefined.
533
+ function findDomain(cwd, framework) {
534
+ const valid = (raw, from) => {
535
+ // Only a plain URL. `https://${process.env.X}` is code, not a domain: written as-is it gives https://undefined/….
536
+ if (raw.includes("${")) return undefined
537
+ try {
538
+ const u = new URL(raw)
539
+ if (!/^https?:$/.test(u.protocol) || /^(localhost|127\.0\.0\.1|example\.com|www\.example\.com)$/.test(u.hostname)) return undefined
540
+ // Dev tunnels: never the real domain.
541
+ if (/(^|\.)(ngrok-free\.app|ngrok\.app|ngrok\.io|ngrok\.dev|trycloudflare\.com|loca\.lt)$/.test(u.hostname)) return undefined
542
+ // Preview deploys: Vercel branch URLs (app-git-main-team.vercel.app), Netlify deploy previews, Cloudflare Pages previews (<hash>.app.pages.dev).
543
+ // A website's own *.vercel.app / *.pages.dev name is kept: for some websites it is the real domain.
544
+ if (/-git-[^.]+\.vercel\.app$|^deploy-preview-\d+--[^.]+\.netlify\.app$|^[0-9a-f]{8}\.[^.]+\.pages\.dev$/.test(u.hostname)) return undefined
545
+ // http:// → https:// (canonical URLs on http are a bad signal; every host serves https).
546
+ if (u.protocol === "http:") return { origin: `https://${u.host}`, from, upgraded: true }
547
+ return { origin: u.origin, from }
548
+ } catch {
549
+ return undefined
550
+ }
551
+ }
552
+ if (framework === "nextjs") {
553
+ const app = nextAppDir(cwd)
554
+ const dirs = [app, ...fs.readdirSync(path.join(cwd, app)).filter((e) => e.startsWith("[") || isGroup(e)).map((e) => `${app}/${e}`)]
555
+ for (const dir of dirs) {
556
+ for (const f of LAYOUTS) {
557
+ const code = readFirst(cwd, [`${dir}/${f}`])
558
+ // new URL("https://acme.com"), or the fallback in new URL(process.env.X ?? "https://acme.com").
559
+ const m =
560
+ /metadataBase\s*:\s*new URL\(\s*["'`](https?:\/\/[^"'`]+)["'`]/.exec(code) ??
561
+ /metadataBase\s*:\s*new URL\([^)"'`]*(?:\?\?|\|\|)\s*["'`](https?:\/\/[^"'`]+)["'`]/.exec(code)
562
+ const hit = m && valid(m[1], `${dir}/${f}`)
563
+ if (hit) return hit
564
+ }
565
+ }
566
+ }
567
+ if (framework === "astro") {
568
+ for (const f of ASTRO_CONFIGS) {
569
+ const m = /\bsite\s*:\s*["'`](https?:\/\/[^"'`]+)["'`]/.exec(readFirst(cwd, [f]))
570
+ const hit = m && valid(m[1], f)
571
+ if (hit) return hit
572
+ }
573
+ }
574
+ const re =
575
+ /^\s*(?:export\s+)?(?:NEXT_PUBLIC_SITE_URL|NEXT_PUBLIC_BASE_URL|NEXT_PUBLIC_APP_URL|NEXT_PUBLIC_URL|PUBLIC_SITE_URL|SITE_URL|APP_URL|BASE_URL)\s*=\s*["']?(https?:\/\/[^\s"'#]+)/m
576
+ // Production files first: .env and .env.local often hold a preview or dev URL.
577
+ for (const f of [".env.production.local", ".env.production", ".env", ".env.local"]) {
578
+ try {
579
+ const m = re.exec(fs.readFileSync(path.join(cwd, f), "utf8"))
580
+ const hit = m && valid(m[1], f)
581
+ if (hit) return hit
582
+ } catch {}
583
+ }
584
+ return undefined
134
585
  }
135
586
 
136
587
  function tokenAlreadySet(cwd) {
137
588
  if (process.env.FLOCON_TOKEN) return true
138
589
  const re = /^\s*(export\s+)?FLOCON_TOKEN\s*=\s*\S/m
139
- for (const f of [".env.local", ".env", ".env.development.local", ".env.development"]) {
590
+ // .dev.vars: local secrets on Cloudflare Workers.
591
+ for (const f of [".env.local", ".env", ".env.development.local", ".env.development", ".dev.vars"]) {
140
592
  try {
141
593
  if (re.test(fs.readFileSync(path.join(cwd, f), "utf8"))) return true
142
594
  } catch {}
@@ -144,9 +596,69 @@ function tokenAlreadySet(cwd) {
144
596
  return false
145
597
  }
146
598
 
599
+ // Runs the install. Interactive: a spinner, the package manager output only if it fails.
600
+ async function install(cmd, cwd) {
601
+ const shell = process.platform === "win32"
602
+ if (!interactive) return spawnSync(cmd[0], cmd.slice(1), { cwd, stdio: "inherit", shell }).status === 0
603
+ const spin = p.spinner()
604
+ spin.start(`Installing ${cmd.at(-1)} with ${cmd[0]}`)
605
+ const { code, out } = await new Promise((resolve) => {
606
+ let out = ""
607
+ const child = spawn(cmd[0], cmd.slice(1), { cwd, shell })
608
+ child.stdout.on("data", (d) => (out += d))
609
+ child.stderr.on("data", (d) => (out += d))
610
+ child.on("error", (e) => resolve({ code: 1, out: String(e) }))
611
+ child.on("close", (code) => resolve({ code, out }))
612
+ })
613
+ if (code === 0) spin.stop(`Installed ${cmd.at(-1)}`)
614
+ else {
615
+ spin.stop("Install failed", 1)
616
+ console.error(out.trim())
617
+ }
618
+ return code === 0
619
+ }
620
+
621
+ // The project's formatter, when it has one: { name, cmd } or undefined. The CLI writes its own style (no semicolons):
622
+ // a Prettier or Biome check in the user's CI would fail on the new files.
623
+ function formatter(cwd) {
624
+ const has = (files) => files.some((f) => fs.existsSync(path.join(cwd, f)))
625
+ const pkg = readPackageJson(cwd) ?? {}
626
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies }
627
+ if (has(["biome.json", "biome.jsonc"]) || deps["@biomejs/biome"]) return { name: "Biome", cmd: "npx biome format --write" }
628
+ const prettierFiles = [".prettierrc", ".prettierrc.json", ".prettierrc.yml", ".prettierrc.yaml", ".prettierrc.js", ".prettierrc.cjs", ".prettierrc.mjs", ".prettierrc.ts", "prettier.config.js", "prettier.config.cjs", "prettier.config.mjs", "prettier.config.ts"]
629
+ if (has(prettierFiles) || pkg.prettier || deps.prettier) return { name: "Prettier", cmd: "npx prettier --write" }
630
+ return undefined
631
+ }
632
+
633
+ // Cloudflare Workers / Pages: secrets are not in .env files there.
634
+ function onCloudflare(cwd) {
635
+ const pkg = readPackageJson(cwd) ?? {}
636
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies }
637
+ return ["wrangler.toml", "wrangler.json", "wrangler.jsonc"].some((f) => fs.existsSync(path.join(cwd, f))) || Boolean(deps["@opennextjs/cloudflare"] || deps["@astrojs/cloudflare"] || deps["@cloudflare/vite-plugin"])
638
+ }
639
+
640
+ // Shell-safe path for printed commands: ( ) must be quoted in bash, zsh and PowerShell.
641
+ const shellPath = (dir) => (/[()]/.test(dir) ? `"${dir}"` : dir)
642
+
147
643
  async function main() {
148
644
  const args = parseArgs(process.argv.slice(2))
149
645
  const cwd = process.cwd()
646
+ // --path or --domain given = an AI agent or a script (Cursor and Windsurf run commands in a real terminal): no questions.
647
+ const flagsOnly = args.yes || args.path !== undefined || args.domain !== undefined
648
+ const tty = Boolean(process.stdin.isTTY && process.stdout.isTTY)
649
+ if (!flagsOnly && tty && !canAsk()) {
650
+ // A person on old Node: no silent default path, stop with the one-line command instead.
651
+ fail([
652
+ `Flocon AI: the questions need Node.js 20.12 or newer (you have ${process.versions.node}).`,
653
+ "Upgrade Node.js, or run it with options (no questions), e.g.: npx @flocon-ai/init --path /blog --domain https://acme.com --yes",
654
+ "Nothing was installed or changed."
655
+ ])
656
+ }
657
+ interactive = !flagsOnly && tty
658
+ if (interactive) {
659
+ p = await import("@clack/prompts")
660
+ p.intro("Flocon AI")
661
+ }
150
662
 
151
663
  // 1. Framework
152
664
  const framework = detectFramework(cwd)
@@ -155,77 +667,287 @@ async function main() {
155
667
  if (reason) fail(reason)
156
668
  fail([
157
669
  "Flocon AI: no supported framework found in this folder.",
158
- "Supported: Next.js (App Router), React Router v7 (framework mode), Astro.",
670
+ "Supported: Next.js (App Router), React Router v7+ (framework mode), Astro.",
159
671
  "Run this command at the root of your website. Docs: " + DOCS
160
672
  ])
161
673
  }
162
674
  const { label, pkg } = FRAMEWORKS[framework]
163
- log(`Found: ${label}`)
675
+ if (interactive) p.log.success(`Found ${label}`)
676
+ else log(`Found: ${label}`)
677
+ const blocked = blockedReason(cwd, framework)
678
+ if (blocked) fail(blocked)
164
679
 
165
- // 2. Path (asked before anything is changed)
166
- let answer = args.path
167
- if (answer === undefined) {
168
- if (args.yes || !process.stdin.isTTY) {
169
- answer = "/"
170
- } else {
171
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
172
- answer = await rl.question("Where should Flocon pages go? (e.g. / for the whole website, /blog, /guides) ")
173
- rl.close()
680
+ const rr = framework === "react" ? { app: rrAppDir(cwd), routes: rrRoutesFile(cwd), dir: rrRoutesDir(cwd) } : undefined
681
+ const locale = localeSegment(cwd, framework)
682
+ const hasLocale = Boolean(locale)
683
+ const flat = framework === "react" && usesFlatRoutes(cwd)
684
+ const base = framework === "nextjs" ? `${nextAppDir(cwd)}${locale ? `/[${locale}]` : ""}` : undefined
685
+ const nothing = "Nothing was installed or changed. Docs: " + DOCS
686
+ const ours = findFloconRoute(cwd, framework, rr)
687
+
688
+ // The route for a path: { urlPath, dirPath, optional, file }, or { error }.
689
+ // Next.js route groups: the user types them in the path (/(site)/blog) to use the group's layout.
690
+ const plan = (answer) => {
691
+ const n = normalizePath(answer)
692
+ if (n.error) return { error: n.error }
693
+ if (n.dir !== n.path && framework !== "nextjs") return { error: `Route groups like (site) are a Next.js thing. Use a path without them (e.g. ${n.path}).` }
694
+ // Next.js: a page of the website already serves this URL (e.g. app/page.tsx for "/") → [...slug], not [[...slug]].
695
+ const optional = framework === "nextjs" && !ownPage(cwd, base, n.path)
696
+ return { urlPath: n.path, dirPath: n.dir, optional, file: routeFile(cwd, framework, n.path, n.dir, locale, flat, rr, optional) }
697
+ }
698
+ // Why the route can't go there (lines), or undefined. Checked before anything is installed.
699
+ const problems = ({ file, urlPath }) => {
700
+ // Flocon's own route: handled by the "already set up" step.
701
+ if (ours && routePlace(ours) === routePlace(file)) return undefined
702
+ const other = framework === "nextjs" ? "Pick another path (e.g. /guides)" : "Pick another path (e.g. --path /guides)"
703
+ const taken = takenFile(cwd, framework, file)
704
+ if (taken) return [`Flocon AI: ${taken} is already there (your own route). Flocon needs this file for its pages.`, `${other}, or add the Flocon route yourself where it fits.`]
705
+ const clash = framework === "react" ? reactSplatClash(cwd, urlPath, flat, rr) : clashingRoute(cwd, framework, file, base, urlPath)
706
+ if (clash) return [`Flocon AI: ${clash} is already there. It would catch these URLs before Flocon's route, so Flocon pages would not show.`, `${other}, or add the Flocon route yourself where it fits.`]
707
+ if (framework === "nextjs" && missingRootLayout(cwd, file)) {
708
+ const groups = groupLayouts(cwd, base)
709
+ const example = `/${groups[0] ?? "(site)"}${urlPath === "/" ? "" : urlPath}`
710
+ return [
711
+ `Flocon AI: no root layout above ${file}. Next.js needs one (e.g. ${nextAppDir(cwd)}/layout.tsx), or the build fails.`,
712
+ // Quoted: ( ) unquoted is a syntax error in bash, zsh and PowerShell.
713
+ `Your layouts are inside route groups: put the route in one, e.g. ${interactive ? example : `--path "${example}"`}.`
714
+ ]
174
715
  }
716
+ return undefined
175
717
  }
176
- const normalized = normalizePath(answer)
177
- if (normalized.error) fail([`Invalid path "${answer}": ${normalized.error}`])
178
- const urlPath = normalized.path
179
718
 
180
- // 3. Install
719
+ // 2. Questions (a person in a terminal), one at a time. Nothing is changed before the last "Continue?".
720
+ let answer = args.path
721
+ if (answer === undefined && interactive) {
722
+ const suggestions = pathSuggestions(cwd, framework, base)
723
+ const groupTip = framework === "nextjs" ? "\n Add a route group to use its layout (header, footer), e.g. /(site)/blog." : ""
724
+ answer = await ask(
725
+ p.autocomplete({
726
+ message: `Where should Flocon pages go? Type a path or pick a folder.${groupTip}`,
727
+ placeholder: "/blog",
728
+ maxItems: 8,
729
+ options() {
730
+ const typed = (this.userInput ?? "").trim()
731
+ const typedDir = normalizePath(typed).dir
732
+ const list = suggestions.filter((o) => !typed || o.value.toLowerCase().includes(typed.toLowerCase()))
733
+ // What the user typed, as a choice (only a valid path, not "/(si" while typing a group).
734
+ const custom = typedDir && !list.some((o) => o.value === typedDir) ? [{ value: typedDir, label: typedDir, hint: "new folder" }] : []
735
+ return [...custom, ...list.map((o) => ({ value: o.value, label: o.value, hint: o.hint }))]
736
+ },
737
+ validate(value) {
738
+ if (typeof value !== "string") return "Type a path, e.g. /blog"
739
+ const r = plan(value)
740
+ return r.error ?? problems(r)?.join(" ")
741
+ }
742
+ })
743
+ )
744
+ }
745
+ const route = plan(answer ?? "/")
746
+ if (route.error) fail([`Invalid path "${answer}": ${route.error}`])
747
+ const { urlPath, dirPath, optional, file } = route
748
+ // URLs are case-sensitive: /Blog/x and /blog/x are two different pages.
749
+ if (/[A-Z]/.test(urlPath)) log(`Note: URLs are case-sensitive. Pages will be at ${urlPath}/…, not ${urlPath.toLowerCase()}/….`)
750
+
751
+ // --domain wins over what the project has, and skips the question.
752
+ let domain, domainFrom
753
+ if (args.domain !== undefined) {
754
+ domain = toOrigin(args.domain)
755
+ if (!domain) fail([`Invalid domain "${args.domain}": use your website's domain, e.g. --domain https://acme.com`])
756
+ domainFrom = "--domain"
757
+ } else {
758
+ const found = findDomain(cwd, framework)
759
+ domain = found?.origin
760
+ domainFrom = found?.upgraded ? `${found.from}, http changed to https` : found?.from
761
+ }
762
+ if (interactive && args.domain === undefined) {
763
+ const typed = await ask(
764
+ p.text({
765
+ message: "Your website's domain? (for canonical URLs and the sitemap)",
766
+ placeholder: "https://acme.com — empty = fill it in later",
767
+ initialValue: domain ?? "",
768
+ validate: (v) => (!(v ?? "").trim() || toOrigin(v) ? undefined : "Enter your domain, e.g. https://acme.com")
769
+ })
770
+ )
771
+ const origin = toOrigin(typed)
772
+ if (origin !== domain) domainFrom = "your answer"
773
+ domain = origin
774
+ }
775
+
776
+ let sitemap = args.sitemap
777
+ if (interactive && sitemap) {
778
+ sitemap = await ask(p.confirm({ message: "Add sitemap.xml and llms.txt? (they list your Flocon pages for Google and AI tools)", initialValue: true }))
779
+ }
780
+
781
+ // Flocon is already set up (the command ran before).
782
+ if (ours) {
783
+ // React Router, not flat: the file is always routes/flocon.tsx, the place is the path in the routes file.
784
+ let samePlace = routePlace(ours) === routePlace(file)
785
+ if (samePlace && framework === "react" && !flat && rr.routes) {
786
+ const want = urlPath === "/" ? "*" : `${urlPath.slice(1)}/*`
787
+ const registered = [...readFirst(cwd, [rr.routes]).matchAll(/route\(\s*["'`]([^"'`]*)["'`]\s*,\s*["'`][^"'`]*flocon\.tsx["'`]/g)].map((m) => m[1].replace(/^\//, ""))
788
+ if (registered.length && !registered.includes(want)) samePlace = false
789
+ // Not registered yet: the folder is in the URLs written in the file (`<domain>/blog${path}`).
790
+ if (!registered.length) {
791
+ const m = fs.readFileSync(path.join(cwd, ours), "utf8").match(/https?:\/\/[^/\s`"'$]+(?:\/\$\{[\w.]+\})?((?:\/[\w.-]+)*)\$\{(?:p\.)?path\}/)
792
+ if (m && m[1] !== (urlPath === "/" ? "" : urlPath)) samePlace = false
793
+ }
794
+ }
795
+ if (!samePlace) {
796
+ fail([
797
+ `Flocon AI is already set up at ${ours}. A second route would show every page at two URLs (duplicate content, bad for SEO).`,
798
+ "To move it: delete that file, then run this command again with the new path.",
799
+ nothing
800
+ ])
801
+ }
802
+ log(`Flocon AI is already set up here (${ours}). Nothing was installed.`)
803
+ const appDir = framework === "nextjs" ? nextAppDir(cwd) : undefined
804
+ // A first run left https://example.com (no domain then). Now the domain is known: put it in Flocon's own files only.
805
+ if (domain) {
806
+ const fixed = [ours, ...ownSitemapFiles({ cwd, framework, appDir, rrApp: rr?.app, rrDir: rr?.dir })].filter((f) => {
807
+ const full = path.join(cwd, f)
808
+ const code = fs.readFileSync(full, "utf8")
809
+ if (!code.includes("https://example.com")) return false
810
+ fs.writeFileSync(full, code.replaceAll("https://example.com", domain))
811
+ return true
812
+ })
813
+ if (fixed.length) log(`Domain set to ${domain} in: ${fixed.join(", ")}.`)
814
+ }
815
+ // Only adds what is missing (e.g. a first run with --no-sitemap).
816
+ let code = 0
817
+ if (sitemap) {
818
+ const res = addSitemapAndLlms({ cwd, framework, appDir, rrApp: rr?.app, rrDir: rr?.dir, routesFile: rr?.routes, urlPath, hasLocale, flat, domain, log })
819
+ if (res.manual) code = 1
820
+ }
821
+ if (interactive) p.outro(code ? "Do the step above, then deploy." : "Nothing else to do.")
822
+ process.exit(code)
823
+ }
824
+ const problem = problems(route)
825
+ if (problem) fail([...problem, nothing])
826
+ const cacheComponents = framework === "nextjs" && usesCacheComponents(cwd)
181
827
  const pm = detectPackageManager(cwd)
182
828
  const installCmd = [pm, ...INSTALL_ARGS[pm], pkg]
829
+
830
+ // Last question: the plan, then "Continue?".
831
+ if (interactive) {
832
+ const pages = `${urlPath === "/" ? "" : urlPath}/<page>`
833
+ p.note(
834
+ [
835
+ args.skipInstall ? undefined : `Install ${pkg} (${pm})`,
836
+ `Create ${file}`,
837
+ sitemap ? "Create sitemap.xml + llms.txt (if missing)" : undefined,
838
+ `Pages at ${domain ?? "https://example.com"}${pages}`
839
+ ]
840
+ .filter(Boolean)
841
+ .join("\n"),
842
+ "Plan"
843
+ )
844
+ if (!(await ask(p.confirm({ message: "Continue?", initialValue: true })))) cancelled()
845
+ }
846
+
847
+ // 3. Install
183
848
  if (args.skipInstall) {
184
849
  log(`Skipped install. Package: ${pkg} (package manager: ${pm}).`)
185
850
  } else {
186
- log(`Installing ${pkg} with ${pm}...`)
187
- const res = spawnSync(installCmd[0], installCmd.slice(1), { cwd, stdio: "inherit", shell: process.platform === "win32" })
188
- if (res.status !== 0) fail([`Install failed. Run it yourself: ${installCmd.join(" ")}`])
851
+ if (!interactive) log(`Installing ${pkg} with ${pm}...`)
852
+ if (!(await install(installCmd, cwd))) fail([`Install failed. Run it yourself: ${installCmd.join(" ")}`])
189
853
  }
190
854
 
191
855
  // 4. Route file (never overwrite)
192
- const hasLocale = hasLocaleSegment(cwd, framework)
193
- const file = routeFile(cwd, framework, urlPath, hasLocale)
194
- const template = hasLocale ? TEMPLATES[framework + "Locale"] : TEMPLATES[framework]
195
- let exitCode = 0
196
- if (fileIsTaken(cwd, framework, file)) {
197
- exitCode = 1
198
- log(`\n${file} already exists. I did not touch it. Add this file yourself (e.g. as ${file}):\n`)
199
- log(template)
200
- } else {
201
- const full = path.join(cwd, file)
202
- fs.mkdirSync(path.dirname(full), { recursive: true })
203
- fs.writeFileSync(full, template, { flag: "wx" })
204
- log(`Created ${file}`)
856
+ let template = applyUrlRule(hasLocale ? TEMPLATES[framework + "Locale"] : TEMPLATES[framework], urlPath, hasLocale, domain)
857
+ // Next.js language folder with another name, like app/[lang]: read it as the locale.
858
+ if (framework === "nextjs" && locale && locale !== "locale") {
859
+ template = template
860
+ .replace("params: Promise<{ locale: string;", `params: Promise<{ ${locale}: string;`)
861
+ .replaceAll("const { locale, slug } = await params", `const { ${locale}: locale, slug } = await params`)
862
+ .replace("under the [locale] segment", `under the [${locale}] segment`)
863
+ }
864
+ // Next.js optional catch-all: no slug on the folder's own URL.
865
+ if (optional) {
866
+ template = template
867
+ .replace("One catch-all route serves every Flocon page", "One optional catch-all route ([[...slug]]) serves every Flocon page")
868
+ .replace("slug: string[] }", "slug?: string[] }")
869
+ .replaceAll('"/" + slug.join("/")', '"/" + (slug ?? []).join("/")')
205
870
  }
206
- if (framework === "react") {
871
+ // cacheComponents: `dynamic` is not allowed, `instant = false` lets the page render on each request.
872
+ if (cacheComponents) {
873
+ template = template.replace('export const dynamic = "force-dynamic"', "export const instant = false")
874
+ log("cacheComponents is on: the route uses `export const instant = false` (not `dynamic`).")
875
+ }
876
+ if (framework === "react" && rrOldMeta(cwd)) {
877
+ template = template
878
+ .replace("meta({ loaderData }: Route.MetaArgs)", "meta({ data }: Route.MetaArgs)")
879
+ .replace("if (!loaderData) return", "if (!data) return")
880
+ .replace("page: loaderData, url })", "page: data, url })")
881
+ }
882
+ if (flat) {
883
+ const stem = path.basename(file, ".tsx")
884
+ template = template.replace(/^\/\/ Register it .*\n/, "").replace('"./+types/flocon"', `"./+types/${stem}"`)
885
+ }
886
+ let exitCode = 0
887
+ const full = path.join(cwd, file)
888
+ fs.mkdirSync(path.dirname(full), { recursive: true })
889
+ fs.writeFileSync(full, template, { flag: "wx" })
890
+ log(`Created ${file}`)
891
+ if (framework === "react" && !flat) {
207
892
  const routePath = urlPath === "/" ? "*" : `${urlPath.slice(1)}/*`
208
- log(`\nRegister the route last in app/routes.ts:\n route("${routePath}", "routes/flocon.tsx")`)
893
+ log(`\nRegister the route last in ${rr.routes}:\n route("${routePath}", "routes/flocon.tsx")`)
894
+ // routes.ts often imports only `index` (React Router template): `route` is needed too.
895
+ if (!/import\s*\{[^}]*\broute\b[^}]*\}\s*from\s*["']@react-router\/dev\/routes["']/.test(readFirst(cwd, [rr.routes]))) {
896
+ log(`And add route to the import: import { route } from "@react-router/dev/routes"`)
897
+ }
209
898
  }
210
899
 
211
900
  // 4b. Sitemap + llms.txt (never overwrite)
212
- if (args.sitemap) {
901
+ const urlFiles = template.includes(domain ?? "https://example.com") ? [file] : []
902
+ const created = [file]
903
+ if (sitemap) {
213
904
  const appDir = framework === "nextjs" ? nextAppDir(cwd) : undefined
214
- if (addSitemapAndLlms({ cwd, framework, appDir, urlPath, log })) exitCode = 1
905
+ const res = addSitemapAndLlms({ cwd, framework, appDir, rrApp: rr?.app, rrDir: rr?.dir, routesFile: rr?.routes, urlPath, hasLocale, flat, domain, log })
906
+ if (res.manual) exitCode = 1
907
+ urlFiles.push(...res.files)
908
+ created.push(...res.created)
215
909
  }
910
+ if (urlFiles.length) {
911
+ // Interactive: the user just confirmed the domain, no need to repeat it.
912
+ if (domain && !interactive) {
913
+ log(`\nDomain: ${domain} (${domainFrom === "--domain" ? "from --domain" : `found in ${domainFrom}`}). It is in: ${urlFiles.join(", ")}. Change it there if it is wrong.`)
914
+ } else if (!domain) {
915
+ log(`\nReplace https://example.com with your domain in: ${urlFiles.join(", ")}. The rest of the URL is already set (pages live under ${urlPath}).`)
916
+ }
917
+ }
918
+
919
+ // 4c. Formatter: the new files use the CLI's style.
920
+ const fmt = formatter(cwd)
921
+ if (fmt) log(`\nYour project uses ${fmt.name}. Format the new files: ${fmt.cmd} ${created.map((f) => `"${f}"`).join(" ")}`)
216
922
 
217
923
  // 5. Token (no prompt, no file write)
218
924
  log()
219
- if (tokenAlreadySet(cwd)) {
925
+ const tokenSet = tokenAlreadySet(cwd)
926
+ const cloudflare = onCloudflare(cwd)
927
+ if (tokenSet) {
220
928
  log("FLOCON_TOKEN is already set. Good.")
221
929
  } else {
222
930
  log(
223
- `Flocon needs FLOCON_TOKEN to work. Copy your website token from ${TOKENS_URL}, then add FLOCON_TOKEN=... to your env file (.env.local / .env) and to your host. Never commit it.`
931
+ `Flocon needs FLOCON_TOKEN to work. Copy your website token from ${TOKENS_URL}, then add FLOCON_TOKEN=... to your env file (${cloudflare ? ".dev.vars" : ".env.local / .env"}) and to your host. Never commit it.`
224
932
  )
225
933
  }
934
+ if (cloudflare) {
935
+ log("On Cloudflare: add FLOCON_TOKEN as a Secret (wrangler secret put FLOCON_TOKEN, or type Secret in the dashboard). wrangler deploy removes plain variables.")
936
+ }
937
+
938
+ const pkgJson = readPackageJson(cwd)
939
+ if (framework === "astro" && { ...pkgJson.dependencies, ...pkgJson.devDependencies }["@astrojs/cloudflare"]) {
940
+ log("On Cloudflare, secrets are not in process.env: pass the token option to each helper.")
941
+ }
226
942
 
227
- // 6. Last step
228
- log("\nNext step: add FLOCON_TOKEN to your host, then deploy.")
943
+ // 6. Last step. Many hosts deploy on every push: without the token, Flocon's URLs return an error (500).
944
+ const extra = sitemap ? ", the sitemap and llms.txt" : ""
945
+ const errors = urlPath === "/" ? `every unknown URL of your website${extra} return an error (500)` : `${urlPath}/…${extra} return an error (500)`
946
+ const last = tokenSet
947
+ ? "Next step: add FLOCON_TOKEN to your host too, then deploy."
948
+ : `Next step: add FLOCON_TOKEN to your host BEFORE you push or deploy. Without it, ${errors}.`
949
+ if (interactive) p.outro(last)
950
+ else log(`\n${last}`)
229
951
  process.exit(exitCode)
230
952
  }
231
953