storyforge 0.4.6 → 0.4.8

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.
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ log
4
+ } from "./chunk-GJQ45C5W.js";
5
+
6
+ // src/bridge-poller.ts
7
+ import { spawn, spawnSync } from "child_process";
8
+ import * as crypto from "crypto";
9
+ var HEARTBEAT_INTERVAL_MS = 3e4;
10
+ var POLL_INTERVAL_MS = 3e3;
11
+ var CLI_TIMEOUT_MS = 5 * 60 * 1e3;
12
+ var BridgePoller = class {
13
+ baseUrl;
14
+ token;
15
+ clientVersion;
16
+ instanceId;
17
+ heartbeatTimer = null;
18
+ pollTimer = null;
19
+ inFlight = /* @__PURE__ */ new Set();
20
+ stopped = false;
21
+ constructor(opts) {
22
+ this.baseUrl = opts.baseUrl.replace(/\/$/, "");
23
+ this.token = opts.token;
24
+ this.clientVersion = opts.clientVersion;
25
+ this.instanceId = `${process.pid}-${crypto.randomBytes(2).toString("hex")}-${Date.now()}`;
26
+ }
27
+ start() {
28
+ if (this.heartbeatTimer || this.pollTimer) return;
29
+ log.info(`[bridge] poller starting \xB7 ${this.baseUrl} \xB7 instance=${this.instanceId}`);
30
+ void this.heartbeat();
31
+ this.heartbeatTimer = setInterval(() => {
32
+ void this.heartbeat();
33
+ }, HEARTBEAT_INTERVAL_MS);
34
+ this.pollTimer = setInterval(() => {
35
+ void this.poll();
36
+ }, POLL_INTERVAL_MS);
37
+ }
38
+ async stop() {
39
+ this.stopped = true;
40
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
41
+ if (this.pollTimer) clearInterval(this.pollTimer);
42
+ this.heartbeatTimer = null;
43
+ this.pollTimer = null;
44
+ }
45
+ probeBinary(name) {
46
+ if (!/^[a-z][a-z0-9-]*$/i.test(name)) return { available: false, path: null };
47
+ const r = spawnSync("which", [name], { encoding: "utf-8", timeout: 2e3 });
48
+ if (r.status !== 0) return { available: false, path: null };
49
+ const path = (r.stdout ?? "").trim();
50
+ return { available: !!path, path: path || null };
51
+ }
52
+ async heartbeat() {
53
+ if (this.stopped) return;
54
+ try {
55
+ const resp = await fetch(`${this.baseUrl}/api/cli-bridge/heartbeat`, {
56
+ method: "POST",
57
+ headers: {
58
+ Authorization: `Bearer ${this.token}`,
59
+ "Content-Type": "application/json"
60
+ },
61
+ body: JSON.stringify({
62
+ availableClis: {
63
+ claude: this.probeBinary("claude"),
64
+ codex: this.probeBinary("codex")
65
+ },
66
+ clientVersion: this.clientVersion,
67
+ instanceId: this.instanceId
68
+ }),
69
+ signal: AbortSignal.timeout(1e4)
70
+ });
71
+ if (!resp.ok) {
72
+ const text = await resp.text().catch(() => "");
73
+ log.warn(`[bridge] heartbeat HTTP ${resp.status}: ${text.slice(0, 120)}`);
74
+ }
75
+ } catch (err) {
76
+ log.warn(`[bridge] heartbeat failed: ${err.message}`);
77
+ }
78
+ }
79
+ async poll() {
80
+ if (this.stopped) return;
81
+ let jobs = [];
82
+ try {
83
+ const resp = await fetch(`${this.baseUrl}/api/cli-bridge/claim`, {
84
+ method: "POST",
85
+ headers: {
86
+ Authorization: `Bearer ${this.token}`,
87
+ "Content-Type": "application/json"
88
+ },
89
+ body: JSON.stringify({ instanceId: this.instanceId, limit: 3 }),
90
+ signal: AbortSignal.timeout(1e4)
91
+ });
92
+ if (!resp.ok) {
93
+ if (resp.status === 401) {
94
+ log.warn("[bridge] 401 \u2014 bridge token rejected. Stopping poller. Set BRIDGE_TOKEN to the value Vercel has and restart.");
95
+ await this.stop();
96
+ return;
97
+ }
98
+ const text = await resp.text().catch(() => "");
99
+ log.warn(`[bridge] claim HTTP ${resp.status}: ${text.slice(0, 120)}`);
100
+ return;
101
+ }
102
+ const data = await resp.json();
103
+ jobs = data.jobs ?? [];
104
+ } catch (err) {
105
+ log.warn(`[bridge] claim failed: ${err.message}`);
106
+ return;
107
+ }
108
+ if (jobs.length === 0) return;
109
+ await Promise.all(jobs.map((job) => this.runJob(job)));
110
+ }
111
+ async runJob(job) {
112
+ if (this.inFlight.has(job.id)) return;
113
+ this.inFlight.add(job.id);
114
+ log.info(`[bridge] running job ${job.id.slice(0, 8)} \xB7 ${job.cli}${job.model ? `:${job.model}` : ""}`);
115
+ const startedAt = Date.now();
116
+ try {
117
+ const { text, modelUsed, error } = await this.invokeCli(job.cli, job.model, job.prompt);
118
+ const latencyMs = Date.now() - startedAt;
119
+ await this.respond(job.id, { text, modelUsed, latencyMs, error });
120
+ if (error) {
121
+ log.warn(`[bridge] job ${job.id.slice(0, 8)} failed (${latencyMs}ms): ${error.slice(0, 120)}`);
122
+ } else {
123
+ log.success(`[bridge] job ${job.id.slice(0, 8)} done (${latencyMs}ms \xB7 ${modelUsed})`);
124
+ }
125
+ } catch (err) {
126
+ const latencyMs = Date.now() - startedAt;
127
+ await this.respond(job.id, { error: err.message, latencyMs });
128
+ } finally {
129
+ this.inFlight.delete(job.id);
130
+ }
131
+ }
132
+ async invokeCli(cli, model, prompt) {
133
+ const candidates = cli === "codex" ? model && /^[a-z0-9.\-]+$/i.test(model) ? [model] : ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"] : model && /^[a-z0-9-]+$/i.test(model) ? [model] : ["opus", "sonnet"];
134
+ let lastErr = "";
135
+ for (const m of candidates) {
136
+ try {
137
+ const args = cli === "codex" ? ["exec", "-", "--model", m] : ["-p", "--model", m, "--no-session-persistence"];
138
+ const text = await runSpawn(cli, args, prompt, CLI_TIMEOUT_MS);
139
+ if (text.trim()) return { text, modelUsed: `${cli}:${m}` };
140
+ lastErr = `${cli}/${m} returned empty stdout`;
141
+ } catch (err) {
142
+ lastErr = `${cli}/${m}: ${err.message}`;
143
+ }
144
+ }
145
+ return { error: lastErr || "no model produced output" };
146
+ }
147
+ async respond(jobId, body) {
148
+ try {
149
+ const resp = await fetch(`${this.baseUrl}/api/cli-bridge/respond`, {
150
+ method: "POST",
151
+ headers: {
152
+ Authorization: `Bearer ${this.token}`,
153
+ "Content-Type": "application/json"
154
+ },
155
+ body: JSON.stringify({ jobId, ...body }),
156
+ signal: AbortSignal.timeout(15e3)
157
+ });
158
+ if (!resp.ok) {
159
+ const text = await resp.text().catch(() => "");
160
+ log.warn(`[bridge] respond HTTP ${resp.status}: ${text.slice(0, 120)}`);
161
+ }
162
+ } catch (err) {
163
+ log.warn(`[bridge] respond failed for ${jobId.slice(0, 8)}: ${err.message}`);
164
+ }
165
+ }
166
+ };
167
+ function runSpawn(cmd, args, stdin, timeoutMs) {
168
+ return new Promise((resolve, reject) => {
169
+ const proc = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
170
+ let stdout = "";
171
+ let stderr = "";
172
+ const timer = setTimeout(() => {
173
+ try {
174
+ proc.kill("SIGKILL");
175
+ } catch {
176
+ }
177
+ reject(new Error(`${cmd} timed out after ${timeoutMs}ms`));
178
+ }, timeoutMs);
179
+ proc.stdout.on("data", (d) => {
180
+ stdout += d.toString("utf-8");
181
+ });
182
+ proc.stderr.on("data", (d) => {
183
+ stderr += d.toString("utf-8");
184
+ });
185
+ proc.on("error", (err) => {
186
+ clearTimeout(timer);
187
+ reject(err);
188
+ });
189
+ proc.on("close", (code) => {
190
+ clearTimeout(timer);
191
+ if (code !== 0) reject(new Error(`${cmd} exit ${code}: ${stderr.slice(-300)}`));
192
+ else resolve(stdout);
193
+ });
194
+ proc.stdin.end(stdin);
195
+ });
196
+ }
197
+ export {
198
+ BridgePoller
199
+ };
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/utils/log.ts
4
+ var supportsColor = process.stdout.isTTY && process.env.TERM !== "dumb" && !process.env.NO_COLOR;
5
+ var c = supportsColor ? { blue: "\x1B[34m", green: "\x1B[32m", yellow: "\x1B[33m", red: "\x1B[31m", gray: "\x1B[90m", bold: "\x1B[1m", reset: "\x1B[0m" } : { blue: "", green: "", yellow: "", red: "", gray: "", bold: "", reset: "" };
6
+ var log = {
7
+ info: (msg) => console.log(c.blue + "i" + c.reset, msg),
8
+ success: (msg) => console.log(c.green + "\u2713" + c.reset, msg),
9
+ warn: (msg) => console.log(c.yellow + "!" + c.reset, msg),
10
+ error: (msg) => console.error(c.red + "\u2717" + c.reset, msg),
11
+ step: (n, total, msg) => console.log(c.gray + `[${n}/${total}]` + c.reset, msg),
12
+ banner: (msg) => console.log(c.bold + msg + c.reset)
13
+ };
14
+
15
+ export {
16
+ log
17
+ };
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ log
4
+ } from "./chunk-GJQ45C5W.js";
2
5
 
3
6
  // src/commands/dev.ts
4
7
  import * as fs2 from "fs";
@@ -8,18 +11,6 @@ import * as http from "http";
8
11
  import { execFile, exec as execCb, execSync, spawn } from "child_process";
9
12
  import { promisify } from "util";
10
13
 
11
- // src/utils/log.ts
12
- var supportsColor = process.stdout.isTTY && process.env.TERM !== "dumb" && !process.env.NO_COLOR;
13
- var c = supportsColor ? { blue: "\x1B[34m", green: "\x1B[32m", yellow: "\x1B[33m", red: "\x1B[31m", gray: "\x1B[90m", bold: "\x1B[1m", reset: "\x1B[0m" } : { blue: "", green: "", yellow: "", red: "", gray: "", bold: "", reset: "" };
14
- var log = {
15
- info: (msg) => console.log(c.blue + "i" + c.reset, msg),
16
- success: (msg) => console.log(c.green + "\u2713" + c.reset, msg),
17
- warn: (msg) => console.log(c.yellow + "!" + c.reset, msg),
18
- error: (msg) => console.error(c.red + "\u2717" + c.reset, msg),
19
- step: (n, total, msg) => console.log(c.gray + `[${n}/${total}]` + c.reset, msg),
20
- banner: (msg) => console.log(c.bold + msg + c.reset)
21
- };
22
-
23
14
  // src/config.ts
24
15
  import * as fs from "fs";
25
16
  import * as path from "path";
@@ -589,7 +580,7 @@ function saveMeta(dir, meta) {
589
580
  fs2.writeFileSync(metaPath(dir), JSON.stringify(meta, null, 2));
590
581
  }
591
582
  function detectFolderName(dir) {
592
- return path2.basename(path2.resolve(dir)).replace(/[-_]/g, " ").replace(/\b\w/g, (c2) => c2.toUpperCase());
583
+ return path2.basename(path2.resolve(dir)).replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
593
584
  }
594
585
  function slugify(text) {
595
586
  return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
@@ -698,8 +689,19 @@ async function findOrCreateProject(dir) {
698
689
  };
699
690
  saveMeta(dir, meta);
700
691
  log.success(`Saved ${metaPath(dir)}`);
692
+ ensureProjectAssetTree(dir, channel.slug, project.slug);
701
693
  return meta;
702
694
  }
695
+ function ensureProjectAssetTree(dir, channelSlug, projectSlug) {
696
+ const base = path2.join(dir, "channels", channelSlug, "projects", projectSlug);
697
+ const kinds = ["images", "audio", "clips", "scripts", "brand", "renders", "shorts", "gemini-scenes"];
698
+ for (const k of kinds) {
699
+ const p = path2.join(base, k);
700
+ fs2.mkdirSync(p, { recursive: true });
701
+ const keep = path2.join(p, ".gitkeep");
702
+ if (!fs2.existsSync(keep)) fs2.writeFileSync(keep, "");
703
+ }
704
+ }
703
705
  function findMonorepoRoot(startDir) {
704
706
  let d = startDir;
705
707
  for (let i = 0; i < 6; i++) {
@@ -912,7 +914,7 @@ async function devCommand(options) {
912
914
  return;
913
915
  }
914
916
  const projectComps = findProjectCompositions(dir);
915
- const found = projectComps.find((c2) => c2.name === name);
917
+ const found = projectComps.find((c) => c.name === name);
916
918
  if (found) {
917
919
  res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "text/plain; charset=utf-8" });
918
920
  res.end(fs2.readFileSync(found.absolutePath, "utf-8"));
@@ -982,7 +984,7 @@ async function devCommand(options) {
982
984
  }
983
985
  if (pathname === "/api/script-gen" && req.method === "POST") {
984
986
  const bodyChunks = [];
985
- req.on("data", (c2) => bodyChunks.push(c2));
987
+ req.on("data", (c) => bodyChunks.push(c));
986
988
  req.on("end", async () => {
987
989
  let body;
988
990
  try {
@@ -1076,7 +1078,7 @@ async function devCommand(options) {
1076
1078
  }));
1077
1079
  return;
1078
1080
  }
1079
- const totalSec = chunks.reduce((s, c2) => s + (c2.durationEstimate ?? 0), 0);
1081
+ const totalSec = chunks.reduce((s, c) => s + (c.durationEstimate ?? 0), 0);
1080
1082
  log.success(`[script-gen] ${chunks.length} chunks \xB7 ~${(totalSec / 60).toFixed(1)} min via ${modelUsed}`);
1081
1083
  res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
1082
1084
  res.end(JSON.stringify({
@@ -1096,7 +1098,7 @@ async function devCommand(options) {
1096
1098
  }
1097
1099
  if (pathname === "/api/ai-edit" && req.method === "POST") {
1098
1100
  const bodyChunks = [];
1099
- req.on("data", (c2) => bodyChunks.push(c2));
1101
+ req.on("data", (c) => bodyChunks.push(c));
1100
1102
  req.on("end", async () => {
1101
1103
  let parsed;
1102
1104
  try {
@@ -1256,7 +1258,7 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
1256
1258
  }
1257
1259
  if (pathname === "/api/chunk-compositions/append-images" && req.method === "POST") {
1258
1260
  const bodyChunks = [];
1259
- req.on("data", (c2) => bodyChunks.push(c2));
1261
+ req.on("data", (c) => bodyChunks.push(c));
1260
1262
  req.on("end", () => {
1261
1263
  try {
1262
1264
  const { assemblyId, paths } = JSON.parse(Buffer.concat(bodyChunks).toString("utf-8"));
@@ -1267,7 +1269,7 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
1267
1269
  return;
1268
1270
  }
1269
1271
  const doc = JSON.parse(fs2.readFileSync(compPath, "utf-8"));
1270
- const scene = doc.scenes?.find((c2) => c2.assemblyId === assemblyId);
1272
+ const scene = doc.scenes?.find((c) => c.assemblyId === assemblyId);
1271
1273
  if (!scene) {
1272
1274
  res.writeHead(404, CORS_HEADERS);
1273
1275
  res.end(JSON.stringify({ error: `scene ${assemblyId} not found` }));
@@ -1421,7 +1423,7 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
1421
1423
  const boundary = boundaryM[1];
1422
1424
  const chunks = [];
1423
1425
  await new Promise((resolve2, reject) => {
1424
- req.on("data", (c2) => chunks.push(Buffer.from(c2)));
1426
+ req.on("data", (c) => chunks.push(Buffer.from(c)));
1425
1427
  req.on("end", resolve2);
1426
1428
  req.on("error", reject);
1427
1429
  });
@@ -1520,6 +1522,29 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
1520
1522
  console.log(` Script generation \u2192 ${scriptGenModel}`);
1521
1523
  console.log(` POST http://localhost:${port}/api/script-gen`);
1522
1524
  }
1525
+ const bridgeUrl = process.env.FORGE_BRIDGE_URL ?? WEB_URL;
1526
+ const bridgeToken = process.env.FORGE_BRIDGE_TOKEN ?? process.env.BRIDGE_TOKEN ?? "";
1527
+ if (bridgeToken) {
1528
+ const pkgVersion = (() => {
1529
+ try {
1530
+ const here = path2.dirname(new URL(import.meta.url).pathname);
1531
+ const pkgPath = path2.resolve(here, "..", "package.json");
1532
+ if (fs2.existsSync(pkgPath)) return JSON.parse(fs2.readFileSync(pkgPath, "utf-8")).version;
1533
+ } catch {
1534
+ }
1535
+ return "0.0.0";
1536
+ })();
1537
+ void (async () => {
1538
+ const { BridgePoller } = await import("./bridge-poller-5FFV2LML.js");
1539
+ const poller = new BridgePoller({ baseUrl: bridgeUrl, token: bridgeToken, clientVersion: `storyforge ${pkgVersion}` });
1540
+ poller.start();
1541
+ })();
1542
+ console.log("");
1543
+ console.log(` Bridge tunnel \u2192 ${bridgeUrl}/api/cli-bridge/* (outbound polling)`);
1544
+ } else {
1545
+ console.log("");
1546
+ console.log(" Bridge tunnel disabled \u2014 set FORGE_BRIDGE_TOKEN to expose your CLI seats to forge.algo-thinker.com");
1547
+ }
1523
1548
  console.log("");
1524
1549
  console.log(" Ctrl+C to stop");
1525
1550
  console.log("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyforge",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "StoryForge — local bridge for the Forge video production web app. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "bin": {