launchframe 0.4.8 → 0.4.10
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/launchframe.json +1 -1
- package/.amazonq/rules/project.md +125 -84
- package/.augment/commands/launchframe.md +23 -10
- package/.claude/skills/launchframe/SKILL.md +23 -10
- package/.clinerules +125 -84
- package/.codex/skills/launchframe/SKILL.md +23 -10
- package/.continue/commands/launchframe.md +23 -10
- package/.continue/rules/project.md +126 -86
- package/.cursor/commands/launchframe.md +23 -10
- package/.gemini/commands/launchframe.toml +23 -10
- package/.github/copilot-instructions.md +125 -84
- package/.github/skills/launchframe/SKILL.md +23 -10
- package/.opencode/commands/launchframe.md +23 -10
- package/.windsurf/workflows/launchframe.md +23 -10
- package/AGENTS.md +3 -2
- package/README.md +38 -165
- package/bin/launchframe.mjs +380 -380
- package/docs/research/INSPECTION_GUIDE.md +117 -78
- package/docs/research/LAUNCHFRAME_SUBAGENTS.md +73 -0
- package/package.json +1 -1
- package/scripts/sync-agent-rules.sh +88 -88
- package/tsconfig.json +34 -34
package/bin/launchframe.mjs
CHANGED
|
@@ -1,380 +1,380 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Scaffold a Launchframe project into the current directory (or --dir).
|
|
4
|
-
* Usage: npx launchframe@latest [--dir=path] [--skip-install]
|
|
5
|
-
* Optional: LAUNCHFRAME_SOURCE_URL, LAUNCHFRAME_SAAS_IDEA env vars, or legacy CLI args.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { cp, mkdir, readdir, readFile, stat, writeFile } from "fs/promises";
|
|
9
|
-
import { spawn } from "node:child_process";
|
|
10
|
-
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
11
|
-
import { fileURLToPath } from "node:url";
|
|
12
|
-
|
|
13
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
-
const PKG_ROOT = resolve(__dirname, "..");
|
|
15
|
-
|
|
16
|
-
const DEFAULT_URL = "https://example.com";
|
|
17
|
-
const DEFAULT_SAAS_IDEA =
|
|
18
|
-
"Edit your SaaS pitch in src/lib/launchframe-config.ts, then run /launchframe with your reference URL and the same pitch.";
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Never copy these root entries into a scaffolded app (build artifacts, VCS, the CLI itself).
|
|
22
|
-
* All other root files and folders are copied as-is — including every dotfile and dot-directory
|
|
23
|
-
* so AI agents see the same agent rules and commands as this template.
|
|
24
|
-
*/
|
|
25
|
-
const SKIP_DIR_NAMES = new Set([
|
|
26
|
-
"bin",
|
|
27
|
-
"node_modules",
|
|
28
|
-
".git",
|
|
29
|
-
".next",
|
|
30
|
-
"dist",
|
|
31
|
-
"out",
|
|
32
|
-
"coverage",
|
|
33
|
-
".turbo",
|
|
34
|
-
]);
|
|
35
|
-
|
|
36
|
-
const SKIP_ROOT_FILES = new Set([
|
|
37
|
-
"package-lock.json",
|
|
38
|
-
".DS_Store",
|
|
39
|
-
"Thumbs.db",
|
|
40
|
-
]);
|
|
41
|
-
|
|
42
|
-
async function pathExists(p) {
|
|
43
|
-
try {
|
|
44
|
-
await stat(p);
|
|
45
|
-
return true;
|
|
46
|
-
} catch {
|
|
47
|
-
return false;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function printHelp() {
|
|
52
|
-
console.log(`
|
|
53
|
-
launchframe — scaffold a Next.js app into the current directory (project root)
|
|
54
|
-
|
|
55
|
-
Usage:
|
|
56
|
-
npx launchframe@latest
|
|
57
|
-
|
|
58
|
-
This unpacks the template into the folder you are in (where you ran the command).
|
|
59
|
-
No URL or SaaS arguments are required — edit src/lib/launchframe-config.ts after scaffold.
|
|
60
|
-
|
|
61
|
-
Optional environment variables (same session):
|
|
62
|
-
LAUNCHFRAME_SOURCE_URL Reference site to clone later (https://...)
|
|
63
|
-
LAUNCHFRAME_SAAS_IDEA Short landing-page pitch text
|
|
64
|
-
|
|
65
|
-
Legacy (optional positional args, for scripts only):
|
|
66
|
-
npx launchframe@latest <url> "<saas-idea>"
|
|
67
|
-
|
|
68
|
-
Options:
|
|
69
|
-
--dir, -o Scaffold into this folder instead of the current directory (must be empty)
|
|
70
|
-
--skip-install Do not run npm install after scaffolding
|
|
71
|
-
-h, --help Show this message
|
|
72
|
-
|
|
73
|
-
Note:
|
|
74
|
-
Run inside an empty project folder (no existing package.json / src / next.config).
|
|
75
|
-
The output folder must not be inside the Launchframe npm package directory.
|
|
76
|
-
|
|
77
|
-
Example:
|
|
78
|
-
mkdir my-app && cd my-app
|
|
79
|
-
npx launchframe@latest
|
|
80
|
-
`);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function parseArgs(argv) {
|
|
84
|
-
const out = {
|
|
85
|
-
url: null,
|
|
86
|
-
idea: null,
|
|
87
|
-
dir: null,
|
|
88
|
-
skipInstall: false,
|
|
89
|
-
help: false,
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
const positional = [];
|
|
93
|
-
for (let i = 0; i < argv.length; i++) {
|
|
94
|
-
const a = argv[i];
|
|
95
|
-
if (a === "-h" || a === "--help") {
|
|
96
|
-
out.help = true;
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
if (a === "--skip-install") {
|
|
100
|
-
out.skipInstall = true;
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
if (a === "--dir" || a === "-o") {
|
|
104
|
-
out.dir = argv[++i];
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
if (a.startsWith("--dir=")) {
|
|
108
|
-
out.dir = a.slice("--dir=".length);
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
if (a.startsWith("-o=")) {
|
|
112
|
-
out.dir = a.slice("-o=".length);
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
positional.push(a);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (positional[0]) out.url = positional[0];
|
|
119
|
-
if (positional[1]) out.idea = positional[1];
|
|
120
|
-
|
|
121
|
-
return out;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function validateUrl(raw) {
|
|
125
|
-
let u;
|
|
126
|
-
try {
|
|
127
|
-
u = new URL(raw);
|
|
128
|
-
} catch {
|
|
129
|
-
throw new Error(`Invalid URL: ${raw}`);
|
|
130
|
-
}
|
|
131
|
-
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
132
|
-
throw new Error("URL must start with http:// or https://");
|
|
133
|
-
}
|
|
134
|
-
return u.href;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function slugFromDir(destRootAbs) {
|
|
138
|
-
const base = basename(resolve(destRootAbs));
|
|
139
|
-
return base
|
|
140
|
-
.toLowerCase()
|
|
141
|
-
.replace(/[^a-z0-9-_]/g, "-")
|
|
142
|
-
.replace(/-+/g, "-")
|
|
143
|
-
.replace(/^-|-$/g, "") || "launchframe-app";
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function tsStringLiteral(value) {
|
|
147
|
-
return JSON.stringify(value);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function shouldCopyRootEntry(baseName, isDirectory) {
|
|
151
|
-
if (SKIP_DIR_NAMES.has(baseName)) return false;
|
|
152
|
-
if (!isDirectory && SKIP_ROOT_FILES.has(baseName)) return false;
|
|
153
|
-
return true;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Output cannot be inside the template package tree (would recurse while copying). */
|
|
157
|
-
function isForbiddenOutput(packageRootAbs, destRootAbs) {
|
|
158
|
-
const pkg = resolve(packageRootAbs);
|
|
159
|
-
const dest = resolve(destRootAbs);
|
|
160
|
-
if (dest === pkg) return true;
|
|
161
|
-
const rel = relative(pkg, dest);
|
|
162
|
-
return Boolean(rel) && !rel.startsWith("..") && !isAbsolute(rel);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
async function copyTemplateTree(sourceRoot, destRootAbs) {
|
|
166
|
-
await mkdir(destRootAbs, { recursive: true });
|
|
167
|
-
const entries = await readdir(sourceRoot, { withFileTypes: true });
|
|
168
|
-
const destResolved = resolve(destRootAbs);
|
|
169
|
-
for (const ent of entries) {
|
|
170
|
-
const from = join(sourceRoot, ent.name);
|
|
171
|
-
if (resolve(from) === destResolved) continue;
|
|
172
|
-
if (!shouldCopyRootEntry(ent.name, ent.isDirectory())) continue;
|
|
173
|
-
const to = join(destRootAbs, ent.name);
|
|
174
|
-
await cp(from, to, { recursive: true });
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async function writeGeneratedPackageJson(destRoot, npmPackageName) {
|
|
179
|
-
const raw = await readFile(join(PKG_ROOT, "package.json"), "utf8");
|
|
180
|
-
const pkg = JSON.parse(raw);
|
|
181
|
-
|
|
182
|
-
const nextPkg = {
|
|
183
|
-
name: npmPackageName,
|
|
184
|
-
version: "0.1.0",
|
|
185
|
-
private: true,
|
|
186
|
-
description: pkg.description,
|
|
187
|
-
license: pkg.license,
|
|
188
|
-
engines: pkg.engines,
|
|
189
|
-
scripts: pkg.scripts,
|
|
190
|
-
dependencies: pkg.dependencies,
|
|
191
|
-
devDependencies: pkg.devDependencies,
|
|
192
|
-
keywords: pkg.keywords,
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
await writeFile(
|
|
196
|
-
join(destRoot, "package.json"),
|
|
197
|
-
`${JSON.stringify(nextPkg, null, 2)}\n`,
|
|
198
|
-
"utf8",
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
async function writeLaunchframeArtifacts(destRoot, url, idea) {
|
|
203
|
-
const configTs = `/**
|
|
204
|
-
* Set your reference site and positioning, then use /launchframe with the same URL and SaaS idea.
|
|
205
|
-
* Written by Launchframe CLI — edit freely.
|
|
206
|
-
*/
|
|
207
|
-
export const LAUNCHFRAME_SOURCE_URL = ${tsStringLiteral(url)} as const;
|
|
208
|
-
|
|
209
|
-
export const LAUNCHFRAME_SAAS_IDEA = ${tsStringLiteral(idea)} as const;
|
|
210
|
-
`;
|
|
211
|
-
|
|
212
|
-
await writeFile(join(destRoot, "src", "lib", "launchframe-config.ts"), configTs, "utf8");
|
|
213
|
-
|
|
214
|
-
const ctx = {
|
|
215
|
-
sourceUrl: url,
|
|
216
|
-
saasIdea: idea,
|
|
217
|
-
notes:
|
|
218
|
-
"Edit this file or src/lib/launchframe-config.ts. Use /launchframe with sourceUrl and your SaaS idea for pixel-perfect extraction.",
|
|
219
|
-
};
|
|
220
|
-
await writeFile(
|
|
221
|
-
join(destRoot, "launchframe.context.json"),
|
|
222
|
-
`${JSON.stringify(ctx, null, 2)}\n`,
|
|
223
|
-
"utf8",
|
|
224
|
-
);
|
|
225
|
-
|
|
226
|
-
const md = `# Launchframe context
|
|
227
|
-
|
|
228
|
-
## Reference URL (clone target)
|
|
229
|
-
|
|
230
|
-
${url}
|
|
231
|
-
|
|
232
|
-
## SaaS idea (landing copy)
|
|
233
|
-
|
|
234
|
-
${idea}
|
|
235
|
-
|
|
236
|
-
When running \`/launchframe\` with your agent, use the reference URL above and align hero copy with your SaaS idea while respecting attribution and copyright for third-party brands.
|
|
237
|
-
`;
|
|
238
|
-
|
|
239
|
-
await mkdir(join(destRoot, "docs", "research"), { recursive: true });
|
|
240
|
-
await writeFile(join(destRoot, "docs", "research", "LAUNCHFRAME.md"), md, "utf8");
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
async function writeReadme(destRoot, npmPackageName, url, idea) {
|
|
244
|
-
const body = `# ${npmPackageName}
|
|
245
|
-
|
|
246
|
-
Created with [\`launchframe\`](https://www.npmjs.com/package/launchframe).
|
|
247
|
-
|
|
248
|
-
## Configure
|
|
249
|
-
|
|
250
|
-
Edit \`src/lib/launchframe-config.ts\`:
|
|
251
|
-
|
|
252
|
-
- \`LAUNCHFRAME_SOURCE_URL\` — site to reverse-engineer
|
|
253
|
-
- \`LAUNCHFRAME_SAAS_IDEA\` — landing copy / positioning
|
|
254
|
-
|
|
255
|
-
Current values (from scaffold):
|
|
256
|
-
|
|
257
|
-
- **Reference site:** ${url}
|
|
258
|
-
- **SaaS idea:** ${idea}
|
|
259
|
-
|
|
260
|
-
## Quick start
|
|
261
|
-
|
|
262
|
-
\`\`\`bash
|
|
263
|
-
npm install
|
|
264
|
-
npm run dev
|
|
265
|
-
\`\`\`
|
|
266
|
-
|
|
267
|
-
Open your AI agent and run \`/launchframe <your-reference-url> "your pitch"\` (same values as in the config).
|
|
268
|
-
|
|
269
|
-
See \`AGENTS.md\` for full agent instructions.
|
|
270
|
-
`;
|
|
271
|
-
|
|
272
|
-
await writeFile(join(destRoot, "README.md"), body, "utf8");
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/** Refuse to stomp an existing Next-style project in the target directory. */
|
|
276
|
-
async function assertScaffoldTargetVacant(destRoot) {
|
|
277
|
-
const conflicts = ["package.json", "next.config.ts", "src"];
|
|
278
|
-
for (const c of conflicts) {
|
|
279
|
-
if (await pathExists(join(destRoot, c))) {
|
|
280
|
-
console.error(
|
|
281
|
-
`Refusing to scaffold: "${c}" already exists in this folder.\n` +
|
|
282
|
-
`Create a new empty directory, cd into it, then run:\n` +
|
|
283
|
-
` npx launchframe@latest\n`,
|
|
284
|
-
);
|
|
285
|
-
process.exit(1);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function runNpmInstall(cwd) {
|
|
291
|
-
return new Promise((resolvePromise, reject) => {
|
|
292
|
-
const child = spawn("npm", ["install"], {
|
|
293
|
-
cwd,
|
|
294
|
-
stdio: "inherit",
|
|
295
|
-
shell: process.platform === "win32",
|
|
296
|
-
});
|
|
297
|
-
child.on("exit", (code) => {
|
|
298
|
-
if (code === 0) resolvePromise();
|
|
299
|
-
else reject(new Error(`npm install exited with code ${code}`));
|
|
300
|
-
});
|
|
301
|
-
child.on("error", reject);
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function resolveUrlAndIdea(args) {
|
|
306
|
-
const envUrl =
|
|
307
|
-
process.env.LAUNCHFRAME_SOURCE_URL?.trim() || process.env.LAUNCHFRAME_URL?.trim();
|
|
308
|
-
const envIdea = process.env.LAUNCHFRAME_SAAS_IDEA?.trim();
|
|
309
|
-
|
|
310
|
-
let urlRaw = args.url?.trim() || envUrl || DEFAULT_URL;
|
|
311
|
-
let ideaRaw = args.idea?.trim() || envIdea || DEFAULT_SAAS_IDEA;
|
|
312
|
-
|
|
313
|
-
return { urlRaw, ideaRaw };
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
async function main() {
|
|
317
|
-
const args = parseArgs(process.argv.slice(2));
|
|
318
|
-
if (args.help) {
|
|
319
|
-
printHelp();
|
|
320
|
-
process.exit(0);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
const destRoot = args.dir?.trim()
|
|
324
|
-
? resolve(process.cwd(), args.dir.trim())
|
|
325
|
-
: resolve(process.cwd());
|
|
326
|
-
|
|
327
|
-
if (isForbiddenOutput(PKG_ROOT, destRoot)) {
|
|
328
|
-
console.error(
|
|
329
|
-
"Output folder cannot be inside the Launchframe package directory. Create a new folder outside this package and cd into it, then run npx launchframe@latest again.",
|
|
330
|
-
);
|
|
331
|
-
process.exit(1);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
await assertScaffoldTargetVacant(destRoot);
|
|
335
|
-
|
|
336
|
-
const { urlRaw, ideaRaw } = resolveUrlAndIdea(args);
|
|
337
|
-
|
|
338
|
-
let url;
|
|
339
|
-
try {
|
|
340
|
-
url = validateUrl(urlRaw);
|
|
341
|
-
} catch (e) {
|
|
342
|
-
console.error(String(e.message));
|
|
343
|
-
process.exit(1);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
const idea = ideaRaw.trim() || DEFAULT_SAAS_IDEA;
|
|
347
|
-
const npmPackageName = slugFromDir(destRoot);
|
|
348
|
-
|
|
349
|
-
await mkdir(destRoot, { recursive: true });
|
|
350
|
-
|
|
351
|
-
await copyTemplateTree(PKG_ROOT, destRoot);
|
|
352
|
-
|
|
353
|
-
await writeGeneratedPackageJson(destRoot, npmPackageName);
|
|
354
|
-
await writeLaunchframeArtifacts(destRoot, url, idea);
|
|
355
|
-
await writeReadme(destRoot, npmPackageName, url, idea);
|
|
356
|
-
|
|
357
|
-
console.log(`\nScaffolded Launchframe in:\n ${destRoot}`);
|
|
358
|
-
console.log(` Reference URL: ${url}`);
|
|
359
|
-
console.log(` SaaS idea: ${idea}\n`);
|
|
360
|
-
console.log("Edit src/lib/launchframe-config.ts if you need different values.\n");
|
|
361
|
-
|
|
362
|
-
if (!args.skipInstall) {
|
|
363
|
-
console.log("Running npm install...\n");
|
|
364
|
-
try {
|
|
365
|
-
await runNpmInstall(destRoot);
|
|
366
|
-
console.log("\nDone. From this folder: npm run dev\n");
|
|
367
|
-
} catch (e) {
|
|
368
|
-
console.error(String(e.message));
|
|
369
|
-
console.error("\nRun npm install in this folder manually.\n");
|
|
370
|
-
process.exit(1);
|
|
371
|
-
}
|
|
372
|
-
} else {
|
|
373
|
-
console.log("Skipped npm install (--skip-install). Run npm install in this folder.\n");
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
main().catch((err) => {
|
|
378
|
-
console.error(err);
|
|
379
|
-
process.exit(1);
|
|
380
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Scaffold a Launchframe project into the current directory (or --dir).
|
|
4
|
+
* Usage: npx launchframe@latest [--dir=path] [--skip-install]
|
|
5
|
+
* Optional: LAUNCHFRAME_SOURCE_URL, LAUNCHFRAME_SAAS_IDEA env vars, or legacy CLI args.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { cp, mkdir, readdir, readFile, stat, writeFile } from "fs/promises";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const PKG_ROOT = resolve(__dirname, "..");
|
|
15
|
+
|
|
16
|
+
const DEFAULT_URL = "https://example.com";
|
|
17
|
+
const DEFAULT_SAAS_IDEA =
|
|
18
|
+
"Edit your SaaS pitch in src/lib/launchframe-config.ts, then run /launchframe with your reference URL and the same pitch.";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Never copy these root entries into a scaffolded app (build artifacts, VCS, the CLI itself).
|
|
22
|
+
* All other root files and folders are copied as-is — including every dotfile and dot-directory
|
|
23
|
+
* so AI agents see the same agent rules and commands as this template.
|
|
24
|
+
*/
|
|
25
|
+
const SKIP_DIR_NAMES = new Set([
|
|
26
|
+
"bin",
|
|
27
|
+
"node_modules",
|
|
28
|
+
".git",
|
|
29
|
+
".next",
|
|
30
|
+
"dist",
|
|
31
|
+
"out",
|
|
32
|
+
"coverage",
|
|
33
|
+
".turbo",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const SKIP_ROOT_FILES = new Set([
|
|
37
|
+
"package-lock.json",
|
|
38
|
+
".DS_Store",
|
|
39
|
+
"Thumbs.db",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
async function pathExists(p) {
|
|
43
|
+
try {
|
|
44
|
+
await stat(p);
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function printHelp() {
|
|
52
|
+
console.log(`
|
|
53
|
+
launchframe — scaffold a Next.js app into the current directory (project root)
|
|
54
|
+
|
|
55
|
+
Usage:
|
|
56
|
+
npx launchframe@latest
|
|
57
|
+
|
|
58
|
+
This unpacks the template into the folder you are in (where you ran the command).
|
|
59
|
+
No URL or SaaS arguments are required — edit src/lib/launchframe-config.ts after scaffold.
|
|
60
|
+
|
|
61
|
+
Optional environment variables (same session):
|
|
62
|
+
LAUNCHFRAME_SOURCE_URL Reference site to clone later (https://...)
|
|
63
|
+
LAUNCHFRAME_SAAS_IDEA Short landing-page pitch text
|
|
64
|
+
|
|
65
|
+
Legacy (optional positional args, for scripts only):
|
|
66
|
+
npx launchframe@latest <url> "<saas-idea>"
|
|
67
|
+
|
|
68
|
+
Options:
|
|
69
|
+
--dir, -o Scaffold into this folder instead of the current directory (must be empty)
|
|
70
|
+
--skip-install Do not run npm install after scaffolding
|
|
71
|
+
-h, --help Show this message
|
|
72
|
+
|
|
73
|
+
Note:
|
|
74
|
+
Run inside an empty project folder (no existing package.json / src / next.config).
|
|
75
|
+
The output folder must not be inside the Launchframe npm package directory.
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
mkdir my-app && cd my-app
|
|
79
|
+
npx launchframe@latest
|
|
80
|
+
`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseArgs(argv) {
|
|
84
|
+
const out = {
|
|
85
|
+
url: null,
|
|
86
|
+
idea: null,
|
|
87
|
+
dir: null,
|
|
88
|
+
skipInstall: false,
|
|
89
|
+
help: false,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const positional = [];
|
|
93
|
+
for (let i = 0; i < argv.length; i++) {
|
|
94
|
+
const a = argv[i];
|
|
95
|
+
if (a === "-h" || a === "--help") {
|
|
96
|
+
out.help = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (a === "--skip-install") {
|
|
100
|
+
out.skipInstall = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (a === "--dir" || a === "-o") {
|
|
104
|
+
out.dir = argv[++i];
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (a.startsWith("--dir=")) {
|
|
108
|
+
out.dir = a.slice("--dir=".length);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (a.startsWith("-o=")) {
|
|
112
|
+
out.dir = a.slice("-o=".length);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
positional.push(a);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (positional[0]) out.url = positional[0];
|
|
119
|
+
if (positional[1]) out.idea = positional[1];
|
|
120
|
+
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateUrl(raw) {
|
|
125
|
+
let u;
|
|
126
|
+
try {
|
|
127
|
+
u = new URL(raw);
|
|
128
|
+
} catch {
|
|
129
|
+
throw new Error(`Invalid URL: ${raw}`);
|
|
130
|
+
}
|
|
131
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
132
|
+
throw new Error("URL must start with http:// or https://");
|
|
133
|
+
}
|
|
134
|
+
return u.href;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function slugFromDir(destRootAbs) {
|
|
138
|
+
const base = basename(resolve(destRootAbs));
|
|
139
|
+
return base
|
|
140
|
+
.toLowerCase()
|
|
141
|
+
.replace(/[^a-z0-9-_]/g, "-")
|
|
142
|
+
.replace(/-+/g, "-")
|
|
143
|
+
.replace(/^-|-$/g, "") || "launchframe-app";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function tsStringLiteral(value) {
|
|
147
|
+
return JSON.stringify(value);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function shouldCopyRootEntry(baseName, isDirectory) {
|
|
151
|
+
if (SKIP_DIR_NAMES.has(baseName)) return false;
|
|
152
|
+
if (!isDirectory && SKIP_ROOT_FILES.has(baseName)) return false;
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Output cannot be inside the template package tree (would recurse while copying). */
|
|
157
|
+
function isForbiddenOutput(packageRootAbs, destRootAbs) {
|
|
158
|
+
const pkg = resolve(packageRootAbs);
|
|
159
|
+
const dest = resolve(destRootAbs);
|
|
160
|
+
if (dest === pkg) return true;
|
|
161
|
+
const rel = relative(pkg, dest);
|
|
162
|
+
return Boolean(rel) && !rel.startsWith("..") && !isAbsolute(rel);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function copyTemplateTree(sourceRoot, destRootAbs) {
|
|
166
|
+
await mkdir(destRootAbs, { recursive: true });
|
|
167
|
+
const entries = await readdir(sourceRoot, { withFileTypes: true });
|
|
168
|
+
const destResolved = resolve(destRootAbs);
|
|
169
|
+
for (const ent of entries) {
|
|
170
|
+
const from = join(sourceRoot, ent.name);
|
|
171
|
+
if (resolve(from) === destResolved) continue;
|
|
172
|
+
if (!shouldCopyRootEntry(ent.name, ent.isDirectory())) continue;
|
|
173
|
+
const to = join(destRootAbs, ent.name);
|
|
174
|
+
await cp(from, to, { recursive: true });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function writeGeneratedPackageJson(destRoot, npmPackageName) {
|
|
179
|
+
const raw = await readFile(join(PKG_ROOT, "package.json"), "utf8");
|
|
180
|
+
const pkg = JSON.parse(raw);
|
|
181
|
+
|
|
182
|
+
const nextPkg = {
|
|
183
|
+
name: npmPackageName,
|
|
184
|
+
version: "0.1.0",
|
|
185
|
+
private: true,
|
|
186
|
+
description: pkg.description,
|
|
187
|
+
license: pkg.license,
|
|
188
|
+
engines: pkg.engines,
|
|
189
|
+
scripts: pkg.scripts,
|
|
190
|
+
dependencies: pkg.dependencies,
|
|
191
|
+
devDependencies: pkg.devDependencies,
|
|
192
|
+
keywords: pkg.keywords,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
await writeFile(
|
|
196
|
+
join(destRoot, "package.json"),
|
|
197
|
+
`${JSON.stringify(nextPkg, null, 2)}\n`,
|
|
198
|
+
"utf8",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function writeLaunchframeArtifacts(destRoot, url, idea) {
|
|
203
|
+
const configTs = `/**
|
|
204
|
+
* Set your reference site and positioning, then use /launchframe with the same URL and SaaS idea.
|
|
205
|
+
* Written by Launchframe CLI — edit freely.
|
|
206
|
+
*/
|
|
207
|
+
export const LAUNCHFRAME_SOURCE_URL = ${tsStringLiteral(url)} as const;
|
|
208
|
+
|
|
209
|
+
export const LAUNCHFRAME_SAAS_IDEA = ${tsStringLiteral(idea)} as const;
|
|
210
|
+
`;
|
|
211
|
+
|
|
212
|
+
await writeFile(join(destRoot, "src", "lib", "launchframe-config.ts"), configTs, "utf8");
|
|
213
|
+
|
|
214
|
+
const ctx = {
|
|
215
|
+
sourceUrl: url,
|
|
216
|
+
saasIdea: idea,
|
|
217
|
+
notes:
|
|
218
|
+
"Edit this file or src/lib/launchframe-config.ts. Use /launchframe with sourceUrl and your SaaS idea for pixel-perfect extraction.",
|
|
219
|
+
};
|
|
220
|
+
await writeFile(
|
|
221
|
+
join(destRoot, "launchframe.context.json"),
|
|
222
|
+
`${JSON.stringify(ctx, null, 2)}\n`,
|
|
223
|
+
"utf8",
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const md = `# Launchframe context
|
|
227
|
+
|
|
228
|
+
## Reference URL (clone target)
|
|
229
|
+
|
|
230
|
+
${url}
|
|
231
|
+
|
|
232
|
+
## SaaS idea (landing copy)
|
|
233
|
+
|
|
234
|
+
${idea}
|
|
235
|
+
|
|
236
|
+
When running \`/launchframe\` with your agent, use the reference URL above and align hero copy with your SaaS idea while respecting attribution and copyright for third-party brands.
|
|
237
|
+
`;
|
|
238
|
+
|
|
239
|
+
await mkdir(join(destRoot, "docs", "research"), { recursive: true });
|
|
240
|
+
await writeFile(join(destRoot, "docs", "research", "LAUNCHFRAME.md"), md, "utf8");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function writeReadme(destRoot, npmPackageName, url, idea) {
|
|
244
|
+
const body = `# ${npmPackageName}
|
|
245
|
+
|
|
246
|
+
Created with [\`launchframe\`](https://www.npmjs.com/package/launchframe).
|
|
247
|
+
|
|
248
|
+
## Configure
|
|
249
|
+
|
|
250
|
+
Edit \`src/lib/launchframe-config.ts\`:
|
|
251
|
+
|
|
252
|
+
- \`LAUNCHFRAME_SOURCE_URL\` — site to reverse-engineer
|
|
253
|
+
- \`LAUNCHFRAME_SAAS_IDEA\` — landing copy / positioning
|
|
254
|
+
|
|
255
|
+
Current values (from scaffold):
|
|
256
|
+
|
|
257
|
+
- **Reference site:** ${url}
|
|
258
|
+
- **SaaS idea:** ${idea}
|
|
259
|
+
|
|
260
|
+
## Quick start
|
|
261
|
+
|
|
262
|
+
\`\`\`bash
|
|
263
|
+
npm install
|
|
264
|
+
npm run dev
|
|
265
|
+
\`\`\`
|
|
266
|
+
|
|
267
|
+
Open your AI agent and run \`/launchframe <your-reference-url> "your pitch"\` (same values as in the config).
|
|
268
|
+
|
|
269
|
+
See \`AGENTS.md\` for full agent instructions.
|
|
270
|
+
`;
|
|
271
|
+
|
|
272
|
+
await writeFile(join(destRoot, "README.md"), body, "utf8");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Refuse to stomp an existing Next-style project in the target directory. */
|
|
276
|
+
async function assertScaffoldTargetVacant(destRoot) {
|
|
277
|
+
const conflicts = ["package.json", "next.config.ts", "src"];
|
|
278
|
+
for (const c of conflicts) {
|
|
279
|
+
if (await pathExists(join(destRoot, c))) {
|
|
280
|
+
console.error(
|
|
281
|
+
`Refusing to scaffold: "${c}" already exists in this folder.\n` +
|
|
282
|
+
`Create a new empty directory, cd into it, then run:\n` +
|
|
283
|
+
` npx launchframe@latest\n`,
|
|
284
|
+
);
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function runNpmInstall(cwd) {
|
|
291
|
+
return new Promise((resolvePromise, reject) => {
|
|
292
|
+
const child = spawn("npm", ["install"], {
|
|
293
|
+
cwd,
|
|
294
|
+
stdio: "inherit",
|
|
295
|
+
shell: process.platform === "win32",
|
|
296
|
+
});
|
|
297
|
+
child.on("exit", (code) => {
|
|
298
|
+
if (code === 0) resolvePromise();
|
|
299
|
+
else reject(new Error(`npm install exited with code ${code}`));
|
|
300
|
+
});
|
|
301
|
+
child.on("error", reject);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function resolveUrlAndIdea(args) {
|
|
306
|
+
const envUrl =
|
|
307
|
+
process.env.LAUNCHFRAME_SOURCE_URL?.trim() || process.env.LAUNCHFRAME_URL?.trim();
|
|
308
|
+
const envIdea = process.env.LAUNCHFRAME_SAAS_IDEA?.trim();
|
|
309
|
+
|
|
310
|
+
let urlRaw = args.url?.trim() || envUrl || DEFAULT_URL;
|
|
311
|
+
let ideaRaw = args.idea?.trim() || envIdea || DEFAULT_SAAS_IDEA;
|
|
312
|
+
|
|
313
|
+
return { urlRaw, ideaRaw };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function main() {
|
|
317
|
+
const args = parseArgs(process.argv.slice(2));
|
|
318
|
+
if (args.help) {
|
|
319
|
+
printHelp();
|
|
320
|
+
process.exit(0);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const destRoot = args.dir?.trim()
|
|
324
|
+
? resolve(process.cwd(), args.dir.trim())
|
|
325
|
+
: resolve(process.cwd());
|
|
326
|
+
|
|
327
|
+
if (isForbiddenOutput(PKG_ROOT, destRoot)) {
|
|
328
|
+
console.error(
|
|
329
|
+
"Output folder cannot be inside the Launchframe package directory. Create a new folder outside this package and cd into it, then run npx launchframe@latest again.",
|
|
330
|
+
);
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
await assertScaffoldTargetVacant(destRoot);
|
|
335
|
+
|
|
336
|
+
const { urlRaw, ideaRaw } = resolveUrlAndIdea(args);
|
|
337
|
+
|
|
338
|
+
let url;
|
|
339
|
+
try {
|
|
340
|
+
url = validateUrl(urlRaw);
|
|
341
|
+
} catch (e) {
|
|
342
|
+
console.error(String(e.message));
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const idea = ideaRaw.trim() || DEFAULT_SAAS_IDEA;
|
|
347
|
+
const npmPackageName = slugFromDir(destRoot);
|
|
348
|
+
|
|
349
|
+
await mkdir(destRoot, { recursive: true });
|
|
350
|
+
|
|
351
|
+
await copyTemplateTree(PKG_ROOT, destRoot);
|
|
352
|
+
|
|
353
|
+
await writeGeneratedPackageJson(destRoot, npmPackageName);
|
|
354
|
+
await writeLaunchframeArtifacts(destRoot, url, idea);
|
|
355
|
+
await writeReadme(destRoot, npmPackageName, url, idea);
|
|
356
|
+
|
|
357
|
+
console.log(`\nScaffolded Launchframe in:\n ${destRoot}`);
|
|
358
|
+
console.log(` Reference URL: ${url}`);
|
|
359
|
+
console.log(` SaaS idea: ${idea}\n`);
|
|
360
|
+
console.log("Edit src/lib/launchframe-config.ts if you need different values.\n");
|
|
361
|
+
|
|
362
|
+
if (!args.skipInstall) {
|
|
363
|
+
console.log("Running npm install...\n");
|
|
364
|
+
try {
|
|
365
|
+
await runNpmInstall(destRoot);
|
|
366
|
+
console.log("\nDone. From this folder: npm run dev\n");
|
|
367
|
+
} catch (e) {
|
|
368
|
+
console.error(String(e.message));
|
|
369
|
+
console.error("\nRun npm install in this folder manually.\n");
|
|
370
|
+
process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
console.log("Skipped npm install (--skip-install). Run npm install in this folder.\n");
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
main().catch((err) => {
|
|
378
|
+
console.error(err);
|
|
379
|
+
process.exit(1);
|
|
380
|
+
});
|