storyforge 0.1.2 → 0.2.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/dist/index.js +441 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
// src/commands/dev.ts
|
|
4
4
|
import * as fs2 from "fs";
|
|
5
|
+
import * as os2 from "os";
|
|
5
6
|
import * as path2 from "path";
|
|
6
7
|
import * as http from "http";
|
|
7
|
-
import { execFile } from "child_process";
|
|
8
|
+
import { execFile, exec as execCb, execSync } from "child_process";
|
|
9
|
+
import { promisify } from "util";
|
|
8
10
|
|
|
9
11
|
// src/utils/log.ts
|
|
10
12
|
var supportsColor = process.stdout.isTTY && process.env.TERM !== "dumb" && !process.env.NO_COLOR;
|
|
@@ -41,6 +43,7 @@ function loadCredentials() {
|
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
// src/commands/dev.ts
|
|
46
|
+
var exec = promisify(execCb);
|
|
44
47
|
var PORT = 4444;
|
|
45
48
|
var WEB_URL = "https://forge.algo-thinker.com";
|
|
46
49
|
function getApiConfig() {
|
|
@@ -183,6 +186,54 @@ async function findOrCreateProject(dir) {
|
|
|
183
186
|
log.success(`Saved ${metaPath(dir)}`);
|
|
184
187
|
return meta;
|
|
185
188
|
}
|
|
189
|
+
function findMonorepoRoot(startDir) {
|
|
190
|
+
let d = startDir;
|
|
191
|
+
for (let i = 0; i < 6; i++) {
|
|
192
|
+
const parent = path2.dirname(d);
|
|
193
|
+
if (parent === d) break;
|
|
194
|
+
if (fs2.existsSync(path2.join(parent, "turbo.json")) || fs2.existsSync(path2.join(parent, "apps")) && fs2.existsSync(path2.join(parent, "packages"))) return parent;
|
|
195
|
+
d = parent;
|
|
196
|
+
}
|
|
197
|
+
return startDir;
|
|
198
|
+
}
|
|
199
|
+
function findProjectCompositions(dir) {
|
|
200
|
+
const SKIP = /* @__PURE__ */ new Set(["Root.tsx", "index.tsx", "DocumentaryVideo.tsx", "DocumentaryVideoV2.tsx"]);
|
|
201
|
+
const seen = /* @__PURE__ */ new Set();
|
|
202
|
+
const result = [];
|
|
203
|
+
function scanDir(absoluteDir, base) {
|
|
204
|
+
if (!fs2.existsSync(absoluteDir)) return;
|
|
205
|
+
function scan(d, rel) {
|
|
206
|
+
if (!fs2.existsSync(d)) return;
|
|
207
|
+
for (const entry of fs2.readdirSync(d, { withFileTypes: true })) {
|
|
208
|
+
if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
|
|
209
|
+
const full = path2.join(d, entry.name);
|
|
210
|
+
const entryRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
211
|
+
if (entry.isDirectory()) {
|
|
212
|
+
scan(full, entryRel);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (entry.name.endsWith(".tsx") && !SKIP.has(entry.name) && !seen.has(entry.name)) {
|
|
216
|
+
seen.add(entry.name);
|
|
217
|
+
result.push({ name: entry.name.replace(/\.tsx$/, ""), source: "project", relPath: entryRel, basePath: base, absolutePath: full });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
scan(absoluteDir, "");
|
|
222
|
+
}
|
|
223
|
+
scanDir(path2.join(dir, "src", "remotion"), dir);
|
|
224
|
+
const root = findMonorepoRoot(dir);
|
|
225
|
+
if (root !== dir) {
|
|
226
|
+
scanDir(path2.join(root, "src", "remotion"), root);
|
|
227
|
+
const packagesDir = path2.join(root, "packages");
|
|
228
|
+
if (fs2.existsSync(packagesDir)) {
|
|
229
|
+
for (const pkg of fs2.readdirSync(packagesDir, { withFileTypes: true })) {
|
|
230
|
+
if (!pkg.isDirectory() || pkg.name.startsWith(".")) continue;
|
|
231
|
+
scanDir(path2.join(packagesDir, pkg.name, "src"), path2.join(packagesDir, pkg.name));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
186
237
|
function getMime(file) {
|
|
187
238
|
const ext = path2.extname(file).toLowerCase();
|
|
188
239
|
const types = {
|
|
@@ -218,6 +269,9 @@ function collectFiles(dir, base = "") {
|
|
|
218
269
|
}
|
|
219
270
|
return result;
|
|
220
271
|
}
|
|
272
|
+
function stripFences(text) {
|
|
273
|
+
return text.replace(/^```(?:tsx?|jsx?|typescript|javascript)?\n?/m, "").replace(/\n?```\s*$/m, "").trim();
|
|
274
|
+
}
|
|
221
275
|
function openBrowser(url) {
|
|
222
276
|
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
223
277
|
execFile(cmd, [url], (err) => {
|
|
@@ -272,9 +326,15 @@ async function devCommand(options) {
|
|
|
272
326
|
return;
|
|
273
327
|
}
|
|
274
328
|
if (pathname === "/api/assets") {
|
|
329
|
+
const imgExts = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"]);
|
|
330
|
+
const legacyH = collectFiles(path2.join(dir, "images-horizontal"), "images-horizontal");
|
|
331
|
+
const geminiScenes = collectFiles(
|
|
332
|
+
path2.join(dir, "public", "tesla-fsd", "gemini-scenes"),
|
|
333
|
+
"public/tesla-fsd/gemini-scenes"
|
|
334
|
+
).filter((f) => imgExts.has(path2.extname(f.name).toLowerCase()));
|
|
275
335
|
const assets = {
|
|
276
336
|
audio: collectFiles(path2.join(dir, "audio"), "audio"),
|
|
277
|
-
imagesH:
|
|
337
|
+
imagesH: [...legacyH, ...geminiScenes],
|
|
278
338
|
imagesV: collectFiles(path2.join(dir, "images-vertical"), "images-vertical"),
|
|
279
339
|
clips: collectFiles(path2.join(dir, "clips"), "clips"),
|
|
280
340
|
scripts: collectFiles(path2.join(dir, "scripts"), "scripts"),
|
|
@@ -285,6 +345,298 @@ async function devCommand(options) {
|
|
|
285
345
|
res.end(JSON.stringify(assets));
|
|
286
346
|
return;
|
|
287
347
|
}
|
|
348
|
+
if (pathname === "/api/compositions") {
|
|
349
|
+
const compositionsDir = path2.join(os2.homedir(), ".forge", "compositions", meta.projectId);
|
|
350
|
+
fs2.mkdirSync(compositionsDir, { recursive: true });
|
|
351
|
+
const savedFiles = fs2.readdirSync(compositionsDir).filter((f) => f.endsWith(".tsx"));
|
|
352
|
+
const saved = savedFiles.map((f) => ({ name: f.replace(/\.tsx$/, ""), source: "saved" }));
|
|
353
|
+
const project = findProjectCompositions(dir);
|
|
354
|
+
res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
|
|
355
|
+
res.end(JSON.stringify([...saved, ...project]));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const compGetMatch = pathname.match(/^\/api\/compositions\/([^/]+)$/);
|
|
359
|
+
if (req.method === "GET" && compGetMatch) {
|
|
360
|
+
const name = decodeURIComponent(compGetMatch[1]).replace(/[^a-zA-Z0-9_-]/g, "");
|
|
361
|
+
if (!name) {
|
|
362
|
+
res.writeHead(400, CORS_HEADERS);
|
|
363
|
+
res.end("Invalid name");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const compositionsDir = path2.join(os2.homedir(), ".forge", "compositions", meta.projectId);
|
|
367
|
+
const savedPath = path2.join(compositionsDir, `${name}.tsx`);
|
|
368
|
+
if (fs2.existsSync(savedPath)) {
|
|
369
|
+
res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "text/plain; charset=utf-8" });
|
|
370
|
+
res.end(fs2.readFileSync(savedPath, "utf-8"));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const projectComps = findProjectCompositions(dir);
|
|
374
|
+
const found = projectComps.find((c2) => c2.name === name);
|
|
375
|
+
if (found) {
|
|
376
|
+
res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "text/plain; charset=utf-8" });
|
|
377
|
+
res.end(fs2.readFileSync(found.absolutePath, "utf-8"));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
res.writeHead(404, CORS_HEADERS);
|
|
381
|
+
res.end("Not found");
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const compPostMatch = pathname.match(/^\/api\/compositions\/([^/]+)$/);
|
|
385
|
+
if (req.method === "POST" && compPostMatch) {
|
|
386
|
+
const name = compPostMatch[1].replace(/[^a-zA-Z0-9_-]/g, "");
|
|
387
|
+
if (!name) {
|
|
388
|
+
res.writeHead(400, CORS_HEADERS);
|
|
389
|
+
res.end("Invalid name");
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const compositionsDir = path2.join(os2.homedir(), ".forge", "compositions", meta.projectId);
|
|
393
|
+
fs2.mkdirSync(compositionsDir, { recursive: true });
|
|
394
|
+
const filePath = path2.join(compositionsDir, `${name}.tsx`);
|
|
395
|
+
const chunks = [];
|
|
396
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
397
|
+
req.on("end", () => {
|
|
398
|
+
const body = Buffer.concat(chunks).toString("utf-8");
|
|
399
|
+
fs2.writeFileSync(filePath, body, "utf-8");
|
|
400
|
+
res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
|
|
401
|
+
res.end(JSON.stringify({ ok: true, path: filePath }));
|
|
402
|
+
});
|
|
403
|
+
req.on("error", () => {
|
|
404
|
+
res.writeHead(500, CORS_HEADERS);
|
|
405
|
+
res.end("Upload error");
|
|
406
|
+
});
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (pathname === "/api/ai-edit" && req.method === "POST") {
|
|
410
|
+
const bodyChunks = [];
|
|
411
|
+
req.on("data", (c2) => bodyChunks.push(c2));
|
|
412
|
+
req.on("end", async () => {
|
|
413
|
+
let parsed;
|
|
414
|
+
try {
|
|
415
|
+
parsed = JSON.parse(Buffer.concat(bodyChunks).toString("utf-8"));
|
|
416
|
+
} catch {
|
|
417
|
+
res.writeHead(400, CORS_HEADERS);
|
|
418
|
+
res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const { code, instruction, context } = parsed;
|
|
422
|
+
const prompt2 = `You are editing a Remotion TSX composition for video production.
|
|
423
|
+
|
|
424
|
+
SANDBOX RULES \u2014 strictly followed:
|
|
425
|
+
- Only 'react' and 'remotion' can be imported
|
|
426
|
+
- From remotion: AbsoluteFill, Img, Audio, OffthreadVideo, Sequence, useCurrentFrame, useVideoConfig, interpolate, spring
|
|
427
|
+
- Use inline styles only (no className / CSS)
|
|
428
|
+
- Always export a default function as the composition
|
|
429
|
+
|
|
430
|
+
PROPS injected by the player at runtime:
|
|
431
|
+
images \u2014 Array<{ url: string; name: string }> (the chunk's images, 16:9)
|
|
432
|
+
narrationText \u2014 string | null
|
|
433
|
+
masterAudioUrl \u2014 string | null
|
|
434
|
+
startSec \u2014 number (offset into master audio where this chunk begins)
|
|
435
|
+
fps \u2014 number (always 30)
|
|
436
|
+
|
|
437
|
+
Chunk context:
|
|
438
|
+
title: ${context?.chunkTitle ?? ""}
|
|
439
|
+
narration: ${(context?.narrationText ?? "").slice(0, 500)}
|
|
440
|
+
images: ${context?.imageCount ?? 0} horizontal images
|
|
441
|
+
duration: ~${(context?.durationSec ?? 0).toFixed(1)}s
|
|
442
|
+
|
|
443
|
+
Current composition:
|
|
444
|
+
${code}
|
|
445
|
+
|
|
446
|
+
User instruction: ${instruction}
|
|
447
|
+
|
|
448
|
+
Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
|
|
449
|
+
const tmpFile = path2.join(os2.tmpdir(), `forge-ai-${Date.now()}.txt`);
|
|
450
|
+
fs2.writeFileSync(tmpFile, prompt2, "utf-8");
|
|
451
|
+
const cleanup = () => {
|
|
452
|
+
if (fs2.existsSync(tmpFile)) fs2.unlinkSync(tmpFile);
|
|
453
|
+
};
|
|
454
|
+
const respond = (code2, model) => {
|
|
455
|
+
res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
|
|
456
|
+
res.end(JSON.stringify({ code: code2, model }));
|
|
457
|
+
};
|
|
458
|
+
try {
|
|
459
|
+
const { stdout } = await exec(
|
|
460
|
+
`cat "${tmpFile}" | claude -p --model claude-sonnet-4-6 --no-session-persistence`,
|
|
461
|
+
{ maxBuffer: 4 * 1024 * 1024, timeout: 9e4 }
|
|
462
|
+
);
|
|
463
|
+
const cleaned = stripFences(stdout.trim());
|
|
464
|
+
if (cleaned) {
|
|
465
|
+
cleanup();
|
|
466
|
+
respond(cleaned, "claude-sonnet-4-6 (CLI)");
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
} catch {
|
|
470
|
+
log.warn("Claude CLI unavailable \u2014 trying Codex CLI...");
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
const { stdout } = await exec(
|
|
474
|
+
`codex -q ${JSON.stringify(prompt2)}`,
|
|
475
|
+
{ maxBuffer: 4 * 1024 * 1024, timeout: 9e4 }
|
|
476
|
+
);
|
|
477
|
+
const cleaned = stripFences(stdout.trim());
|
|
478
|
+
if (cleaned && cleaned.includes("export default")) {
|
|
479
|
+
cleanup();
|
|
480
|
+
respond(cleaned, "gpt-5.4 (codex CLI)");
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
} catch {
|
|
484
|
+
log.warn("Codex CLI unavailable \u2014 trying API keys...");
|
|
485
|
+
}
|
|
486
|
+
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
487
|
+
if (anthropicKey) {
|
|
488
|
+
try {
|
|
489
|
+
const aResp = await fetch("https://api.anthropic.com/v1/messages", {
|
|
490
|
+
method: "POST",
|
|
491
|
+
headers: {
|
|
492
|
+
"x-api-key": anthropicKey,
|
|
493
|
+
"anthropic-version": "2023-06-01",
|
|
494
|
+
"content-type": "application/json"
|
|
495
|
+
},
|
|
496
|
+
body: JSON.stringify({
|
|
497
|
+
model: "claude-sonnet-4-6",
|
|
498
|
+
max_tokens: 4096,
|
|
499
|
+
messages: [{ role: "user", content: prompt2 }]
|
|
500
|
+
})
|
|
501
|
+
});
|
|
502
|
+
if (aResp.ok) {
|
|
503
|
+
const aData = await aResp.json();
|
|
504
|
+
const cleaned = stripFences((aData.content?.[0]?.text ?? "").trim());
|
|
505
|
+
if (cleaned) {
|
|
506
|
+
cleanup();
|
|
507
|
+
respond(cleaned, "claude-sonnet-4-6 (API)");
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
} catch {
|
|
512
|
+
log.warn("Anthropic API failed \u2014 trying OpenAI API...");
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
const openAiKey = process.env.OPENAI_API_KEY;
|
|
516
|
+
if (openAiKey) {
|
|
517
|
+
try {
|
|
518
|
+
const oResp = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
519
|
+
method: "POST",
|
|
520
|
+
headers: { "Authorization": `Bearer ${openAiKey}`, "Content-Type": "application/json" },
|
|
521
|
+
body: JSON.stringify({
|
|
522
|
+
model: "gpt-5.4",
|
|
523
|
+
max_tokens: 4096,
|
|
524
|
+
messages: [{ role: "user", content: prompt2 }]
|
|
525
|
+
})
|
|
526
|
+
});
|
|
527
|
+
if (oResp.ok) {
|
|
528
|
+
const oData = await oResp.json();
|
|
529
|
+
const cleaned = stripFences((oData.choices?.[0]?.message?.content ?? "").trim());
|
|
530
|
+
if (cleaned) {
|
|
531
|
+
cleanup();
|
|
532
|
+
respond(cleaned, "gpt-5.4 (API)");
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
} catch {
|
|
537
|
+
log.warn("OpenAI API failed.");
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
cleanup();
|
|
541
|
+
res.writeHead(500, CORS_HEADERS);
|
|
542
|
+
res.end(JSON.stringify({
|
|
543
|
+
error: [
|
|
544
|
+
"No AI provider succeeded. Configure at least one:",
|
|
545
|
+
" \u2022 Install Claude CLI: https://claude.ai/code (free with Claude subscription)",
|
|
546
|
+
" \u2022 Install Codex CLI: npm install -g @openai/codex",
|
|
547
|
+
" \u2022 Set ANTHROPIC_API_KEY in your shell",
|
|
548
|
+
" \u2022 Set OPENAI_API_KEY in your shell"
|
|
549
|
+
].join("\n")
|
|
550
|
+
}));
|
|
551
|
+
});
|
|
552
|
+
req.on("error", () => {
|
|
553
|
+
res.writeHead(500, CORS_HEADERS);
|
|
554
|
+
res.end("Request error");
|
|
555
|
+
});
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (pathname === "/api/chunk-compositions") {
|
|
559
|
+
const compPath = path2.join(dir, "chunk-compositions.json");
|
|
560
|
+
if (!fs2.existsSync(compPath)) {
|
|
561
|
+
res.writeHead(404, CORS_HEADERS);
|
|
562
|
+
res.end("chunk-compositions.json not found");
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
|
|
566
|
+
res.end(fs2.readFileSync(compPath));
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const listMatch = pathname.match(/^\/api\/list\/(.+)$/);
|
|
570
|
+
if (listMatch) {
|
|
571
|
+
const relPath = decodeURIComponent(listMatch[1]);
|
|
572
|
+
const fullPath = path2.resolve(path2.join(dir, relPath));
|
|
573
|
+
if (!fullPath.startsWith(path2.resolve(dir))) {
|
|
574
|
+
res.writeHead(403, CORS_HEADERS);
|
|
575
|
+
res.end("Forbidden");
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (!fs2.existsSync(fullPath) || !fs2.statSync(fullPath).isDirectory()) {
|
|
579
|
+
res.writeHead(404, CORS_HEADERS);
|
|
580
|
+
res.end("Not a directory");
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"]);
|
|
584
|
+
const baseUrl = `http://localhost:${port}`;
|
|
585
|
+
const files = fs2.readdirSync(fullPath).filter((f) => !f.startsWith(".") && IMAGE_EXTS.has(path2.extname(f).toLowerCase())).filter((f) => fs2.statSync(path2.join(fullPath, f)).isFile()).sort().map((f) => {
|
|
586
|
+
const normalized = relPath.endsWith("/") ? relPath : relPath + "/";
|
|
587
|
+
const url2 = normalized.startsWith("public/") ? `${baseUrl}/api/public/${normalized.slice("public/".length)}${f}` : `${baseUrl}/api/file/${normalized}${f}`;
|
|
588
|
+
return { name: f, url: url2 };
|
|
589
|
+
});
|
|
590
|
+
res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
|
|
591
|
+
res.end(JSON.stringify(files));
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const publicFileMatch = pathname.match(/^\/api\/public\/(.+)$/);
|
|
595
|
+
if (publicFileMatch) {
|
|
596
|
+
const filePath = decodeURIComponent(publicFileMatch[1]);
|
|
597
|
+
const publicDir = path2.join(dir, "public");
|
|
598
|
+
if (!fs2.existsSync(publicDir)) {
|
|
599
|
+
res.writeHead(404, CORS_HEADERS);
|
|
600
|
+
res.end("No public directory");
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const fullPath = path2.resolve(path2.join(publicDir, filePath));
|
|
604
|
+
if (!fullPath.startsWith(path2.resolve(publicDir))) {
|
|
605
|
+
res.writeHead(403, CORS_HEADERS);
|
|
606
|
+
res.end("Forbidden");
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (!fs2.existsSync(fullPath)) {
|
|
610
|
+
res.writeHead(404, CORS_HEADERS);
|
|
611
|
+
res.end("Not found");
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const stat = fs2.statSync(fullPath);
|
|
615
|
+
const mime = getMime(fullPath);
|
|
616
|
+
const range = req.headers.range;
|
|
617
|
+
if (range && (mime.startsWith("audio/") || mime.startsWith("video/"))) {
|
|
618
|
+
const parts = range.replace(/bytes=/, "").split("-");
|
|
619
|
+
const start = parseInt(parts[0], 10);
|
|
620
|
+
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
|
|
621
|
+
res.writeHead(206, {
|
|
622
|
+
...CORS_HEADERS,
|
|
623
|
+
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
|
|
624
|
+
"Accept-Ranges": "bytes",
|
|
625
|
+
"Content-Length": end - start + 1,
|
|
626
|
+
"Content-Type": mime
|
|
627
|
+
});
|
|
628
|
+
fs2.createReadStream(fullPath, { start, end }).pipe(res);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
res.writeHead(200, {
|
|
632
|
+
...CORS_HEADERS,
|
|
633
|
+
"Content-Type": mime,
|
|
634
|
+
"Content-Length": stat.size,
|
|
635
|
+
"Cache-Control": "public, max-age=3600"
|
|
636
|
+
});
|
|
637
|
+
fs2.createReadStream(fullPath).pipe(res);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
288
640
|
const fileMatch = pathname.match(/^\/api\/file\/(.+)$/);
|
|
289
641
|
if (fileMatch) {
|
|
290
642
|
const filePath = decodeURIComponent(fileMatch[1]);
|
|
@@ -324,11 +676,73 @@ async function devCommand(options) {
|
|
|
324
676
|
fs2.createReadStream(fullPath).pipe(res);
|
|
325
677
|
return;
|
|
326
678
|
}
|
|
679
|
+
const uploadMatch = pathname.match(/^\/api\/upload\/(.+)$/);
|
|
680
|
+
if (uploadMatch && req.method === "POST") {
|
|
681
|
+
const relDir = decodeURIComponent(uploadMatch[1]);
|
|
682
|
+
const projectDir = path2.resolve(dir);
|
|
683
|
+
const fullDir = path2.resolve(path2.join(dir, relDir));
|
|
684
|
+
if (!fullDir.startsWith(projectDir)) {
|
|
685
|
+
res.writeHead(400, CORS_HEADERS);
|
|
686
|
+
res.end(JSON.stringify({ error: "path traversal" }));
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
try {
|
|
690
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
691
|
+
const boundaryM = contentType.match(/boundary=([^\s;]+)/);
|
|
692
|
+
if (!boundaryM) {
|
|
693
|
+
res.writeHead(400, CORS_HEADERS);
|
|
694
|
+
res.end(JSON.stringify({ error: "no boundary" }));
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const boundary = boundaryM[1];
|
|
698
|
+
const chunks = [];
|
|
699
|
+
await new Promise((resolve2, reject) => {
|
|
700
|
+
req.on("data", (c2) => chunks.push(Buffer.from(c2)));
|
|
701
|
+
req.on("end", resolve2);
|
|
702
|
+
req.on("error", reject);
|
|
703
|
+
});
|
|
704
|
+
const body = Buffer.concat(chunks);
|
|
705
|
+
const headerEnd = body.indexOf(Buffer.from("\r\n\r\n"));
|
|
706
|
+
if (headerEnd === -1) {
|
|
707
|
+
res.writeHead(400, CORS_HEADERS);
|
|
708
|
+
res.end(JSON.stringify({ error: "bad multipart" }));
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const headerSection = body.slice(0, headerEnd).toString();
|
|
712
|
+
const filenameM = headerSection.match(/filename="([^"]+)"/);
|
|
713
|
+
if (!filenameM) {
|
|
714
|
+
res.writeHead(400, CORS_HEADERS);
|
|
715
|
+
res.end(JSON.stringify({ error: "no filename" }));
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const filename = path2.basename(filenameM[1]);
|
|
719
|
+
const dataStart = headerEnd + 4;
|
|
720
|
+
const endMarker = Buffer.from(`\r
|
|
721
|
+
--${boundary}--`);
|
|
722
|
+
const endIdx = body.indexOf(endMarker, dataStart);
|
|
723
|
+
const data = endIdx !== -1 ? body.slice(dataStart, endIdx) : body.slice(dataStart);
|
|
724
|
+
if (!fs2.existsSync(fullDir)) fs2.mkdirSync(fullDir, { recursive: true });
|
|
725
|
+
const writePath = path2.resolve(path2.join(fullDir, filename));
|
|
726
|
+
if (!writePath.startsWith(projectDir)) {
|
|
727
|
+
res.writeHead(400, CORS_HEADERS);
|
|
728
|
+
res.end(JSON.stringify({ error: "path traversal" }));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
fs2.writeFileSync(writePath, data);
|
|
732
|
+
const relativePath = path2.relative(dir, writePath).replace(/\\/g, "/");
|
|
733
|
+
res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
|
|
734
|
+
res.end(JSON.stringify({ path: relativePath }));
|
|
735
|
+
} catch (e) {
|
|
736
|
+
res.writeHead(500, CORS_HEADERS);
|
|
737
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
738
|
+
}
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
327
741
|
res.writeHead(404, CORS_HEADERS);
|
|
328
742
|
res.end(JSON.stringify({ error: "Not found" }));
|
|
329
743
|
});
|
|
330
744
|
server.listen(port, () => {
|
|
331
|
-
const webUrl = `${WEB_URL}/forge/p/${meta.projectId}/
|
|
745
|
+
const webUrl = `${WEB_URL}/forge/p/${meta.projectId}/pipeline`;
|
|
332
746
|
console.log("");
|
|
333
747
|
log.success("Running");
|
|
334
748
|
console.log("");
|
|
@@ -341,6 +755,30 @@ async function devCommand(options) {
|
|
|
341
755
|
log.info("Opening browser...");
|
|
342
756
|
openBrowser(webUrl);
|
|
343
757
|
}
|
|
758
|
+
const aiMethods = [];
|
|
759
|
+
try {
|
|
760
|
+
execSync("which claude", { stdio: "ignore" });
|
|
761
|
+
aiMethods.push("Claude CLI \u2192 claude-sonnet-4-6");
|
|
762
|
+
} catch {
|
|
763
|
+
}
|
|
764
|
+
try {
|
|
765
|
+
execSync("which codex", { stdio: "ignore" });
|
|
766
|
+
aiMethods.push("Codex CLI \u2192 gpt-5.4");
|
|
767
|
+
} catch {
|
|
768
|
+
}
|
|
769
|
+
if (process.env.ANTHROPIC_API_KEY) aiMethods.push("Anthropic API \u2192 claude-sonnet-4-6");
|
|
770
|
+
if (process.env.OPENAI_API_KEY) aiMethods.push("OpenAI API \u2192 gpt-5.4");
|
|
771
|
+
console.log("");
|
|
772
|
+
if (aiMethods.length > 0) {
|
|
773
|
+
console.log(" AI edit chain (tried in order):");
|
|
774
|
+
aiMethods.forEach((m, i) => console.log(` ${i + 1}. ${m}`));
|
|
775
|
+
} else {
|
|
776
|
+
console.log(" No AI providers found. To enable composition AI editing:");
|
|
777
|
+
console.log(" Install Claude CLI: https://claude.ai/code (free with subscription)");
|
|
778
|
+
console.log(" Install Codex CLI: npm install -g @openai/codex");
|
|
779
|
+
console.log(" Or set ANTHROPIC_API_KEY / OPENAI_API_KEY in your shell");
|
|
780
|
+
}
|
|
781
|
+
console.log("");
|
|
344
782
|
console.log(" Ctrl+C to stop");
|
|
345
783
|
console.log("");
|
|
346
784
|
});
|