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.
Files changed (50) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/cli/chatgpt-auth.d.ts +30 -5
  3. package/dist/cli/chatgpt-auth.d.ts.map +1 -1
  4. package/dist/cli/chatgpt-auth.js +257 -79
  5. package/dist/cli/cli.d.ts.map +1 -1
  6. package/dist/cli/cli.js +132 -28
  7. package/dist/cli/dev-server.d.ts +30 -0
  8. package/dist/cli/dev-server.d.ts.map +1 -0
  9. package/dist/cli/dev-server.js +266 -0
  10. package/dist/cli/dev-server.test.d.ts +2 -0
  11. package/dist/cli/dev-server.test.d.ts.map +1 -0
  12. package/dist/cli/dev-server.test.js +95 -0
  13. package/dist/cli/generate.d.ts +8 -0
  14. package/dist/cli/generate.d.ts.map +1 -1
  15. package/dist/cli/generate.js +170 -58
  16. package/dist/cli/generate.test.js +50 -1
  17. package/dist/cli/login.d.ts +38 -3
  18. package/dist/cli/login.d.ts.map +1 -1
  19. package/dist/cli/login.js +44 -17
  20. package/dist/cli/login.test.d.ts +2 -0
  21. package/dist/cli/login.test.d.ts.map +1 -0
  22. package/dist/cli/login.test.js +158 -0
  23. package/dist/cli/open-browser.d.ts +3 -1
  24. package/dist/cli/open-browser.d.ts.map +1 -1
  25. package/dist/cli/open-browser.js +2 -2
  26. package/dist/cli/xai-auth.d.ts +3 -1
  27. package/dist/cli/xai-auth.d.ts.map +1 -1
  28. package/dist/cli/xai-auth.js +13 -3
  29. package/dist/vite/angled-screen-shader.d.ts +5 -0
  30. package/dist/vite/angled-screen-shader.d.ts.map +1 -1
  31. package/dist/vite/angled-screen-shader.js +77 -34
  32. package/dist/vite/generated-media-client.d.ts.map +1 -1
  33. package/dist/vite/generated-media-client.js +2 -2
  34. package/dist/vite/vite-plugin.d.ts.map +1 -1
  35. package/dist/vite/vite-plugin.js +128 -10
  36. package/package.json +6 -10
  37. package/skills/egaki/SKILL.md +14 -0
  38. package/src/cli/chatgpt-auth.ts +379 -89
  39. package/src/cli/cli.ts +161 -27
  40. package/src/cli/dev-server.test.ts +109 -0
  41. package/src/cli/dev-server.ts +303 -0
  42. package/src/cli/generate.test.ts +57 -0
  43. package/src/cli/generate.ts +191 -61
  44. package/src/cli/login.test.ts +185 -0
  45. package/src/cli/login.ts +68 -16
  46. package/src/cli/open-browser.ts +7 -6
  47. package/src/cli/xai-auth.ts +15 -3
  48. package/src/vite/angled-screen-shader.tsx +81 -34
  49. package/src/vite/generated-media-client.tsx +6 -1
  50. package/src/vite/vite-plugin.ts +137 -10
package/src/cli/cli.ts CHANGED
@@ -7,7 +7,12 @@
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 {
11
+ colors as pc,
12
+ goke,
13
+ isAgent,
14
+ type GokeExecutionContext,
15
+ } from 'goke'
11
16
  import { z } from 'zod'
12
17
  import dedent from 'string-dedent'
13
18
  import {
@@ -19,7 +24,6 @@ import {
19
24
  import { select, isCancel, cancel } from '@clack/prompts'
20
25
  import fs from 'node:fs'
21
26
  import path from 'node:path'
22
- import pc from 'picocolors'
23
27
  import pkg from '../../package.json' with { type: 'json' }
24
28
  import {
25
29
  injectCredentialsToEnv,
@@ -39,6 +43,7 @@ import {
39
43
  import {
40
44
  loginInteractive,
41
45
  loginNonInteractive,
46
+ resolveLoginAction,
42
47
  showLoginStatus,
43
48
  removeLogin,
44
49
  readKeyFromStdin,
@@ -116,8 +121,8 @@ cli
116
121
  dedent`
117
122
  Configure API keys for image generation providers.
118
123
  Interactive mode: shows a provider picker and secure key input.
119
- Non-interactive mode: pass --provider and --key flags, or pipe key via stdin.
120
- Keys are saved to ~/.config/egaki/credentials.json (mode 0600).
124
+ Non-interactive mode: pass \`--provider\` and \`--key\`, or pipe the key via stdin.
125
+ Browser OAuth runs in the background for AI agents and non-TTY shells.
121
126
  `,
122
127
  )
123
128
  .option(
@@ -148,32 +153,100 @@ cli
148
153
  .example('egaki login --show')
149
154
  .example('# Remove a stored key')
150
155
  .example('egaki login --remove google')
151
- .action(async (options) => {
152
- if (options.show) {
153
- showLoginStatus()
156
+ .action(async (options, ctx) => {
157
+ const action = resolveLoginAction({
158
+ options,
159
+ daemonProvider: ctx.daemon.isDaemon
160
+ ? ctx.process.env.EGAKI_LOGIN_PROVIDER
161
+ : undefined,
162
+ isAgent: isAgent || !process.stdin.isTTY,
163
+ })
164
+ if (action instanceof Error) {
165
+ ctx.console.error(action.message)
166
+ ctx.process.exit(1)
154
167
  return
155
168
  }
156
169
 
157
- if (options.remove) {
158
- removeLogin(options.remove)
170
+ if (action.type === 'show') {
171
+ showLoginStatus({ loginRunning: await ctx.daemon.isRunning() })
172
+ return
173
+ }
174
+ if (action.type === 'remove') {
175
+ removeLogin(action.provider)
176
+ return
177
+ }
178
+ if (action.type === 'oauth-daemon') {
179
+ await loginNonInteractive({
180
+ provider: action.provider,
181
+ key: '',
182
+ background: true,
183
+ })
184
+ return
185
+ }
186
+ if (action.type === 'oauth-client') {
187
+ await startOAuthLogin({ provider: action.provider, ctx })
188
+ return
189
+ }
190
+ if (action.type === 'api-key') {
191
+ await loginNonInteractive({
192
+ provider: action.provider,
193
+ key: action.key || (await readKeyFromStdin()),
194
+ })
159
195
  return
160
196
  }
161
197
 
162
- // Non-interactive: --provider + --key or stdin
163
- if (options.provider) {
164
- // OAuth providers use browser flow — skip key reading
165
- if (options.provider === 'chatgpt' || options.provider === 'xai-oauth') {
166
- await loginNonInteractive({ provider: options.provider, key: '' })
167
- return
168
- }
169
- const key = options.key || (await readKeyFromStdin())
170
- await loginNonInteractive({ provider: options.provider, key })
198
+ await loginInteractive({
199
+ loginOAuth: (provider) => startOAuthLogin({ provider, ctx }),
200
+ })
201
+ })
202
+
203
+ cli
204
+ .command(
205
+ 'login status <provider>',
206
+ 'Check one provider login. Exits with status 1 when login is not complete.',
207
+ )
208
+ .example('egaki login status chatgpt')
209
+ .example('egaki login status xai-oauth')
210
+ .action(async (provider, _options, ctx) => {
211
+ const info = PROVIDERS[provider]
212
+ if (!info) {
213
+ ctx.console.error(`Unknown provider: ${provider}`)
214
+ ctx.process.exit(1)
215
+ return
216
+ }
217
+
218
+ const status = getKeyStatus(provider)
219
+ if (status.available) {
220
+ ctx.console.log(`${info.label} is configured.`)
171
221
  return
172
222
  }
173
223
 
174
- // Interactive mode
175
- await loginInteractive()
224
+ const loginRunning = await ctx.daemon.forCommand('login').isRunning()
225
+ ctx.console.error(
226
+ loginRunning
227
+ ? `An OAuth login is running, but ${info.label} is not configured yet.`
228
+ : `${info.label} is not configured.`,
229
+ )
230
+ ctx.process.exit(1)
231
+ })
232
+
233
+ async function startOAuthLogin({
234
+ provider,
235
+ ctx,
236
+ }: {
237
+ provider: string
238
+ ctx: GokeExecutionContext
239
+ }) {
240
+ const runInBackground = isAgent || !process.stdin.isTTY
241
+ await ctx.daemon.start({
242
+ attach: !runInBackground,
243
+ timeoutMs: 10 * 60 * 1000,
244
+ env: { EGAKI_LOGIN_PROVIDER: provider },
176
245
  })
246
+ if (!runInBackground) return
247
+ ctx.console.log('Login is running in the background and the browser will open.')
248
+ ctx.console.log(`After approval, verify with: egaki login status ${provider}`)
249
+ }
177
250
 
178
251
  // ─── subscribe command ───────────────────────────────────────────────────────
179
252
 
@@ -185,8 +258,9 @@ cli
185
258
  You can also use your own provider keys (Google/OpenAI/Replicate/Fal)
186
259
  via 'egaki login --provider <name> --key <key>' if you prefer BYOK.
187
260
  Egaki subscription avoids managing one key per provider.
188
- Three plans: Starter ($9/mo, 100 credits), Pro ($29/mo, 500 credits),
189
- Unlimited ($99/mo, 2000 credits). One credit ≈ one standard image.
261
+ Two plans: Plus ($29/mo) and Pro ($99/mo). Each plan includes a
262
+ monthly dollar budget of generation usage equal to its price;
263
+ generations count their marked-up provider cost against the budget.
190
264
  Interactive mode: pick a plan and get a checkout URL (email prefill optional).
191
265
  Non-interactive: --email is optional and only pre-fills checkout.
192
266
  `,
@@ -197,12 +271,12 @@ cli
197
271
  )
198
272
  .option(
199
273
  '--plan [plan]',
200
- z.string().describe('Plan ID: starter, pro, or unlimited (default: pro)'),
274
+ z.enum(['plus', 'pro']).describe('Plan ID: plus ($29/mo) or pro ($99/mo)'),
201
275
  )
202
276
  .example('# Interactive subscribe')
203
277
  .example('egaki subscribe')
204
278
  .example('# Non-interactive (for agents)')
205
- .example('egaki subscribe --email user@example.com --plan pro')
279
+ .example('egaki subscribe --email user@example.com --plan plus')
206
280
  .example('# Non-interactive without email prefill')
207
281
  .example('egaki subscribe --plan pro')
208
282
  .action(async (options) => {
@@ -235,8 +309,8 @@ cli
235
309
  .command(
236
310
  'usage',
237
311
  dedent`
238
- Show your current Egaki credit usage for this billing period.
239
- Displays plan, credits used, credits remaining, and period info.
312
+ Show your current Egaki usage for this billing period.
313
+ Displays plan, dollars spent, remaining budget, and period info.
240
314
  `,
241
315
  )
242
316
  .example('egaki usage')
@@ -244,6 +318,65 @@ cli
244
318
  await showUsage()
245
319
  })
246
320
 
321
+ // ─── dev command ─────────────────────────────────────────────────────────────
322
+
323
+ cli
324
+ .command(
325
+ 'dev [entry]',
326
+ dedent`
327
+ Start the MDX video dev server with zero setup.
328
+ Pass an .mdx file (or a directory containing one) — no package.json,
329
+ vite.config.ts, or npm install needed. All dependencies (react,
330
+ remotion, vite) come from the egaki CLI's own installation.
331
+ Uses a random free port unless --port is given.
332
+ `,
333
+ )
334
+ .option(
335
+ '-p, --port [port]',
336
+ z.coerce.number().int().describe('Fixed port (default: random free port)'),
337
+ )
338
+ .option(
339
+ '--host [host]',
340
+ z.string().describe('Host to bind (default: localhost)'),
341
+ )
342
+ .option('--open', 'Open the player in the default browser')
343
+ .example('# Serve a single MDX file, no project setup')
344
+ .example('egaki dev video.mdx')
345
+ .example('# Serve the current directory (auto-discovers video.mdx)')
346
+ .example('egaki dev')
347
+ .example('# Fixed port + open browser')
348
+ .example('egaki dev intro.mdx --port 5199 --open')
349
+ .action(async (entry, options) => {
350
+ const { startDevServer } = await import('./dev-server.js')
351
+ const result = await startDevServer({
352
+ entry,
353
+ port: options.port,
354
+ host: options.host,
355
+ })
356
+ if (result instanceof Error) {
357
+ console.error(pc.red(result.message))
358
+ process.exit(1)
359
+ }
360
+ console.log()
361
+ console.log(` ${pc.green('➜')} egaki dev server running`)
362
+ console.log(` ${pc.dim('root:')} ${result.root}`)
363
+ console.log(` ${pc.dim('url:')} ${pc.cyan(result.url)}`)
364
+ console.log()
365
+ console.log(` ${pc.dim('SDK available in browser console as')} ${pc.yellow('window.egakiSDK')}`)
366
+ console.log(` ${pc.dim('.seekTo(frame)')} ${pc.dim('jump to a frame')}`)
367
+ console.log(` ${pc.dim('.screenshot()')} ${pc.dim('capture frame as data URL')}`)
368
+ console.log(` ${pc.dim('.filmstrip({...})')} ${pc.dim('grid of frames across scenes')}`)
369
+ console.log(` ${pc.dim('.export({...})')} ${pc.dim('render MP4 via WebCodecs')}`)
370
+ console.log(` ${pc.dim('.getInfo()')} ${pc.dim('composition metadata')}`)
371
+ console.log()
372
+ console.log(` ${pc.dim('Docs:')} ${pc.cyan('https://raw.githubusercontent.com/remorses/egaki/main/cli/skills/egaki/SKILL.md')}`)
373
+ console.log()
374
+ if (options.open) {
375
+ const { openUrlInBrowser } = await import('./open-browser.js')
376
+ await openUrlInBrowser(result.url)
377
+ }
378
+ })
379
+
247
380
  // ─── image command ───────────────────────────────────────────────────────────
248
381
 
249
382
  cli
@@ -277,7 +410,7 @@ cli
277
410
  z
278
411
  .string()
279
412
  .describe(
280
- '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',
413
+ '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',
281
414
  ),
282
415
  )
283
416
  .option(
@@ -1415,6 +1548,7 @@ const UPLOAD_GATEWAY_BASE = 'https://egaki.org'
1415
1548
  const MAX_UPLOAD_SIZE = 100 * 1024 * 1024
1416
1549
 
1417
1550
  cli.help()
1551
+ cli.completions()
1418
1552
  cli.version(pkg.version)
1419
1553
 
1420
1554
  export { cli }
@@ -0,0 +1,109 @@
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
+
8
+ import { describe, test, expect, afterAll } from 'vitest'
9
+ import fs from 'node:fs'
10
+ import os from 'node:os'
11
+ import path from 'node:path'
12
+ import dedent from 'string-dedent'
13
+ import { startDevServer, findFreePort, type RunningDevServer } from './dev-server.ts'
14
+
15
+ const cleanups: Array<() => Promise<void>> = []
16
+
17
+ afterAll(async () => {
18
+ for (const cleanup of cleanups) {
19
+ await cleanup()
20
+ }
21
+ })
22
+
23
+ function makeScratchProject(): string {
24
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'egaki-dev-test-'))
25
+ const MDX = dedent`
26
+ ---
27
+ width: 1920
28
+ height: 1080
29
+ fps: 30
30
+ ---
31
+
32
+ # Hello duration=2
33
+
34
+ Zero-config dev server test.
35
+ `
36
+ fs.writeFileSync(path.join(dir, 'video.mdx'), MDX)
37
+ cleanups.push(async () => {
38
+ fs.rmSync(dir, { recursive: true, force: true })
39
+ })
40
+ return dir
41
+ }
42
+
43
+ describe('findFreePort', () => {
44
+ test('returns a valid port number', async () => {
45
+ const port = await findFreePort()
46
+ expect(port).toBeGreaterThan(0)
47
+ expect(port).toBeLessThan(65536)
48
+ })
49
+ })
50
+
51
+ describe('startDevServer', () => {
52
+ test('returns Error for missing entry', async () => {
53
+ const result = await startDevServer({ entry: '/nonexistent/path/video.mdx' })
54
+ expect(result).toBeInstanceOf(Error)
55
+ if (result instanceof Error) {
56
+ expect(result.message).toContain('entry not found')
57
+ }
58
+ })
59
+
60
+ test('returns Error for directory without mdx files', async () => {
61
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'egaki-dev-empty-'))
62
+ cleanups.push(async () => {
63
+ fs.rmSync(dir, { recursive: true, force: true })
64
+ })
65
+ const result = await startDevServer({ entry: dir })
66
+ expect(result).toBeInstanceOf(Error)
67
+ if (result instanceof Error) {
68
+ expect(result.message).toContain('no .mdx files found')
69
+ }
70
+ })
71
+
72
+ test('returns Error for non-mdx file entry', async () => {
73
+ const dir = makeScratchProject()
74
+ const txtPath = path.join(dir, 'notes.txt')
75
+ fs.writeFileSync(txtPath, 'hi')
76
+ const result = await startDevServer({ entry: txtPath })
77
+ expect(result).toBeInstanceOf(Error)
78
+ if (result instanceof Error) {
79
+ expect(result.message).toContain('must be an .mdx file')
80
+ }
81
+ })
82
+
83
+ test('serves a scratch mdx file with no package.json or node_modules', async () => {
84
+ const dir = makeScratchProject()
85
+ const result = await startDevServer({
86
+ entry: path.join(dir, 'video.mdx'),
87
+ })
88
+ expect(result).not.toBeInstanceOf(Error)
89
+ const running = result as RunningDevServer
90
+ cleanups.push(() => running.close())
91
+
92
+ expect(running.root).toBe(dir)
93
+ expect(running.url).toMatch(/^http:\/\/localhost:\d+\/$/)
94
+
95
+ const response = await fetch(running.url)
96
+ expect(response.status).toBe(200)
97
+ expect(response.headers.get('content-type')).toContain('text/html')
98
+ const html = await response.text()
99
+ // RSC streaming: the doctype shell arrives in a separate flush, so
100
+ // assert on stable player UI markup instead of '<html'.
101
+ expect(html).toContain('Export MP4')
102
+
103
+ // The dependency shim was created and is CLI-owned
104
+ expect(fs.existsSync(path.join(dir, 'node_modules', '.egaki-shim'))).toBe(true)
105
+ expect(
106
+ fs.lstatSync(path.join(dir, 'node_modules', 'egaki')).isSymbolicLink(),
107
+ ).toBe(true)
108
+ }, 120_000)
109
+ })
@@ -0,0 +1,303 @@
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
+
22
+ import fs from 'node:fs'
23
+ import net from 'node:net'
24
+ import os from 'node:os'
25
+ import path from 'node:path'
26
+ import crypto from 'node:crypto'
27
+ import { createRequire } from 'node:module'
28
+ import { fileURLToPath } from 'node:url'
29
+ import type { ViteDevServer } from 'vite'
30
+ import { video } from '../vite/vite-plugin.ts'
31
+
32
+ // Package root: this file runs from src/cli/ (dev) or dist/cli/ (published).
33
+ // Going up 2 levels reaches the egaki package root either way.
34
+ const __pkgRoot = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '../..')
35
+ const egakiRequire = createRequire(path.join(__pkgRoot, 'package.json'))
36
+
37
+ const SHIM_MARKER = '.egaki-shim'
38
+
39
+ export interface StartDevServerOptions {
40
+ /** Path to an .mdx entry file, or a directory containing .mdx files.
41
+ * Defaults to the current working directory (auto-discovers video.mdx
42
+ * > index.mdx > first .mdx alphabetically). */
43
+ entry?: string
44
+ /** Fixed port. When omitted, a random free port is chosen. */
45
+ port?: number
46
+ /** Host to bind. Defaults to localhost. */
47
+ host?: string
48
+ }
49
+
50
+ export interface RunningDevServer {
51
+ url: string
52
+ port: number
53
+ root: string
54
+ server: ViteDevServer
55
+ close: () => Promise<void>
56
+ }
57
+
58
+ /** Find a random free TCP port by binding to port 0. */
59
+ export async function findFreePort(host = '127.0.0.1'): Promise<number> {
60
+ return new Promise((resolve, reject) => {
61
+ const probe = net.createServer()
62
+ probe.once('error', reject)
63
+ probe.listen(0, host, () => {
64
+ const address = probe.address() as net.AddressInfo
65
+ probe.close(() => resolve(address.port))
66
+ })
67
+ })
68
+ }
69
+
70
+ /** Locate the real directory of a package installed in egaki's own tree.
71
+ * Tries pkg/package.json first (works for most packages), then falls
72
+ * back to resolving the main entry and walking up to the package root
73
+ * (for packages whose exports map hides ./package.json). */
74
+ function packageDirFromEgaki(pkg: string): string | undefined {
75
+ try {
76
+ return path.dirname(fs.realpathSync(egakiRequire.resolve(`${pkg}/package.json`)))
77
+ } catch {
78
+ // Fall through to entry-based lookup
79
+ }
80
+ try {
81
+ let dir = path.dirname(fs.realpathSync(egakiRequire.resolve(pkg)))
82
+ while (true) {
83
+ const pkgJsonPath = path.join(dir, 'package.json')
84
+ if (fs.existsSync(pkgJsonPath)) {
85
+ const name = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).name
86
+ if (name === pkg) return dir
87
+ }
88
+ const parent = path.dirname(dir)
89
+ if (parent === dir) return undefined
90
+ dir = parent
91
+ }
92
+ } catch {
93
+ return undefined
94
+ }
95
+ }
96
+
97
+ /** All packages the shim should link: egaki itself plus every dependency
98
+ * and (present) optional/peer dependency of the egaki package. */
99
+ function shimPackageDirs(): Map<string, string> {
100
+ const pkgJson = JSON.parse(
101
+ fs.readFileSync(path.join(__pkgRoot, 'package.json'), 'utf-8'),
102
+ )
103
+ const names = new Set<string>([
104
+ ...Object.keys(pkgJson.dependencies ?? {}),
105
+ ...Object.keys(pkgJson.optionalDependencies ?? {}),
106
+ ...Object.keys(pkgJson.peerDependencies ?? {}),
107
+ ])
108
+ const dirs = new Map<string, string>()
109
+ dirs.set('egaki', fs.realpathSync(__pkgRoot))
110
+ for (const name of names) {
111
+ const dir = packageDirFromEgaki(name)
112
+ if (dir) dirs.set(name, dir)
113
+ }
114
+ return dirs
115
+ }
116
+
117
+ /** Ensure bare imports resolve from the project root.
118
+ *
119
+ * - egaki already resolvable from root → real project, do nothing
120
+ * - no node_modules (or a CLI-owned shim) → (re)create the shim
121
+ * - foreign node_modules without egaki → error with guidance
122
+ */
123
+ export function ensureDependencyShim(root: string): Error | 'normal' | 'shim' {
124
+ const nodeModulesDir = path.join(root, 'node_modules')
125
+ const isShim = fs.existsSync(path.join(nodeModulesDir, SHIM_MARKER))
126
+
127
+ if (!isShim) {
128
+ // Walk up from root looking for node_modules/egaki — a plain fs walk
129
+ // instead of createRequire().resolve() because test runners (tsx,
130
+ // vitest) patch Node's resolver and make it resolve 'egaki' from the
131
+ // wrong context, silently skipping shim creation.
132
+ let dir = root
133
+ while (true) {
134
+ if (fs.existsSync(path.join(dir, 'node_modules', 'egaki', 'package.json'))) {
135
+ return 'normal'
136
+ }
137
+ const parent = path.dirname(dir)
138
+ if (parent === dir) break
139
+ dir = parent
140
+ }
141
+ if (fs.existsSync(nodeModulesDir)) {
142
+ return new Error(
143
+ `${nodeModulesDir} exists but 'egaki' is not installed there.\n` +
144
+ `Either run 'npm install egaki' in ${root}, or remove node_modules ` +
145
+ `to let 'egaki dev' manage dependencies automatically.`,
146
+ )
147
+ }
148
+ }
149
+
150
+ fs.mkdirSync(nodeModulesDir, { recursive: true })
151
+ fs.writeFileSync(
152
+ path.join(nodeModulesDir, SHIM_MARKER),
153
+ 'This node_modules was generated by `egaki dev`. Safe to delete.\n',
154
+ )
155
+ for (const [name, targetDir] of shimPackageDirs()) {
156
+ const linkPath = path.join(nodeModulesDir, name)
157
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true })
158
+ const existing = fs.lstatSync(linkPath, { throwIfNoEntry: false })
159
+ if (existing) {
160
+ if (!existing.isSymbolicLink()) continue
161
+ if (fs.readlinkSync(linkPath) === targetDir) continue
162
+ fs.unlinkSync(linkPath)
163
+ }
164
+ fs.symlinkSync(targetDir, linkPath, 'dir')
165
+ }
166
+ return 'shim'
167
+ }
168
+
169
+ /** Walk up from a real path and collect the parent of every ancestor
170
+ * directory named node_modules. These parents are safe fs.allow roots
171
+ * that cover hoisted/pnpm-store dependency locations. */
172
+ function nodeModulesAncestorRoots(realPath: string): string[] {
173
+ const roots: string[] = []
174
+ let dir = realPath
175
+ while (true) {
176
+ const parent = path.dirname(dir)
177
+ if (parent === dir) break
178
+ if (path.basename(dir) === 'node_modules') roots.push(parent)
179
+ dir = parent
180
+ }
181
+ return roots
182
+ }
183
+
184
+ /** Compute fs.allow roots so Vite can serve framework + dependency files
185
+ * that live outside the user's project root (in the CLI's install tree). */
186
+ function computeFsAllow(root: string): string[] {
187
+ const allow = new Set<string>([root])
188
+ const probes = [path.join(__pkgRoot, 'package.json')]
189
+ for (const id of ['react', 'react-dom', 'remotion', 'vite', 'spiceflow']) {
190
+ try {
191
+ probes.push(egakiRequire.resolve(id))
192
+ } catch {
193
+ // Probe missing: skip
194
+ }
195
+ }
196
+ for (const probe of probes) {
197
+ try {
198
+ const real = fs.realpathSync(probe)
199
+ allow.add(path.dirname(real))
200
+ for (const ancestorRoot of nodeModulesAncestorRoots(real)) {
201
+ allow.add(ancestorRoot)
202
+ }
203
+ } catch {
204
+ // Probe missing: skip
205
+ }
206
+ }
207
+ allow.add(fs.realpathSync(__pkgRoot))
208
+ return [...allow]
209
+ }
210
+
211
+ /** Resolve the entry option to { root, entryFile }.
212
+ * Accepts a file path, a directory, or nothing (cwd). */
213
+ function resolveEntry(entry: string | undefined): Error | { root: string; entryFile?: string } {
214
+ const target = path.resolve(process.cwd(), entry ?? '.')
215
+ if (!fs.existsSync(target)) {
216
+ return new Error(`entry not found: ${target}`)
217
+ }
218
+ const stat = fs.statSync(target)
219
+ if (stat.isDirectory()) {
220
+ const hasMdx = fs
221
+ .readdirSync(target)
222
+ .some((name) => name.endsWith('.mdx'))
223
+ if (!hasMdx) {
224
+ return new Error(
225
+ `no .mdx files found in ${target}\nCreate a video.mdx file or pass one explicitly: egaki dev path/to/video.mdx`,
226
+ )
227
+ }
228
+ return { root: target }
229
+ }
230
+ if (!/\.mdx$/.test(target)) {
231
+ return new Error(`entry must be an .mdx file or a directory, got: ${target}`)
232
+ }
233
+ return { root: path.dirname(target), entryFile: target }
234
+ }
235
+
236
+ /** Warm up the server: the very first request can 500 transiently while
237
+ * the dep optimizer discovers new deps and reloads the ssr program (a
238
+ * browser recovers via auto-reload, but agents/tests hit the URL once).
239
+ * Retry until the page serves 200 so the returned URL is ready to use. */
240
+ async function warmUp(url: string, timeoutMs: number): Promise<void> {
241
+ const deadline = Date.now() + timeoutMs
242
+ while (Date.now() < deadline) {
243
+ try {
244
+ const response = await fetch(url)
245
+ if (response.ok) return
246
+ } catch {
247
+ // Server not accepting connections yet
248
+ }
249
+ await new Promise((resolve) => setTimeout(resolve, 300))
250
+ }
251
+ console.warn(`[egaki] dev server warmup did not reach 200 within ${timeoutMs}ms`)
252
+ }
253
+
254
+ /** Start the zero-config dev server. Returns Error | RunningDevServer. */
255
+ export async function startDevServer(
256
+ options: StartDevServerOptions = {},
257
+ ): Promise<Error | RunningDevServer> {
258
+ const entryResult = resolveEntry(options.entry)
259
+ if (entryResult instanceof Error) return entryResult
260
+ const { root, entryFile } = entryResult
261
+
262
+ const shimResult = ensureDependencyShim(root)
263
+ if (shimResult instanceof Error) return shimResult
264
+
265
+ const host = options.host ?? 'localhost'
266
+ const port = options.port ?? (await findFreePort())
267
+
268
+ const rootHash = crypto.createHash('sha256').update(root).digest('hex').slice(0, 12)
269
+ const cacheDir = path.join(os.homedir(), '.cache', 'egaki', 'vite', rootHash)
270
+
271
+ const { createServer } = await import('vite')
272
+
273
+ const server = await createServer({
274
+ configFile: false,
275
+ root,
276
+ cacheDir,
277
+ plugins: [video(entryFile ? { entry: entryFile } : undefined)],
278
+ resolve: {
279
+ dedupe: ['react', 'react-dom', 'remotion', '@remotion/player', '@remotion/media'],
280
+ },
281
+ server: {
282
+ port,
283
+ strictPort: true,
284
+ host,
285
+ fs: {
286
+ allow: computeFsAllow(root),
287
+ },
288
+ },
289
+ })
290
+
291
+ await server.listen()
292
+
293
+ const url = `http://${host}:${port}/`
294
+ await warmUp(url, 60_000)
295
+
296
+ return {
297
+ url,
298
+ port,
299
+ root,
300
+ server,
301
+ close: () => server.close(),
302
+ }
303
+ }