launchframe 0.2.5 → 0.3.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.
- package/.amazonq/cli-agents/clone-website.json +9 -0
- package/{template/.amazonq → .amazonq}/rules/project.md +28 -18
- package/{template/.augment → .augment}/commands/clone-website.md +25 -25
- package/{template/.github → .claude}/skills/clone-website/SKILL.md +25 -25
- package/.claude/skills/marketing-social-proof-motion/SKILL.md +47 -0
- package/{template/.clinerules → .clinerules} +33 -19
- package/{template/.claude → .codex}/skills/clone-website/SKILL.md +25 -25
- package/{template/.continue → .continue}/commands/clone-website.md +25 -25
- package/{template/.continue → .continue}/rules/project.md +28 -18
- package/{template/.windsurf/workflows → .cursor/commands}/clone-website.md +25 -25
- package/.cursor/commands/marketing-social-proof-motion.md +42 -0
- package/{template/.cursor → .cursor}/rules/project.mdc +1 -4
- package/{template/.gemini → .gemini}/commands/clone-website.toml +25 -25
- package/{template/.github → .github}/copilot-instructions.md +28 -18
- package/{template/.codex → .github}/skills/clone-website/SKILL.md +25 -25
- package/{template/.opencode → .opencode}/commands/clone-website.md +25 -25
- package/{template/.cursor/commands → .windsurf/workflows}/clone-website.md +25 -25
- package/{template/AGENTS.md → AGENTS.md} +24 -12
- package/LICENSE +1 -1
- package/README.md +76 -102
- package/{template/docs → docs}/research/INSPECTION_GUIDE.md +11 -8
- package/launchframe.config.json +14 -0
- package/package.json +54 -52
- package/{template/scripts → scripts}/recon-playwright.mjs +90 -17
- package/src/components/marketing/scribewise-landing.tsx +34 -0
- package/bin/launchframe.mjs +0 -315
- package/template/.amazonq/cli-agents/clone-website.json +0 -9
- package/template/.claude/skills/marketing-landing-production/SKILL.md +0 -36
- package/template/.cursor/commands/marketing-landing-production.md +0 -31
- package/template/README.md +0 -121
- package/template/package.json +0 -63
- /package/{template/.aider.conf.yml → .aider.conf.yml} +0 -0
- /package/{template/.dockerignore → .dockerignore} +0 -0
- /package/{template/.gitattributes → .gitattributes} +0 -0
- /package/{template/.github → .github}/ISSUE_TEMPLATE/bug_report.yml +0 -0
- /package/{template/.github → .github}/ISSUE_TEMPLATE/config.yml +0 -0
- /package/{template/.github → .github}/ISSUE_TEMPLATE/feature_request.yml +0 -0
- /package/{template/.github → .github}/PULL_REQUEST_TEMPLATE.md +0 -0
- /package/{template/.github → .github}/copilot-setup-steps.yml +0 -0
- /package/{template/.github → .github}/workflows/ci.yml +0 -0
- /package/{template/.nvmrc → .nvmrc} +0 -0
- /package/{template/.windsurfrules → .windsurfrules} +0 -0
- /package/{template/CHANGELOG.md → CHANGELOG.md} +0 -0
- /package/{template/CLAUDE.md → CLAUDE.md} +0 -0
- /package/{template/Dockerfile → Dockerfile} +0 -0
- /package/{template/Dockerfile.dev → Dockerfile.dev} +0 -0
- /package/{template/GEMINI.md → GEMINI.md} +0 -0
- /package/{template/START_HERE.md → START_HERE.md} +0 -0
- /package/{template/components.json → components.json} +0 -0
- /package/{template/docker-compose.yml → docker-compose.yml} +0 -0
- /package/{template/docs → docs}/design-references/.gitkeep +0 -0
- /package/{template/docs → docs}/design-references/comparison.png +0 -0
- /package/{template/docs → docs}/design-references/playwright-example.com-1440px.png +0 -0
- /package/{template/docs → docs}/design-references/playwright-example.com-390px.png +0 -0
- /package/{template/eslint.config.mjs → eslint.config.mjs} +0 -0
- /package/{template/next.config.ts → next.config.ts} +0 -0
- /package/{template/postcss.config.mjs → postcss.config.mjs} +0 -0
- /package/{template/public → public}/images/.gitkeep +0 -0
- /package/{template/public → public}/seo/.gitkeep +0 -0
- /package/{template/public → public}/videos/.gitkeep +0 -0
- /package/{template/scripts → scripts}/.gitkeep +0 -0
- /package/{template/scripts → scripts}/sync-agent-rules.sh +0 -0
- /package/{template/scripts → scripts}/sync-skills.mjs +0 -0
- /package/{template/src → src}/app/favicon.ico +0 -0
- /package/{template/src → src}/app/globals.css +0 -0
- /package/{template/src → src}/app/layout.tsx +0 -0
- /package/{template/src → src}/app/page.tsx +0 -0
- /package/{template/src → src}/components/ui/button.tsx +0 -0
- /package/{template/src → src}/hooks/.gitkeep +0 -0
- /package/{template/src → src}/lib/utils.ts +0 -0
- /package/{template/src → src}/types/.gitkeep +0 -0
- /package/{template/tsconfig.json → tsconfig.json} +0 -0
|
@@ -23,17 +23,19 @@ If automation hits a bot wall, **do not pretend extraction succeeded** — captu
|
|
|
23
23
|
- [ ] **Gestures** — drag, pan, hover follow (often Framer Motion)
|
|
24
24
|
- [ ] **Implementation rule** — use **`framer-motion`** for anything beyond trivial single-property CSS `transition`. Record **duration, easing, delay, stagger**, and **trigger** (scroll, hover, tap) in specs.
|
|
25
25
|
|
|
26
|
-
### 3.
|
|
26
|
+
### 3. Idea-tailored illustration & motifs *(production landings)*
|
|
27
27
|
|
|
28
|
-
When the reference page is **sparse** (mostly type + gray boxes) or after a **rebrand** the UI still reads generic,
|
|
28
|
+
When the reference page is **sparse** (mostly type + gray boxes) or after a **rebrand** the UI still reads generic, illustration is required — and it must be **unique to the SaaS idea** (`launchframe.config.json#idea`), not reusable wallpaper.
|
|
29
29
|
|
|
30
|
-
- [ ] **
|
|
31
|
-
- [ ] **
|
|
32
|
-
- [ ] **
|
|
33
|
-
- [ ] **
|
|
34
|
-
- [ ] **
|
|
30
|
+
- [ ] **Metaphor list** — 3–6 nouns/verbs derived from `idea`; every bespoke asset maps to ≥ one entry
|
|
31
|
+
- [ ] **Uniqueness check** — If the scene works unchanged for another product category, revise
|
|
32
|
+
- [ ] **Inline SVG** — hero shapes, dividers, card mini-scenes; note `viewBox`, **`currentColor`** vs fixed fills, **Idea tie-in** sentence per asset
|
|
33
|
+
- [ ] **Pixel art / sprites** — only when character reinforces the metaphor; palette hex table; `imageRendering` / grid discipline
|
|
34
|
+
- [ ] **Motif thread** — recurring element echoing hero + OG + favicon **for this product**, not a random geometric pattern
|
|
35
|
+
- [ ] **Accent tokens** — primary/secondary roles aligned with idea personality (extract from reference or define in `:root`)
|
|
36
|
+
- [ ] **Motion tiers A–E** — document with **reduced-motion** fallback (`prefers-reduced-motion`)
|
|
35
37
|
|
|
36
|
-
See **`AGENTS.md` → Production polish for marketing landings** for tier definitions and
|
|
38
|
+
See **`AGENTS.md` → Production polish for marketing landings** for tier definitions and folders (`src/components/marketing/art/`, `public/images/marketing/`).
|
|
37
39
|
|
|
38
40
|
---
|
|
39
41
|
|
|
@@ -44,6 +46,7 @@ This guide outlines what to capture when inspecting a target website via Chrome
|
|
|
44
46
|
## Phase 1: Visual Audit
|
|
45
47
|
|
|
46
48
|
### Screenshots to Capture
|
|
49
|
+
- [ ] **Single-page landing — full scroll depth** — slow-scroll until lazy sections stabilize; **`fullPage`** / stitched capture so footer and below-fold bands are included (not viewport-only hero grabs)
|
|
47
50
|
- [ ] Every distinct page — desktop, tablet, mobile
|
|
48
51
|
- [ ] Dark mode variants (if applicable)
|
|
49
52
|
- [ ] Light mode variants (if applicable)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://launchframe.dev/schema/launchframe.config.json",
|
|
3
|
+
"url": "https://vercel.com/",
|
|
4
|
+
"idea": "saas-idea",
|
|
5
|
+
"createdAt": "2026-05-15T00:27:49.833Z",
|
|
6
|
+
"launchframeVersion": "0.2.6",
|
|
7
|
+
"notes": [
|
|
8
|
+
"The /clone-website skill reads this file at the start of every run.",
|
|
9
|
+
"After scaffold: open this folder in Cursor (or your AI editor) and say **Build it** — same workflow.",
|
|
10
|
+
"`url` is the visual source-of-truth (clone its layout, spacing, tokens, motion).",
|
|
11
|
+
"`idea` is the rebranding directive applied AFTER the pixel-perfect clone.",
|
|
12
|
+
"Edit either field and re-invoke the skill to re-run."
|
|
13
|
+
]
|
|
14
|
+
}
|
package/package.json
CHANGED
|
@@ -1,52 +1,54 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "launchframe",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "launchframe",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "clone any website into a clean, modern Next.js codebase using AI coding agents",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"claude-code",
|
|
9
|
+
"website-clone",
|
|
10
|
+
"reverse-engineering",
|
|
11
|
+
"nextjs",
|
|
12
|
+
"ai",
|
|
13
|
+
"template",
|
|
14
|
+
"tailwindcss",
|
|
15
|
+
"shadcn-ui"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=24"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"dev": "next dev",
|
|
22
|
+
"build": "next build",
|
|
23
|
+
"start": "next start",
|
|
24
|
+
"lint": "eslint",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"check": "npm run lint && npm run typecheck && npm run build",
|
|
27
|
+
"recon": "node scripts/recon-playwright.mjs",
|
|
28
|
+
"recon:headed": "node scripts/recon-playwright.mjs --headed"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@base-ui/react": "^1.3.0",
|
|
32
|
+
"class-variance-authority": "^0.7.1",
|
|
33
|
+
"clsx": "^2.1.1",
|
|
34
|
+
"framer-motion": "^12.4.0",
|
|
35
|
+
"lucide-react": "^1.6.0",
|
|
36
|
+
"next": "16.2.1",
|
|
37
|
+
"react": "19.2.4",
|
|
38
|
+
"react-dom": "19.2.4",
|
|
39
|
+
"shadcn": "^4.1.0",
|
|
40
|
+
"tailwind-merge": "^3.5.0",
|
|
41
|
+
"tw-animate-css": "^1.4.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@tailwindcss/postcss": "^4",
|
|
45
|
+
"@types/node": "^24",
|
|
46
|
+
"@types/react": "^19",
|
|
47
|
+
"@types/react-dom": "^19",
|
|
48
|
+
"eslint": "^9",
|
|
49
|
+
"eslint-config-next": "16.2.1",
|
|
50
|
+
"playwright": "^1.49.1",
|
|
51
|
+
"tailwindcss": "^4",
|
|
52
|
+
"typescript": "^5"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Reads URL from launchframe.config.json (or --url). Writes:
|
|
6
6
|
* - docs/design-references/playwright-<host>-<w>px.png (full page)
|
|
7
|
-
* - docs/research/computed-snapshot.json (styles + asset inventory + bot-wall hint)
|
|
7
|
+
* - docs/research/computed-snapshot.json (styles + merged asset inventory + bot-wall hint)
|
|
8
8
|
* - docs/research/MEDIA_MANIFEST.md (table of discovered media URLs)
|
|
9
9
|
*
|
|
10
10
|
* Usage:
|
|
@@ -52,27 +52,91 @@ function safeHost(url) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Scroll the full document depth slowly so lazy-loaded media and below-fold DOM resolve.
|
|
57
|
+
* Repeats until scrollHeight stabilizes (handles incremental infinite layouts up to max rounds).
|
|
58
|
+
*/
|
|
56
59
|
async function scrollFullPage(page) {
|
|
57
60
|
await page.evaluate(async () => {
|
|
58
61
|
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
const stepSize = () => Math.max(1, Math.floor(window.innerHeight * 0.85));
|
|
63
|
+
|
|
64
|
+
let unchangedStreak = 0;
|
|
65
|
+
let prevTotal = -1;
|
|
66
|
+
|
|
67
|
+
for (let round = 0; round < 12; round++) {
|
|
68
|
+
let y = 0;
|
|
69
|
+
let limit = document.documentElement.scrollHeight;
|
|
70
|
+
|
|
71
|
+
while (y < limit) {
|
|
72
|
+
y = Math.min(y + stepSize(), limit);
|
|
73
|
+
window.scrollTo(0, y);
|
|
74
|
+
await delay(130);
|
|
75
|
+
limit = document.documentElement.scrollHeight;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
window.scrollTo(0, document.documentElement.scrollHeight);
|
|
79
|
+
await delay(420);
|
|
80
|
+
|
|
81
|
+
const h = document.documentElement.scrollHeight;
|
|
82
|
+
|
|
83
|
+
window.scrollTo(0, 0);
|
|
84
|
+
await delay(220);
|
|
85
|
+
|
|
86
|
+
if (h === prevTotal) unchangedStreak++;
|
|
87
|
+
else unchangedStreak = 0;
|
|
88
|
+
prevTotal = h;
|
|
89
|
+
|
|
90
|
+
if (unchangedStreak >= 2) break;
|
|
66
91
|
}
|
|
67
|
-
window.scrollTo(0, 0);
|
|
68
|
-
await delay(300);
|
|
69
92
|
});
|
|
70
93
|
}
|
|
71
94
|
|
|
95
|
+
function mergeAssetInventory(base, extra) {
|
|
96
|
+
if (!extra) return base;
|
|
97
|
+
const imgKey = (i) => i.src || "";
|
|
98
|
+
const seenImg = new Set(base.images.map(imgKey));
|
|
99
|
+
const images = [...base.images];
|
|
100
|
+
for (const i of extra.images) {
|
|
101
|
+
const k = imgKey(i);
|
|
102
|
+
if (k && !seenImg.has(k)) {
|
|
103
|
+
seenImg.add(k);
|
|
104
|
+
images.push(i);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const vidKey = (v) => `${v.src || ""}|${v.poster || ""}`;
|
|
109
|
+
const seenVid = new Set(base.videos.map(vidKey));
|
|
110
|
+
const videos = [...base.videos];
|
|
111
|
+
for (const v of extra.videos) {
|
|
112
|
+
const k = vidKey(v);
|
|
113
|
+
if ((v.src || v.poster) && !seenVid.has(k)) {
|
|
114
|
+
seenVid.add(k);
|
|
115
|
+
videos.push(v);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const seenBg = new Set(base.backgroundImages.map((b) => b.url));
|
|
120
|
+
const backgroundImages = [...base.backgroundImages];
|
|
121
|
+
for (const b of extra.backgroundImages) {
|
|
122
|
+
if (b.url && !seenBg.has(b.url)) {
|
|
123
|
+
seenBg.add(b.url);
|
|
124
|
+
backgroundImages.push(b);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
images,
|
|
130
|
+
videos,
|
|
131
|
+
backgroundImages,
|
|
132
|
+
svgCount: Math.max(base.svgCount ?? 0, extra.svgCount ?? 0),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
72
136
|
async function gatherPageData(page) {
|
|
73
137
|
return page.evaluate(() => {
|
|
74
138
|
const title = document.title || "";
|
|
75
|
-
const text = (document.body?.innerText || "").slice(0,
|
|
139
|
+
const text = (document.body?.innerText || "").slice(0, 80000);
|
|
76
140
|
const likelyBotWall =
|
|
77
141
|
/just a moment/i.test(title) ||
|
|
78
142
|
/checking your browser/i.test(text) ||
|
|
@@ -145,7 +209,7 @@ async function gatherPageData(page) {
|
|
|
145
209
|
return {
|
|
146
210
|
title,
|
|
147
211
|
likelyBotWall,
|
|
148
|
-
textSample: text.slice(0,
|
|
212
|
+
textSample: text.slice(0, 24000),
|
|
149
213
|
assetInventory: {
|
|
150
214
|
images: imgs.filter((i) => i.src),
|
|
151
215
|
videos,
|
|
@@ -272,23 +336,32 @@ async function main() {
|
|
|
272
336
|
await new Promise((r) => setTimeout(r, 2500));
|
|
273
337
|
snapshot.finalUrl = page.url();
|
|
274
338
|
|
|
339
|
+
let mergedInventory = null;
|
|
340
|
+
let desktopLandmarks = null;
|
|
341
|
+
|
|
275
342
|
for (const w of [1440, 390]) {
|
|
276
343
|
await page.setViewportSize({ width: w, height: w === 1440 ? 900 : 844 });
|
|
277
|
-
|
|
344
|
+
await scrollFullPage(page);
|
|
278
345
|
|
|
279
346
|
const shotPath = join(ROOT, "docs", "design-references", `playwright-${host}-${w}px.png`);
|
|
280
347
|
await page.screenshot({ path: shotPath, fullPage: true });
|
|
281
348
|
snapshot.viewports[w] = { screenshot: `docs/design-references/playwright-${host}-${w}px.png` };
|
|
282
349
|
|
|
350
|
+
const data = await gatherPageData(page);
|
|
351
|
+
|
|
283
352
|
if (w === 1440) {
|
|
284
|
-
const data = await gatherPageData(page);
|
|
285
353
|
snapshot.title = data.title;
|
|
286
354
|
snapshot.likelyBotWall = data.likelyBotWall;
|
|
287
|
-
|
|
288
|
-
|
|
355
|
+
mergedInventory = data.assetInventory;
|
|
356
|
+
desktopLandmarks = data.computedLandmarks;
|
|
289
357
|
snapshot.textSample = data.textSample;
|
|
358
|
+
} else {
|
|
359
|
+
mergedInventory = mergeAssetInventory(mergedInventory, data.assetInventory);
|
|
290
360
|
}
|
|
291
361
|
}
|
|
362
|
+
|
|
363
|
+
snapshot.assetInventory = mergedInventory;
|
|
364
|
+
snapshot.computedLandmarks = desktopLandmarks;
|
|
292
365
|
} finally {
|
|
293
366
|
await browser.close();
|
|
294
367
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
|
|
5
|
+
export function ScribewiseLanding() {
|
|
6
|
+
return (
|
|
7
|
+
<main className="relative flex flex-1 flex-col items-center justify-center gap-8 overflow-hidden px-6 py-24 text-center">
|
|
8
|
+
<div
|
|
9
|
+
aria-hidden
|
|
10
|
+
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,color-mix(in_oklab,var(--primary)_28%,transparent),transparent)]"
|
|
11
|
+
/>
|
|
12
|
+
<p className="text-muted-foreground text-xs font-medium tracking-[0.2em] uppercase">
|
|
13
|
+
Launchframe template
|
|
14
|
+
</p>
|
|
15
|
+
<div className="max-w-2xl space-y-4">
|
|
16
|
+
<h1 className="text-balance font-semibold text-4xl tracking-tight sm:text-5xl">
|
|
17
|
+
Ready for `/clone-website`
|
|
18
|
+
</h1>
|
|
19
|
+
<p className="text-muted-foreground text-balance text-lg">
|
|
20
|
+
Add your target URL and SaaS idea via <code className="text-foreground">launchframe.config.json</code>,
|
|
21
|
+
run recon if needed, then let your agent ship the full landing.
|
|
22
|
+
</p>
|
|
23
|
+
</div>
|
|
24
|
+
<div className="flex flex-wrap items-center justify-center gap-3">
|
|
25
|
+
<Button nativeButton={false} render={<Link href="https://github.com/evangruhlkey/launchframe" />}>
|
|
26
|
+
Launchframe docs
|
|
27
|
+
</Button>
|
|
28
|
+
<Button variant="outline" nativeButton={false} render={<Link href="/" />}>
|
|
29
|
+
Replace this page
|
|
30
|
+
</Button>
|
|
31
|
+
</div>
|
|
32
|
+
</main>
|
|
33
|
+
);
|
|
34
|
+
}
|
package/bin/launchframe.mjs
DELETED
|
@@ -1,315 +0,0 @@
|
|
|
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
|
-
}
|