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.
- package/README.md +78 -0
- package/dist/index.js +455 -0
- 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
|
+
}
|