storyforge 0.2.0 → 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 +401 -14
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -5,7 +5,8 @@ import * as fs2 from "fs";
|
|
|
5
5
|
import * as os2 from "os";
|
|
6
6
|
import * as path2 from "path";
|
|
7
7
|
import * as http from "http";
|
|
8
|
-
import { execFile } from "child_process";
|
|
8
|
+
import { execFile, exec as execCb, execSync } from "child_process";
|
|
9
|
+
import { promisify } from "util";
|
|
9
10
|
|
|
10
11
|
// src/utils/log.ts
|
|
11
12
|
var supportsColor = process.stdout.isTTY && process.env.TERM !== "dumb" && !process.env.NO_COLOR;
|
|
@@ -42,6 +43,7 @@ function loadCredentials() {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
// src/commands/dev.ts
|
|
46
|
+
var exec = promisify(execCb);
|
|
45
47
|
var PORT = 4444;
|
|
46
48
|
var WEB_URL = "https://forge.algo-thinker.com";
|
|
47
49
|
function getApiConfig() {
|
|
@@ -184,6 +186,54 @@ async function findOrCreateProject(dir) {
|
|
|
184
186
|
log.success(`Saved ${metaPath(dir)}`);
|
|
185
187
|
return meta;
|
|
186
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
|
+
}
|
|
187
237
|
function getMime(file) {
|
|
188
238
|
const ext = path2.extname(file).toLowerCase();
|
|
189
239
|
const types = {
|
|
@@ -219,6 +269,9 @@ function collectFiles(dir, base = "") {
|
|
|
219
269
|
}
|
|
220
270
|
return result;
|
|
221
271
|
}
|
|
272
|
+
function stripFences(text) {
|
|
273
|
+
return text.replace(/^```(?:tsx?|jsx?|typescript|javascript)?\n?/m, "").replace(/\n?```\s*$/m, "").trim();
|
|
274
|
+
}
|
|
222
275
|
function openBrowser(url) {
|
|
223
276
|
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
224
277
|
execFile(cmd, [url], (err) => {
|
|
@@ -273,9 +326,15 @@ async function devCommand(options) {
|
|
|
273
326
|
return;
|
|
274
327
|
}
|
|
275
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()));
|
|
276
335
|
const assets = {
|
|
277
336
|
audio: collectFiles(path2.join(dir, "audio"), "audio"),
|
|
278
|
-
imagesH:
|
|
337
|
+
imagesH: [...legacyH, ...geminiScenes],
|
|
279
338
|
imagesV: collectFiles(path2.join(dir, "images-vertical"), "images-vertical"),
|
|
280
339
|
clips: collectFiles(path2.join(dir, "clips"), "clips"),
|
|
281
340
|
scripts: collectFiles(path2.join(dir, "scripts"), "scripts"),
|
|
@@ -289,30 +348,37 @@ async function devCommand(options) {
|
|
|
289
348
|
if (pathname === "/api/compositions") {
|
|
290
349
|
const compositionsDir = path2.join(os2.homedir(), ".forge", "compositions", meta.projectId);
|
|
291
350
|
fs2.mkdirSync(compositionsDir, { recursive: true });
|
|
292
|
-
const
|
|
293
|
-
const
|
|
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);
|
|
294
354
|
res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
|
|
295
|
-
res.end(JSON.stringify(
|
|
355
|
+
res.end(JSON.stringify([...saved, ...project]));
|
|
296
356
|
return;
|
|
297
357
|
}
|
|
298
358
|
const compGetMatch = pathname.match(/^\/api\/compositions\/([^/]+)$/);
|
|
299
359
|
if (req.method === "GET" && compGetMatch) {
|
|
300
|
-
const name = compGetMatch[1].replace(/[^a-zA-Z0-9_-]/g, "");
|
|
360
|
+
const name = decodeURIComponent(compGetMatch[1]).replace(/[^a-zA-Z0-9_-]/g, "");
|
|
301
361
|
if (!name) {
|
|
302
362
|
res.writeHead(400, CORS_HEADERS);
|
|
303
363
|
res.end("Invalid name");
|
|
304
364
|
return;
|
|
305
365
|
}
|
|
306
366
|
const compositionsDir = path2.join(os2.homedir(), ".forge", "compositions", meta.projectId);
|
|
307
|
-
const
|
|
308
|
-
if (
|
|
309
|
-
res.writeHead(
|
|
310
|
-
res.end(
|
|
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"));
|
|
311
378
|
return;
|
|
312
379
|
}
|
|
313
|
-
|
|
314
|
-
res.
|
|
315
|
-
res.end(content);
|
|
380
|
+
res.writeHead(404, CORS_HEADERS);
|
|
381
|
+
res.end("Not found");
|
|
316
382
|
return;
|
|
317
383
|
}
|
|
318
384
|
const compPostMatch = pathname.match(/^\/api\/compositions\/([^/]+)$/);
|
|
@@ -334,6 +400,241 @@ async function devCommand(options) {
|
|
|
334
400
|
res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
|
|
335
401
|
res.end(JSON.stringify({ ok: true, path: filePath }));
|
|
336
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);
|
|
337
638
|
return;
|
|
338
639
|
}
|
|
339
640
|
const fileMatch = pathname.match(/^\/api\/file\/(.+)$/);
|
|
@@ -375,11 +676,73 @@ async function devCommand(options) {
|
|
|
375
676
|
fs2.createReadStream(fullPath).pipe(res);
|
|
376
677
|
return;
|
|
377
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
|
+
}
|
|
378
741
|
res.writeHead(404, CORS_HEADERS);
|
|
379
742
|
res.end(JSON.stringify({ error: "Not found" }));
|
|
380
743
|
});
|
|
381
744
|
server.listen(port, () => {
|
|
382
|
-
const webUrl = `${WEB_URL}/forge/p/${meta.projectId}/
|
|
745
|
+
const webUrl = `${WEB_URL}/forge/p/${meta.projectId}/pipeline`;
|
|
383
746
|
console.log("");
|
|
384
747
|
log.success("Running");
|
|
385
748
|
console.log("");
|
|
@@ -392,6 +755,30 @@ async function devCommand(options) {
|
|
|
392
755
|
log.info("Opening browser...");
|
|
393
756
|
openBrowser(webUrl);
|
|
394
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("");
|
|
395
782
|
console.log(" Ctrl+C to stop");
|
|
396
783
|
console.log("");
|
|
397
784
|
});
|