launchframe 0.3.1 → 0.4.1

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 (95) hide show
  1. package/.amazonq/cli-agents/clone-website.json +9 -0
  2. package/.amazonq/cli-agents/launchframe.json +9 -0
  3. package/.amazonq/rules/project.md +158 -0
  4. package/{template/.augment → .augment}/commands/clone-website.md +35 -112
  5. package/.augment/commands/launchframe.md +46 -0
  6. package/.claude/skills/clone-website/SKILL.md +487 -0
  7. package/.claude/skills/launchframe/SKILL.md +45 -0
  8. package/.clinerules +158 -0
  9. package/.codex/skills/clone-website/SKILL.md +487 -0
  10. package/.codex/skills/launchframe/SKILL.md +45 -0
  11. package/{template/.continue → .continue}/commands/clone-website.md +35 -112
  12. package/.continue/commands/launchframe.md +47 -0
  13. package/.continue/rules/project.md +162 -0
  14. package/{template/.cursor → .cursor}/commands/clone-website.md +35 -112
  15. package/.cursor/commands/launchframe.md +42 -0
  16. package/.cursor/rules/project.mdc +7 -0
  17. package/{template/.gemini → .gemini}/commands/clone-website.toml +35 -112
  18. package/.gemini/commands/launchframe.toml +48 -0
  19. package/.github/copilot-instructions.md +158 -0
  20. package/.github/skills/clone-website/SKILL.md +487 -0
  21. package/.github/skills/launchframe/SKILL.md +45 -0
  22. package/.gitignore +49 -0
  23. package/{template/.opencode → .opencode}/commands/clone-website.md +35 -112
  24. package/.opencode/commands/launchframe.md +45 -0
  25. package/.windsurf/workflows/clone-website.md +484 -0
  26. package/.windsurf/workflows/launchframe.md +42 -0
  27. package/AGENTS.md +66 -0
  28. package/README.md +149 -31
  29. package/bin/launchframe.mjs +348 -315
  30. package/docs/research/INSPECTION_GUIDE.md +90 -0
  31. package/package.json +73 -26
  32. package/scripts/sync-skills.mjs +124 -0
  33. package/{template/src → src}/app/globals.css +1 -93
  34. package/{template/src → src}/app/layout.tsx +16 -5
  35. package/src/app/page.tsx +40 -0
  36. package/src/lib/launchframe-config.ts +8 -0
  37. package/template/.amazonq/cli-agents/clone-website.json +0 -9
  38. package/template/.amazonq/rules/project.md +0 -281
  39. package/template/.claude/skills/clone-website/SKILL.md +0 -564
  40. package/template/.claude/skills/marketing-social-proof-motion/SKILL.md +0 -47
  41. package/template/.clinerules +0 -285
  42. package/template/.codex/skills/clone-website/SKILL.md +0 -564
  43. package/template/.continue/rules/project.md +0 -285
  44. package/template/.cursor/commands/marketing-social-proof-motion.md +0 -42
  45. package/template/.cursor/rules/project.mdc +0 -22
  46. package/template/.github/copilot-instructions.md +0 -281
  47. package/template/.github/skills/clone-website/SKILL.md +0 -564
  48. package/template/.nvmrc +0 -1
  49. package/template/.windsurf/workflows/clone-website.md +0 -561
  50. package/template/AGENTS.md +0 -160
  51. package/template/LICENSE +0 -21
  52. package/template/README.md +0 -121
  53. package/template/START_HERE.md +0 -15
  54. package/template/docs/design-references/playwright-example.com-1440px.png +0 -0
  55. package/template/docs/design-references/playwright-example.com-390px.png +0 -0
  56. package/template/docs/research/INSPECTION_GUIDE.md +0 -124
  57. package/template/launchframe.config.json +0 -14
  58. package/template/package-lock.json +0 -9873
  59. package/template/package.json +0 -54
  60. package/template/scripts/.gitkeep +0 -0
  61. package/template/scripts/recon-playwright.mjs +0 -396
  62. package/template/scripts/sync-skills.mjs +0 -111
  63. package/template/src/app/page.tsx +0 -5
  64. package/template/src/components/marketing/scribewise-landing.tsx +0 -34
  65. package/template/src/hooks/.gitkeep +0 -0
  66. package/template/src/types/.gitkeep +0 -0
  67. /package/{template/.aider.conf.yml → .aider.conf.yml} +0 -0
  68. /package/{template/.dockerignore → .dockerignore} +0 -0
  69. /package/{template/.gitattributes → .gitattributes} +0 -0
  70. /package/{template/.github → .github}/ISSUE_TEMPLATE/bug_report.yml +0 -0
  71. /package/{template/.github → .github}/ISSUE_TEMPLATE/config.yml +0 -0
  72. /package/{template/.github → .github}/ISSUE_TEMPLATE/feature_request.yml +0 -0
  73. /package/{template/.github → .github}/PULL_REQUEST_TEMPLATE.md +0 -0
  74. /package/{template/.github → .github}/copilot-setup-steps.yml +0 -0
  75. /package/{template/.github → .github}/workflows/ci.yml +0 -0
  76. /package/{template/.windsurfrules → .windsurfrules} +0 -0
  77. /package/{template/CLAUDE.md → CLAUDE.md} +0 -0
  78. /package/{template/Dockerfile → Dockerfile} +0 -0
  79. /package/{template/Dockerfile.dev → Dockerfile.dev} +0 -0
  80. /package/{template/GEMINI.md → GEMINI.md} +0 -0
  81. /package/{template/components.json → components.json} +0 -0
  82. /package/{template/docker-compose.yml → docker-compose.yml} +0 -0
  83. /package/{template/docs → docs}/design-references/.gitkeep +0 -0
  84. /package/{template/docs → docs}/design-references/comparison.png +0 -0
  85. /package/{template/eslint.config.mjs → eslint.config.mjs} +0 -0
  86. /package/{template/next.config.ts → next.config.ts} +0 -0
  87. /package/{template/postcss.config.mjs → postcss.config.mjs} +0 -0
  88. /package/{template/public/images → scripts}/.gitkeep +0 -0
  89. /package/{template/scripts → scripts}/sync-agent-rules.sh +0 -0
  90. /package/{template/src → src}/app/favicon.ico +0 -0
  91. /package/{template/src → src}/components/ui/button.tsx +0 -0
  92. /package/{template/public/seo → src/hooks}/.gitkeep +0 -0
  93. /package/{template/src → src}/lib/utils.ts +0 -0
  94. /package/{template/public/videos → src/types}/.gitkeep +0 -0
  95. /package/{template/tsconfig.json → tsconfig.json} +0 -0
@@ -1,315 +1,348 @@
1
- #!/usr/bin/env node
2
- // @ts-check
3
- /**
4
- * launchframe scaffold an AI-cloner project pointed at any URL + SaaS idea.
5
- *
6
- * Usage:
7
- * npx launchframe@latest <url> "<saas idea>" [--dir <path>] [--force] [--skip-install]
8
- *
9
- * Behavior:
10
- * 1. Validates the URL and SaaS-idea string.
11
- * 2. Copies the bundled `template/` payload into the project root (current
12
- * directory by default) so dotfolders like `.cursor` and `.claude` live at
13
- * the workspace root when you open that folder in your editor. Use
14
- * `--dir <name>` for a subdirectory if you prefer.
15
- * 3. Writes `launchframe.config.json` into the new project so the bundled
16
- * `/clone-website` skill knows which URL to clone and how to re-skin it
17
- * for your SaaS idea.
18
- * 4. Runs `npm install` in the new project (so the user does not have to).
19
- * 5. Prints one line: open the folder in Cursor and tell the AI **Build it**.
20
- */
21
-
22
- import { spawnSync } from "node:child_process";
23
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
24
- import { createRequire } from "node:module";
25
- import { dirname, basename, isAbsolute, join, resolve } from "node:path";
26
- import { fileURLToPath } from "node:url";
27
-
28
- const __filename = fileURLToPath(import.meta.url);
29
- const __dirname = dirname(__filename);
30
- const pkgRoot = resolve(__dirname, "..");
31
- const templateDir = join(pkgRoot, "template");
32
- const require = createRequire(import.meta.url);
33
- const pkgJson = require(join(pkgRoot, "package.json"));
34
-
35
- const COLORS = {
36
- reset: "\x1b[0m",
37
- bold: "\x1b[1m",
38
- dim: "\x1b[2m",
39
- red: "\x1b[31m",
40
- green: "\x1b[32m",
41
- yellow: "\x1b[33m",
42
- cyan: "\x1b[36m",
43
- };
44
- const supportsColor = process.stdout.isTTY && !process.env.NO_COLOR;
45
- const c = (color, s) => (supportsColor ? COLORS[color] + s + COLORS.reset : s);
46
-
47
- function printHelp() {
48
- console.log(`
49
- ${c("bold", "launchframe")} ${c("dim", `v${pkgJson.version}`)}
50
- Scaffold an AI-cloner project pointed at any URL + SaaS idea.
51
-
52
- ${c("bold", "Usage:")}
53
- npx launchframe@latest <url> "<saas idea>" [options]
54
-
55
- ${c("bold", "Arguments:")}
56
- <url> URL of the site you want to clone (e.g. https://linear.app)
57
- <saas idea> One-line description of the SaaS you're building
58
- (used to re-skin copy/branding after the visual clone)
59
-
60
- ${c("bold", "Options:")}
61
- --dir <path> Output folder (default: . — current directory / project root)
62
- --force Overwrite merging files into a non-empty directory (use with care)
63
- --skip-install Skip npm install after scaffold (faster for CI / debugging)
64
- --help, -h Show this message
65
- --version, -v Show the launchframe version
66
-
67
- ${c("bold", "Example:")}
68
- npx launchframe@latest https://linear.app "AI-powered customer feedback platform"
69
- # From an empty folder (or git init only), files land in . so .cursor/ works at workspace root
70
- npx launchframe@latest https://vercel.com "DevOps for ML" --dir launchframe-app
71
- npx launchframe@latest https://stripe.com "Billing for AI agents" --skip-install
72
- `);
73
- }
74
-
75
- function exitErr(msg, code = 1) {
76
- console.error(c("red", `\nlaunchframe: ${msg}`));
77
- console.error(c("dim", "Run `npx launchframe --help` for usage.\n"));
78
- process.exit(code);
79
- }
80
-
81
- function parseArgs(argv) {
82
- const positional = [];
83
- const opts = {
84
- dir: ".",
85
- force: false,
86
- skipInstall: false,
87
- help: false,
88
- version: false,
89
- };
90
- for (let i = 0; i < argv.length; i++) {
91
- const a = argv[i];
92
- if (a === "--help" || a === "-h") opts.help = true;
93
- else if (a === "--version" || a === "-v") opts.version = true;
94
- else if (a === "--force" || a === "-f") opts.force = true;
95
- else if (a === "--skip-install") opts.skipInstall = true;
96
- else if (a === "--dir" || a === "-d") {
97
- const next = argv[++i];
98
- if (!next || next.startsWith("-")) exitErr("`--dir` requires a value");
99
- opts.dir = next;
100
- } else if (a.startsWith("--dir=")) {
101
- opts.dir = a.slice("--dir=".length);
102
- } else if (a.startsWith("-")) {
103
- exitErr(`unknown option: ${a}`);
104
- } else {
105
- positional.push(a);
106
- }
107
- }
108
- return { positional, opts };
109
- }
110
-
111
- function normalizeUrl(raw) {
112
- let candidate = raw.trim();
113
- if (!/^https?:\/\//i.test(candidate)) candidate = `https://${candidate}`;
114
- try {
115
- const u = new URL(candidate);
116
- if (!u.hostname || !u.hostname.includes(".")) throw new Error("missing hostname");
117
- return u.toString();
118
- } catch {
119
- exitErr(`invalid URL: ${raw}`);
120
- }
121
- }
122
-
123
- function isDirEmpty(dir) {
124
- try {
125
- return readdirSync(dir).length === 0;
126
- } catch {
127
- return true;
128
- }
129
- }
130
-
131
- /** True if the folder is empty or only has git/bootstrap noise (so we can scaffold at project root). */
132
- function isScaffoldRootUsable(dir) {
133
- let names;
134
- try {
135
- names = readdirSync(dir);
136
- } catch {
137
- return true;
138
- }
139
- if (names.length === 0) return true;
140
- const allowedOnly = new Set([".git", ".gitignore", ".gitattributes"]);
141
- return names.every((n) => allowedOnly.has(n));
142
- }
143
-
144
- function npmPackageSlug(dirName, targetDir) {
145
- const raw =
146
- dirName === "." || dirName === "./"
147
- ? basename(targetDir) || "launchframe-app"
148
- : dirName;
149
- const slug = raw
150
- .toLowerCase()
151
- .replace(/[^a-z0-9-_]/g, "-")
152
- .replace(/-+/g, "-")
153
- .replace(/^-|-$/g, "")
154
- .slice(0, 64);
155
- return slug || "launchframe-app";
156
- }
157
-
158
- function rewritePackageJson(targetDir, packageSlug, url, idea) {
159
- const pkgPath = join(targetDir, "package.json");
160
- if (!existsSync(pkgPath)) return;
161
- let pkg;
162
- try {
163
- pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
164
- } catch {
165
- return;
166
- }
167
- pkg.name = packageSlug;
168
- pkg.version = "0.1.0";
169
- pkg.private = true;
170
- pkg.description = `Launchframe project — clone of ${url} reframed as: ${idea}`;
171
- delete pkg.author;
172
- delete pkg.homepage;
173
- delete pkg.repository;
174
- delete pkg.bugs;
175
- writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
176
- }
177
-
178
- function writeLaunchframeConfig(targetDir, url, idea) {
179
- const cfg = {
180
- $schema: "https://launchframe.dev/schema/launchframe.config.json",
181
- url,
182
- idea,
183
- createdAt: new Date().toISOString(),
184
- launchframeVersion: pkgJson.version,
185
- notes: [
186
- "The /clone-website skill reads this file at the start of every run.",
187
- "After scaffold: open this folder in Cursor (or your AI editor) and say **Build it** — same workflow.",
188
- "`url` is the visual source-of-truth (clone its layout, spacing, tokens, motion).",
189
- "`idea` is the rebranding directive applied AFTER the pixel-perfect clone.",
190
- "Edit either field and re-invoke the skill to re-run.",
191
- ],
192
- };
193
- writeFileSync(
194
- join(targetDir, "launchframe.config.json"),
195
- JSON.stringify(cfg, null, 2) + "\n",
196
- "utf8"
197
- );
198
- }
199
-
200
- function runNpmInstall(targetDir) {
201
- console.log(c("dim", "\nRunning npm install (this may take a minute)...\n"));
202
- const result = spawnSync("npm", ["install", "--no-fund", "--no-audit"], {
203
- cwd: targetDir,
204
- stdio: "inherit",
205
- shell: true,
206
- env: process.env,
207
- });
208
- if (result.status !== 0) {
209
- console.error(
210
- c("yellow", "\nlaunchframe: npm install exited with an error. ") +
211
- c("dim", `Fix the issue and run \`npm install\` inside the project folder, or re-run with \`--force\`.\n`)
212
- );
213
- process.exit(result.status === null ? 1 : result.status);
214
- }
215
- console.log(c("green", "\n\u2713 npm install finished.\n"));
216
- }
217
-
218
- function nextSteps(projectDir, dirLabel, openHint, url, idea) {
219
- console.log(`
220
- ${c("green", "\u2713")} Done — ${c("bold", dirLabel)} is ready.
221
-
222
- ${c("dim", "Target URL:")} ${c("cyan", url)}
223
- ${c("dim", "SaaS idea:")} ${c("cyan", idea)}
224
- ${c("dim", "Folder:")} ${projectDir}
225
-
226
- ${c("bold", "All you do next:")}
227
- 1. Open ${c("cyan", openHint)} in ${c("bold", "Cursor")} ${c("dim", "(File \u2192 Open Folder)")}
228
- 2. In chat, say: ${c("bold", "Build it")}
229
- ${c("dim", "Your AI reads launchframe.config.json + AGENTS.md and runs the full clone + rebrand workflow.")}
230
- ${c("dim", "You can also type ") + c("cyan", "/clone-website") + c("dim", " if you prefer.")}
231
-
232
- ${c("dim", "Dotfolders (.cursor, .claude, …) are at this project root so rules and skills apply when you open this folder.")}
233
- ${c("dim", "Other editors: same folder — say Build it, or run the /clone-website skill for your tool.")}
234
- ${c("dim", "Edit launchframe.config.json anytime to change URL or SaaS idea.")}
235
- `);
236
- }
237
-
238
- function main() {
239
- const { positional, opts } = parseArgs(process.argv.slice(2));
240
- if (opts.help) return printHelp();
241
- if (opts.version) return console.log(pkgJson.version);
242
-
243
- if (positional.length < 2) {
244
- if (positional.length === 0) {
245
- console.error(c("red", "\nlaunchframe: missing <url> and <saas idea>"));
246
- } else {
247
- console.error(c("red", '\nlaunchframe: missing <saas idea> (wrap it in quotes: "...")'));
248
- }
249
- printHelp();
250
- process.exit(1);
251
- }
252
- if (positional.length > 2) {
253
- exitErr(
254
- `too many positional arguments. Wrap your SaaS idea in quotes: ` +
255
- `\`npx launchframe ${positional[0]} "${positional.slice(1).join(" ")}"\``
256
- );
257
- }
258
-
259
- const url = normalizeUrl(positional[0]);
260
- const idea = positional[1].trim();
261
- if (!idea) exitErr("SaaS idea cannot be empty");
262
-
263
- const dirNameRaw = opts.dir;
264
- const targetDir = isAbsolute(dirNameRaw) ? dirNameRaw : resolve(process.cwd(), dirNameRaw);
265
-
266
- const isRootDot = dirNameRaw === "." || dirNameRaw === "./";
267
- const usable = isDirEmpty(targetDir) || isScaffoldRootUsable(targetDir);
268
- if (existsSync(targetDir) && !usable && !opts.force) {
269
- exitErr(
270
- `target directory \`${dirNameRaw}\` is not empty.\n` +
271
- `Create an empty folder (or \`git init\` only), run again from there, or pass \`--force\` to merge files, ` +
272
- `or use \`--dir launchframe-app\` to scaffold into a subdirectory.`
273
- );
274
- }
275
-
276
- if (!existsSync(templateDir)) {
277
- exitErr(
278
- `bundled template not found at \`${templateDir}\`.\n` +
279
- `This usually means the launchframe package was installed incompletely. ` +
280
- `Try: \`npm install -g launchframe@latest\` or re-run \`npx launchframe@latest ...\`.`
281
- );
282
- }
283
-
284
- mkdirSync(targetDir, { recursive: true });
285
-
286
- console.log(c("dim", `\nCopying template \u2192 ${targetDir} ...`));
287
- cpSync(templateDir, targetDir, {
288
- recursive: true,
289
- force: opts.force,
290
- filter: (src) => {
291
- const lower = src.toLowerCase();
292
- if (lower.endsWith(`${"\\"}node_modules`) || lower.endsWith("/node_modules")) return false;
293
- if (lower.endsWith(`${"\\"}.next`) || lower.endsWith("/.next")) return false;
294
- if (lower.endsWith("package-lock.json")) return false;
295
- return true;
296
- },
297
- });
298
-
299
- const packageSlug = npmPackageSlug(dirNameRaw, targetDir);
300
- rewritePackageJson(targetDir, packageSlug, url, idea);
301
- writeLaunchframeConfig(targetDir, url, idea);
302
-
303
- if (!opts.skipInstall) runNpmInstall(targetDir);
304
-
305
- const dirLabel = isRootDot ? "this folder (project root)" : dirNameRaw;
306
- const openHint = isRootDot ? "this folder" : `./${dirNameRaw}`;
307
- nextSteps(targetDir, dirLabel, openHint, url, idea);
308
- }
309
-
310
- try {
311
- main();
312
- } catch (err) {
313
- console.error(c("red", `\nlaunchframe: ${err?.message || err}\n`));
314
- process.exit(1);
315
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Scaffold a Launchframe project: clone reference URL via AI workflow + SaaS landing copy.
4
+ * Usage: npx launchframe@latest <url> "<saas-idea>" [--dir=name] [--skip-install]
5
+ */
6
+
7
+ import { cp, mkdir, readdir, readFile, writeFile } from "fs/promises";
8
+ import { spawn } from "node:child_process";
9
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const PKG_ROOT = resolve(__dirname, "..");
14
+
15
+ /**
16
+ * Never copy these root entries into a scaffolded app (build artifacts, VCS, the CLI itself).
17
+ * All other root files and folders are copied as-is — including every dotfile and dot-directory
18
+ * so AI agents see the same agent rules, skills, and IDE metadata as this template.
19
+ */
20
+ const SKIP_DIR_NAMES = new Set([
21
+ "bin",
22
+ "node_modules",
23
+ ".git",
24
+ ".next",
25
+ "dist",
26
+ "out",
27
+ "coverage",
28
+ ".turbo",
29
+ ]);
30
+
31
+ const SKIP_ROOT_FILES = new Set([
32
+ "package-lock.json",
33
+ ".DS_Store",
34
+ "Thumbs.db",
35
+ ]);
36
+
37
+ function printHelp() {
38
+ console.log(`
39
+ launchframe — scaffold a Next.js app for cloning a reference site + your SaaS idea
40
+
41
+ Usage:
42
+ npx launchframe@latest <url> "<saas-idea>" [options]
43
+
44
+ Arguments:
45
+ <url> HTTPS URL of the site to reverse-engineer (visual reference)
46
+ "<saas-idea>" Short pitch / positioning that should appear on the landing page
47
+
48
+ Options:
49
+ --dir, -o Output folder (default: first label of hostname + "-launchframe")
50
+ --skip-install Do not run npm install after scaffolding
51
+ -h, --help Show this message
52
+
53
+ Note:
54
+ The output folder must not live inside the Launchframe package directory (the folder that contains this CLI).
55
+ Scaffolding copies every root file and folder from the template (including dotfiles such as .cursor, .claude,
56
+ .github, etc.) except build/cache directories, so your AI tools see the same rules and commands as upstream.
57
+
58
+ Example:
59
+ npx launchframe@latest https://stripe.com "AI invoicing for freelancers"
60
+ `);
61
+ }
62
+
63
+ function parseArgs(argv) {
64
+ const out = {
65
+ url: null,
66
+ idea: null,
67
+ dir: null,
68
+ skipInstall: false,
69
+ help: false,
70
+ };
71
+
72
+ const positional = [];
73
+ for (let i = 0; i < argv.length; i++) {
74
+ const a = argv[i];
75
+ if (a === "-h" || a === "--help") {
76
+ out.help = true;
77
+ continue;
78
+ }
79
+ if (a === "--skip-install") {
80
+ out.skipInstall = true;
81
+ continue;
82
+ }
83
+ if (a === "--dir" || a === "-o") {
84
+ out.dir = argv[++i];
85
+ continue;
86
+ }
87
+ if (a.startsWith("--dir=")) {
88
+ out.dir = a.slice("--dir=".length);
89
+ continue;
90
+ }
91
+ if (a.startsWith("-o=")) {
92
+ out.dir = a.slice("-o=".length);
93
+ continue;
94
+ }
95
+ positional.push(a);
96
+ }
97
+
98
+ if (positional[0]) out.url = positional[0];
99
+ if (positional[1]) out.idea = positional[1];
100
+
101
+ return out;
102
+ }
103
+
104
+ function validateUrl(raw) {
105
+ let u;
106
+ try {
107
+ u = new URL(raw);
108
+ } catch {
109
+ throw new Error(`Invalid URL: ${raw}`);
110
+ }
111
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
112
+ throw new Error("URL must start with http:// or https://");
113
+ }
114
+ return u.href;
115
+ }
116
+
117
+ function defaultDirName(urlStr) {
118
+ try {
119
+ const host = new URL(urlStr).hostname.replace(/^www\./i, "");
120
+ const label = host.split(".")[0] || "site";
121
+ const safe = label.replace(/[^a-zA-Z0-9-_]/g, "-").toLowerCase();
122
+ return `${safe || "site"}-launchframe`;
123
+ } catch {
124
+ return "launchframe-app";
125
+ }
126
+
127
+ }
128
+
129
+ function slugFromDir(dir) {
130
+ const base = dir.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "launchframe-app";
131
+ return base
132
+ .toLowerCase()
133
+ .replace(/[^a-z0-9-_]/g, "-")
134
+ .replace(/-+/g, "-")
135
+ .replace(/^-|-$/g, "") || "launchframe-app";
136
+ }
137
+
138
+ function tsStringLiteral(value) {
139
+ return JSON.stringify(value);
140
+ }
141
+
142
+ function shouldCopyRootEntry(baseName, isDirectory) {
143
+ if (SKIP_DIR_NAMES.has(baseName)) return false;
144
+ if (!isDirectory && SKIP_ROOT_FILES.has(baseName)) return false;
145
+ return true;
146
+ }
147
+
148
+ /** Output cannot be inside the template package tree (would recurse while copying). */
149
+ function isForbiddenOutput(packageRootAbs, destRootAbs) {
150
+ const pkg = resolve(packageRootAbs);
151
+ const dest = resolve(destRootAbs);
152
+ if (dest === pkg) return true;
153
+ const rel = relative(pkg, dest);
154
+ return Boolean(rel) && !rel.startsWith("..") && !isAbsolute(rel);
155
+ }
156
+
157
+ async function copyTemplateTree(sourceRoot, destRootAbs) {
158
+ await mkdir(destRootAbs, { recursive: true });
159
+ const entries = await readdir(sourceRoot, { withFileTypes: true });
160
+ const destResolved = resolve(destRootAbs);
161
+ for (const ent of entries) {
162
+ const from = join(sourceRoot, ent.name);
163
+ if (resolve(from) === destResolved) continue;
164
+ if (!shouldCopyRootEntry(ent.name, ent.isDirectory())) continue;
165
+ const to = join(destRootAbs, ent.name);
166
+ await cp(from, to, { recursive: true });
167
+ }
168
+ }
169
+
170
+ async function writeGeneratedPackageJson(destRoot, npmPackageName) {
171
+ const raw = await readFile(join(PKG_ROOT, "package.json"), "utf8");
172
+ const pkg = JSON.parse(raw);
173
+
174
+ const nextPkg = {
175
+ name: npmPackageName,
176
+ version: "0.1.0",
177
+ private: true,
178
+ description: pkg.description,
179
+ license: pkg.license,
180
+ engines: pkg.engines,
181
+ scripts: pkg.scripts,
182
+ dependencies: pkg.dependencies,
183
+ devDependencies: pkg.devDependencies,
184
+ keywords: pkg.keywords,
185
+ };
186
+
187
+ await writeFile(
188
+ join(destRoot, "package.json"),
189
+ `${JSON.stringify(nextPkg, null, 2)}\n`,
190
+ "utf8",
191
+ );
192
+ }
193
+
194
+ async function writeLaunchframeArtifacts(destRoot, url, idea) {
195
+ const configTs = `/**
196
+ * Written by Launchframe CLI — edit freely after scaffolding.
197
+ */
198
+ export const LAUNCHFRAME_SOURCE_URL = ${tsStringLiteral(url)} as const;
199
+
200
+ export const LAUNCHFRAME_SAAS_IDEA = ${tsStringLiteral(idea)} as const;
201
+ `;
202
+
203
+ await writeFile(join(destRoot, "src", "lib", "launchframe-config.ts"), configTs, "utf8");
204
+
205
+ const ctx = {
206
+ sourceUrl: url,
207
+ saasIdea: idea,
208
+ notes:
209
+ "Use /clone-website with sourceUrl for pixel-perfect extraction. Align landing copy with saasIdea.",
210
+ };
211
+ await writeFile(
212
+ join(destRoot, "launchframe.context.json"),
213
+ `${JSON.stringify(ctx, null, 2)}\n`,
214
+ "utf8",
215
+ );
216
+
217
+ const md = `# Launchframe context
218
+
219
+ ## Reference URL (clone target)
220
+
221
+ ${url}
222
+
223
+ ## SaaS idea (landing copy)
224
+
225
+ ${idea}
226
+
227
+ When running \`/clone-website\`, pass the reference URL above. After structural cloning, rewrite headings and hero copy so they clearly communicate the SaaS idea while respecting attribution and copyright for third-party brands.
228
+ `;
229
+
230
+ await mkdir(join(destRoot, "docs", "research"), { recursive: true });
231
+ await writeFile(join(destRoot, "docs", "research", "LAUNCHFRAME.md"), md, "utf8");
232
+ }
233
+
234
+ async function writeReadme(destRoot, npmPackageName, url, idea) {
235
+ const body = `# ${npmPackageName}
236
+
237
+ Created with [\`launchframe\`](https://www.npmjs.com/package/launchframe).
238
+
239
+ ## Inputs
240
+
241
+ - **Reference site:** ${url}
242
+ - **SaaS idea:** ${idea}
243
+
244
+ ## Quick start
245
+
246
+ \`\`\`bash
247
+ npm install
248
+ npm run dev
249
+ \`\`\`
250
+
251
+ Open your AI agent (Cursor, Claude Code, etc.) and run:
252
+
253
+ \`\`\`
254
+ /clone-website ${url}
255
+ \`\`\`
256
+
257
+ Keep the SaaS positioning from \`launchframe.context.json\` / \`src/lib/launchframe-config.ts\` when adapting cloned sections.
258
+
259
+ See \`AGENTS.md\` for full agent instructions.
260
+ `;
261
+
262
+ await writeFile(join(destRoot, "README.md"), body, "utf8");
263
+ }
264
+
265
+ function runNpmInstall(cwd) {
266
+ return new Promise((resolvePromise, reject) => {
267
+ const child = spawn("npm", ["install"], {
268
+ cwd,
269
+ stdio: "inherit",
270
+ shell: process.platform === "win32",
271
+ });
272
+ child.on("exit", (code) => {
273
+ if (code === 0) resolvePromise();
274
+ else reject(new Error(`npm install exited with code ${code}`));
275
+ });
276
+ child.on("error", reject);
277
+ });
278
+ }
279
+
280
+ async function main() {
281
+ const args = parseArgs(process.argv.slice(2));
282
+ if (args.help) {
283
+ printHelp();
284
+ process.exit(0);
285
+ }
286
+
287
+ if (!args.url || !args.idea) {
288
+ console.error("Error: missing URL or SaaS idea.\n");
289
+ printHelp();
290
+ process.exit(1);
291
+ }
292
+
293
+ let url;
294
+ try {
295
+ url = validateUrl(args.url);
296
+ } catch (e) {
297
+ console.error(String(e.message));
298
+ process.exit(1);
299
+ }
300
+
301
+ const idea = args.idea.trim();
302
+ if (!idea) {
303
+ console.error("Error: SaaS idea cannot be empty.");
304
+ process.exit(1);
305
+ }
306
+
307
+ const dirName = args.dir?.trim() || defaultDirName(url);
308
+ const destRoot = resolve(process.cwd(), dirName);
309
+ const npmPackageName = slugFromDir(dirName);
310
+
311
+ if (isForbiddenOutput(PKG_ROOT, destRoot)) {
312
+ console.error(
313
+ "Output folder cannot be inside the Launchframe package directory. Run from a parent folder or choose another path.",
314
+ );
315
+ process.exit(1);
316
+ }
317
+
318
+ await mkdir(destRoot, { recursive: true });
319
+
320
+ await copyTemplateTree(PKG_ROOT, destRoot);
321
+
322
+ await writeGeneratedPackageJson(destRoot, npmPackageName);
323
+ await writeLaunchframeArtifacts(destRoot, url, idea);
324
+ await writeReadme(destRoot, npmPackageName, url, idea);
325
+
326
+ console.log(`\nCreated Launchframe project at ${destRoot}`);
327
+ console.log(` Reference URL: ${url}`);
328
+ console.log(` SaaS idea: ${idea}\n`);
329
+
330
+ if (!args.skipInstall) {
331
+ console.log("Running npm install...\n");
332
+ try {
333
+ await runNpmInstall(destRoot);
334
+ console.log("\nDone. Next: cd " + JSON.stringify(dirName) + " && npm run dev\n");
335
+ } catch (e) {
336
+ console.error(String(e.message));
337
+ console.error("\nDependencies were not installed. Run npm install inside the folder manually.\n");
338
+ process.exit(1);
339
+ }
340
+ } else {
341
+ console.log("Skipped npm install (--skip-install). Run npm install inside the folder.\n");
342
+ }
343
+ }
344
+
345
+ main().catch((err) => {
346
+ console.error(err);
347
+ process.exit(1);
348
+ });