egaki 0.9.0 → 0.11.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/CHANGELOG.md +72 -0
- package/dist/cli/chatgpt-auth.d.ts +30 -5
- package/dist/cli/chatgpt-auth.d.ts.map +1 -1
- package/dist/cli/chatgpt-auth.js +257 -79
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +132 -28
- package/dist/cli/dev-server.d.ts +30 -0
- package/dist/cli/dev-server.d.ts.map +1 -0
- package/dist/cli/dev-server.js +266 -0
- package/dist/cli/dev-server.test.d.ts +2 -0
- package/dist/cli/dev-server.test.d.ts.map +1 -0
- package/dist/cli/dev-server.test.js +95 -0
- package/dist/cli/generate.d.ts +8 -0
- package/dist/cli/generate.d.ts.map +1 -1
- package/dist/cli/generate.js +170 -58
- package/dist/cli/generate.test.js +50 -1
- package/dist/cli/login.d.ts +38 -3
- package/dist/cli/login.d.ts.map +1 -1
- package/dist/cli/login.js +44 -17
- package/dist/cli/login.test.d.ts +2 -0
- package/dist/cli/login.test.d.ts.map +1 -0
- package/dist/cli/login.test.js +158 -0
- package/dist/cli/open-browser.d.ts +3 -1
- package/dist/cli/open-browser.d.ts.map +1 -1
- package/dist/cli/open-browser.js +2 -2
- package/dist/cli/xai-auth.d.ts +3 -1
- package/dist/cli/xai-auth.d.ts.map +1 -1
- package/dist/cli/xai-auth.js +13 -3
- package/dist/vite/angled-screen-shader.d.ts +5 -0
- package/dist/vite/angled-screen-shader.d.ts.map +1 -1
- package/dist/vite/angled-screen-shader.js +77 -34
- package/dist/vite/generated-media-client.d.ts.map +1 -1
- package/dist/vite/generated-media-client.js +2 -2
- package/dist/vite/vite-plugin.d.ts.map +1 -1
- package/dist/vite/vite-plugin.js +128 -10
- package/package.json +6 -10
- package/skills/egaki/SKILL.md +14 -0
- package/src/cli/chatgpt-auth.ts +379 -89
- package/src/cli/cli.ts +161 -27
- package/src/cli/dev-server.test.ts +109 -0
- package/src/cli/dev-server.ts +303 -0
- package/src/cli/generate.test.ts +57 -0
- package/src/cli/generate.ts +191 -61
- package/src/cli/login.test.ts +185 -0
- package/src/cli/login.ts +68 -16
- package/src/cli/open-browser.ts +7 -6
- package/src/cli/xai-auth.ts +15 -3
- package/src/vite/angled-screen-shader.tsx +81 -34
- package/src/vite/generated-media-client.tsx +6 -1
- package/src/vite/vite-plugin.ts +137 -10
package/dist/cli/cli.js
CHANGED
|
@@ -7,19 +7,18 @@
|
|
|
7
7
|
// - imagen-* models → generateImage() with google.image()
|
|
8
8
|
// - all other models → generateText() with responseModalities: ['IMAGE']
|
|
9
9
|
// The CLI auto-detects which path to use based on model ID prefix.
|
|
10
|
-
import { goke } from 'goke';
|
|
10
|
+
import { colors as pc, goke, isAgent, } from 'goke';
|
|
11
11
|
import { z } from 'zod';
|
|
12
12
|
import dedent from 'string-dedent';
|
|
13
13
|
import { APICallError, NoImageGeneratedError, NoVideoGeneratedError, RetryError, } from 'ai';
|
|
14
14
|
import { select, isCancel, cancel } from '@clack/prompts';
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import path from 'node:path';
|
|
17
|
-
import pc from 'picocolors';
|
|
18
17
|
import pkg from '../../package.json' with { type: 'json' };
|
|
19
18
|
import { injectCredentialsToEnv, PROVIDERS, getKeyStatus, } from './credentials.js';
|
|
20
19
|
import { DEFAULT_MODEL, DEFAULT_VIDEO_MODEL, getModelConfig, } from './models.js';
|
|
21
20
|
import { CATALOG, VIDEO_CATALOG, describeProviderValues, } from './model-catalog.js';
|
|
22
|
-
import { loginInteractive, loginNonInteractive, showLoginStatus, removeLogin, readKeyFromStdin, } from './login.js';
|
|
21
|
+
import { loginInteractive, loginNonInteractive, resolveLoginAction, showLoginStatus, removeLogin, readKeyFromStdin, } from './login.js';
|
|
23
22
|
import { subscribeInteractive, subscribeNonInteractive, unsubscribe, showUsage, } from './subscription.js';
|
|
24
23
|
import { generateImageUncached as generateImage, generateVideoUncached as generateVideo, calculateCost, ValidationError, } from './generate.js';
|
|
25
24
|
import { generateSpeechUncached as generateSpeech, } from './speech-generate.js';
|
|
@@ -47,8 +46,8 @@ cli
|
|
|
47
46
|
.command('login', dedent `
|
|
48
47
|
Configure API keys for image generation providers.
|
|
49
48
|
Interactive mode: shows a provider picker and secure key input.
|
|
50
|
-
Non-interactive mode: pass
|
|
51
|
-
|
|
49
|
+
Non-interactive mode: pass \`--provider\` and \`--key\`, or pipe the key via stdin.
|
|
50
|
+
Browser OAuth runs in the background for AI agents and non-TTY shells.
|
|
52
51
|
`)
|
|
53
52
|
.option('-p, --provider [name]', z
|
|
54
53
|
.string()
|
|
@@ -67,29 +66,84 @@ cli
|
|
|
67
66
|
.example('egaki login --show')
|
|
68
67
|
.example('# Remove a stored key')
|
|
69
68
|
.example('egaki login --remove google')
|
|
70
|
-
.action(async (options) => {
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
.action(async (options, ctx) => {
|
|
70
|
+
const action = resolveLoginAction({
|
|
71
|
+
options,
|
|
72
|
+
daemonProvider: ctx.daemon.isDaemon
|
|
73
|
+
? ctx.process.env.EGAKI_LOGIN_PROVIDER
|
|
74
|
+
: undefined,
|
|
75
|
+
isAgent: isAgent || !process.stdin.isTTY,
|
|
76
|
+
});
|
|
77
|
+
if (action instanceof Error) {
|
|
78
|
+
ctx.console.error(action.message);
|
|
79
|
+
ctx.process.exit(1);
|
|
73
80
|
return;
|
|
74
81
|
}
|
|
75
|
-
if (
|
|
76
|
-
|
|
82
|
+
if (action.type === 'show') {
|
|
83
|
+
showLoginStatus({ loginRunning: await ctx.daemon.isRunning() });
|
|
77
84
|
return;
|
|
78
85
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
if (action.type === 'remove') {
|
|
87
|
+
removeLogin(action.provider);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (action.type === 'oauth-daemon') {
|
|
91
|
+
await loginNonInteractive({
|
|
92
|
+
provider: action.provider,
|
|
93
|
+
key: '',
|
|
94
|
+
background: true,
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (action.type === 'oauth-client') {
|
|
99
|
+
await startOAuthLogin({ provider: action.provider, ctx });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (action.type === 'api-key') {
|
|
103
|
+
await loginNonInteractive({
|
|
104
|
+
provider: action.provider,
|
|
105
|
+
key: action.key || (await readKeyFromStdin()),
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
await loginInteractive({
|
|
110
|
+
loginOAuth: (provider) => startOAuthLogin({ provider, ctx }),
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
cli
|
|
114
|
+
.command('login status <provider>', 'Check one provider login. Exits with status 1 when login is not complete.')
|
|
115
|
+
.example('egaki login status chatgpt')
|
|
116
|
+
.example('egaki login status xai-oauth')
|
|
117
|
+
.action(async (provider, _options, ctx) => {
|
|
118
|
+
const info = PROVIDERS[provider];
|
|
119
|
+
if (!info) {
|
|
120
|
+
ctx.console.error(`Unknown provider: ${provider}`);
|
|
121
|
+
ctx.process.exit(1);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const status = getKeyStatus(provider);
|
|
125
|
+
if (status.available) {
|
|
126
|
+
ctx.console.log(`${info.label} is configured.`);
|
|
88
127
|
return;
|
|
89
128
|
}
|
|
90
|
-
|
|
91
|
-
|
|
129
|
+
const loginRunning = await ctx.daemon.forCommand('login').isRunning();
|
|
130
|
+
ctx.console.error(loginRunning
|
|
131
|
+
? `An OAuth login is running, but ${info.label} is not configured yet.`
|
|
132
|
+
: `${info.label} is not configured.`);
|
|
133
|
+
ctx.process.exit(1);
|
|
92
134
|
});
|
|
135
|
+
async function startOAuthLogin({ provider, ctx, }) {
|
|
136
|
+
const runInBackground = isAgent || !process.stdin.isTTY;
|
|
137
|
+
await ctx.daemon.start({
|
|
138
|
+
attach: !runInBackground,
|
|
139
|
+
timeoutMs: 10 * 60 * 1000,
|
|
140
|
+
env: { EGAKI_LOGIN_PROVIDER: provider },
|
|
141
|
+
});
|
|
142
|
+
if (!runInBackground)
|
|
143
|
+
return;
|
|
144
|
+
ctx.console.log('Login is running in the background and the browser will open.');
|
|
145
|
+
ctx.console.log(`After approval, verify with: egaki login status ${provider}`);
|
|
146
|
+
}
|
|
93
147
|
// ─── subscribe command ───────────────────────────────────────────────────────
|
|
94
148
|
cli
|
|
95
149
|
.command('subscribe', dedent `
|
|
@@ -97,17 +151,18 @@ cli
|
|
|
97
151
|
You can also use your own provider keys (Google/OpenAI/Replicate/Fal)
|
|
98
152
|
via 'egaki login --provider <name> --key <key>' if you prefer BYOK.
|
|
99
153
|
Egaki subscription avoids managing one key per provider.
|
|
100
|
-
|
|
101
|
-
|
|
154
|
+
Two plans: Plus ($29/mo) and Pro ($99/mo). Each plan includes a
|
|
155
|
+
monthly dollar budget of generation usage equal to its price;
|
|
156
|
+
generations count their marked-up provider cost against the budget.
|
|
102
157
|
Interactive mode: pick a plan and get a checkout URL (email prefill optional).
|
|
103
158
|
Non-interactive: --email is optional and only pre-fills checkout.
|
|
104
159
|
`)
|
|
105
160
|
.option('-e, --email [email]', z.string().describe('Optional email prefill for checkout (skips interactive prompt)'))
|
|
106
|
-
.option('--plan [plan]', z.
|
|
161
|
+
.option('--plan [plan]', z.enum(['plus', 'pro']).describe('Plan ID: plus ($29/mo) or pro ($99/mo)'))
|
|
107
162
|
.example('# Interactive subscribe')
|
|
108
163
|
.example('egaki subscribe')
|
|
109
164
|
.example('# Non-interactive (for agents)')
|
|
110
|
-
.example('egaki subscribe --email user@example.com --plan
|
|
165
|
+
.example('egaki subscribe --email user@example.com --plan plus')
|
|
111
166
|
.example('# Non-interactive without email prefill')
|
|
112
167
|
.example('egaki subscribe --plan pro')
|
|
113
168
|
.action(async (options) => {
|
|
@@ -131,13 +186,61 @@ cli
|
|
|
131
186
|
// ─── usage command ───────────────────────────────────────────────────────────
|
|
132
187
|
cli
|
|
133
188
|
.command('usage', dedent `
|
|
134
|
-
Show your current Egaki
|
|
135
|
-
Displays plan,
|
|
189
|
+
Show your current Egaki usage for this billing period.
|
|
190
|
+
Displays plan, dollars spent, remaining budget, and period info.
|
|
136
191
|
`)
|
|
137
192
|
.example('egaki usage')
|
|
138
193
|
.action(async () => {
|
|
139
194
|
await showUsage();
|
|
140
195
|
});
|
|
196
|
+
// ─── dev command ─────────────────────────────────────────────────────────────
|
|
197
|
+
cli
|
|
198
|
+
.command('dev [entry]', dedent `
|
|
199
|
+
Start the MDX video dev server with zero setup.
|
|
200
|
+
Pass an .mdx file (or a directory containing one) — no package.json,
|
|
201
|
+
vite.config.ts, or npm install needed. All dependencies (react,
|
|
202
|
+
remotion, vite) come from the egaki CLI's own installation.
|
|
203
|
+
Uses a random free port unless --port is given.
|
|
204
|
+
`)
|
|
205
|
+
.option('-p, --port [port]', z.coerce.number().int().describe('Fixed port (default: random free port)'))
|
|
206
|
+
.option('--host [host]', z.string().describe('Host to bind (default: localhost)'))
|
|
207
|
+
.option('--open', 'Open the player in the default browser')
|
|
208
|
+
.example('# Serve a single MDX file, no project setup')
|
|
209
|
+
.example('egaki dev video.mdx')
|
|
210
|
+
.example('# Serve the current directory (auto-discovers video.mdx)')
|
|
211
|
+
.example('egaki dev')
|
|
212
|
+
.example('# Fixed port + open browser')
|
|
213
|
+
.example('egaki dev intro.mdx --port 5199 --open')
|
|
214
|
+
.action(async (entry, options) => {
|
|
215
|
+
const { startDevServer } = await import('./dev-server.js');
|
|
216
|
+
const result = await startDevServer({
|
|
217
|
+
entry,
|
|
218
|
+
port: options.port,
|
|
219
|
+
host: options.host,
|
|
220
|
+
});
|
|
221
|
+
if (result instanceof Error) {
|
|
222
|
+
console.error(pc.red(result.message));
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
console.log();
|
|
226
|
+
console.log(` ${pc.green('➜')} egaki dev server running`);
|
|
227
|
+
console.log(` ${pc.dim('root:')} ${result.root}`);
|
|
228
|
+
console.log(` ${pc.dim('url:')} ${pc.cyan(result.url)}`);
|
|
229
|
+
console.log();
|
|
230
|
+
console.log(` ${pc.dim('SDK available in browser console as')} ${pc.yellow('window.egakiSDK')}`);
|
|
231
|
+
console.log(` ${pc.dim('.seekTo(frame)')} ${pc.dim('jump to a frame')}`);
|
|
232
|
+
console.log(` ${pc.dim('.screenshot()')} ${pc.dim('capture frame as data URL')}`);
|
|
233
|
+
console.log(` ${pc.dim('.filmstrip({...})')} ${pc.dim('grid of frames across scenes')}`);
|
|
234
|
+
console.log(` ${pc.dim('.export({...})')} ${pc.dim('render MP4 via WebCodecs')}`);
|
|
235
|
+
console.log(` ${pc.dim('.getInfo()')} ${pc.dim('composition metadata')}`);
|
|
236
|
+
console.log();
|
|
237
|
+
console.log(` ${pc.dim('Docs:')} ${pc.cyan('https://raw.githubusercontent.com/remorses/egaki/main/cli/skills/egaki/SKILL.md')}`);
|
|
238
|
+
console.log();
|
|
239
|
+
if (options.open) {
|
|
240
|
+
const { openUrlInBrowser } = await import('./open-browser.js');
|
|
241
|
+
await openUrlInBrowser(result.url);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
141
244
|
// ─── image command ───────────────────────────────────────────────────────────
|
|
142
245
|
cli
|
|
143
246
|
.command('image <prompt>', dedent `
|
|
@@ -155,7 +258,7 @@ cli
|
|
|
155
258
|
.option('-n, --count [n]', z.number().default(1).describe('Number of images to generate'))
|
|
156
259
|
.option('--aspect-ratio [ratio]', z
|
|
157
260
|
.string()
|
|
158
|
-
.describe('Aspect ratio for the generated image. Imagen supports: 1:1, 3:4, 4:3, 9:16, 16:9. Gemini supports additional ratios: 2:3, 3:2, 4:5, 5:4, 21:9'))
|
|
261
|
+
.describe('Aspect ratio for the generated image. Imagen supports: 1:1, 3:4, 4:3, 9:16, 16:9. Gemini supports additional ratios: 2:3, 3:2, 4:5, 5:4, 21:9. OpenAI models (gpt-image-*, dall-e-*) have no native aspect ratio parameter, so the ratio is snapped to the closest supported size and stated in the prompt text'))
|
|
159
262
|
.option('--seed [seed]', z.number().describe('Seed for reproducible generation. Same seed + same prompt = same image'))
|
|
160
263
|
.option('--image-size [size]', z
|
|
161
264
|
.enum(['1K', '2K', '4K'])
|
|
@@ -973,6 +1076,7 @@ const UPLOAD_GATEWAY_BASE = 'https://egaki.org';
|
|
|
973
1076
|
/** Max upload size in bytes (must match gateway limit). */
|
|
974
1077
|
const MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
|
|
975
1078
|
cli.help();
|
|
1079
|
+
cli.completions();
|
|
976
1080
|
cli.version(pkg.version);
|
|
977
1081
|
export { cli };
|
|
978
1082
|
async function runProviderCall(operation, call) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ViteDevServer } from 'vite';
|
|
2
|
+
export interface StartDevServerOptions {
|
|
3
|
+
/** Path to an .mdx entry file, or a directory containing .mdx files.
|
|
4
|
+
* Defaults to the current working directory (auto-discovers video.mdx
|
|
5
|
+
* > index.mdx > first .mdx alphabetically). */
|
|
6
|
+
entry?: string;
|
|
7
|
+
/** Fixed port. When omitted, a random free port is chosen. */
|
|
8
|
+
port?: number;
|
|
9
|
+
/** Host to bind. Defaults to localhost. */
|
|
10
|
+
host?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface RunningDevServer {
|
|
13
|
+
url: string;
|
|
14
|
+
port: number;
|
|
15
|
+
root: string;
|
|
16
|
+
server: ViteDevServer;
|
|
17
|
+
close: () => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/** Find a random free TCP port by binding to port 0. */
|
|
20
|
+
export declare function findFreePort(host?: string): Promise<number>;
|
|
21
|
+
/** Ensure bare imports resolve from the project root.
|
|
22
|
+
*
|
|
23
|
+
* - egaki already resolvable from root → real project, do nothing
|
|
24
|
+
* - no node_modules (or a CLI-owned shim) → (re)create the shim
|
|
25
|
+
* - foreign node_modules without egaki → error with guidance
|
|
26
|
+
*/
|
|
27
|
+
export declare function ensureDependencyShim(root: string): Error | 'normal' | 'shim';
|
|
28
|
+
/** Start the zero-config dev server. Returns Error | RunningDevServer. */
|
|
29
|
+
export declare function startDevServer(options?: StartDevServerOptions): Promise<Error | RunningDevServer>;
|
|
30
|
+
//# sourceMappingURL=dev-server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../../src/cli/dev-server.ts"],"names":[],"mappings":"AA4BA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,MAAM,CAAA;AAUzC,MAAM,WAAW,qBAAqB;IACpC;;oDAEgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,aAAa,CAAA;IACrB,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC3B;AAED,wDAAwD;AACxD,wBAAsB,YAAY,CAAC,IAAI,SAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAStE;AAiDD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,CA4C5E;AAuFD,0EAA0E;AAC1E,wBAAsB,cAAc,CAClC,OAAO,GAAE,qBAA0B,GAClC,OAAO,CAAC,KAAK,GAAG,gBAAgB,CAAC,CA8CnC"}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Zero-config dev server for egaki MDX videos.
|
|
2
|
+
//
|
|
3
|
+
// `egaki dev video.mdx` boots a Vite dev server with inline config
|
|
4
|
+
// (configFile: false) — no package.json, vite.config.ts, or npm install
|
|
5
|
+
// needed in the user's folder.
|
|
6
|
+
//
|
|
7
|
+
// Dependency resolution: when the folder has no usable node_modules, a
|
|
8
|
+
// shim node_modules is created containing symlinks to egaki itself and
|
|
9
|
+
// every egaki dependency (react, remotion, vite, ...) resolved from the
|
|
10
|
+
// CLI's own installation. A plain fallback resolveId plugin is NOT enough
|
|
11
|
+
// because Vite's dep optimizer resolves optimizeDeps.include entries with
|
|
12
|
+
// an internal resolver (alias + vite:resolve only) that skips user
|
|
13
|
+
// plugins — without real node_modules those includes fail and CJS deps
|
|
14
|
+
// like safe-mdx break in the browser. The shim makes standard node
|
|
15
|
+
// resolution work everywhere (vite:resolve, optimizer, tailwind scans).
|
|
16
|
+
// The shim dir contains a .egaki-shim marker so it can be refreshed
|
|
17
|
+
// idempotently and recognized as CLI-owned.
|
|
18
|
+
//
|
|
19
|
+
// Vite's cache dir is placed under ~/.cache/egaki/vite/<hash> so repeated
|
|
20
|
+
// runs reuse the prebundle without polluting the user's folder further.
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import net from 'node:net';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import crypto from 'node:crypto';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { video } from "../vite/vite-plugin.js";
|
|
29
|
+
// Package root: this file runs from src/cli/ (dev) or dist/cli/ (published).
|
|
30
|
+
// Going up 2 levels reaches the egaki package root either way.
|
|
31
|
+
const __pkgRoot = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '../..');
|
|
32
|
+
const egakiRequire = createRequire(path.join(__pkgRoot, 'package.json'));
|
|
33
|
+
const SHIM_MARKER = '.egaki-shim';
|
|
34
|
+
/** Find a random free TCP port by binding to port 0. */
|
|
35
|
+
export async function findFreePort(host = '127.0.0.1') {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const probe = net.createServer();
|
|
38
|
+
probe.once('error', reject);
|
|
39
|
+
probe.listen(0, host, () => {
|
|
40
|
+
const address = probe.address();
|
|
41
|
+
probe.close(() => resolve(address.port));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Locate the real directory of a package installed in egaki's own tree.
|
|
46
|
+
* Tries pkg/package.json first (works for most packages), then falls
|
|
47
|
+
* back to resolving the main entry and walking up to the package root
|
|
48
|
+
* (for packages whose exports map hides ./package.json). */
|
|
49
|
+
function packageDirFromEgaki(pkg) {
|
|
50
|
+
try {
|
|
51
|
+
return path.dirname(fs.realpathSync(egakiRequire.resolve(`${pkg}/package.json`)));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Fall through to entry-based lookup
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
let dir = path.dirname(fs.realpathSync(egakiRequire.resolve(pkg)));
|
|
58
|
+
while (true) {
|
|
59
|
+
const pkgJsonPath = path.join(dir, 'package.json');
|
|
60
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
61
|
+
const name = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).name;
|
|
62
|
+
if (name === pkg)
|
|
63
|
+
return dir;
|
|
64
|
+
}
|
|
65
|
+
const parent = path.dirname(dir);
|
|
66
|
+
if (parent === dir)
|
|
67
|
+
return undefined;
|
|
68
|
+
dir = parent;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** All packages the shim should link: egaki itself plus every dependency
|
|
76
|
+
* and (present) optional/peer dependency of the egaki package. */
|
|
77
|
+
function shimPackageDirs() {
|
|
78
|
+
const pkgJson = JSON.parse(fs.readFileSync(path.join(__pkgRoot, 'package.json'), 'utf-8'));
|
|
79
|
+
const names = new Set([
|
|
80
|
+
...Object.keys(pkgJson.dependencies ?? {}),
|
|
81
|
+
...Object.keys(pkgJson.optionalDependencies ?? {}),
|
|
82
|
+
...Object.keys(pkgJson.peerDependencies ?? {}),
|
|
83
|
+
]);
|
|
84
|
+
const dirs = new Map();
|
|
85
|
+
dirs.set('egaki', fs.realpathSync(__pkgRoot));
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
const dir = packageDirFromEgaki(name);
|
|
88
|
+
if (dir)
|
|
89
|
+
dirs.set(name, dir);
|
|
90
|
+
}
|
|
91
|
+
return dirs;
|
|
92
|
+
}
|
|
93
|
+
/** Ensure bare imports resolve from the project root.
|
|
94
|
+
*
|
|
95
|
+
* - egaki already resolvable from root → real project, do nothing
|
|
96
|
+
* - no node_modules (or a CLI-owned shim) → (re)create the shim
|
|
97
|
+
* - foreign node_modules without egaki → error with guidance
|
|
98
|
+
*/
|
|
99
|
+
export function ensureDependencyShim(root) {
|
|
100
|
+
const nodeModulesDir = path.join(root, 'node_modules');
|
|
101
|
+
const isShim = fs.existsSync(path.join(nodeModulesDir, SHIM_MARKER));
|
|
102
|
+
if (!isShim) {
|
|
103
|
+
// Walk up from root looking for node_modules/egaki — a plain fs walk
|
|
104
|
+
// instead of createRequire().resolve() because test runners (tsx,
|
|
105
|
+
// vitest) patch Node's resolver and make it resolve 'egaki' from the
|
|
106
|
+
// wrong context, silently skipping shim creation.
|
|
107
|
+
let dir = root;
|
|
108
|
+
while (true) {
|
|
109
|
+
if (fs.existsSync(path.join(dir, 'node_modules', 'egaki', 'package.json'))) {
|
|
110
|
+
return 'normal';
|
|
111
|
+
}
|
|
112
|
+
const parent = path.dirname(dir);
|
|
113
|
+
if (parent === dir)
|
|
114
|
+
break;
|
|
115
|
+
dir = parent;
|
|
116
|
+
}
|
|
117
|
+
if (fs.existsSync(nodeModulesDir)) {
|
|
118
|
+
return new Error(`${nodeModulesDir} exists but 'egaki' is not installed there.\n` +
|
|
119
|
+
`Either run 'npm install egaki' in ${root}, or remove node_modules ` +
|
|
120
|
+
`to let 'egaki dev' manage dependencies automatically.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
|
124
|
+
fs.writeFileSync(path.join(nodeModulesDir, SHIM_MARKER), 'This node_modules was generated by `egaki dev`. Safe to delete.\n');
|
|
125
|
+
for (const [name, targetDir] of shimPackageDirs()) {
|
|
126
|
+
const linkPath = path.join(nodeModulesDir, name);
|
|
127
|
+
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
128
|
+
const existing = fs.lstatSync(linkPath, { throwIfNoEntry: false });
|
|
129
|
+
if (existing) {
|
|
130
|
+
if (!existing.isSymbolicLink())
|
|
131
|
+
continue;
|
|
132
|
+
if (fs.readlinkSync(linkPath) === targetDir)
|
|
133
|
+
continue;
|
|
134
|
+
fs.unlinkSync(linkPath);
|
|
135
|
+
}
|
|
136
|
+
fs.symlinkSync(targetDir, linkPath, 'dir');
|
|
137
|
+
}
|
|
138
|
+
return 'shim';
|
|
139
|
+
}
|
|
140
|
+
/** Walk up from a real path and collect the parent of every ancestor
|
|
141
|
+
* directory named node_modules. These parents are safe fs.allow roots
|
|
142
|
+
* that cover hoisted/pnpm-store dependency locations. */
|
|
143
|
+
function nodeModulesAncestorRoots(realPath) {
|
|
144
|
+
const roots = [];
|
|
145
|
+
let dir = realPath;
|
|
146
|
+
while (true) {
|
|
147
|
+
const parent = path.dirname(dir);
|
|
148
|
+
if (parent === dir)
|
|
149
|
+
break;
|
|
150
|
+
if (path.basename(dir) === 'node_modules')
|
|
151
|
+
roots.push(parent);
|
|
152
|
+
dir = parent;
|
|
153
|
+
}
|
|
154
|
+
return roots;
|
|
155
|
+
}
|
|
156
|
+
/** Compute fs.allow roots so Vite can serve framework + dependency files
|
|
157
|
+
* that live outside the user's project root (in the CLI's install tree). */
|
|
158
|
+
function computeFsAllow(root) {
|
|
159
|
+
const allow = new Set([root]);
|
|
160
|
+
const probes = [path.join(__pkgRoot, 'package.json')];
|
|
161
|
+
for (const id of ['react', 'react-dom', 'remotion', 'vite', 'spiceflow']) {
|
|
162
|
+
try {
|
|
163
|
+
probes.push(egakiRequire.resolve(id));
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Probe missing: skip
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const probe of probes) {
|
|
170
|
+
try {
|
|
171
|
+
const real = fs.realpathSync(probe);
|
|
172
|
+
allow.add(path.dirname(real));
|
|
173
|
+
for (const ancestorRoot of nodeModulesAncestorRoots(real)) {
|
|
174
|
+
allow.add(ancestorRoot);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// Probe missing: skip
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
allow.add(fs.realpathSync(__pkgRoot));
|
|
182
|
+
return [...allow];
|
|
183
|
+
}
|
|
184
|
+
/** Resolve the entry option to { root, entryFile }.
|
|
185
|
+
* Accepts a file path, a directory, or nothing (cwd). */
|
|
186
|
+
function resolveEntry(entry) {
|
|
187
|
+
const target = path.resolve(process.cwd(), entry ?? '.');
|
|
188
|
+
if (!fs.existsSync(target)) {
|
|
189
|
+
return new Error(`entry not found: ${target}`);
|
|
190
|
+
}
|
|
191
|
+
const stat = fs.statSync(target);
|
|
192
|
+
if (stat.isDirectory()) {
|
|
193
|
+
const hasMdx = fs
|
|
194
|
+
.readdirSync(target)
|
|
195
|
+
.some((name) => name.endsWith('.mdx'));
|
|
196
|
+
if (!hasMdx) {
|
|
197
|
+
return new Error(`no .mdx files found in ${target}\nCreate a video.mdx file or pass one explicitly: egaki dev path/to/video.mdx`);
|
|
198
|
+
}
|
|
199
|
+
return { root: target };
|
|
200
|
+
}
|
|
201
|
+
if (!/\.mdx$/.test(target)) {
|
|
202
|
+
return new Error(`entry must be an .mdx file or a directory, got: ${target}`);
|
|
203
|
+
}
|
|
204
|
+
return { root: path.dirname(target), entryFile: target };
|
|
205
|
+
}
|
|
206
|
+
/** Warm up the server: the very first request can 500 transiently while
|
|
207
|
+
* the dep optimizer discovers new deps and reloads the ssr program (a
|
|
208
|
+
* browser recovers via auto-reload, but agents/tests hit the URL once).
|
|
209
|
+
* Retry until the page serves 200 so the returned URL is ready to use. */
|
|
210
|
+
async function warmUp(url, timeoutMs) {
|
|
211
|
+
const deadline = Date.now() + timeoutMs;
|
|
212
|
+
while (Date.now() < deadline) {
|
|
213
|
+
try {
|
|
214
|
+
const response = await fetch(url);
|
|
215
|
+
if (response.ok)
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// Server not accepting connections yet
|
|
220
|
+
}
|
|
221
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
222
|
+
}
|
|
223
|
+
console.warn(`[egaki] dev server warmup did not reach 200 within ${timeoutMs}ms`);
|
|
224
|
+
}
|
|
225
|
+
/** Start the zero-config dev server. Returns Error | RunningDevServer. */
|
|
226
|
+
export async function startDevServer(options = {}) {
|
|
227
|
+
const entryResult = resolveEntry(options.entry);
|
|
228
|
+
if (entryResult instanceof Error)
|
|
229
|
+
return entryResult;
|
|
230
|
+
const { root, entryFile } = entryResult;
|
|
231
|
+
const shimResult = ensureDependencyShim(root);
|
|
232
|
+
if (shimResult instanceof Error)
|
|
233
|
+
return shimResult;
|
|
234
|
+
const host = options.host ?? 'localhost';
|
|
235
|
+
const port = options.port ?? (await findFreePort());
|
|
236
|
+
const rootHash = crypto.createHash('sha256').update(root).digest('hex').slice(0, 12);
|
|
237
|
+
const cacheDir = path.join(os.homedir(), '.cache', 'egaki', 'vite', rootHash);
|
|
238
|
+
const { createServer } = await import('vite');
|
|
239
|
+
const server = await createServer({
|
|
240
|
+
configFile: false,
|
|
241
|
+
root,
|
|
242
|
+
cacheDir,
|
|
243
|
+
plugins: [video(entryFile ? { entry: entryFile } : undefined)],
|
|
244
|
+
resolve: {
|
|
245
|
+
dedupe: ['react', 'react-dom', 'remotion', '@remotion/player', '@remotion/media'],
|
|
246
|
+
},
|
|
247
|
+
server: {
|
|
248
|
+
port,
|
|
249
|
+
strictPort: true,
|
|
250
|
+
host,
|
|
251
|
+
fs: {
|
|
252
|
+
allow: computeFsAllow(root),
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
await server.listen();
|
|
257
|
+
const url = `http://${host}:${port}/`;
|
|
258
|
+
await warmUp(url, 60_000);
|
|
259
|
+
return {
|
|
260
|
+
url,
|
|
261
|
+
port,
|
|
262
|
+
root,
|
|
263
|
+
server,
|
|
264
|
+
close: () => server.close(),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dev-server.test.d.ts","sourceRoot":"","sources":["../../src/cli/dev-server.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Tests for the zero-config dev server (egaki dev).
|
|
2
|
+
//
|
|
3
|
+
// Creates a temp directory containing ONLY a video.mdx file — no
|
|
4
|
+
// package.json, no vite.config.ts, no node_modules — and verifies the
|
|
5
|
+
// dev server boots, serves HTML at /, and resolves all framework deps
|
|
6
|
+
// from the CLI's own installation.
|
|
7
|
+
import { describe, test, expect, afterAll } from 'vitest';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import dedent from 'string-dedent';
|
|
12
|
+
import { startDevServer, findFreePort } from "./dev-server.js";
|
|
13
|
+
const cleanups = [];
|
|
14
|
+
afterAll(async () => {
|
|
15
|
+
for (const cleanup of cleanups) {
|
|
16
|
+
await cleanup();
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
function makeScratchProject() {
|
|
20
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'egaki-dev-test-'));
|
|
21
|
+
const MDX = dedent `
|
|
22
|
+
---
|
|
23
|
+
width: 1920
|
|
24
|
+
height: 1080
|
|
25
|
+
fps: 30
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
# Hello duration=2
|
|
29
|
+
|
|
30
|
+
Zero-config dev server test.
|
|
31
|
+
`;
|
|
32
|
+
fs.writeFileSync(path.join(dir, 'video.mdx'), MDX);
|
|
33
|
+
cleanups.push(async () => {
|
|
34
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
describe('findFreePort', () => {
|
|
39
|
+
test('returns a valid port number', async () => {
|
|
40
|
+
const port = await findFreePort();
|
|
41
|
+
expect(port).toBeGreaterThan(0);
|
|
42
|
+
expect(port).toBeLessThan(65536);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe('startDevServer', () => {
|
|
46
|
+
test('returns Error for missing entry', async () => {
|
|
47
|
+
const result = await startDevServer({ entry: '/nonexistent/path/video.mdx' });
|
|
48
|
+
expect(result).toBeInstanceOf(Error);
|
|
49
|
+
if (result instanceof Error) {
|
|
50
|
+
expect(result.message).toContain('entry not found');
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
test('returns Error for directory without mdx files', async () => {
|
|
54
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'egaki-dev-empty-'));
|
|
55
|
+
cleanups.push(async () => {
|
|
56
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
const result = await startDevServer({ entry: dir });
|
|
59
|
+
expect(result).toBeInstanceOf(Error);
|
|
60
|
+
if (result instanceof Error) {
|
|
61
|
+
expect(result.message).toContain('no .mdx files found');
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
test('returns Error for non-mdx file entry', async () => {
|
|
65
|
+
const dir = makeScratchProject();
|
|
66
|
+
const txtPath = path.join(dir, 'notes.txt');
|
|
67
|
+
fs.writeFileSync(txtPath, 'hi');
|
|
68
|
+
const result = await startDevServer({ entry: txtPath });
|
|
69
|
+
expect(result).toBeInstanceOf(Error);
|
|
70
|
+
if (result instanceof Error) {
|
|
71
|
+
expect(result.message).toContain('must be an .mdx file');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
test('serves a scratch mdx file with no package.json or node_modules', async () => {
|
|
75
|
+
const dir = makeScratchProject();
|
|
76
|
+
const result = await startDevServer({
|
|
77
|
+
entry: path.join(dir, 'video.mdx'),
|
|
78
|
+
});
|
|
79
|
+
expect(result).not.toBeInstanceOf(Error);
|
|
80
|
+
const running = result;
|
|
81
|
+
cleanups.push(() => running.close());
|
|
82
|
+
expect(running.root).toBe(dir);
|
|
83
|
+
expect(running.url).toMatch(/^http:\/\/localhost:\d+\/$/);
|
|
84
|
+
const response = await fetch(running.url);
|
|
85
|
+
expect(response.status).toBe(200);
|
|
86
|
+
expect(response.headers.get('content-type')).toContain('text/html');
|
|
87
|
+
const html = await response.text();
|
|
88
|
+
// RSC streaming: the doctype shell arrives in a separate flush, so
|
|
89
|
+
// assert on stable player UI markup instead of '<html'.
|
|
90
|
+
expect(html).toContain('Export MP4');
|
|
91
|
+
// The dependency shim was created and is CLI-owned
|
|
92
|
+
expect(fs.existsSync(path.join(dir, 'node_modules', '.egaki-shim'))).toBe(true);
|
|
93
|
+
expect(fs.lstatSync(path.join(dir, 'node_modules', 'egaki')).isSymbolicLink()).toBe(true);
|
|
94
|
+
}, 120_000);
|
|
95
|
+
});
|
package/dist/cli/generate.d.ts
CHANGED
|
@@ -114,6 +114,14 @@ export declare function calculateCost(cost: {
|
|
|
114
114
|
resolution?: string;
|
|
115
115
|
hasVideoInput?: boolean;
|
|
116
116
|
}, count?: number): number | null;
|
|
117
|
+
/**
|
|
118
|
+
* Translate an aspect ratio request into a prompt prefix + concrete `size` for
|
|
119
|
+
* models that have no native aspect ratio parameter.
|
|
120
|
+
*/
|
|
121
|
+
export declare function aspectRatioFallback(prompt: string, aspectRatio: `${number}:${number}` | undefined, sizes: readonly string[]): {
|
|
122
|
+
prompt: string;
|
|
123
|
+
size?: `${number}x${number}`;
|
|
124
|
+
};
|
|
117
125
|
/**
|
|
118
126
|
* Generate images from a text prompt. Auto-detects which generation strategy
|
|
119
127
|
* to use (image API, text model, or ChatGPT Responses API) based on model ID.
|