screenci 0.0.4 → 0.0.6

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 (94) hide show
  1. package/README.md +227 -0
  2. package/cli.ts +1111 -0
  3. package/dist/cli.d.ts +4 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +896 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/e2e/instrument.e2e.d.ts +2 -0
  8. package/dist/e2e/instrument.e2e.d.ts.map +1 -0
  9. package/dist/e2e/instrument.e2e.js +661 -0
  10. package/dist/e2e/instrument.e2e.js.map +1 -0
  11. package/dist/index.d.ts +18 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +15 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/playwright.config.d.ts +3 -0
  16. package/dist/playwright.config.d.ts.map +1 -0
  17. package/dist/playwright.config.js +21 -0
  18. package/dist/playwright.config.js.map +1 -0
  19. package/dist/reporter.d.ts +9 -0
  20. package/dist/reporter.d.ts.map +1 -0
  21. package/dist/reporter.js +49 -0
  22. package/dist/reporter.js.map +1 -0
  23. package/dist/src/asset.d.ts +90 -0
  24. package/dist/src/asset.d.ts.map +1 -0
  25. package/dist/src/asset.js +74 -0
  26. package/dist/src/asset.js.map +1 -0
  27. package/dist/src/autoZoom.d.ts +40 -0
  28. package/dist/src/autoZoom.d.ts.map +1 -0
  29. package/dist/src/autoZoom.js +88 -0
  30. package/dist/src/autoZoom.js.map +1 -0
  31. package/dist/src/caption.d.ts +152 -0
  32. package/dist/src/caption.d.ts.map +1 -0
  33. package/dist/src/caption.js +240 -0
  34. package/dist/src/caption.js.map +1 -0
  35. package/dist/src/caption.test-d.d.ts +2 -0
  36. package/dist/src/caption.test-d.d.ts.map +1 -0
  37. package/dist/src/caption.test-d.js +50 -0
  38. package/dist/src/caption.test-d.js.map +1 -0
  39. package/dist/src/config.d.ts +42 -0
  40. package/dist/src/config.d.ts.map +1 -0
  41. package/dist/src/config.js +147 -0
  42. package/dist/src/config.js.map +1 -0
  43. package/dist/src/defaults.d.ts +63 -0
  44. package/dist/src/defaults.d.ts.map +1 -0
  45. package/dist/src/defaults.js +66 -0
  46. package/dist/src/defaults.js.map +1 -0
  47. package/dist/src/dimensions.d.ts +29 -0
  48. package/dist/src/dimensions.d.ts.map +1 -0
  49. package/dist/src/dimensions.js +47 -0
  50. package/dist/src/dimensions.js.map +1 -0
  51. package/dist/src/events.d.ts +203 -0
  52. package/dist/src/events.d.ts.map +1 -0
  53. package/dist/src/events.js +227 -0
  54. package/dist/src/events.js.map +1 -0
  55. package/dist/src/hide.d.ts +27 -0
  56. package/dist/src/hide.d.ts.map +1 -0
  57. package/dist/src/hide.js +49 -0
  58. package/dist/src/hide.js.map +1 -0
  59. package/dist/src/instrument.d.ts +15 -0
  60. package/dist/src/instrument.d.ts.map +1 -0
  61. package/dist/src/instrument.js +910 -0
  62. package/dist/src/instrument.js.map +1 -0
  63. package/dist/src/logger.d.ts +7 -0
  64. package/dist/src/logger.d.ts.map +1 -0
  65. package/dist/src/logger.js +13 -0
  66. package/dist/src/logger.js.map +1 -0
  67. package/dist/src/reporter.d.ts +9 -0
  68. package/dist/src/reporter.d.ts.map +1 -0
  69. package/dist/src/reporter.js +50 -0
  70. package/dist/src/reporter.js.map +1 -0
  71. package/dist/src/sanitize.d.ts +5 -0
  72. package/dist/src/sanitize.d.ts.map +1 -0
  73. package/dist/src/sanitize.js +11 -0
  74. package/dist/src/sanitize.js.map +1 -0
  75. package/dist/src/types.d.ts +544 -0
  76. package/dist/src/types.d.ts.map +1 -0
  77. package/dist/src/types.js +2 -0
  78. package/dist/src/types.js.map +1 -0
  79. package/dist/src/video.d.ts +138 -0
  80. package/dist/src/video.d.ts.map +1 -0
  81. package/dist/src/video.js +415 -0
  82. package/dist/src/video.js.map +1 -0
  83. package/dist/src/voices.d.ts +60 -0
  84. package/dist/src/voices.d.ts.map +1 -0
  85. package/dist/src/voices.js +42 -0
  86. package/dist/src/voices.js.map +1 -0
  87. package/dist/src/xvfb.d.ts +22 -0
  88. package/dist/src/xvfb.d.ts.map +1 -0
  89. package/dist/src/xvfb.js +87 -0
  90. package/dist/src/xvfb.js.map +1 -0
  91. package/dist/tsconfig.tsbuildinfo +1 -0
  92. package/package.json +45 -4
  93. package/bin/index.js +0 -3
  94. package/index.js +0 -1
package/dist/cli.js ADDED
@@ -0,0 +1,896 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ import { spawn, spawnSync } from 'child_process';
3
+ import { createReadStream } from 'fs';
4
+ import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from 'fs';
5
+ import { createHash } from 'crypto';
6
+ import { createServer } from 'http';
7
+ import { mkdir, readdir, readFile, stat, writeFile } from 'fs/promises';
8
+ import { dirname, relative as pathRelative, resolve } from 'path';
9
+ import { createInterface } from 'readline/promises';
10
+ import { fileURLToPath } from 'url';
11
+ import { logger } from './src/logger.js';
12
+ function clearDirectory(dir) {
13
+ mkdirSync(dir, { recursive: true });
14
+ for (const entry of readdirSync(dir)) {
15
+ rmSync(resolve(dir, entry), { recursive: true, force: true });
16
+ }
17
+ }
18
+ function findScreenCIConfig(customPath) {
19
+ if (customPath) {
20
+ const resolvedPath = resolve(process.cwd(), customPath);
21
+ if (existsSync(resolvedPath)) {
22
+ return resolvedPath;
23
+ }
24
+ return null;
25
+ }
26
+ const cwd = process.cwd();
27
+ const configPath = resolve(cwd, 'screenci.config.ts');
28
+ if (existsSync(configPath)) {
29
+ return configPath;
30
+ }
31
+ return null;
32
+ }
33
+ function findRepoRoot(startDir) {
34
+ let current = startDir;
35
+ while (true) {
36
+ if (existsSync(resolve(current, '.git')) ||
37
+ existsSync(resolve(current, 'pnpm-workspace.yaml'))) {
38
+ return current;
39
+ }
40
+ const parent = resolve(current, '..');
41
+ if (parent === current)
42
+ return null;
43
+ current = parent;
44
+ }
45
+ }
46
+ function parseArgs(args) {
47
+ const command = args[0];
48
+ if (command === undefined) {
49
+ logger.error('Error: No command provided');
50
+ logger.error('Available commands: record, dev, upload-latest, init');
51
+ process.exit(1);
52
+ }
53
+ let configPath;
54
+ let noContainer = false;
55
+ const otherArgs = [];
56
+ for (let i = 1; i < args.length; i++) {
57
+ const arg = args[i];
58
+ if (arg === '--config' || arg === '-c') {
59
+ const nextArg = args[i + 1];
60
+ if (nextArg !== undefined) {
61
+ configPath = nextArg;
62
+ i++; // skip next arg
63
+ }
64
+ else {
65
+ logger.error('Error: --config requires a path argument');
66
+ process.exit(1);
67
+ }
68
+ }
69
+ else if (arg === '--no-container') {
70
+ noContainer = true;
71
+ }
72
+ else if (arg !== undefined) {
73
+ otherArgs.push(arg);
74
+ }
75
+ }
76
+ return { command, configPath, noContainer, otherArgs };
77
+ }
78
+ async function findLatestEntry(screenciDir) {
79
+ let entries;
80
+ try {
81
+ entries = await readdir(screenciDir);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ let latestEntry = null;
87
+ let latestMtime = 0;
88
+ for (const entry of entries) {
89
+ try {
90
+ const entryPath = resolve(screenciDir, entry);
91
+ const s = await stat(entryPath);
92
+ if (s.mtimeMs > latestMtime) {
93
+ latestMtime = s.mtimeMs;
94
+ latestEntry = entry;
95
+ }
96
+ }
97
+ catch {
98
+ // skip unreadable entries
99
+ }
100
+ }
101
+ return latestEntry;
102
+ }
103
+ async function uploadAssets(data, apiUrl, secret, recordingId, configDir) {
104
+ const assetEvents = data.events.filter((e) => e.type === 'assetStart');
105
+ if (assetEvents.length === 0)
106
+ return;
107
+ // Deduplicate by name — each unique asset name is uploaded once
108
+ const seenNames = new Set();
109
+ for (const event of assetEvents) {
110
+ const assetPath = event.path;
111
+ if (seenNames.has(event.name))
112
+ continue;
113
+ seenNames.add(event.name);
114
+ // Resolve the asset file. Recording runs in a Docker container where configDir → /app,
115
+ // so stored paths may be container-internal absolute or relative paths.
116
+ // Resolution order:
117
+ // 1. Path as-is (works for absolute host paths)
118
+ // 2. Relative path resolved from configDir/videos (the video scripts directory)
119
+ // 3. Container path translated: /some/path → configDir/../some/path
120
+ const candidates = [
121
+ assetPath,
122
+ resolve(configDir, 'videos', assetPath),
123
+ resolve(configDir, pathRelative('/app', assetPath)),
124
+ ];
125
+ let fileBuffer;
126
+ let resolvedPath = assetPath;
127
+ for (const candidate of candidates) {
128
+ try {
129
+ fileBuffer = await readFile(candidate);
130
+ resolvedPath = candidate;
131
+ break;
132
+ }
133
+ catch {
134
+ // try next
135
+ }
136
+ }
137
+ if (fileBuffer === undefined) {
138
+ logger.warn(`Asset file not found, skipping upload: ${assetPath}`);
139
+ continue;
140
+ }
141
+ const sha256 = createHash('sha256').update(fileBuffer).digest('hex');
142
+ const ext = assetPath.split('.').pop()?.toLowerCase() ?? 'bin';
143
+ const contentTypeMap = {
144
+ png: 'image/png',
145
+ jpg: 'image/jpeg',
146
+ jpeg: 'image/jpeg',
147
+ gif: 'image/gif',
148
+ webp: 'image/webp',
149
+ mp4: 'video/mp4',
150
+ webm: 'video/webm',
151
+ svg: 'image/svg+xml',
152
+ };
153
+ const contentType = contentTypeMap[ext] ?? 'application/octet-stream';
154
+ try {
155
+ const res = await fetch(`${apiUrl}/cli/upload/${recordingId}/asset`, {
156
+ method: 'PUT',
157
+ headers: {
158
+ 'Content-Type': 'application/json',
159
+ 'X-ScreenCI-Secret': secret,
160
+ },
161
+ body: JSON.stringify({
162
+ sha256,
163
+ fileBase64: fileBuffer.toString('base64'),
164
+ contentType,
165
+ assetName: event.name,
166
+ }),
167
+ });
168
+ if (!res.ok) {
169
+ const text = await res.text();
170
+ logger.warn(`Failed to upload asset ${assetPath}: ${res.status} ${text}`);
171
+ }
172
+ else {
173
+ logger.info(`Asset uploaded: ${assetPath}`);
174
+ }
175
+ }
176
+ catch (err) {
177
+ logger.warn(`Network error uploading asset ${assetPath}:`, err);
178
+ }
179
+ }
180
+ }
181
+ async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specificEntry) {
182
+ let entries;
183
+ try {
184
+ entries = await readdir(screenciDir);
185
+ }
186
+ catch {
187
+ logger.warn('No .screenci directory found, skipping upload');
188
+ return;
189
+ }
190
+ if (specificEntry !== undefined) {
191
+ entries = entries.filter((e) => e === specificEntry);
192
+ }
193
+ for (const entry of entries) {
194
+ const dataJsonPath = resolve(screenciDir, entry, 'data.json');
195
+ if (!existsSync(dataJsonPath))
196
+ continue;
197
+ let data;
198
+ try {
199
+ const raw = await readFile(dataJsonPath, 'utf-8');
200
+ data = JSON.parse(raw);
201
+ }
202
+ catch {
203
+ logger.warn(`Failed to read ${dataJsonPath}, skipping`);
204
+ continue;
205
+ }
206
+ const videoName = data.metadata?.videoName ?? entry;
207
+ logger.info(`Uploading "${videoName}"...`);
208
+ try {
209
+ // Step 1: register upload and get recordingId
210
+ const startResponse = await fetch(`${apiUrl}/cli/upload/start`, {
211
+ method: 'POST',
212
+ headers: {
213
+ 'Content-Type': 'application/json',
214
+ 'X-ScreenCI-Secret': secret,
215
+ },
216
+ body: JSON.stringify({ projectName, videoName, data }),
217
+ });
218
+ if (!startResponse.ok) {
219
+ const text = await startResponse.text();
220
+ logger.warn(`Failed to start upload for "${videoName}": ${startResponse.status} ${text}`);
221
+ continue;
222
+ }
223
+ const { recordingId } = (await startResponse.json());
224
+ // Step 1b: upload asset files referenced in data.json
225
+ await uploadAssets(data, apiUrl, secret, recordingId, resolve(screenciDir, '..'));
226
+ // Step 2: stream the recording video file (if it exists)
227
+ const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
228
+ if (existsSync(recordingPath)) {
229
+ const fileStat = await stat(recordingPath);
230
+ const stream = createReadStream(recordingPath);
231
+ const recordingResponse = await fetch(`${apiUrl}/cli/upload/${recordingId}/recording`, {
232
+ method: 'PUT',
233
+ headers: {
234
+ 'Content-Type': 'video/mp4',
235
+ 'Content-Length': String(fileStat.size),
236
+ 'X-ScreenCI-Secret': secret,
237
+ },
238
+ body: stream,
239
+ // @ts-expect-error Node.js fetch supports duplex for streaming
240
+ duplex: 'half',
241
+ });
242
+ if (!recordingResponse.ok) {
243
+ const text = await recordingResponse.text();
244
+ logger.warn(`Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}`);
245
+ continue;
246
+ }
247
+ }
248
+ logger.info(`Uploaded "${videoName}" successfully`);
249
+ }
250
+ catch (err) {
251
+ logger.warn(`Network error uploading "${videoName}":`, err);
252
+ }
253
+ }
254
+ }
255
+ async function uploadLatest(configPath) {
256
+ const resolvedConfigPath = findScreenCIConfig(configPath);
257
+ if (!resolvedConfigPath) {
258
+ const errorMsg = configPath
259
+ ? `Error: Config file not found: ${configPath}`
260
+ : 'Error: screenci.config.ts not found in current directory';
261
+ logger.error(errorMsg);
262
+ process.exit(1);
263
+ }
264
+ let screenciConfig;
265
+ try {
266
+ const configModule = await import(resolvedConfigPath);
267
+ screenciConfig = configModule.default;
268
+ }
269
+ catch (err) {
270
+ logger.error('Failed to load config:', err);
271
+ process.exit(1);
272
+ }
273
+ if (screenciConfig.envFile) {
274
+ const envFilePath = resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
275
+ try {
276
+ process.loadEnvFile(envFilePath);
277
+ }
278
+ catch (err) {
279
+ logger.warn(`Failed to load env file ${envFilePath}:`, err);
280
+ }
281
+ }
282
+ const convexUrl = screenciConfig.apiUrl ?? process.env.SCREENCI_URL;
283
+ if (!convexUrl) {
284
+ logger.error('No API URL configured. Set apiUrl in screenci.config.ts or SCREENCI_URL env var.');
285
+ process.exit(1);
286
+ }
287
+ const secret = process.env.SCREENCI_SECRET;
288
+ if (!secret) {
289
+ logger.error('No secret configured. Set SCREENCI_SECRET in your .env file (get it from the API Key page in the dashboard).');
290
+ process.exit(1);
291
+ }
292
+ const configDir = dirname(resolvedConfigPath);
293
+ const screenciDir = resolve(configDir, '.screenci');
294
+ const latestEntry = await findLatestEntry(screenciDir);
295
+ if (!latestEntry) {
296
+ logger.warn('No recordings found in .screenci directory');
297
+ return;
298
+ }
299
+ logger.info(`Uploading latest recording: "${latestEntry}"`);
300
+ await uploadRecordings(screenciDir, screenciConfig.projectName, convexUrl, secret, latestEntry);
301
+ }
302
+ function generateConfig(projectName) {
303
+ return `import { defineConfig } from 'screenci'
304
+
305
+ export default defineConfig({
306
+ projectName: ${JSON.stringify(projectName)},
307
+ apiUrl: process.env.SCREENCI_URL ?? 'http://localhost:8787',
308
+ envFile: '.env',
309
+ videoDir: './videos',
310
+ forbidOnly: !!process.env.CI,
311
+ reporter: 'html',
312
+ use: {
313
+ trace: 'retain-on-failure',
314
+ sendTraces: true,
315
+ recordOptions: {
316
+ aspectRatio: '16:9',
317
+ quality: '1080p',
318
+ fps: 30,
319
+ },
320
+ },
321
+ projects: [
322
+ {
323
+ name: 'chromium',
324
+ },
325
+ ],
326
+ })
327
+ `;
328
+ }
329
+ function generatePackageJson(projectName, localPackagePath) {
330
+ const npmName = projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
331
+ const screenciVersion = localPackagePath
332
+ ? `file:${localPackagePath}`
333
+ : 'latest';
334
+ return (JSON.stringify({
335
+ name: npmName,
336
+ version: '1.0.0',
337
+ description: '',
338
+ type: 'module',
339
+ scripts: {
340
+ record: 'screenci record',
341
+ 'upload-latest': 'screenci upload-latest',
342
+ dev: 'screenci dev',
343
+ },
344
+ dependencies: {
345
+ screenci: screenciVersion,
346
+ },
347
+ devDependencies: {
348
+ '@types/node': '^25.0.0',
349
+ tsx: '^4.21.0',
350
+ },
351
+ }, null, 2) + '\n');
352
+ }
353
+ function generateDockerfile() {
354
+ return `FROM screenci
355
+
356
+ COPY package.json ./
357
+ COPY screenci.config.ts ./
358
+ COPY videos ./videos
359
+ `;
360
+ }
361
+ function generateGitignore() {
362
+ return `/playwright-report/
363
+ .screenci
364
+ node_modules/
365
+ .env
366
+ `;
367
+ }
368
+ function generateGithubAction() {
369
+ return `name: Record
370
+
371
+ on:
372
+ push:
373
+ branches: [main]
374
+ workflow_dispatch:
375
+
376
+ jobs:
377
+ record:
378
+ runs-on: ubuntu-latest
379
+ steps:
380
+ - name: Check SCREENCI_SECRET
381
+ env:
382
+ SCREENCI_SECRET: \${{ secrets.SCREENCI_SECRET }}
383
+ run: |
384
+ if [ -z "$SCREENCI_SECRET" ]; then
385
+ echo "::error::SCREENCI_SECRET is not set. Add it under Settings → Secrets and variables → Actions."
386
+ exit 1
387
+ fi
388
+
389
+ - uses: actions/checkout@v4
390
+
391
+ - name: Build Docker image
392
+ run: docker build -t screenci-project .
393
+
394
+ - name: Record
395
+ env:
396
+ SCREENCI_SECRET: \${{ secrets.SCREENCI_SECRET }}
397
+ run: |
398
+ docker run --rm \\
399
+ -e SCREENCI_SECRET \\
400
+ -e SCREENCI_IN_CONTAINER=true \\
401
+ -e SCREENCI_RECORD=true \\
402
+ screenci-project \\
403
+ npm run record
404
+ `;
405
+ }
406
+ function openBrowser(url) {
407
+ const cmd = process.platform === 'darwin'
408
+ ? 'open'
409
+ : process.platform === 'win32'
410
+ ? 'start'
411
+ : 'xdg-open';
412
+ spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
413
+ }
414
+ async function performBrowserLogin(appUrl) {
415
+ return new Promise((resolve, reject) => {
416
+ const server = createServer((req, res) => {
417
+ try {
418
+ const reqUrl = new URL(req.url ?? '/', 'http://localhost');
419
+ const secret = reqUrl.searchParams.get('secret');
420
+ if (secret) {
421
+ res.writeHead(200, { 'Content-Type': 'text/html' });
422
+ res.end('<html><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><p style="font-size:1.2rem">Authentication successful! You can close this tab.</p></body></html>');
423
+ server.close();
424
+ resolve(secret);
425
+ }
426
+ else {
427
+ res.writeHead(400, { 'Content-Type': 'text/html' });
428
+ res.end('<html><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><p style="color:red;font-size:1.2rem">Authentication failed: no secret received. Please try again.</p></body></html>');
429
+ server.close();
430
+ reject(new Error('No secret received in callback'));
431
+ }
432
+ }
433
+ catch (err) {
434
+ res.writeHead(500);
435
+ res.end('Internal error');
436
+ server.close();
437
+ reject(err);
438
+ }
439
+ });
440
+ server.listen(0, '127.0.0.1', () => {
441
+ const port = server.address().port;
442
+ const callbackUrl = `http://localhost:${port}/callback`;
443
+ const loginUrl = `${appUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
444
+ logger.info('Opening browser for authentication...');
445
+ logger.info(`If the browser does not open automatically, visit:`);
446
+ logger.info(` ${loginUrl}`);
447
+ openBrowser(loginUrl);
448
+ });
449
+ const timeout = setTimeout(() => {
450
+ server.close();
451
+ reject(new Error('Authentication timed out after 5 minutes'));
452
+ }, 5 * 60 * 1000);
453
+ server.on('close', () => clearTimeout(timeout));
454
+ });
455
+ }
456
+ function generateExampleVideo() {
457
+ return `import { video } from 'screenci'
458
+
459
+ video('Example video', async ({ page }) => {
460
+ await page.goto('https://example.com')
461
+ await page.waitForTimeout(3000)
462
+ })
463
+ `;
464
+ }
465
+ async function promptLine(question) {
466
+ const rl = createInterface({
467
+ input: process.stdin,
468
+ output: process.stdout,
469
+ });
470
+ try {
471
+ const answer = await rl.question(question);
472
+ return answer.trim();
473
+ }
474
+ finally {
475
+ rl.close();
476
+ }
477
+ }
478
+ async function promptProjectName() {
479
+ return promptLine('Project name: ');
480
+ }
481
+ function toKebabCase(name) {
482
+ return name
483
+ .toLowerCase()
484
+ .replace(/\s+/g, '-')
485
+ .replace(/[^a-z0-9-]/g, '-')
486
+ .replace(/-+/g, '-')
487
+ .replace(/^-|-$/g, '');
488
+ }
489
+ async function runInitAuth() {
490
+ const devPort = process.env.DEV_PORT;
491
+ const appUrl = process.env.SCREENCI_APP_URL ??
492
+ (devPort ? `http://localhost:${devPort}` : 'https://app.screenci.com');
493
+ try {
494
+ const secret = await performBrowserLogin(appUrl);
495
+ await writeFile(resolve(process.cwd(), '.env'), `SCREENCI_SECRET=${secret}\n`);
496
+ logger.info('API key saved to .env');
497
+ }
498
+ catch (err) {
499
+ const msg = err instanceof Error ? err.message : String(err);
500
+ logger.warn(`Authentication failed: ${msg}`);
501
+ logger.info('You can add SCREENCI_SECRET manually to .env later (get it from the API Key page in the dashboard).');
502
+ }
503
+ }
504
+ function checkNodeVersion() {
505
+ const [major] = process.versions.node.split('.').map(Number);
506
+ if (major === undefined || major < 18) {
507
+ logger.error(`Error: Node.js 18 or higher is required (current: v${process.versions.node})`);
508
+ process.exit(1);
509
+ }
510
+ }
511
+ async function runInit(projectNameArg, localPackagePath) {
512
+ checkNodeVersion();
513
+ let projectName = projectNameArg?.trim();
514
+ if (!projectName) {
515
+ projectName = await promptProjectName();
516
+ }
517
+ if (!projectName) {
518
+ logger.error('Error: Project name is required');
519
+ process.exit(1);
520
+ }
521
+ const dirName = toKebabCase(projectName);
522
+ const projectDir = resolve(process.cwd(), dirName);
523
+ if (existsSync(projectDir)) {
524
+ logger.error(`Error: Directory "${dirName}" already exists`);
525
+ process.exit(1);
526
+ }
527
+ await mkdir(resolve(projectDir, 'videos'), { recursive: true });
528
+ await mkdir(resolve(projectDir, '.github', 'workflows'), { recursive: true });
529
+ await writeFile(resolve(projectDir, 'screenci.config.ts'), generateConfig(projectName));
530
+ await writeFile(resolve(projectDir, 'package.json'), generatePackageJson(dirName, localPackagePath));
531
+ await writeFile(resolve(projectDir, 'Dockerfile'), generateDockerfile());
532
+ await writeFile(resolve(projectDir, '.gitignore'), generateGitignore());
533
+ await writeFile(resolve(projectDir, 'videos', 'example.video.ts'), generateExampleVideo());
534
+ await writeFile(resolve(projectDir, '.github', 'workflows', 'record.yml'), generateGithubAction());
535
+ logger.info(`Initialized screenci project "${projectName}" in ${dirName}/`);
536
+ logger.info('Files created:');
537
+ logger.info(' screenci.config.ts');
538
+ logger.info(' package.json');
539
+ logger.info(' Dockerfile');
540
+ logger.info(' .gitignore');
541
+ logger.info(' videos/example.video.ts');
542
+ logger.info(' .github/workflows/record.yml');
543
+ logger.info('');
544
+ logger.info('Running npm install...');
545
+ await spawnInherited('npm', ['install', '--prefix', projectDir]);
546
+ logger.info('');
547
+ logger.info('Next steps:');
548
+ logger.info(` cd ${dirName}`);
549
+ logger.info(' screenci record');
550
+ }
551
+ export async function main() {
552
+ const args = process.argv.slice(2);
553
+ const { command, configPath, noContainer, otherArgs } = parseArgs(args);
554
+ switch (command) {
555
+ case 'record': {
556
+ const useContainer = !noContainer && process.env.SCREENCI_IN_CONTAINER !== 'true';
557
+ // Validate early so we don't build the container unnecessarily
558
+ if (useContainer) {
559
+ validateArgs(otherArgs);
560
+ }
561
+ // On the host, acquire secret before recording if missing
562
+ if (process.env.SCREENCI_IN_CONTAINER !== 'true') {
563
+ const resolvedConfigForSecret = findScreenCIConfig(configPath);
564
+ if (resolvedConfigForSecret) {
565
+ let envFilePath = null;
566
+ try {
567
+ const configModule = await import(resolvedConfigForSecret);
568
+ const screenciConfig = configModule.default;
569
+ envFilePath = screenciConfig.envFile
570
+ ? resolve(dirname(resolvedConfigForSecret), screenciConfig.envFile)
571
+ : null;
572
+ if (envFilePath) {
573
+ try {
574
+ process.loadEnvFile(envFilePath);
575
+ }
576
+ catch {
577
+ // env file may not exist yet
578
+ }
579
+ }
580
+ }
581
+ catch (err) {
582
+ if (!process.env.SCREENCI_SECRET) {
583
+ const msg = err instanceof Error ? err.message : String(err);
584
+ logger.error(`Failed to acquire secret: ${msg}`);
585
+ process.exit(1);
586
+ }
587
+ // Config import failed but SCREENCI_SECRET is already in env — continue
588
+ }
589
+ if (!process.env.SCREENCI_SECRET) {
590
+ logger.info('SCREENCI_SECRET not found. Opening browser to sign in and select a plan...');
591
+ const devPort = process.env.DEV_PORT;
592
+ const appUrl = process.env.SCREENCI_APP_URL ??
593
+ (devPort
594
+ ? `http://localhost:${devPort}`
595
+ : 'https://app.screenci.com');
596
+ const secret = await performBrowserLogin(appUrl);
597
+ const savePath = envFilePath ?? resolve(dirname(resolvedConfigForSecret), '.env');
598
+ await writeFile(savePath, `SCREENCI_SECRET=${secret}\n`);
599
+ process.env.SCREENCI_SECRET = secret;
600
+ logger.info('API key saved.');
601
+ }
602
+ }
603
+ }
604
+ if (useContainer) {
605
+ await runWithContainer(otherArgs, configPath);
606
+ }
607
+ else {
608
+ await run(command, otherArgs, configPath);
609
+ }
610
+ // Upload only from the host, not from inside the container
611
+ if (process.env.SCREENCI_IN_CONTAINER === 'true')
612
+ break;
613
+ // After recording, upload results to Convex if configured
614
+ const resolvedConfigPath = findScreenCIConfig(configPath);
615
+ if (resolvedConfigPath) {
616
+ try {
617
+ const configModule = await import(resolvedConfigPath);
618
+ const screenciConfig = configModule.default;
619
+ if (screenciConfig.envFile) {
620
+ const envFilePath = resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
621
+ try {
622
+ process.loadEnvFile(envFilePath);
623
+ }
624
+ catch (err) {
625
+ logger.warn(`Failed to load env file ${envFilePath}:`, err);
626
+ }
627
+ }
628
+ const convexUrl = screenciConfig.apiUrl ?? process.env.SCREENCI_URL;
629
+ if (!convexUrl) {
630
+ logger.info('No API URL configured, skipping upload. Set apiUrl in screenci.config.ts or SCREENCI_URL env var.');
631
+ break;
632
+ }
633
+ const secret = process.env.SCREENCI_SECRET;
634
+ if (!secret) {
635
+ logger.info('No secret configured, skipping upload. Set SCREENCI_SECRET in your .env file.');
636
+ break;
637
+ }
638
+ const configDir = dirname(resolvedConfigPath);
639
+ const screenciDir = resolve(configDir, '.screenci');
640
+ await uploadRecordings(screenciDir, screenciConfig.projectName, convexUrl, secret);
641
+ }
642
+ catch (err) {
643
+ logger.warn('Failed to load config for upload:', err);
644
+ }
645
+ }
646
+ break;
647
+ }
648
+ case 'dev':
649
+ await run(command, otherArgs, configPath);
650
+ break;
651
+ case 'upload-latest':
652
+ await uploadLatest(configPath);
653
+ break;
654
+ case 'init': {
655
+ if (otherArgs[0] === 'auth') {
656
+ await runInitAuth();
657
+ }
658
+ else {
659
+ const localFlagIndex = otherArgs.indexOf('--local');
660
+ let localPackagePath;
661
+ let initArgs = otherArgs;
662
+ if (localFlagIndex !== -1) {
663
+ const cliDir = dirname(fileURLToPath(import.meta.url));
664
+ // cli.ts is at package root; dist/cli.js is one level down
665
+ localPackagePath = existsSync(resolve(cliDir, 'package.json'))
666
+ ? cliDir
667
+ : resolve(cliDir, '..');
668
+ initArgs = otherArgs.filter((_, i) => i !== localFlagIndex);
669
+ }
670
+ await runInit(initArgs[0], localPackagePath);
671
+ }
672
+ break;
673
+ }
674
+ default:
675
+ logger.error(`Unknown command: ${command}`);
676
+ logger.error('Available commands: record, dev, upload-latest, init');
677
+ process.exit(1);
678
+ }
679
+ }
680
+ function validateArgs(args) {
681
+ const disallowedFlags = ['--fully-parallel', '--workers', '-j', '--retries'];
682
+ for (const arg of args) {
683
+ if (arg === undefined)
684
+ continue;
685
+ // Check if it's a disallowed flag
686
+ if (disallowedFlags.includes(arg)) {
687
+ throw new Error(`Flag "${arg}" is not supported by screenci. ` +
688
+ 'screenci enforces sequential test execution with a single worker and no retries for proper video recording.');
689
+ }
690
+ // Check if it's a --workers=N, -j=N, or --retries=N format
691
+ if (arg.startsWith('--workers=') ||
692
+ arg.startsWith('-j=') ||
693
+ arg.startsWith('--retries=')) {
694
+ throw new Error(`Flag "${arg}" is not supported by screenci. ` +
695
+ 'screenci enforces sequential test execution with a single worker and no retries for proper video recording.');
696
+ }
697
+ }
698
+ }
699
+ function spawnInherited(cmd, args) {
700
+ const child = spawn(cmd, args, { stdio: 'inherit' });
701
+ const forwardSignal = (signal) => {
702
+ logger.info(`Received ${signal}, stopping...`);
703
+ if (!child.killed) {
704
+ child.kill(signal);
705
+ }
706
+ const forceKill = setTimeout(() => {
707
+ if (child.exitCode === null) {
708
+ logger.info('Forcing kill after timeout...');
709
+ child.kill('SIGKILL');
710
+ }
711
+ }, 3000);
712
+ forceKill.unref();
713
+ };
714
+ process.on('SIGINT', forwardSignal);
715
+ process.on('SIGTERM', forwardSignal);
716
+ return new Promise((resolve, reject) => {
717
+ child.on('close', (code) => {
718
+ process.off('SIGINT', forwardSignal);
719
+ process.off('SIGTERM', forwardSignal);
720
+ if (code === 0) {
721
+ resolve();
722
+ }
723
+ else {
724
+ reject(new Error(`${cmd} exited with code ${code}`));
725
+ }
726
+ });
727
+ child.on('error', (err) => {
728
+ process.off('SIGINT', forwardSignal);
729
+ process.off('SIGTERM', forwardSignal);
730
+ reject(err);
731
+ });
732
+ });
733
+ }
734
+ export function detectContainerRuntime() {
735
+ for (const runtime of ['podman', 'docker']) {
736
+ const result = spawnSync(runtime, ['--version'], { stdio: 'ignore' });
737
+ if (result.status === 0 && result.error === undefined) {
738
+ return runtime;
739
+ }
740
+ }
741
+ logger.error('Error: Neither podman nor docker found.');
742
+ logger.error('Please install podman (recommended) or docker to use screenci record.');
743
+ logger.error(' podman: https://podman.io/docs/installation');
744
+ logger.error(' docker: https://docs.docker.com/get-docker/');
745
+ process.exit(1);
746
+ }
747
+ async function runWithContainer(additionalArgs, customConfigPath) {
748
+ const configPath = findScreenCIConfig(customConfigPath);
749
+ if (!configPath) {
750
+ const errorMsg = customConfigPath
751
+ ? `Error: Config file not found: ${customConfigPath}`
752
+ : 'Error: screenci.config.ts not found in current directory';
753
+ logger.error(errorMsg);
754
+ process.exit(1);
755
+ }
756
+ const configDir = dirname(configPath);
757
+ const dockerfilePath = resolve(configDir, 'Dockerfile');
758
+ if (!existsSync(dockerfilePath)) {
759
+ logger.error(`Error: Dockerfile not found at ${dockerfilePath}`);
760
+ logger.error('Container mode requires a Dockerfile next to screenci.config.ts');
761
+ process.exit(1);
762
+ }
763
+ const repoRoot = findRepoRoot(configDir);
764
+ if (!repoRoot) {
765
+ logger.error('Error: Could not find repository root (.git or pnpm-workspace.yaml)');
766
+ process.exit(1);
767
+ }
768
+ const containerRuntime = detectContainerRuntime();
769
+ const cliDir = dirname(fileURLToPath(import.meta.url));
770
+ const screenciDockerfilePath = resolve(cliDir, 'Dockerfile');
771
+ logger.info(`Building container image with ${containerRuntime}...`);
772
+ logger.info(`Using Dockerfile: ${screenciDockerfilePath}`);
773
+ logger.info(`Build context: ${repoRoot}`);
774
+ await spawnInherited(containerRuntime, [
775
+ 'build',
776
+ '-f',
777
+ screenciDockerfilePath,
778
+ '-t',
779
+ 'screenci',
780
+ repoRoot,
781
+ ]);
782
+ logger.info(`Using Dockerfile: ${dockerfilePath}`);
783
+ logger.info(`Build context: ${configDir}`);
784
+ await spawnInherited(containerRuntime, [
785
+ 'build',
786
+ '-f',
787
+ dockerfilePath,
788
+ '-t',
789
+ 'screenci',
790
+ configDir,
791
+ ]);
792
+ clearDirectory(resolve(configDir, '.screenci'));
793
+ logger.info('Running recording in container...');
794
+ await spawnInherited(containerRuntime, [
795
+ 'run',
796
+ '--rm',
797
+ '-e',
798
+ 'SCREENCI_IN_CONTAINER=true',
799
+ '-e',
800
+ 'SCREENCI_RECORD=true',
801
+ '-v',
802
+ `${configDir}/.screenci:/app/.screenci`,
803
+ '-v',
804
+ `${configPath}:/app/screenci.config.ts`,
805
+ '-v',
806
+ `${configDir}/videos:/app/videos`,
807
+ 'screenci',
808
+ 'screenci',
809
+ 'record',
810
+ ...additionalArgs,
811
+ ]);
812
+ }
813
+ async function run(command, additionalArgs, customConfigPath) {
814
+ const configPath = findScreenCIConfig(customConfigPath);
815
+ if (!configPath) {
816
+ const errorMsg = customConfigPath
817
+ ? `Error: Config file not found: ${customConfigPath}`
818
+ : 'Error: screenci.config.ts not found in current directory';
819
+ logger.error(errorMsg);
820
+ process.exit(1);
821
+ }
822
+ // Only validate args for record command (dev allows parallel execution)
823
+ if (command === 'record') {
824
+ validateArgs(additionalArgs);
825
+ const screenciDir = resolve(dirname(configPath), '.screenci');
826
+ clearDirectory(screenciDir);
827
+ }
828
+ // For dev command: use --ui unless --headed is specified
829
+ const isHeaded = additionalArgs.includes('--headed');
830
+ const shouldUseUI = command === 'dev' && !isHeaded;
831
+ const mode = command === 'dev' ? (isHeaded ? 'headed mode' : 'UI mode') : 'recorder';
832
+ logger.info(`Running ScreenCI ${mode} with npx...`);
833
+ logger.info(`Using config: ${configPath}`);
834
+ const playwrightArgs = [
835
+ 'playwright',
836
+ 'test',
837
+ '--config',
838
+ configPath,
839
+ ...(shouldUseUI ? ['--ui'] : []),
840
+ ...additionalArgs,
841
+ ];
842
+ const child = spawn('npx', playwrightArgs, {
843
+ stdio: 'inherit',
844
+ env: {
845
+ ...process.env,
846
+ // Enable recording only for record command
847
+ ...(command === 'record' ? { SCREENCI_RECORD: 'true' } : {}),
848
+ },
849
+ });
850
+ const forwardSignal = (signal) => {
851
+ logger.info(`Received ${signal}, stopping recording...`);
852
+ if (!child.killed) {
853
+ child.kill(signal);
854
+ }
855
+ // Force-kill after 3 s if the child hasn't actually exited yet.
856
+ // child.killed becomes true as soon as we send the signal, so we check
857
+ // child.exitCode instead — it stays null until the process truly exits.
858
+ // unref() so the timer doesn't keep the process alive on its own.
859
+ const forceKill = setTimeout(() => {
860
+ if (child.exitCode === null) {
861
+ logger.info('Forcing kill after timeout...');
862
+ child.kill('SIGKILL');
863
+ }
864
+ }, 3000);
865
+ forceKill.unref();
866
+ };
867
+ process.on('SIGINT', forwardSignal);
868
+ process.on('SIGTERM', forwardSignal);
869
+ return new Promise((resolve, reject) => {
870
+ child.on('close', (code) => {
871
+ process.off('SIGINT', forwardSignal);
872
+ process.off('SIGTERM', forwardSignal);
873
+ if (code === 0) {
874
+ resolve();
875
+ }
876
+ else {
877
+ reject(new Error(`Playwright exited with code ${code}`));
878
+ }
879
+ });
880
+ child.on('error', (err) => {
881
+ reject(err);
882
+ });
883
+ });
884
+ }
885
+ // Only run if this file is being executed directly
886
+ // Check if this module is the main module (handles symlinks properly)
887
+ const currentFile = fileURLToPath(import.meta.url);
888
+ const mainFile = process.argv[1] ? realpathSync(process.argv[1]) : null;
889
+ if (mainFile &&
890
+ (currentFile === mainFile || currentFile === realpathSync(mainFile))) {
891
+ main().catch((error) => {
892
+ logger.error('Error:', error.message);
893
+ process.exit(1);
894
+ });
895
+ }
896
+ //# sourceMappingURL=cli.js.map