storyforge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +78 -0
  2. package/dist/index.js +455 -0
  3. package/package.json +27 -0
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # StoryForge
2
+
3
+ Local bridge for the [Forge](https://forge.algo-thinker.com) video production web app.
4
+
5
+ **Zero runtime dependencies.** Single-file bundled output, no transitive installs.
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ cd your-video-project
11
+ npx storyforge
12
+ ```
13
+
14
+ That's it. `storyforge` auto-detects the folder, links it to a project in your dashboard, starts a local server, and opens your browser.
15
+
16
+ ## What it does
17
+
18
+ 1. **Auto-detects** the current folder as a project (name from folder name)
19
+ 2. **Links** to an existing project in the dashboard, or creates a new one
20
+ 3. **Saves** `forge.json` in the folder so next time it's instant
21
+ 4. **Starts** a local HTTP server at `http://localhost:4444`
22
+ 5. **Opens** `https://forge.algo-thinker.com/forge/p/{id}/assets` in your browser
23
+
24
+ The web UI then reads local files directly from your disk — no upload/download cycle needed for audio, images, or rendered clips.
25
+
26
+ ## Folder structure
27
+
28
+ StoryForge expects any of these subfolders (all optional):
29
+
30
+ ```
31
+ your-project/
32
+ ├── audio/ # Narration audio files
33
+ ├── images-horizontal/ # 16:9 images
34
+ ├── images-vertical/ # 9:16 images
35
+ ├── clips/ # Rendered video clips
36
+ ├── scripts/ # Narration text files
37
+ ├── gemini-prompts/ # AI image prompts
38
+ ├── word-timings/ # Word-level timestamp JSON
39
+ └── brand/ # Brand assets (logos, intros, etc.)
40
+ ```
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ # One-time use (recommended)
46
+ npx storyforge
47
+
48
+ # Global install
49
+ npm install -g storyforge
50
+ storyforge
51
+ ```
52
+
53
+ ## Commands
54
+
55
+ ```
56
+ storyforge Start dev server + open browser (default)
57
+ storyforge login Log in to the Forge API
58
+ storyforge --help Show help
59
+ storyforge --version Show version
60
+ ```
61
+
62
+ ## Options
63
+
64
+ ```
65
+ --port <port> HTTP server port (default: 4444)
66
+ --dir <dir> Project directory (default: current)
67
+ --no-open Do not open browser automatically
68
+ ```
69
+
70
+ ## Security
71
+
72
+ - **Zero runtime dependencies** — the entire CLI is one bundled 14KB file
73
+ - **No telemetry** — makes outbound calls only to the Forge API you configure
74
+ - **Local-first** — your files never leave your disk unless you explicitly push them
75
+
76
+ ## License
77
+
78
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,455 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/commands/dev.ts
4
+ import * as fs2 from "fs";
5
+ import * as path2 from "path";
6
+ import * as http from "http";
7
+ import { execFile } from "child_process";
8
+
9
+ // src/utils/log.ts
10
+ var supportsColor = process.stdout.isTTY && process.env.TERM !== "dumb" && !process.env.NO_COLOR;
11
+ 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: "" };
12
+ var log = {
13
+ info: (msg) => console.log(c.blue + "i" + c.reset, msg),
14
+ success: (msg) => console.log(c.green + "\u2713" + c.reset, msg),
15
+ warn: (msg) => console.log(c.yellow + "!" + c.reset, msg),
16
+ error: (msg) => console.error(c.red + "\u2717" + c.reset, msg),
17
+ step: (n, total, msg) => console.log(c.gray + `[${n}/${total}]` + c.reset, msg),
18
+ banner: (msg) => console.log(c.bold + msg + c.reset)
19
+ };
20
+
21
+ // src/config.ts
22
+ import * as fs from "fs";
23
+ import * as path from "path";
24
+ import * as os from "os";
25
+ var FORGE_DIR = path.join(os.homedir(), ".forge");
26
+ var CREDS_PATH = path.join(FORGE_DIR, "credentials.json");
27
+ function getForgeDir() {
28
+ fs.mkdirSync(FORGE_DIR, { recursive: true });
29
+ return FORGE_DIR;
30
+ }
31
+ function saveCredentials(creds) {
32
+ getForgeDir();
33
+ fs.writeFileSync(CREDS_PATH, JSON.stringify(creds, null, 2));
34
+ }
35
+ function loadCredentials() {
36
+ try {
37
+ return JSON.parse(fs.readFileSync(CREDS_PATH, "utf-8"));
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ // src/commands/dev.ts
44
+ var PORT = 4444;
45
+ var WEB_URL = "https://forge.algo-thinker.com";
46
+ function getApiConfig() {
47
+ const creds = loadCredentials();
48
+ return {
49
+ url: process.env.NEXT_PUBLIC_SUPABASE_URL || creds?.apiUrl || "https://5ermrkmy.us-west.insforge.app",
50
+ key: process.env.INSFORGE_API_KEY || creds?.accessToken || creds?.anonKey || ""
51
+ };
52
+ }
53
+ var DEFAULT_USER_ID = "00000000-0000-0000-0000-000000000001";
54
+ var CORS_HEADERS = {
55
+ "Access-Control-Allow-Origin": "*",
56
+ "Access-Control-Allow-Methods": "GET, POST, PATCH, OPTIONS",
57
+ "Access-Control-Allow-Headers": "Content-Type"
58
+ };
59
+ function metaPath(dir) {
60
+ return path2.join(dir, "forge.json");
61
+ }
62
+ function loadMeta(dir) {
63
+ try {
64
+ return JSON.parse(fs2.readFileSync(metaPath(dir), "utf-8"));
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+ function saveMeta(dir, meta) {
70
+ fs2.writeFileSync(metaPath(dir), JSON.stringify(meta, null, 2));
71
+ }
72
+ function detectFolderName(dir) {
73
+ return path2.basename(path2.resolve(dir)).replace(/[-_]/g, " ").replace(/\b\w/g, (c2) => c2.toUpperCase());
74
+ }
75
+ function slugify(text) {
76
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
77
+ }
78
+ function scanAssets(dir) {
79
+ const subdirs = [
80
+ "audio",
81
+ "images-horizontal",
82
+ "images-vertical",
83
+ "clips",
84
+ "scripts",
85
+ "gemini-prompts",
86
+ "word-timings",
87
+ "brand"
88
+ ];
89
+ const found = {};
90
+ for (const sub of subdirs) {
91
+ const p = path2.join(dir, sub);
92
+ if (fs2.existsSync(p)) {
93
+ const entries = fs2.readdirSync(p, { withFileTypes: true });
94
+ found[sub] = entries.filter((e) => !e.name.startsWith(".") && !e.isDirectory()).length;
95
+ }
96
+ }
97
+ return found;
98
+ }
99
+ async function dbQuery(method, apiPath, body) {
100
+ const { url, key } = getApiConfig();
101
+ if (!key) {
102
+ throw new Error("Not authenticated. Run: forge login");
103
+ }
104
+ const resp = await fetch(`${url}${apiPath}`, {
105
+ method,
106
+ headers: {
107
+ "Authorization": `Bearer ${key}`,
108
+ "Content-Type": "application/json",
109
+ "Prefer": "return=representation"
110
+ },
111
+ body: body ? JSON.stringify(body) : void 0
112
+ });
113
+ if (!resp.ok) {
114
+ const text = await resp.text();
115
+ throw new Error(`API ${method} ${apiPath} failed (${resp.status}): ${text}`);
116
+ }
117
+ if (resp.status === 204) return null;
118
+ return resp.json();
119
+ }
120
+ async function findOrCreateProject(dir) {
121
+ const existing = loadMeta(dir);
122
+ if (existing) {
123
+ try {
124
+ const projects = await dbQuery("GET", `/api/database/records/projects?id=eq.${existing.projectId}`);
125
+ if (projects && projects.length > 0) return existing;
126
+ } catch {
127
+ }
128
+ log.warn("Previous link is stale. Creating fresh link...");
129
+ }
130
+ const folderName = detectFolderName(dir);
131
+ const slug = slugify(folderName);
132
+ log.info(`Auto-detected project: "${folderName}"`);
133
+ let channel;
134
+ const channels = await dbQuery("GET", "/api/database/records/channels?select=id,name,slug&limit=1");
135
+ if (channels && channels.length > 0) {
136
+ channel = channels[0];
137
+ } else {
138
+ const created = await dbQuery("POST", "/api/database/records/channels", [{
139
+ user_id: DEFAULT_USER_ID,
140
+ name: "My Channel",
141
+ slug: "my-channel",
142
+ style_preset: "custom",
143
+ brand_config: {
144
+ colors: { primary: "#000000", accent: "#3b82f6", bg: "#ffffff" },
145
+ fonts: { title: "Inter", body: "Inter", mono: "JetBrains Mono", karaoke: "Inter" },
146
+ social: {}
147
+ }
148
+ }]);
149
+ channel = created[0];
150
+ log.success(`Created channel: ${channel.name}`);
151
+ }
152
+ let project;
153
+ const existingProjects = await dbQuery(
154
+ "GET",
155
+ `/api/database/records/projects?channel_id=eq.${channel.id}&slug=eq.${slug}&limit=1`
156
+ );
157
+ if (existingProjects && existingProjects.length > 0) {
158
+ project = existingProjects[0];
159
+ log.info(`Linked to existing project: ${project.title}`);
160
+ } else {
161
+ const created = await dbQuery("POST", "/api/database/records/projects", [{
162
+ channel_id: channel.id,
163
+ title: folderName,
164
+ slug,
165
+ style: "custom",
166
+ status: "draft",
167
+ aspect_ratios: ["16:9", "9:16"]
168
+ }]);
169
+ project = created[0];
170
+ log.success(`Created project: ${project.title}`);
171
+ }
172
+ const meta = {
173
+ projectId: project.id,
174
+ channelId: channel.id,
175
+ title: project.title,
176
+ channelName: channel.name,
177
+ channelSlug: channel.slug,
178
+ linkedAt: (/* @__PURE__ */ new Date()).toISOString()
179
+ };
180
+ saveMeta(dir, meta);
181
+ log.success(`Saved ${metaPath(dir)}`);
182
+ return meta;
183
+ }
184
+ function getMime(file) {
185
+ const ext = path2.extname(file).toLowerCase();
186
+ const types = {
187
+ ".png": "image/png",
188
+ ".jpg": "image/jpeg",
189
+ ".jpeg": "image/jpeg",
190
+ ".webp": "image/webp",
191
+ ".mp3": "audio/mpeg",
192
+ ".wav": "audio/wav",
193
+ ".m4a": "audio/mp4",
194
+ ".mp4": "video/mp4",
195
+ ".mov": "video/quicktime",
196
+ ".webm": "video/webm",
197
+ ".json": "application/json",
198
+ ".md": "text/markdown",
199
+ ".txt": "text/plain"
200
+ };
201
+ return types[ext] || "application/octet-stream";
202
+ }
203
+ function collectFiles(dir, base = "") {
204
+ if (!fs2.existsSync(dir)) return [];
205
+ const result = [];
206
+ for (const entry of fs2.readdirSync(dir, { withFileTypes: true })) {
207
+ if (entry.name.startsWith(".")) continue;
208
+ const full = path2.join(dir, entry.name);
209
+ const rel = base ? `${base}/${entry.name}` : entry.name;
210
+ if (entry.isDirectory()) {
211
+ result.push(...collectFiles(full, rel));
212
+ } else {
213
+ const stat = fs2.statSync(full);
214
+ result.push({ path: rel, name: entry.name, size: stat.size, type: getMime(full) });
215
+ }
216
+ }
217
+ return result;
218
+ }
219
+ function openBrowser(url) {
220
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
221
+ execFile(cmd, [url], (err) => {
222
+ if (err) log.warn(`Could not open browser automatically. Open manually: ${url}`);
223
+ });
224
+ }
225
+ async function devCommand(options) {
226
+ const port = parseInt(options.port || String(PORT), 10);
227
+ const dir = path2.resolve(options.dir || process.cwd());
228
+ console.log("");
229
+ log.info(`Forge \u2014 ${dir}`);
230
+ const counts = scanAssets(dir);
231
+ const total = Object.values(counts).reduce((a, b) => a + b, 0);
232
+ if (total > 0) {
233
+ console.log("");
234
+ console.log(" Detected assets:");
235
+ for (const [name, count] of Object.entries(counts)) {
236
+ if (count > 0) console.log(` ${name.padEnd(20)} ${count} files`);
237
+ }
238
+ console.log("");
239
+ }
240
+ let meta;
241
+ try {
242
+ meta = await findOrCreateProject(dir);
243
+ } catch (err) {
244
+ log.error(`Failed: ${err.message}`);
245
+ process.exit(1);
246
+ }
247
+ const server = http.createServer(async (req, res) => {
248
+ if (req.method === "OPTIONS") {
249
+ res.writeHead(204, CORS_HEADERS);
250
+ res.end();
251
+ return;
252
+ }
253
+ const url = new URL(req.url || "/", `http://localhost:${port}`);
254
+ const pathname = url.pathname;
255
+ if (pathname === "/api/health") {
256
+ res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
257
+ res.end(JSON.stringify({
258
+ status: "ok",
259
+ projectId: meta.projectId,
260
+ channelId: meta.channelId,
261
+ title: meta.title,
262
+ channelSlug: meta.channelSlug,
263
+ dir
264
+ }));
265
+ return;
266
+ }
267
+ if (pathname === "/api/project") {
268
+ res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
269
+ res.end(JSON.stringify({ ...meta, dir }));
270
+ return;
271
+ }
272
+ if (pathname === "/api/assets") {
273
+ const assets = {
274
+ audio: collectFiles(path2.join(dir, "audio")),
275
+ imagesH: collectFiles(path2.join(dir, "images-horizontal")),
276
+ imagesV: collectFiles(path2.join(dir, "images-vertical")),
277
+ clips: collectFiles(path2.join(dir, "clips")),
278
+ scripts: collectFiles(path2.join(dir, "scripts")),
279
+ prompts: collectFiles(path2.join(dir, "gemini-prompts")),
280
+ brand: collectFiles(path2.join(dir, "brand"))
281
+ };
282
+ res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
283
+ res.end(JSON.stringify(assets));
284
+ return;
285
+ }
286
+ const fileMatch = pathname.match(/^\/api\/file\/(.+)$/);
287
+ if (fileMatch) {
288
+ const filePath = decodeURIComponent(fileMatch[1]);
289
+ const fullPath = path2.join(dir, filePath);
290
+ if (!fullPath.startsWith(dir)) {
291
+ res.writeHead(403, CORS_HEADERS);
292
+ res.end("Forbidden");
293
+ return;
294
+ }
295
+ if (!fs2.existsSync(fullPath)) {
296
+ res.writeHead(404, CORS_HEADERS);
297
+ res.end("Not found");
298
+ return;
299
+ }
300
+ const stat = fs2.statSync(fullPath);
301
+ const mime = getMime(fullPath);
302
+ const range = req.headers.range;
303
+ if (range && (mime.startsWith("audio/") || mime.startsWith("video/"))) {
304
+ const parts = range.replace(/bytes=/, "").split("-");
305
+ const start = parseInt(parts[0], 10);
306
+ const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
307
+ res.writeHead(206, {
308
+ ...CORS_HEADERS,
309
+ "Content-Range": `bytes ${start}-${end}/${stat.size}`,
310
+ "Accept-Ranges": "bytes",
311
+ "Content-Length": end - start + 1,
312
+ "Content-Type": mime
313
+ });
314
+ fs2.createReadStream(fullPath, { start, end }).pipe(res);
315
+ return;
316
+ }
317
+ res.writeHead(200, {
318
+ ...CORS_HEADERS,
319
+ "Content-Type": mime,
320
+ "Content-Length": stat.size
321
+ });
322
+ fs2.createReadStream(fullPath).pipe(res);
323
+ return;
324
+ }
325
+ res.writeHead(404, CORS_HEADERS);
326
+ res.end(JSON.stringify({ error: "Not found" }));
327
+ });
328
+ server.listen(port, () => {
329
+ const webUrl = `${WEB_URL}/forge/p/${meta.projectId}/assets`;
330
+ console.log("");
331
+ log.success("Running");
332
+ console.log("");
333
+ console.log(` Project ${meta.title}`);
334
+ console.log(` Channel ${meta.channelName}`);
335
+ console.log(` Server http://localhost:${port}`);
336
+ console.log(` Dashboard ${webUrl}`);
337
+ console.log("");
338
+ if (!options.noOpen) {
339
+ log.info("Opening browser...");
340
+ openBrowser(webUrl);
341
+ }
342
+ console.log(" Ctrl+C to stop");
343
+ console.log("");
344
+ });
345
+ }
346
+
347
+ // src/commands/login.ts
348
+ import * as readline from "readline";
349
+ function prompt(question) {
350
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
351
+ return new Promise((resolve2) => {
352
+ rl.question(question, (answer) => {
353
+ rl.close();
354
+ resolve2(answer.trim());
355
+ });
356
+ });
357
+ }
358
+ async function loginCommand() {
359
+ log.banner("\nStoryForge \u2014 Login\n");
360
+ const apiUrl = await prompt("API URL (enter for default): ") || "https://5ermrkmy.us-west.insforge.app";
361
+ const anonKey = await prompt("Anon/API key: ");
362
+ const email = await prompt("Email: ");
363
+ const password = await prompt("Password: ");
364
+ try {
365
+ const resp = await fetch(`${apiUrl}/api/auth/sessions`, {
366
+ method: "POST",
367
+ headers: { "Content-Type": "application/json" },
368
+ body: JSON.stringify({ email, password })
369
+ });
370
+ if (!resp.ok) {
371
+ const errText = await resp.text();
372
+ log.error(`Login failed (${resp.status}): ${errText}`);
373
+ process.exit(1);
374
+ }
375
+ const data = await resp.json();
376
+ saveCredentials({
377
+ apiUrl,
378
+ anonKey,
379
+ accessToken: data.accessToken || anonKey,
380
+ refreshToken: data.refreshToken
381
+ });
382
+ log.success(`Logged in as ${email}`);
383
+ log.info("Credentials saved to ~/.forge/credentials.json");
384
+ } catch (err) {
385
+ log.error(`Login failed: ${err.message}`);
386
+ process.exit(1);
387
+ }
388
+ }
389
+
390
+ // src/index.ts
391
+ var VERSION = "0.1.0";
392
+ var HELP = `
393
+ storyforge \u2014 local bridge for the Forge video production web app
394
+
395
+ Usage:
396
+ storyforge Auto-link current folder, start server, open browser (default)
397
+ storyforge [options]
398
+ storyforge login Log in
399
+ storyforge --help Show this help
400
+ storyforge --version Show version
401
+
402
+ Options:
403
+ --port <port> Port number (default: 4444)
404
+ --dir <dir> Project directory (default: current)
405
+ --no-open Do not open browser automatically
406
+
407
+ In any folder with assets (audio/, images-horizontal/, images-vertical/, scripts/),
408
+ just run: storyforge
409
+ `.trim();
410
+ function parseArgs(argv) {
411
+ const opts = {};
412
+ for (let i = 0; i < argv.length; i++) {
413
+ const arg = argv[i];
414
+ if (!arg.startsWith("--")) continue;
415
+ const key = arg.slice(2);
416
+ if (key === "no-open") {
417
+ opts.open = false;
418
+ continue;
419
+ }
420
+ const next = argv[i + 1];
421
+ if (next && !next.startsWith("--")) {
422
+ opts[key] = next;
423
+ i++;
424
+ } else {
425
+ opts[key] = true;
426
+ }
427
+ }
428
+ return opts;
429
+ }
430
+ async function main() {
431
+ const args = process.argv.slice(2);
432
+ if (args.includes("--help") || args.includes("-h")) {
433
+ console.log(HELP);
434
+ process.exit(0);
435
+ }
436
+ if (args.includes("--version") || args.includes("-v")) {
437
+ console.log(VERSION);
438
+ process.exit(0);
439
+ }
440
+ const firstArg = args[0];
441
+ if (firstArg === "login") {
442
+ await loginCommand();
443
+ return;
444
+ }
445
+ const opts = parseArgs(args);
446
+ await devCommand({
447
+ port: typeof opts.port === "string" ? opts.port : void 0,
448
+ dir: typeof opts.dir === "string" ? opts.dir : void 0,
449
+ noOpen: opts.open === false
450
+ });
451
+ }
452
+ main().catch((err) => {
453
+ console.error("\x1B[31m\u2717\x1B[0m", err?.message ?? err);
454
+ process.exit(1);
455
+ });
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "storyforge",
3
+ "version": "0.1.0",
4
+ "description": "StoryForge — local bridge for the Forge video production web app. Zero runtime dependencies.",
5
+ "type": "module",
6
+ "bin": { "storyforge": "./dist/index.js" },
7
+ "files": ["dist/", "README.md"],
8
+ "scripts": {
9
+ "build": "tsup",
10
+ "lint": "tsc --noEmit",
11
+ "prepublishOnly": "npm run build"
12
+ },
13
+ "dependencies": {},
14
+ "devDependencies": {
15
+ "tsup": "8.5.0",
16
+ "typescript": "5.8.3"
17
+ },
18
+ "publishConfig": { "access": "public" },
19
+ "keywords": ["video", "remotion", "storyforge", "cli", "forge"],
20
+ "license": "MIT",
21
+ "engines": { "node": ">=18.0.0" },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/backspacevenkat/forge.git",
25
+ "directory": "packages/cli"
26
+ }
27
+ }