screenci 0.0.9 → 0.0.11

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 (68) hide show
  1. package/README.md +44 -58
  2. package/dist/Dockerfile +48 -0
  3. package/dist/cli.d.ts +21 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +1346 -384
  6. package/dist/cli.js.map +1 -1
  7. package/dist/e2e/instrument.e2e.js +12 -0
  8. package/dist/e2e/instrument.e2e.js.map +1 -1
  9. package/dist/index.d.ts +9 -8
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3 -2
  12. package/dist/index.js.map +1 -1
  13. package/dist/playwright.config.d.ts +1 -1
  14. package/dist/src/asset.d.ts +27 -67
  15. package/dist/src/asset.d.ts.map +1 -1
  16. package/dist/src/asset.js +44 -45
  17. package/dist/src/asset.js.map +1 -1
  18. package/dist/src/caption.d.ts +164 -54
  19. package/dist/src/caption.d.ts.map +1 -1
  20. package/dist/src/caption.js +304 -131
  21. package/dist/src/caption.js.map +1 -1
  22. package/dist/src/events.d.ts +69 -27
  23. package/dist/src/events.d.ts.map +1 -1
  24. package/dist/src/events.js +64 -40
  25. package/dist/src/events.js.map +1 -1
  26. package/dist/src/instrument.d.ts.map +1 -1
  27. package/dist/src/instrument.js +142 -35
  28. package/dist/src/instrument.js.map +1 -1
  29. package/dist/src/logger.d.ts.map +1 -1
  30. package/dist/src/logger.js +2 -1
  31. package/dist/src/logger.js.map +1 -1
  32. package/dist/src/recording.d.ts +4 -0
  33. package/dist/src/recording.d.ts.map +1 -0
  34. package/dist/src/recording.js +2 -0
  35. package/dist/src/recording.js.map +1 -0
  36. package/dist/src/recordingData.d.ts +145 -0
  37. package/dist/src/recordingData.d.ts.map +1 -0
  38. package/dist/src/recordingData.js +2 -0
  39. package/dist/src/recordingData.js.map +1 -0
  40. package/dist/src/types.d.ts +201 -71
  41. package/dist/src/types.d.ts.map +1 -1
  42. package/dist/src/types.js +31 -1
  43. package/dist/src/types.js.map +1 -1
  44. package/dist/src/video.d.ts.map +1 -1
  45. package/dist/src/video.js +29 -24
  46. package/dist/src/video.js.map +1 -1
  47. package/dist/src/voices.d.ts +344 -41
  48. package/dist/src/voices.d.ts.map +1 -1
  49. package/dist/src/voices.js +261 -30
  50. package/dist/src/voices.js.map +1 -1
  51. package/dist/test-fixtures/screenci.config.d.ts +5 -0
  52. package/dist/test-fixtures/screenci.config.d.ts.map +1 -0
  53. package/dist/test-fixtures/screenci.config.js +4 -0
  54. package/dist/test-fixtures/screenci.config.js.map +1 -0
  55. package/dist/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +39 -3
  57. package/skills/playwright-cli/SKILL.md +348 -0
  58. package/skills/screenci/SKILL.md +55 -0
  59. package/skills/screenci/references/init.md +47 -0
  60. package/skills/screenci/references/record.md +41 -0
  61. package/dist/reporter.d.ts +0 -9
  62. package/dist/reporter.d.ts.map +0 -1
  63. package/dist/reporter.js +0 -49
  64. package/dist/reporter.js.map +0 -1
  65. package/dist/src/caption.test-d.d.ts +0 -2
  66. package/dist/src/caption.test-d.d.ts.map +0 -1
  67. package/dist/src/caption.test-d.js +0 -50
  68. package/dist/src/caption.test-d.js.map +0 -1
package/dist/cli.js CHANGED
@@ -1,14 +1,350 @@
1
- #!/usr/bin/env -S npx tsx
1
+ #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from 'child_process';
3
3
  import { createReadStream } from 'fs';
4
- import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from 'fs';
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, } from 'fs';
5
5
  import { createHash } from 'crypto';
6
6
  import { createServer } from 'http';
7
7
  import { mkdir, readdir, readFile, stat, writeFile } from 'fs/promises';
8
8
  import { dirname, relative as pathRelative, resolve } from 'path';
9
- import { createInterface } from 'readline/promises';
10
9
  import { fileURLToPath } from 'url';
10
+ import { Command, CommanderError } from 'commander';
11
+ import { input, confirm } from '@inquirer/prompts';
12
+ import ora from 'ora';
13
+ import pc from 'picocolors';
11
14
  import { logger } from './src/logger.js';
15
+ function resolveRecordingFileCandidates(filePath, configDir) {
16
+ return [
17
+ filePath,
18
+ resolve(configDir, 'videos', filePath),
19
+ resolve(configDir, pathRelative('/app', filePath)),
20
+ ];
21
+ }
22
+ async function readRecordingFile(filePath, configDir) {
23
+ for (const candidate of resolveRecordingFileCandidates(filePath, configDir)) {
24
+ try {
25
+ return { buffer: await readFile(candidate), resolvedPath: candidate };
26
+ }
27
+ catch {
28
+ // try next candidate
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+ function contentTypeForPath(filePath) {
34
+ const ext = filePath.split('.').pop()?.toLowerCase() ?? 'bin';
35
+ const contentTypeMap = {
36
+ png: 'image/png',
37
+ jpg: 'image/jpeg',
38
+ jpeg: 'image/jpeg',
39
+ gif: 'image/gif',
40
+ webp: 'image/webp',
41
+ mp4: 'video/mp4',
42
+ webm: 'video/webm',
43
+ mp3: 'audio/mpeg',
44
+ svg: 'image/svg+xml',
45
+ };
46
+ return contentTypeMap[ext] ?? 'application/octet-stream';
47
+ }
48
+ class UploadCancelledError extends Error {
49
+ constructor(message = 'Upload cancelled') {
50
+ super(message);
51
+ this.name = 'UploadCancelledError';
52
+ }
53
+ }
54
+ function isUploadCancelledError(err) {
55
+ return (err instanceof UploadCancelledError ||
56
+ (err instanceof Error &&
57
+ (err.name === 'AbortError' || err.name === 'UploadCancelledError')));
58
+ }
59
+ function createUploadAbortController(activityLabel) {
60
+ const controller = new AbortController();
61
+ let cleanedUp = false;
62
+ const cleanup = () => {
63
+ if (cleanedUp)
64
+ return;
65
+ cleanedUp = true;
66
+ process.off('SIGINT', handleSigint);
67
+ process.off('SIGTERM', handleSigterm);
68
+ process.stdin.off('data', handleStdinData);
69
+ };
70
+ const abortUpload = (signal) => {
71
+ if (controller.signal.aborted)
72
+ return;
73
+ logger.info(`Received ${signal}, stopping ${activityLabel}...`);
74
+ cleanup();
75
+ controller.abort(new UploadCancelledError(`${activityLabel} cancelled`));
76
+ process.kill(process.pid, signal);
77
+ };
78
+ const handleStdinData = (chunk) => {
79
+ const bytes = typeof chunk === 'string'
80
+ ? Buffer.from(chunk, 'utf8')
81
+ : Buffer.from(chunk);
82
+ if (bytes.includes(0x03)) {
83
+ abortUpload('SIGINT');
84
+ }
85
+ };
86
+ const handleSigint = () => abortUpload('SIGINT');
87
+ const handleSigterm = () => abortUpload('SIGTERM');
88
+ process.on('SIGINT', handleSigint);
89
+ process.on('SIGTERM', handleSigterm);
90
+ process.stdin.on('data', handleStdinData);
91
+ return {
92
+ signal: controller.signal,
93
+ throwIfAborted: () => {
94
+ if (controller.signal.aborted) {
95
+ throw controller.signal.reason instanceof Error
96
+ ? controller.signal.reason
97
+ : new UploadCancelledError(`${activityLabel} cancelled`);
98
+ }
99
+ },
100
+ cleanup,
101
+ };
102
+ }
103
+ function parseDockerfileVersion(dockerfilePath) {
104
+ let content;
105
+ try {
106
+ content = readFileSync(dockerfilePath, 'utf-8');
107
+ }
108
+ catch {
109
+ return 'unknown';
110
+ }
111
+ const fromLine = content
112
+ .split('\n')
113
+ .find((line) => line.trim().toUpperCase().startsWith('FROM'));
114
+ if (!fromLine)
115
+ return 'unknown';
116
+ const match = fromLine.match(/:([^\s@]+)/);
117
+ return match?.[1] ?? 'unknown';
118
+ }
119
+ const CONTAINER_RUNTIME_DOCS_URL = 'https://screenci.com/guides/getting-started/#prerequisites';
120
+ function prerequisitesMessage() {
121
+ return `See prerequisites: ${pc.blue(CONTAINER_RUNTIME_DOCS_URL)}`;
122
+ }
123
+ const MIN_CONTAINER_RUNTIME_MAJOR_VERSION = {
124
+ podman: 5,
125
+ docker: 28,
126
+ };
127
+ function parseContainerRuntimeMajorVersion(versionOutput) {
128
+ const match = versionOutput.match(/(\d+)(?:\.\d+){0,2}/);
129
+ if (!match)
130
+ return null;
131
+ const majorVersion = Number.parseInt(match[1] ?? '', 10);
132
+ return Number.isNaN(majorVersion) ? null : majorVersion;
133
+ }
134
+ function checkContainerRuntime(runtime) {
135
+ const result = spawnSync(runtime, ['--version'], { encoding: 'utf8' });
136
+ if (result.status !== 0 || result.error !== undefined) {
137
+ return null;
138
+ }
139
+ const version = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
140
+ return {
141
+ runtime,
142
+ version,
143
+ majorVersion: parseContainerRuntimeMajorVersion(version),
144
+ };
145
+ }
146
+ function getPreferredContainerRuntime() {
147
+ const podman = checkContainerRuntime('podman');
148
+ if (podman)
149
+ return podman;
150
+ return checkContainerRuntime('docker');
151
+ }
152
+ function exitContainerRuntimeNotFound(runtime) {
153
+ logger.error(`Error: ${runtime} not found.`);
154
+ logger.error(`Install ${runtime} or remove the --${runtime} flag.`);
155
+ logger.error(prerequisitesMessage());
156
+ process.exit(1);
157
+ }
158
+ function warnIfContainerRuntimeVersionIsOld(runtimeCheck) {
159
+ const minimumVersion = MIN_CONTAINER_RUNTIME_MAJOR_VERSION[runtimeCheck.runtime];
160
+ if (runtimeCheck.majorVersion !== null &&
161
+ runtimeCheck.majorVersion < minimumVersion) {
162
+ logger.warn(`${runtimeCheck.runtime} ${minimumVersion}+ recommended (detected: ${runtimeCheck.version}). Most likely this still works, continuing.`);
163
+ logger.warn(prerequisitesMessage());
164
+ }
165
+ }
166
+ function spawnSilent(cmd, args, cwd) {
167
+ return new Promise((resolve, reject) => {
168
+ const child = spawn(cmd, args, { stdio: 'pipe', ...(cwd ? { cwd } : {}) });
169
+ const childSignals = forwardChildSignals(child, cmd);
170
+ child.on('close', (code, signal) => {
171
+ const forwardedSignal = childSignals.getForwardedSignal();
172
+ childSignals.cleanup();
173
+ if (forwardedSignal) {
174
+ process.kill(process.pid, forwardedSignal);
175
+ return;
176
+ }
177
+ if (signal) {
178
+ process.kill(process.pid, signal);
179
+ return;
180
+ }
181
+ else if (code === 0) {
182
+ resolve();
183
+ }
184
+ else {
185
+ reject(new Error(`${cmd} exited with code ${code}`));
186
+ }
187
+ });
188
+ child.on('error', (err) => {
189
+ childSignals.cleanup();
190
+ reject(err);
191
+ });
192
+ });
193
+ }
194
+ function spawnCaptured(cmd, args, cwd) {
195
+ return new Promise((resolve, reject) => {
196
+ const child = spawn(cmd, args, { stdio: 'pipe', ...(cwd ? { cwd } : {}) });
197
+ const childSignals = forwardChildSignals(child, cmd);
198
+ let stdout = '';
199
+ let stderr = '';
200
+ child.stdout?.on('data', (chunk) => {
201
+ stdout += chunk.toString();
202
+ });
203
+ child.stderr?.on('data', (chunk) => {
204
+ stderr += chunk.toString();
205
+ });
206
+ child.on('close', (code, signal) => {
207
+ const forwardedSignal = childSignals.getForwardedSignal();
208
+ childSignals.cleanup();
209
+ if (forwardedSignal) {
210
+ process.kill(process.pid, forwardedSignal);
211
+ return;
212
+ }
213
+ if (signal) {
214
+ process.kill(process.pid, signal);
215
+ return;
216
+ }
217
+ resolve({ code, stdout, stderr });
218
+ });
219
+ child.on('error', (err) => {
220
+ childSignals.cleanup();
221
+ reject(err);
222
+ });
223
+ });
224
+ }
225
+ function forwardChildSignals(child, activityLabel) {
226
+ let forwardedSignal = null;
227
+ let forceKillTimer = null;
228
+ const forwardSignal = (signal) => {
229
+ if (forwardedSignal !== null)
230
+ return;
231
+ forwardedSignal = signal;
232
+ if (process.env.SCREENCI_SIGNAL_LOGGING !== 'silent') {
233
+ logger.info(`Received ${signal}, stopping ${activityLabel}...`);
234
+ }
235
+ if (!child.killed) {
236
+ child.kill(signal);
237
+ }
238
+ forceKillTimer = setTimeout(() => {
239
+ if (child.exitCode === null) {
240
+ if (process.env.SCREENCI_SIGNAL_LOGGING !== 'silent') {
241
+ logger.info(`Forcing ${activityLabel} to stop after timeout...`);
242
+ }
243
+ child.kill('SIGKILL');
244
+ }
245
+ }, 3000);
246
+ forceKillTimer.unref();
247
+ };
248
+ const handleSigint = () => forwardSignal('SIGINT');
249
+ const handleSigterm = () => forwardSignal('SIGTERM');
250
+ process.on('SIGINT', handleSigint);
251
+ process.on('SIGTERM', handleSigterm);
252
+ return {
253
+ cleanup: () => {
254
+ if (forceKillTimer !== null) {
255
+ clearTimeout(forceKillTimer);
256
+ }
257
+ process.off('SIGINT', handleSigint);
258
+ process.off('SIGTERM', handleSigterm);
259
+ },
260
+ getForwardedSignal: () => forwardedSignal,
261
+ };
262
+ }
263
+ async function getPlaywrightChromiumInstallStatus(projectDir, verbose = false) {
264
+ try {
265
+ if (verbose) {
266
+ logger.info('Checking Playwright Chromium install with: npx playwright install --list');
267
+ }
268
+ const list = await spawnCaptured('npx', ['playwright', 'install', '--list'], projectDir);
269
+ if (verbose) {
270
+ if (list.stdout.trim() !== '')
271
+ logger.info(list.stdout.trimEnd());
272
+ if (list.stderr.trim() !== '')
273
+ logger.info(list.stderr.trimEnd());
274
+ logger.info(`Check result: exit code ${list.code ?? 'null'}`);
275
+ }
276
+ const output = `${list.stdout}\n${list.stderr}`;
277
+ const versionMatch = output.match(/\bchromium-(\d+)\b/i);
278
+ if (list.code === 0 && versionMatch) {
279
+ if (verbose) {
280
+ logger.info(`Chromium check result: Chromium found in Playwright install list (version ${versionMatch[1]})`);
281
+ }
282
+ return { needed: false, version: versionMatch[1] ?? null };
283
+ }
284
+ if (verbose) {
285
+ logger.info('Chromium check result: Chromium not found in Playwright install list');
286
+ }
287
+ }
288
+ catch {
289
+ // If the probe fails, default to asking.
290
+ }
291
+ if (verbose) {
292
+ logger.info('Chromium check result: unable to verify, install still recommended');
293
+ }
294
+ return { needed: true, version: null };
295
+ }
296
+ const CONTAINER_LOG_FILTER = [
297
+ /^Running ScreenCI /,
298
+ /^Using config:/,
299
+ /^Starting Xvfb /,
300
+ /^Xvfb started /,
301
+ /^Recording video to:/,
302
+ /^Recording with /,
303
+ /^Stopping recording\.\.\./,
304
+ /^FFmpeg exited /,
305
+ /^Video saved to:/,
306
+ /^Events saved to:/,
307
+ ];
308
+ function spawnContainerRecording(cmd, args) {
309
+ return new Promise((resolve, reject) => {
310
+ const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'pipe'] });
311
+ const childSignals = forwardChildSignals(child, 'screenci record');
312
+ function forwardFiltered(chunk, out) {
313
+ const lines = chunk.toString().split('\n');
314
+ for (const line of lines) {
315
+ if (line === '')
316
+ continue;
317
+ if (!CONTAINER_LOG_FILTER.some((re) => re.test(line.trimStart()))) {
318
+ out.write(line + '\n');
319
+ }
320
+ }
321
+ }
322
+ child.stdout?.on('data', (chunk) => forwardFiltered(chunk, process.stdout));
323
+ child.stderr?.on('data', (chunk) => forwardFiltered(chunk, process.stderr));
324
+ child.on('close', (code, signal) => {
325
+ const forwardedSignal = childSignals.getForwardedSignal();
326
+ childSignals.cleanup();
327
+ if (forwardedSignal) {
328
+ process.kill(process.pid, forwardedSignal);
329
+ return;
330
+ }
331
+ if (signal) {
332
+ process.kill(process.pid, signal);
333
+ return;
334
+ }
335
+ else if (code === 0) {
336
+ resolve();
337
+ }
338
+ else {
339
+ reject(new Error(`${cmd} exited with code ${code}`));
340
+ }
341
+ });
342
+ child.on('error', (err) => {
343
+ childSignals.cleanup();
344
+ reject(err);
345
+ });
346
+ });
347
+ }
12
348
  function clearDirectory(dir) {
13
349
  mkdirSync(dir, { recursive: true });
14
350
  for (const entry of readdirSync(dir)) {
@@ -45,38 +381,6 @@ function findRepoRoot(startDir) {
45
381
  current = parent;
46
382
  }
47
383
  }
48
- function parseArgs(args) {
49
- const command = args[0];
50
- if (command === undefined) {
51
- logger.error('Error: No command provided');
52
- logger.error('Available commands: record, dev, upload-latest, init');
53
- process.exit(1);
54
- }
55
- let configPath;
56
- let noContainer = false;
57
- const otherArgs = [];
58
- for (let i = 1; i < args.length; i++) {
59
- const arg = args[i];
60
- if (arg === '--config' || arg === '-c') {
61
- const nextArg = args[i + 1];
62
- if (nextArg !== undefined) {
63
- configPath = nextArg;
64
- i++; // skip next arg
65
- }
66
- else {
67
- logger.error('Error: --config requires a path argument');
68
- process.exit(1);
69
- }
70
- }
71
- else if (arg === '--no-container') {
72
- noContainer = true;
73
- }
74
- else if (arg !== undefined) {
75
- otherArgs.push(arg);
76
- }
77
- }
78
- return { command, configPath, noContainer, otherArgs };
79
- }
80
384
  async function findLatestEntry(screenciDir) {
81
385
  let entries;
82
386
  try {
@@ -102,58 +406,260 @@ async function findLatestEntry(screenciDir) {
102
406
  }
103
407
  return latestEntry;
104
408
  }
105
- async function uploadAssets(data, apiUrl, secret, recordingId, configDir) {
106
- const assetEvents = data.events.filter((e) => e.type === 'assetStart');
107
- if (assetEvents.length === 0)
108
- return;
109
- // Deduplicate by name — each unique asset name is uploaded once
110
- const seenNames = new Set();
111
- for (const event of assetEvents) {
112
- const assetPath = event.path;
113
- if (seenNames.has(event.name))
114
- continue;
115
- seenNames.add(event.name);
116
- // Resolve the asset file. Recording runs in a Docker container where configDir → /app,
117
- // so stored paths may be container-internal absolute or relative paths.
118
- // Resolution order:
119
- // 1. Path as-is (works for absolute host paths)
120
- // 2. Relative path resolved from configDir/videos (the video scripts directory)
121
- // 3. Container path translated: /some/path → configDir/../some/path
122
- const candidates = [
123
- assetPath,
124
- resolve(configDir, 'videos', assetPath),
125
- resolve(configDir, pathRelative('/app', assetPath)),
126
- ];
127
- let fileBuffer;
128
- let resolvedPath = assetPath;
129
- for (const candidate of candidates) {
130
- try {
131
- fileBuffer = await readFile(candidate);
132
- resolvedPath = candidate;
133
- break;
409
+ async function hashFile(filePath) {
410
+ return new Promise((resolveHash, reject) => {
411
+ const hash = createHash('sha256');
412
+ const stream = createReadStream(filePath);
413
+ stream.on('data', (chunk) => hash.update(chunk));
414
+ stream.on('error', reject);
415
+ stream.on('end', () => resolveHash(hash.digest('hex')));
416
+ });
417
+ }
418
+ async function prepareCustomVoiceAssets(data, configDir) {
419
+ const customVoiceRefsByPath = new Map();
420
+ for (const event of data.events) {
421
+ if (event.type === 'captionStart' && event.translations) {
422
+ for (const translation of Object.values(event.translations)) {
423
+ if (typeof translation.voice === 'object' &&
424
+ translation.voice !== null &&
425
+ 'assetPath' in translation.voice &&
426
+ typeof translation.voice.assetPath === 'string') {
427
+ const voiceRef = translation.voice;
428
+ const refs = customVoiceRefsByPath.get(voiceRef.assetPath) ?? [];
429
+ refs.push(voiceRef);
430
+ customVoiceRefsByPath.set(voiceRef.assetPath, refs);
431
+ }
134
432
  }
135
- catch {
136
- // try next
433
+ }
434
+ if (event.type === 'videoCaptionStart' && event.translations) {
435
+ for (const translation of Object.values(event.translations)) {
436
+ if ('text' in translation &&
437
+ typeof translation.voice === 'object' &&
438
+ translation.voice !== null &&
439
+ 'assetPath' in translation.voice &&
440
+ typeof translation.voice.assetPath === 'string') {
441
+ const voiceRef = translation.voice;
442
+ const refs = customVoiceRefsByPath.get(voiceRef.assetPath) ?? [];
443
+ refs.push(voiceRef);
444
+ customVoiceRefsByPath.set(voiceRef.assetPath, refs);
445
+ }
446
+ }
447
+ }
448
+ }
449
+ const preparedAssets = [];
450
+ for (const [voicePath, refs] of customVoiceRefsByPath) {
451
+ const resolvedFile = await readRecordingFile(voicePath, configDir);
452
+ if (resolvedFile === null) {
453
+ const existingHash = refs.find((ref) => typeof ref.assetHash === 'string')?.assetHash;
454
+ if (!existingHash) {
455
+ throw new Error(`Custom voice file not found and no cached assetHash available: ${voicePath}`);
137
456
  }
457
+ logger.warn(`Custom voice file not found locally, assuming previously uploaded recording asset is valid: ${voicePath}`);
458
+ for (const ref of refs) {
459
+ ref.assetHash = existingHash;
460
+ }
461
+ preparedAssets.push({
462
+ fileHash: existingHash,
463
+ path: voicePath,
464
+ size: 0,
465
+ contentType: contentTypeForPath(voicePath),
466
+ });
467
+ continue;
138
468
  }
139
- if (fileBuffer === undefined) {
140
- logger.warn(`Asset file not found, skipping upload: ${assetPath}`);
469
+ const { buffer: fileBuffer, resolvedPath } = resolvedFile;
470
+ const assetHash = createHash('sha256').update(fileBuffer).digest('hex');
471
+ const contentType = contentTypeForPath(resolvedPath);
472
+ for (const ref of refs) {
473
+ ref.assetHash = assetHash;
474
+ }
475
+ preparedAssets.push({
476
+ fileHash: assetHash,
477
+ path: voicePath,
478
+ size: fileBuffer.byteLength,
479
+ fileBuffer,
480
+ contentType,
481
+ });
482
+ }
483
+ return preparedAssets;
484
+ }
485
+ async function collectUploadAssets(data, configDir) {
486
+ const assets = new Map();
487
+ for (const event of data.events) {
488
+ if (event.type === 'assetStart') {
489
+ if (assets.has(`name:${event.name}`))
490
+ continue;
491
+ const resolvedFile = await readRecordingFile(event.path, configDir);
492
+ if (resolvedFile === null) {
493
+ logger.warn(`Asset file not found, skipping upload: ${event.path}`);
494
+ continue;
495
+ }
496
+ const { buffer: fileBuffer, resolvedPath } = resolvedFile;
497
+ assets.set(`name:${event.name}`, {
498
+ fileHash: createHash('sha256').update(fileBuffer).digest('hex'),
499
+ path: event.path,
500
+ name: event.name,
501
+ size: fileBuffer.byteLength,
502
+ fileBuffer,
503
+ contentType: contentTypeForPath(resolvedPath),
504
+ });
141
505
  continue;
142
506
  }
143
- const sha256 = createHash('sha256').update(fileBuffer).digest('hex');
144
- const ext = assetPath.split('.').pop()?.toLowerCase() ?? 'bin';
145
- const contentTypeMap = {
146
- png: 'image/png',
147
- jpg: 'image/jpeg',
148
- jpeg: 'image/jpeg',
149
- gif: 'image/gif',
150
- webp: 'image/webp',
151
- mp4: 'video/mp4',
152
- webm: 'video/webm',
153
- svg: 'image/svg+xml',
154
- };
155
- const contentType = contentTypeMap[ext] ?? 'application/octet-stream';
507
+ if (event.type === 'videoCaptionStart') {
508
+ // Single-language: hash already computed during recording, use assetPath to read file
509
+ if (typeof event.assetHash === 'string' &&
510
+ !assets.has(`hash:${event.assetHash}`)) {
511
+ const resolvedFile = typeof event.assetPath === 'string'
512
+ ? await readRecordingFile(event.assetPath, configDir)
513
+ : null;
514
+ assets.set(`hash:${event.assetHash}`, {
515
+ fileHash: event.assetHash,
516
+ path: event.assetPath ?? event.assetHash,
517
+ size: resolvedFile?.buffer.byteLength ?? 0,
518
+ ...(resolvedFile !== null && {
519
+ fileBuffer: resolvedFile.buffer,
520
+ contentType: contentTypeForPath(resolvedFile.resolvedPath),
521
+ }),
522
+ });
523
+ }
524
+ // Multi-language: each translation carries its own hash
525
+ if (event.translations) {
526
+ for (const translation of Object.values(event.translations)) {
527
+ if (typeof translation === 'object' &&
528
+ translation !== null &&
529
+ 'assetHash' in translation &&
530
+ typeof translation.assetHash === 'string' &&
531
+ !assets.has(`hash:${translation.assetHash}`)) {
532
+ const resolvedFile = 'assetPath' in translation &&
533
+ typeof translation.assetPath === 'string'
534
+ ? await readRecordingFile(translation.assetPath, configDir)
535
+ : null;
536
+ assets.set(`hash:${translation.assetHash}`, {
537
+ fileHash: translation.assetHash,
538
+ path: translation.assetPath ??
539
+ translation.assetHash,
540
+ size: resolvedFile?.buffer.byteLength ?? 0,
541
+ ...(resolvedFile !== null && {
542
+ fileBuffer: resolvedFile.buffer,
543
+ contentType: contentTypeForPath(resolvedFile.resolvedPath),
544
+ }),
545
+ });
546
+ }
547
+ }
548
+ }
549
+ }
550
+ }
551
+ for (const asset of await prepareCustomVoiceAssets(data, configDir)) {
552
+ assets.set(`path:${asset.path}`, asset);
553
+ }
554
+ return [...assets.values()];
555
+ }
556
+ export function stripVoicePath(voice) {
557
+ if (typeof voice !== 'string') {
558
+ return { assetHash: voice.assetHash };
559
+ }
560
+ return voice;
561
+ }
562
+ export function annotateRecordingDataWithAssetHashes(data, assets) {
563
+ const byName = new Map();
564
+ for (const asset of assets) {
565
+ if (typeof asset.name === 'string')
566
+ byName.set(asset.name, asset.fileHash);
567
+ }
568
+ return {
569
+ ...data,
570
+ events: data.events.map((event) => {
571
+ if (event.type === 'assetStart') {
572
+ const fileHash = byName.get(event.name);
573
+ return fileHash ? { ...event, fileHash } : event;
574
+ }
575
+ if (event.type === 'captionStart' && event.translations) {
576
+ const translations = Object.fromEntries(Object.entries(event.translations).map(([language, translation]) => {
577
+ if (translation.voice === undefined) {
578
+ return [language, translation];
579
+ }
580
+ return [
581
+ language,
582
+ {
583
+ ...translation,
584
+ voice: stripVoicePath(translation.voice),
585
+ },
586
+ ];
587
+ }));
588
+ return { ...event, translations };
589
+ }
590
+ if (event.type !== 'videoCaptionStart')
591
+ return event;
592
+ // Strip assetPath from translations — hash was already computed during recording
593
+ if (event.translations) {
594
+ const translations = Object.fromEntries(Object.entries(event.translations).map(([language, translation]) => {
595
+ if ('assetHash' in translation) {
596
+ const { assetPath: _removed, ...rest } = translation;
597
+ return [language, rest];
598
+ }
599
+ if ('voice' in translation) {
600
+ return [
601
+ language,
602
+ {
603
+ ...translation,
604
+ ...(translation.voice !== undefined
605
+ ? { voice: stripVoicePath(translation.voice) }
606
+ : {}),
607
+ },
608
+ ];
609
+ }
610
+ return [language, translation];
611
+ }));
612
+ return { ...event, translations };
613
+ }
614
+ // Single-language: strip assetPath, keep assetHash
615
+ if (typeof event.assetHash === 'string') {
616
+ const { assetPath: _removed, ...rest } = event;
617
+ return rest;
618
+ }
619
+ return event;
620
+ }),
621
+ };
622
+ }
623
+ function hint401(status, secret) {
624
+ if (status !== 401 || !secret)
625
+ return '';
626
+ const frontendUrl = getDevFrontendUrl();
627
+ return `\nThe secret may have been deleted or belongs to a different organisation. Check your secrets at ${frontendUrl}/secrets`;
628
+ }
629
+ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIfAborted) {
630
+ for (const asset of assets) {
631
+ throwIfAborted();
156
632
  try {
633
+ const checkRes = await fetch(`${apiUrl}/cli/upload/${recordingId}/asset/check`, {
634
+ method: 'POST',
635
+ headers: {
636
+ 'Content-Type': 'application/json',
637
+ 'X-ScreenCI-Secret': secret,
638
+ },
639
+ body: JSON.stringify({
640
+ fileHash: asset.fileHash,
641
+ contentType: asset.contentType,
642
+ size: asset.size,
643
+ path: asset.path,
644
+ ...(typeof asset.name === 'string' ? { name: asset.name } : {}),
645
+ }),
646
+ signal,
647
+ });
648
+ if (!checkRes.ok) {
649
+ const text = await checkRes.text();
650
+ logger.warn(`Failed to check asset ${asset.path}: ${checkRes.status} ${text}${hint401(checkRes.status, secret)}`);
651
+ continue;
652
+ }
653
+ const checkBody = (await checkRes.json());
654
+ if (checkBody.exists) {
655
+ logger.info(`Asset already exists: ${asset.path}`);
656
+ continue;
657
+ }
658
+ if (!asset.fileBuffer || !asset.contentType) {
659
+ logger.warn(`Asset bytes not available for upload and backend does not have it yet: ${asset.path}`);
660
+ continue;
661
+ }
662
+ throwIfAborted();
157
663
  const res = await fetch(`${apiUrl}/cli/upload/${recordingId}/asset`, {
158
664
  method: 'PUT',
159
665
  headers: {
@@ -161,100 +667,209 @@ async function uploadAssets(data, apiUrl, secret, recordingId, configDir) {
161
667
  'X-ScreenCI-Secret': secret,
162
668
  },
163
669
  body: JSON.stringify({
164
- sha256,
165
- fileBase64: fileBuffer.toString('base64'),
166
- contentType,
167
- assetName: event.name,
670
+ fileHash: asset.fileHash,
671
+ fileBase64: asset.fileBuffer.toString('base64'),
672
+ contentType: asset.contentType,
673
+ size: asset.size,
674
+ path: asset.path,
675
+ ...(typeof asset.name === 'string' ? { name: asset.name } : {}),
168
676
  }),
677
+ signal,
169
678
  });
170
679
  if (!res.ok) {
171
680
  const text = await res.text();
172
- logger.warn(`Failed to upload asset ${assetPath}: ${res.status} ${text}`);
681
+ if (res.status === 409 && text.includes('already exists')) {
682
+ logger.info(`Asset already exists: ${asset.path}`);
683
+ }
684
+ else {
685
+ logger.warn(`Failed to upload asset ${asset.path}: ${res.status} ${text}${hint401(res.status, secret)}`);
686
+ }
173
687
  }
174
688
  else {
175
- logger.info(`Asset uploaded: ${assetPath}`);
689
+ logger.info(`Asset uploaded: ${asset.path}`);
176
690
  }
177
691
  }
178
692
  catch (err) {
179
- logger.warn(`Network error uploading asset ${assetPath}:`, err);
693
+ if (isUploadCancelledError(err)) {
694
+ throw err;
695
+ }
696
+ logger.warn(`Network error uploading asset ${asset.path}:`, err);
180
697
  }
181
698
  }
182
699
  }
183
700
  async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specificEntry) {
701
+ const uploadAbort = createUploadAbortController('upload');
184
702
  let entries;
185
703
  try {
186
704
  entries = await readdir(screenciDir);
187
705
  }
188
706
  catch {
189
707
  logger.warn('No .screenci directory found, skipping upload');
190
- return;
708
+ return null;
191
709
  }
192
710
  if (specificEntry !== undefined) {
193
711
  entries = entries.filter((e) => e === specificEntry);
194
712
  }
195
- for (const entry of entries) {
196
- const dataJsonPath = resolve(screenciDir, entry, 'data.json');
197
- if (!existsSync(dataJsonPath))
198
- continue;
199
- let data;
200
- try {
201
- const raw = await readFile(dataJsonPath, 'utf-8');
202
- data = JSON.parse(raw);
203
- }
204
- catch {
205
- logger.warn(`Failed to read ${dataJsonPath}, skipping`);
206
- continue;
207
- }
208
- const videoName = data.metadata?.videoName ?? entry;
209
- logger.info(`Uploading "${videoName}"...`);
210
- try {
211
- // Step 1: register upload and get recordingId
212
- const startResponse = await fetch(`${apiUrl}/cli/upload/start`, {
213
- method: 'POST',
214
- headers: {
215
- 'Content-Type': 'application/json',
216
- 'X-ScreenCI-Secret': secret,
217
- },
218
- body: JSON.stringify({ projectName, videoName, data }),
219
- });
220
- if (!startResponse.ok) {
221
- const text = await startResponse.text();
222
- logger.warn(`Failed to start upload for "${videoName}": ${startResponse.status} ${text}`);
713
+ let firstProjectId = null;
714
+ try {
715
+ for (const entry of entries) {
716
+ uploadAbort.throwIfAborted();
717
+ const dataJsonPath = resolve(screenciDir, entry, 'data.json');
718
+ if (!existsSync(dataJsonPath))
223
719
  continue;
720
+ let data;
721
+ try {
722
+ const raw = await readFile(dataJsonPath, 'utf-8');
723
+ data = JSON.parse(raw);
224
724
  }
225
- const { recordingId } = (await startResponse.json());
226
- // Step 1b: upload asset files referenced in data.json
227
- await uploadAssets(data, apiUrl, secret, recordingId, resolve(screenciDir, '..'));
228
- // Step 2: stream the recording video file (if it exists)
229
- const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
230
- if (existsSync(recordingPath)) {
231
- const fileStat = await stat(recordingPath);
232
- const stream = createReadStream(recordingPath);
233
- const recordingResponse = await fetch(`${apiUrl}/cli/upload/${recordingId}/recording`, {
234
- method: 'PUT',
725
+ catch {
726
+ logger.warn(`Failed to read ${dataJsonPath}, skipping`);
727
+ continue;
728
+ }
729
+ const videoName = data.metadata?.videoName ?? entry;
730
+ const preparedUploadAssets = await collectUploadAssets(data, resolve(screenciDir, '..'));
731
+ data = annotateRecordingDataWithAssetHashes(data, preparedUploadAssets);
732
+ const uploadSpinner = ora(`Uploading "${videoName}"`).start();
733
+ try {
734
+ uploadAbort.throwIfAborted();
735
+ const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
736
+ const recordingHash = existsSync(recordingPath)
737
+ ? await hashFile(recordingPath)
738
+ : undefined;
739
+ // Step 1: register upload and get recordingId
740
+ const startResponse = await fetch(`${apiUrl}/cli/upload/start`, {
741
+ method: 'POST',
235
742
  headers: {
236
- 'Content-Type': 'video/mp4',
237
- 'Content-Length': String(fileStat.size),
743
+ 'Content-Type': 'application/json',
238
744
  'X-ScreenCI-Secret': secret,
239
745
  },
240
- body: stream,
241
- // @ts-expect-error Node.js fetch supports duplex for streaming
242
- duplex: 'half',
746
+ body: JSON.stringify({
747
+ projectName,
748
+ videoName,
749
+ data,
750
+ ...(recordingHash !== undefined ? { recordingHash } : {}),
751
+ expectedAssets: preparedUploadAssets.map((asset) => ({
752
+ fileHash: asset.fileHash,
753
+ size: asset.size,
754
+ path: asset.path,
755
+ ...(typeof asset.contentType === 'string'
756
+ ? { contentType: asset.contentType }
757
+ : {}),
758
+ ...(typeof asset.name === 'string' ? { name: asset.name } : {}),
759
+ })),
760
+ }),
761
+ signal: uploadAbort.signal,
243
762
  });
244
- if (!recordingResponse.ok) {
245
- const text = await recordingResponse.text();
246
- logger.warn(`Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}`);
763
+ if (!startResponse.ok) {
764
+ const text = await startResponse.text();
765
+ uploadSpinner.fail(`Failed to upload "${videoName}"`);
766
+ logger.warn(`Failed to start upload for "${videoName}": ${startResponse.status} ${text}${hint401(startResponse.status, secret)}`);
247
767
  continue;
248
768
  }
769
+ const { recordingId, projectId } = (await startResponse.json());
770
+ if (firstProjectId === null) {
771
+ firstProjectId = projectId;
772
+ }
773
+ // Step 1b: upload all referenced files via the shared asset flow
774
+ await uploadAssets(preparedUploadAssets, apiUrl, secret, recordingId, uploadAbort.signal, uploadAbort.throwIfAborted);
775
+ // Step 2: stream the recording video file (if it exists)
776
+ if (existsSync(recordingPath)) {
777
+ uploadAbort.throwIfAborted();
778
+ const fileStat = await stat(recordingPath);
779
+ const stream = createReadStream(recordingPath);
780
+ const abortStream = () => {
781
+ stream.destroy(new UploadCancelledError(`Upload cancelled for "${videoName}"`));
782
+ };
783
+ uploadAbort.signal.addEventListener('abort', abortStream, {
784
+ once: true,
785
+ });
786
+ try {
787
+ const recordingResponse = await fetch(`${apiUrl}/cli/upload/${recordingId}/recording`, {
788
+ method: 'PUT',
789
+ headers: {
790
+ 'Content-Type': 'video/mp4',
791
+ 'Content-Length': String(fileStat.size),
792
+ 'X-ScreenCI-Secret': secret,
793
+ },
794
+ body: stream,
795
+ signal: uploadAbort.signal,
796
+ // @ts-expect-error Node.js fetch supports duplex for streaming
797
+ duplex: 'half',
798
+ });
799
+ if (!recordingResponse.ok) {
800
+ const text = await recordingResponse.text();
801
+ uploadSpinner.fail(`Failed to upload "${videoName}"`);
802
+ logger.warn(`Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`);
803
+ continue;
804
+ }
805
+ }
806
+ finally {
807
+ uploadAbort.signal.removeEventListener('abort', abortStream);
808
+ }
809
+ }
810
+ uploadSpinner.succeed(`Uploaded "${videoName}"`);
811
+ }
812
+ catch (err) {
813
+ if (isUploadCancelledError(err)) {
814
+ uploadSpinner.fail(`Cancelled "${videoName}"`);
815
+ throw err;
816
+ }
817
+ uploadSpinner.fail(`Error uploading "${videoName}"`);
818
+ logger.warn(`Network error uploading "${videoName}":`, err);
249
819
  }
250
- logger.info(`Uploaded "${videoName}" successfully`);
251
- }
252
- catch (err) {
253
- logger.warn(`Network error uploading "${videoName}":`, err);
254
820
  }
821
+ return firstProjectId;
255
822
  }
823
+ finally {
824
+ uploadAbort.cleanup();
825
+ }
826
+ }
827
+ export function getDevBackendUrl() {
828
+ const devBackendPort = process.env.DEV_BACKEND_PORT;
829
+ return devBackendPort
830
+ ? `http://localhost:${devBackendPort}`
831
+ : 'https://api.screenci.com';
832
+ }
833
+ export function getDevFrontendUrl() {
834
+ const devFrontendPort = process.env.DEV_FRONTEND_PORT;
835
+ return devFrontendPort
836
+ ? `http://localhost:${devFrontendPort}`
837
+ : 'https://app.screenci.com';
256
838
  }
257
839
  async function uploadLatest(configPath) {
840
+ const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
841
+ const apiUrl = getDevBackendUrl();
842
+ const secret = process.env.SCREENCI_SECRET;
843
+ if (!secret) {
844
+ logger.error('No secret configured. Set SCREENCI_SECRET in your .env file (get it from the API Key page in the dashboard).');
845
+ process.exit(1);
846
+ }
847
+ const configDir = dirname(resolvedConfigPath);
848
+ const screenciDir = resolve(configDir, '.screenci');
849
+ const latestEntry = await findLatestEntry(screenciDir);
850
+ if (!latestEntry) {
851
+ logger.warn('No recordings found in .screenci directory');
852
+ return;
853
+ }
854
+ const appUrl = getDevFrontendUrl();
855
+ logger.info(`Uploading latest recording: "${latestEntry}"`);
856
+ let projectId = null;
857
+ try {
858
+ projectId = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret, latestEntry);
859
+ }
860
+ catch (err) {
861
+ if (isUploadCancelledError(err)) {
862
+ process.exit(130);
863
+ }
864
+ throw err;
865
+ }
866
+ if (projectId !== null) {
867
+ logger.info('');
868
+ logger.info('Recording finished, results available at:');
869
+ logger.info(pc.cyan(`${appUrl}/project/${projectId}`));
870
+ }
871
+ }
872
+ async function loadScreenCIConfigAndEnv(configPath) {
258
873
  const resolvedConfigPath = findScreenCIConfig(configPath);
259
874
  if (!resolvedConfigPath) {
260
875
  const errorMsg = configPath
@@ -281,32 +896,61 @@ async function uploadLatest(configPath) {
281
896
  logger.warn(`Failed to load env file ${envFilePath}:`, err);
282
897
  }
283
898
  }
284
- const convexUrl = screenciConfig.apiUrl ?? process.env.SCREENCI_URL;
285
- if (!convexUrl) {
286
- logger.error('No API URL configured. Set apiUrl in screenci.config.ts or SCREENCI_URL env var.');
287
- process.exit(1);
288
- }
899
+ return { resolvedConfigPath, screenciConfig };
900
+ }
901
+ async function requireScreenCISecret(configPath) {
902
+ const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
289
903
  const secret = process.env.SCREENCI_SECRET;
290
904
  if (!secret) {
291
905
  logger.error('No secret configured. Set SCREENCI_SECRET in your .env file (get it from the API Key page in the dashboard).');
292
906
  process.exit(1);
293
907
  }
294
- const configDir = dirname(resolvedConfigPath);
295
- const screenciDir = resolve(configDir, '.screenci');
296
- const latestEntry = await findLatestEntry(screenciDir);
297
- if (!latestEntry) {
298
- logger.warn('No recordings found in .screenci directory');
299
- return;
908
+ return {
909
+ resolvedConfigPath,
910
+ screenciConfig,
911
+ secret,
912
+ apiUrl: getDevBackendUrl(),
913
+ };
914
+ }
915
+ async function fetchProjectInfo(configPath) {
916
+ const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath);
917
+ const url = new URL(`${apiUrl}/cli/project-info`);
918
+ url.searchParams.set('projectName', screenciConfig.projectName);
919
+ const res = await fetch(url.toString(), {
920
+ headers: {
921
+ 'X-ScreenCI-Secret': secret,
922
+ },
923
+ });
924
+ if (!res.ok) {
925
+ const text = await res.text();
926
+ throw new Error(`Failed to fetch project info: ${res.status} ${text}${hint401(res.status, secret)}`);
300
927
  }
301
- logger.info(`Uploading latest recording: "${latestEntry}"`);
302
- await uploadRecordings(screenciDir, screenciConfig.projectName, convexUrl, secret, latestEntry);
928
+ return (await res.json());
929
+ }
930
+ async function printProjectInfo(configPath) {
931
+ const info = await fetchProjectInfo(configPath);
932
+ process.stdout.write(`${JSON.stringify(info, null, 2)}\n`);
933
+ }
934
+ async function updateVideoVisibility(videoId, isPublic, configPath) {
935
+ const { secret, apiUrl } = await requireScreenCISecret(configPath);
936
+ const method = isPublic ? 'PUT' : 'DELETE';
937
+ const res = await fetch(`${apiUrl}/cli/public-video/${videoId}`, {
938
+ method,
939
+ headers: {
940
+ 'X-ScreenCI-Secret': secret,
941
+ },
942
+ });
943
+ if (!res.ok) {
944
+ const text = await res.text();
945
+ throw new Error(`Failed to ${isPublic ? 'make public' : 'make private'}: ${res.status} ${text}${hint401(res.status, secret)}`);
946
+ }
947
+ logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`);
303
948
  }
304
949
  function generateConfig(projectName) {
305
950
  return `import { defineConfig } from 'screenci'
306
951
 
307
952
  export default defineConfig({
308
953
  projectName: ${JSON.stringify(projectName)},
309
- apiUrl: process.env.SCREENCI_URL ?? 'http://localhost:8787',
310
954
  envFile: '.env',
311
955
  videoDir: './videos',
312
956
  forbidOnly: !!process.env.CI,
@@ -328,11 +972,18 @@ export default defineConfig({
328
972
  })
329
973
  `;
330
974
  }
331
- function generatePackageJson(projectName, localPackagePath) {
975
+ function generatePackageJson(projectName, localPackagePath, includePlaywrightCli = false) {
332
976
  const npmName = projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
333
977
  const screenciVersion = localPackagePath
334
978
  ? `file:${localPackagePath}`
335
979
  : 'latest';
980
+ const devDependencies = {
981
+ '@types/node': '^25.0.0',
982
+ tsx: '^4.21.0',
983
+ };
984
+ if (includePlaywrightCli) {
985
+ devDependencies['@playwright/cli'] = 'latest';
986
+ }
336
987
  return (JSON.stringify({
337
988
  name: npmName,
338
989
  version: '1.0.0',
@@ -340,16 +991,13 @@ function generatePackageJson(projectName, localPackagePath) {
340
991
  type: 'module',
341
992
  scripts: {
342
993
  record: 'screenci record',
343
- 'upload-latest': 'screenci upload-latest',
994
+ retry: 'screenci retry',
344
995
  dev: 'screenci dev',
345
996
  },
346
997
  dependencies: {
347
998
  screenci: screenciVersion,
348
999
  },
349
- devDependencies: {
350
- '@types/node': '^25.0.0',
351
- tsx: '^4.21.0',
352
- },
1000
+ devDependencies,
353
1001
  }, null, 2) + '\n');
354
1002
  }
355
1003
  function generateDockerfile() {
@@ -443,9 +1091,9 @@ async function performBrowserLogin(appUrl) {
443
1091
  const port = server.address().port;
444
1092
  const callbackUrl = `http://localhost:${port}/callback`;
445
1093
  const loginUrl = `${appUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
446
- logger.info('Opening browser for authentication...');
447
1094
  logger.info(`If the browser does not open automatically, visit:`);
448
- logger.info(` ${loginUrl}`);
1095
+ logger.info(pc.cyan(loginUrl));
1096
+ logger.info('');
449
1097
  openBrowser(loginUrl);
450
1098
  });
451
1099
  const timeout = setTimeout(() => {
@@ -464,38 +1112,19 @@ video('Example video', async ({ page }) => {
464
1112
  })
465
1113
  `;
466
1114
  }
467
- async function promptLine(question) {
468
- const rl = createInterface({
469
- input: process.stdin,
470
- output: process.stdout,
471
- });
472
- try {
473
- const answer = await rl.question(question);
474
- return answer.trim();
475
- }
476
- finally {
477
- rl.close();
478
- }
479
- }
480
1115
  async function promptProjectName() {
481
- return promptLine('Project name: ');
1116
+ return input({ message: 'Project name:' });
482
1117
  }
483
- function toKebabCase(name) {
484
- return name
485
- .toLowerCase()
486
- .replace(/\s+/g, '-')
487
- .replace(/[^a-z0-9-]/g, '-')
488
- .replace(/-+/g, '-')
489
- .replace(/^-|-$/g, '');
1118
+ function getProjectDirName(name) {
1119
+ return name.toLowerCase().replace(/\s+/g, '-');
490
1120
  }
491
1121
  async function runInitAuth() {
492
- const devPort = process.env.DEV_PORT;
493
- const appUrl = process.env.SCREENCI_APP_URL ??
494
- (devPort ? `http://localhost:${devPort}` : 'https://app.screenci.com');
1122
+ const appUrl = getDevFrontendUrl();
495
1123
  try {
496
1124
  const secret = await performBrowserLogin(appUrl);
497
- await writeFile(resolve(process.cwd(), '.env'), `SCREENCI_SECRET=${secret}\n`);
498
- logger.info('API key saved to .env');
1125
+ const savePath = resolve(process.cwd(), '.env');
1126
+ await writeFile(savePath, `SCREENCI_SECRET=${secret}\n`);
1127
+ logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
499
1128
  }
500
1129
  catch (err) {
501
1130
  const msg = err instanceof Error ? err.message : String(err);
@@ -510,8 +1139,9 @@ function checkNodeVersion() {
510
1139
  process.exit(1);
511
1140
  }
512
1141
  }
513
- async function runInit(projectNameArg, localPackagePath) {
1142
+ async function runInit(projectNameArg, localPackagePath, verbose = false) {
514
1143
  checkNodeVersion();
1144
+ checkContainerRuntimeForInit();
515
1145
  let projectName = projectNameArg?.trim();
516
1146
  if (!projectName) {
517
1147
  projectName = await promptProjectName();
@@ -520,21 +1150,25 @@ async function runInit(projectNameArg, localPackagePath) {
520
1150
  logger.error('Error: Project name is required');
521
1151
  process.exit(1);
522
1152
  }
523
- const dirName = toKebabCase(projectName);
1153
+ const dirName = getProjectDirName(projectName);
524
1154
  const projectDir = resolve(process.cwd(), dirName);
525
1155
  if (existsSync(projectDir)) {
526
1156
  logger.error(`Error: Directory "${dirName}" already exists`);
527
1157
  process.exit(1);
528
1158
  }
1159
+ const shouldAddPlaywrightCli = await confirm({
1160
+ message: 'Do you want to write videos with an AI agent based on a URL? playwright-cli is recommended and will be added as a dev dependency.',
1161
+ default: true,
1162
+ });
529
1163
  await mkdir(resolve(projectDir, 'videos'), { recursive: true });
530
1164
  await mkdir(resolve(projectDir, '.github', 'workflows'), { recursive: true });
531
1165
  await writeFile(resolve(projectDir, 'screenci.config.ts'), generateConfig(projectName));
532
- await writeFile(resolve(projectDir, 'package.json'), generatePackageJson(dirName, localPackagePath));
1166
+ await writeFile(resolve(projectDir, 'package.json'), generatePackageJson(dirName, localPackagePath, shouldAddPlaywrightCli));
533
1167
  await writeFile(resolve(projectDir, 'Dockerfile'), generateDockerfile());
534
1168
  await writeFile(resolve(projectDir, '.gitignore'), generateGitignore());
535
1169
  await writeFile(resolve(projectDir, 'videos', 'example.video.ts'), generateExampleVideo());
536
1170
  await writeFile(resolve(projectDir, '.github', 'workflows', 'record.yml'), generateGithubAction());
537
- logger.info(`Initialized screenci project "${projectName}" in ${dirName}/`);
1171
+ logger.info(`Initialized screenci project "${projectName}" in ${projectDir}/`);
538
1172
  logger.info('Files created:');
539
1173
  logger.info(' screenci.config.ts');
540
1174
  logger.info(' package.json');
@@ -543,141 +1177,401 @@ async function runInit(projectNameArg, localPackagePath) {
543
1177
  logger.info(' videos/example.video.ts');
544
1178
  logger.info(' .github/workflows/record.yml');
545
1179
  logger.info('');
546
- logger.info('Running npm install...');
547
- await spawnInherited('npm', ['install', '--prefix', projectDir]);
1180
+ logger.info('screenci requires dependencies to be installed.');
1181
+ const shouldInstallSkills = await confirm({
1182
+ message: "Add ScreenCI skills now with 'npx --yes skills add screenci/screenci --skill screenci --skill playwright-cli -y'?",
1183
+ default: true,
1184
+ });
1185
+ if (shouldInstallSkills) {
1186
+ if (verbose) {
1187
+ logger.info("Running 'npx --yes skills add screenci/screenci --skill screenci --skill playwright-cli -y'...");
1188
+ await spawnInherited('npx', [
1189
+ '--yes',
1190
+ 'skills',
1191
+ 'add',
1192
+ 'screenci/screenci',
1193
+ '--skill',
1194
+ 'screenci',
1195
+ '--skill',
1196
+ 'playwright-cli',
1197
+ '-y',
1198
+ ], projectDir, 'screenci init');
1199
+ }
1200
+ else {
1201
+ const spinner = ora('Adding ScreenCI skills...').start();
1202
+ try {
1203
+ await spawnSilent('npx', [
1204
+ '--yes',
1205
+ 'skills',
1206
+ 'add',
1207
+ 'screenci/screenci',
1208
+ '--skill',
1209
+ 'screenci',
1210
+ '--skill',
1211
+ 'playwright-cli',
1212
+ '-y',
1213
+ ], projectDir);
1214
+ spinner.succeed('ScreenCI skills added');
1215
+ }
1216
+ catch (err) {
1217
+ spinner.fail('ScreenCI skills install failed');
1218
+ throw err;
1219
+ }
1220
+ }
1221
+ }
1222
+ const shouldInstall = await confirm({
1223
+ message: "Run 'npm install' now?",
1224
+ default: true,
1225
+ });
1226
+ let chromiumReady = false;
1227
+ if (shouldInstall) {
1228
+ if (verbose) {
1229
+ logger.info("Running 'npm install'...");
1230
+ await spawnInherited('npm', ['install'], projectDir, 'screenci init');
1231
+ }
1232
+ else {
1233
+ const spinner = ora('Running npm install...').start();
1234
+ try {
1235
+ await spawnSilent('npm', ['install', '--prefix', projectDir]);
1236
+ spinner.succeed('npm install complete');
1237
+ }
1238
+ catch (err) {
1239
+ spinner.fail('npm install failed');
1240
+ throw err;
1241
+ }
1242
+ }
1243
+ const chromiumInstallStatus = await getPlaywrightChromiumInstallStatus(projectDir, verbose);
1244
+ if (!chromiumInstallStatus.needed) {
1245
+ chromiumReady = true;
1246
+ const versionText = chromiumInstallStatus.version
1247
+ ? `, version ${chromiumInstallStatus.version} already installed.`
1248
+ : ' because it is already installed.';
1249
+ logger.info(`${pc.green('✔')} Skipping Chromium installation${versionText}`);
1250
+ }
1251
+ else {
1252
+ logger.info('Local development requires Chromium for Playwright.');
1253
+ const shouldInstallChromium = await confirm({
1254
+ message: "Run 'npx playwright install chromium --with-deps' now?",
1255
+ default: true,
1256
+ });
1257
+ if (shouldInstallChromium) {
1258
+ logger.info("Running 'npx playwright install chromium --with-deps'...");
1259
+ await spawnInherited('npx', ['playwright', 'install', 'chromium', '--with-deps'], projectDir, 'screenci init');
1260
+ chromiumReady = true;
1261
+ }
1262
+ }
1263
+ }
548
1264
  logger.info('');
549
1265
  logger.info('Next steps:');
550
1266
  logger.info(` cd ${dirName}`);
551
- logger.info(' screenci record');
1267
+ if (!shouldInstallSkills) {
1268
+ logger.info(' npx --yes skills add screenci/screenci --skill screenci --skill playwright-cli -y');
1269
+ }
1270
+ if (!shouldInstall) {
1271
+ logger.info(' npm install');
1272
+ }
1273
+ if (!shouldInstall || !chromiumReady) {
1274
+ logger.info(' npx playwright install chromium --with-deps');
1275
+ }
1276
+ logger.info(' npx screenci record');
552
1277
  }
553
1278
  export async function main() {
554
- const args = process.argv.slice(2);
555
- const { command, configPath, noContainer, otherArgs } = parseArgs(args);
556
- switch (command) {
557
- case 'record': {
558
- const useContainer = !noContainer && process.env.SCREENCI_IN_CONTAINER !== 'true';
559
- // Validate early so we don't build the container unnecessarily
560
- if (useContainer) {
561
- validateArgs(otherArgs);
562
- }
563
- // On the host, acquire secret before recording if missing
564
- if (process.env.SCREENCI_IN_CONTAINER !== 'true') {
565
- const resolvedConfigForSecret = findScreenCIConfig(configPath);
566
- if (resolvedConfigForSecret) {
567
- let envFilePath = null;
568
- try {
569
- const configModule = await import(resolvedConfigForSecret);
570
- const screenciConfig = configModule.default;
571
- envFilePath = screenciConfig.envFile
572
- ? resolve(dirname(resolvedConfigForSecret), screenciConfig.envFile)
573
- : null;
574
- if (envFilePath) {
575
- try {
576
- process.loadEnvFile(envFilePath);
577
- }
578
- catch {
579
- // env file may not exist yet
580
- }
581
- }
582
- }
583
- catch (err) {
584
- if (!process.env.SCREENCI_SECRET) {
585
- const msg = err instanceof Error ? err.message : String(err);
586
- logger.error(`Failed to acquire secret: ${msg}`);
587
- process.exit(1);
588
- }
589
- // Config import failed but SCREENCI_SECRET is already in env — continue
590
- }
591
- if (!process.env.SCREENCI_SECRET) {
592
- logger.info('SCREENCI_SECRET not found. Opening browser to sign in and select a plan...');
593
- const devPort = process.env.DEV_PORT;
594
- const appUrl = process.env.SCREENCI_APP_URL ??
595
- (devPort
596
- ? `http://localhost:${devPort}`
597
- : 'https://app.screenci.com');
598
- const secret = await performBrowserLogin(appUrl);
599
- const savePath = envFilePath ?? resolve(dirname(resolvedConfigForSecret), '.env');
600
- await writeFile(savePath, `SCREENCI_SECRET=${secret}\n`);
601
- process.env.SCREENCI_SECRET = secret;
602
- logger.info('API key saved.');
603
- }
604
- }
605
- }
606
- if (useContainer) {
607
- await runWithContainer(otherArgs, configPath);
608
- }
609
- else {
610
- await run(command, otherArgs, configPath);
611
- }
612
- // Upload only from the host, not from inside the container
613
- if (process.env.SCREENCI_IN_CONTAINER === 'true')
614
- break;
615
- // After recording, upload results to Convex if configured
616
- const resolvedConfigPath = findScreenCIConfig(configPath);
617
- if (resolvedConfigPath) {
1279
+ if (process.argv.length <= 2) {
1280
+ logger.error('Error: No command provided');
1281
+ logger.error('Available commands: record, dev, test, info, make-public, make-private, retry, init');
1282
+ process.exit(1);
1283
+ }
1284
+ const program = new Command();
1285
+ program.name('screenci');
1286
+ program.exitOverride();
1287
+ // record command — playwright args pass through as-is
1288
+ program
1289
+ .command('record [playwrightArgs...]')
1290
+ .description('Record videos using Playwright')
1291
+ .allowUnknownOption(true)
1292
+ .action(async () => {
1293
+ const parsed = parseRecordCliArgs(getSubcommandArgv('record'));
1294
+ if (parsed.forcedRuntime === 'both') {
1295
+ logger.error('Error: --podman and --docker cannot be used together');
1296
+ process.exit(1);
1297
+ }
1298
+ const useContainer = !parsed.noContainer && process.env.SCREENCI_IN_CONTAINER !== 'true';
1299
+ // Validate early so we don't build the container unnecessarily
1300
+ if (useContainer) {
1301
+ validateArgs(parsed.otherArgs);
1302
+ }
1303
+ // On the host, acquire secret before recording if missing
1304
+ if (process.env.SCREENCI_IN_CONTAINER !== 'true') {
1305
+ const resolvedConfigForSecret = findScreenCIConfig(parsed.configPath);
1306
+ if (resolvedConfigForSecret) {
1307
+ let envFilePath = null;
618
1308
  try {
619
- const configModule = await import(resolvedConfigPath);
1309
+ const configModule = await import(resolvedConfigForSecret);
620
1310
  const screenciConfig = configModule.default;
621
- if (screenciConfig.envFile) {
622
- const envFilePath = resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
1311
+ envFilePath = screenciConfig.envFile
1312
+ ? resolve(dirname(resolvedConfigForSecret), screenciConfig.envFile)
1313
+ : null;
1314
+ if (envFilePath) {
623
1315
  try {
624
1316
  process.loadEnvFile(envFilePath);
625
1317
  }
626
- catch (err) {
627
- logger.warn(`Failed to load env file ${envFilePath}:`, err);
1318
+ catch {
1319
+ // env file may not exist yet
628
1320
  }
629
1321
  }
630
- const convexUrl = screenciConfig.apiUrl ?? process.env.SCREENCI_URL;
631
- if (!convexUrl) {
632
- logger.info('No API URL configured, skipping upload. Set apiUrl in screenci.config.ts or SCREENCI_URL env var.');
633
- break;
1322
+ }
1323
+ catch (err) {
1324
+ if (!process.env.SCREENCI_SECRET) {
1325
+ const msg = err instanceof Error ? err.message : String(err);
1326
+ logger.error(`Failed to acquire secret: ${msg}`);
1327
+ process.exit(1);
1328
+ }
1329
+ // Config import failed but SCREENCI_SECRET is already in env — continue
1330
+ }
1331
+ if (!process.env.SCREENCI_SECRET) {
1332
+ logger.info('No SCREENCI_SECRET in .env file, opening browser for authentication...');
1333
+ const appUrl = getDevFrontendUrl();
1334
+ const secret = await performBrowserLogin(appUrl);
1335
+ const savePath = envFilePath ?? resolve(dirname(resolvedConfigForSecret), '.env');
1336
+ await writeFile(savePath, `SCREENCI_SECRET=${secret}\n`);
1337
+ process.env.SCREENCI_SECRET = secret;
1338
+ logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
1339
+ }
1340
+ }
1341
+ }
1342
+ if (useContainer) {
1343
+ await runWithContainer(parsed.otherArgs, parsed.configPath, parsed.imageTag, parsed.verbose, parsed.forcedRuntime);
1344
+ }
1345
+ else {
1346
+ await run('record', parsed.otherArgs, parsed.configPath);
1347
+ }
1348
+ // Upload only from the host, not from inside the container
1349
+ if (process.env.SCREENCI_IN_CONTAINER === 'true')
1350
+ return;
1351
+ // After recording, upload results to API if configured
1352
+ const resolvedConfigPath = findScreenCIConfig(parsed.configPath);
1353
+ if (resolvedConfigPath) {
1354
+ try {
1355
+ const configModule = await import(resolvedConfigPath);
1356
+ const screenciConfig = configModule.default;
1357
+ if (screenciConfig.envFile) {
1358
+ const envFilePath = resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
1359
+ try {
1360
+ process.loadEnvFile(envFilePath);
634
1361
  }
635
- const secret = process.env.SCREENCI_SECRET;
636
- if (!secret) {
637
- logger.info('No secret configured, skipping upload. Set SCREENCI_SECRET in your .env file.');
638
- break;
1362
+ catch (err) {
1363
+ logger.warn(`Failed to load env file ${envFilePath}:`, err);
639
1364
  }
640
- const configDir = dirname(resolvedConfigPath);
641
- const screenciDir = resolve(configDir, '.screenci');
642
- await uploadRecordings(screenciDir, screenciConfig.projectName, convexUrl, secret);
1365
+ }
1366
+ const apiUrl = getDevBackendUrl();
1367
+ const appUrl = getDevFrontendUrl();
1368
+ const secret = process.env.SCREENCI_SECRET;
1369
+ if (!secret) {
1370
+ logger.info('No secret configured, skipping upload. Set SCREENCI_SECRET in your .env file.');
1371
+ return;
1372
+ }
1373
+ const configDir = dirname(resolvedConfigPath);
1374
+ const screenciDir = resolve(configDir, '.screenci');
1375
+ let projectId = null;
1376
+ try {
1377
+ projectId = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
643
1378
  }
644
1379
  catch (err) {
645
- logger.warn('Failed to load config for upload:', err);
1380
+ if (isUploadCancelledError(err)) {
1381
+ process.exit(130);
1382
+ }
1383
+ throw err;
646
1384
  }
1385
+ if (projectId !== null) {
1386
+ logger.info('');
1387
+ logger.info('Recording finished, results available at:');
1388
+ logger.info(pc.cyan(`${appUrl}/project/${projectId}`));
1389
+ }
1390
+ }
1391
+ catch (err) {
1392
+ logger.warn('Failed to load config for upload:', err);
1393
+ }
1394
+ }
1395
+ });
1396
+ // dev command — playwright args pass through as-is
1397
+ program
1398
+ .command('dev [playwrightArgs...]')
1399
+ .description('Run Playwright in dev/UI mode')
1400
+ .allowUnknownOption(true)
1401
+ .action(async () => {
1402
+ const parsed = parseDevCliArgs(getSubcommandArgv('dev'));
1403
+ await run('dev', parsed.otherArgs, parsed.configPath);
1404
+ });
1405
+ program
1406
+ .command('test [playwrightArgs...]')
1407
+ .description('Run Playwright test with screenci.config.ts')
1408
+ .allowUnknownOption(true)
1409
+ .action(async () => {
1410
+ const parsed = parseDevCliArgs(getSubcommandArgv('test'));
1411
+ await run('test', parsed.otherArgs, parsed.configPath);
1412
+ });
1413
+ program
1414
+ .command('info')
1415
+ .description('Print remote project info as JSON')
1416
+ .option('-c, --config <path>', 'path to screenci.config.ts')
1417
+ .action(async (options) => {
1418
+ await printProjectInfo(options['config']);
1419
+ });
1420
+ program
1421
+ .command('make-public <id>')
1422
+ .description('Enable public URLs for a video; get the id from screenci info')
1423
+ .option('-c, --config <path>', 'path to screenci.config.ts')
1424
+ .action(async (id, options) => {
1425
+ await updateVideoVisibility(id, true, options['config']);
1426
+ });
1427
+ program
1428
+ .command('make-private <id>')
1429
+ .description('Disable public URLs for a video; get the id from screenci info')
1430
+ .option('-c, --config <path>', 'path to screenci.config.ts')
1431
+ .action(async (id, options) => {
1432
+ await updateVideoVisibility(id, false, options['config']);
1433
+ });
1434
+ // retry command
1435
+ program
1436
+ .command('retry')
1437
+ .description('Retry the latest recording upload')
1438
+ .option('-c, --config <path>', 'path to screenci.config.ts')
1439
+ .action(async (options) => {
1440
+ await uploadLatest(options['config']);
1441
+ });
1442
+ // init command
1443
+ program
1444
+ .command('init [name]')
1445
+ .description('Initialize a new screenci project')
1446
+ .option('--local', 'use local package path (for development)')
1447
+ .option('-v, --verbose', 'verbose output')
1448
+ .action(async (name, options) => {
1449
+ if (name === 'auth') {
1450
+ await runInitAuth();
1451
+ }
1452
+ else {
1453
+ let localPackagePath;
1454
+ if (options['local']) {
1455
+ const cliDir = dirname(fileURLToPath(import.meta.url));
1456
+ // cli.ts is at package root; dist/cli.js is one level down
1457
+ localPackagePath = existsSync(resolve(cliDir, 'package.json'))
1458
+ ? cliDir
1459
+ : resolve(cliDir, '..');
647
1460
  }
648
- break;
1461
+ await runInit(name, localPackagePath, options['verbose'] ?? false);
649
1462
  }
650
- case 'dev':
651
- await run(command, otherArgs, configPath);
652
- break;
653
- case 'upload-latest':
654
- await uploadLatest(configPath);
655
- break;
656
- case 'init': {
657
- if (otherArgs[0] === 'auth') {
658
- await runInitAuth();
1463
+ });
1464
+ try {
1465
+ await program.parseAsync(process.argv);
1466
+ }
1467
+ catch (err) {
1468
+ if (err instanceof CommanderError) {
1469
+ if (err.code === 'commander.unknownCommand') {
1470
+ const unknownCmd = process.argv[2] ?? '';
1471
+ logger.error(`Unknown command: ${unknownCmd}`);
1472
+ process.exit(1);
659
1473
  }
660
- else {
661
- const localFlagIndex = otherArgs.indexOf('--local');
662
- let localPackagePath;
663
- let initArgs = otherArgs;
664
- if (localFlagIndex !== -1) {
665
- const cliDir = dirname(fileURLToPath(import.meta.url));
666
- // cli.ts is at package root; dist/cli.js is one level down
667
- localPackagePath = existsSync(resolve(cliDir, 'package.json'))
668
- ? cliDir
669
- : resolve(cliDir, '..');
670
- initArgs = otherArgs.filter((_, i) => i !== localFlagIndex);
1474
+ if (err.code === 'commander.optionMissingArgument') {
1475
+ if (err.message.includes('--config') ||
1476
+ err.message.includes('-c, --config') ||
1477
+ err.message.includes("'-c'")) {
1478
+ logger.error('Error: --config requires a path argument');
1479
+ process.exit(1);
671
1480
  }
672
- await runInit(initArgs[0], localPackagePath);
1481
+ logger.error(`Error: ${err.message}`);
1482
+ process.exit(1);
673
1483
  }
674
- break;
675
- }
676
- default:
677
- logger.error(`Unknown command: ${command}`);
678
- logger.error('Available commands: record, dev, upload-latest, init');
1484
+ if (err.code === 'commander.help' ||
1485
+ err.code === 'commander.helpDisplayed') {
1486
+ return;
1487
+ }
1488
+ logger.error(`Error: ${err.message}`);
679
1489
  process.exit(1);
1490
+ return;
1491
+ }
1492
+ throw err;
1493
+ }
1494
+ }
1495
+ function getSubcommandArgv(command) {
1496
+ const argv = process.argv.slice(2);
1497
+ const commandIndex = argv.indexOf(command);
1498
+ return commandIndex === -1 ? [] : argv.slice(commandIndex + 1);
1499
+ }
1500
+ function parseRecordCliArgs(args) {
1501
+ let configPath;
1502
+ let noContainer = false;
1503
+ let imageTag;
1504
+ let verbose = false;
1505
+ let forcedRuntime;
1506
+ const otherArgs = [];
1507
+ for (let i = 0; i < args.length; i++) {
1508
+ const arg = args[i];
1509
+ if (arg === undefined)
1510
+ continue;
1511
+ if (arg === '--config' || arg === '-c') {
1512
+ const nextArg = args[i + 1];
1513
+ if (nextArg === undefined) {
1514
+ logger.error('Error: --config requires a path argument');
1515
+ process.exit(1);
1516
+ }
1517
+ configPath = nextArg;
1518
+ i++;
1519
+ }
1520
+ else if (arg === '--no-container') {
1521
+ noContainer = true;
1522
+ }
1523
+ else if (arg === '--verbose' || arg === '-v') {
1524
+ verbose = true;
1525
+ }
1526
+ else if (arg === '--podman') {
1527
+ forcedRuntime = forcedRuntime === 'docker' ? 'both' : 'podman';
1528
+ }
1529
+ else if (arg === '--docker') {
1530
+ forcedRuntime = forcedRuntime === 'podman' ? 'both' : 'docker';
1531
+ }
1532
+ else if (arg === '--tag') {
1533
+ const nextArg = args[i + 1];
1534
+ if (nextArg === undefined) {
1535
+ logger.error('Error: --tag requires a tag argument');
1536
+ process.exit(1);
1537
+ }
1538
+ imageTag = nextArg;
1539
+ i++;
1540
+ }
1541
+ else {
1542
+ otherArgs.push(arg);
1543
+ }
680
1544
  }
1545
+ return {
1546
+ configPath,
1547
+ noContainer,
1548
+ imageTag,
1549
+ verbose,
1550
+ forcedRuntime,
1551
+ otherArgs,
1552
+ };
1553
+ }
1554
+ function parseDevCliArgs(args) {
1555
+ let configPath;
1556
+ const otherArgs = [];
1557
+ for (let i = 0; i < args.length; i++) {
1558
+ const arg = args[i];
1559
+ if (arg === undefined)
1560
+ continue;
1561
+ if (arg === '--config' || arg === '-c') {
1562
+ const nextArg = args[i + 1];
1563
+ if (nextArg === undefined) {
1564
+ logger.error('Error: --config requires a path argument');
1565
+ process.exit(1);
1566
+ }
1567
+ configPath = nextArg;
1568
+ i++;
1569
+ }
1570
+ else {
1571
+ otherArgs.push(arg);
1572
+ }
1573
+ }
1574
+ return { configPath, otherArgs };
681
1575
  }
682
1576
  function validateArgs(args) {
683
1577
  const disallowedFlags = ['--fully-parallel', '--workers', '-j', '--retries'];
@@ -698,28 +1592,22 @@ function validateArgs(args) {
698
1592
  }
699
1593
  }
700
1594
  }
701
- function spawnInherited(cmd, args) {
702
- const child = spawn(cmd, args, { stdio: 'inherit' });
703
- const forwardSignal = (signal) => {
704
- logger.info(`Received ${signal}, stopping...`);
705
- if (!child.killed) {
706
- child.kill(signal);
707
- }
708
- const forceKill = setTimeout(() => {
709
- if (child.exitCode === null) {
710
- logger.info('Forcing kill after timeout...');
711
- child.kill('SIGKILL');
712
- }
713
- }, 3000);
714
- forceKill.unref();
715
- };
716
- process.on('SIGINT', forwardSignal);
717
- process.on('SIGTERM', forwardSignal);
1595
+ function spawnInherited(cmd, args, cwd, activityLabel = cmd) {
1596
+ const child = spawn(cmd, args, { stdio: 'inherit', ...(cwd ? { cwd } : {}) });
1597
+ const childSignals = forwardChildSignals(child, activityLabel);
718
1598
  return new Promise((resolve, reject) => {
719
- child.on('close', (code) => {
720
- process.off('SIGINT', forwardSignal);
721
- process.off('SIGTERM', forwardSignal);
722
- if (code === 0) {
1599
+ child.on('close', (code, signal) => {
1600
+ const forwardedSignal = childSignals.getForwardedSignal();
1601
+ childSignals.cleanup();
1602
+ if (forwardedSignal) {
1603
+ process.kill(process.pid, forwardedSignal);
1604
+ return;
1605
+ }
1606
+ if (signal) {
1607
+ process.kill(process.pid, signal);
1608
+ return;
1609
+ }
1610
+ else if (code === 0) {
723
1611
  resolve();
724
1612
  }
725
1613
  else {
@@ -727,26 +1615,55 @@ function spawnInherited(cmd, args) {
727
1615
  }
728
1616
  });
729
1617
  child.on('error', (err) => {
730
- process.off('SIGINT', forwardSignal);
731
- process.off('SIGTERM', forwardSignal);
1618
+ childSignals.cleanup();
732
1619
  reject(err);
733
1620
  });
734
1621
  });
735
1622
  }
736
- export function detectContainerRuntime() {
737
- for (const runtime of ['podman', 'docker']) {
738
- const result = spawnSync(runtime, ['--version'], { stdio: 'ignore' });
739
- if (result.status === 0 && result.error === undefined) {
740
- return runtime;
741
- }
1623
+ export function detectContainerRuntime(forcedRuntime) {
1624
+ const runtimeCheck = forcedRuntime
1625
+ ? checkContainerRuntime(forcedRuntime)
1626
+ : getPreferredContainerRuntime();
1627
+ if (runtimeCheck) {
1628
+ warnIfContainerRuntimeVersionIsOld(runtimeCheck);
1629
+ return runtimeCheck.runtime;
1630
+ }
1631
+ if (forcedRuntime) {
1632
+ exitContainerRuntimeNotFound(forcedRuntime);
742
1633
  }
743
1634
  logger.error('Error: Neither podman nor docker found.');
744
1635
  logger.error('Please install podman (recommended) or docker to use screenci record.');
745
- logger.error(' podman: https://podman.io/docs/installation');
746
- logger.error(' docker: https://docs.docker.com/get-docker/');
1636
+ logger.error(prerequisitesMessage());
747
1637
  process.exit(1);
748
1638
  }
749
- async function runWithContainer(additionalArgs, customConfigPath) {
1639
+ function checkContainerRuntimeForInit() {
1640
+ const runtimeCheck = getPreferredContainerRuntime();
1641
+ if (!runtimeCheck) {
1642
+ logger.warn('Neither podman nor docker found. Install one before running screenci record.');
1643
+ logger.warn(prerequisitesMessage());
1644
+ return;
1645
+ }
1646
+ warnIfContainerRuntimeVersionIsOld(runtimeCheck);
1647
+ }
1648
+ async function buildImage(cmd, args, label, verbose) {
1649
+ if (verbose) {
1650
+ await spawnInherited(cmd, args);
1651
+ return;
1652
+ }
1653
+ const spinner = ora(label).start();
1654
+ try {
1655
+ await spawnSilent(cmd, args);
1656
+ spinner.succeed(label);
1657
+ }
1658
+ catch (err) {
1659
+ spinner.fail(label);
1660
+ const msg = err instanceof Error ? err.message : String(err);
1661
+ logger.error(msg);
1662
+ logger.error('Run again with --verbose to see the full build output');
1663
+ process.exit(1);
1664
+ }
1665
+ }
1666
+ async function runWithContainer(additionalArgs, customConfigPath, imageTag, verbose = false, forcedRuntime) {
750
1667
  const configPath = findScreenCIConfig(customConfigPath);
751
1668
  if (!configPath) {
752
1669
  const errorMsg = customConfigPath
@@ -767,44 +1684,90 @@ async function runWithContainer(additionalArgs, customConfigPath) {
767
1684
  logger.error('Error: Could not find repository root (.git or pnpm-workspace.yaml)');
768
1685
  process.exit(1);
769
1686
  }
770
- const containerRuntime = detectContainerRuntime();
1687
+ const containerRuntime = detectContainerRuntime(forcedRuntime);
1688
+ const ghcrImage = 'ghcr.io/screenci/record:latest';
1689
+ const dockerfileVersion = parseDockerfileVersion(dockerfilePath);
771
1690
  if (process.env['SCREENCI_LOCAL_IMAGE']) {
772
1691
  logger.info('SCREENCI_LOCAL_IMAGE set — skipping screenci image build');
773
1692
  }
1693
+ else if (imageTag !== undefined) {
1694
+ const remoteImage = `ghcr.io/screenci/record:${imageTag}`;
1695
+ const imageExists = spawnSync(containerRuntime, ['image', 'exists', remoteImage], {
1696
+ stdio: 'ignore',
1697
+ }).status === 0;
1698
+ logger.info(`Using image tag ${imageTag} instead of the version ${dockerfileVersion} from Dockerfile`);
1699
+ if (!imageExists) {
1700
+ await buildImage(containerRuntime, ['pull', remoteImage], 'Pulling screenci image', verbose);
1701
+ }
1702
+ await spawnSilent(containerRuntime, ['tag', remoteImage, ghcrImage]);
1703
+ }
774
1704
  else {
775
1705
  const cliDir = dirname(fileURLToPath(import.meta.url));
1706
+ const screenciPackageRoot = existsSync(resolve(cliDir, 'package.json'))
1707
+ ? cliDir
1708
+ : resolve(cliDir, '..');
776
1709
  const screenciDockerfilePath = resolve(cliDir, 'Dockerfile');
777
- logger.info(`Building container image with ${containerRuntime}...`);
778
- logger.info(`Using Dockerfile: ${screenciDockerfilePath}`);
779
- logger.info(`Build context: ${repoRoot}`);
780
- await spawnInherited(containerRuntime, [
781
- 'build',
782
- '-f',
783
- screenciDockerfilePath,
784
- '-t',
785
- 'screenci',
786
- repoRoot,
787
- ]);
788
- }
789
- logger.info(`Using Dockerfile: ${dockerfilePath}`);
790
- logger.info(`Build context: ${configDir}`);
791
- await spawnInherited(containerRuntime, [
792
- 'build',
793
- '-f',
794
- dockerfilePath,
795
- '-t',
796
- 'screenci',
797
- configDir,
798
- ]);
1710
+ if (verbose) {
1711
+ await spawnInherited(containerRuntime, [
1712
+ 'build',
1713
+ '-f',
1714
+ screenciDockerfilePath,
1715
+ '-t',
1716
+ ghcrImage,
1717
+ screenciPackageRoot,
1718
+ ], undefined, 'building screenci image');
1719
+ await spawnInherited(containerRuntime, ['build', '-f', dockerfilePath, '-t', 'screenci', configDir], undefined, 'building project image');
1720
+ }
1721
+ else {
1722
+ const spinner = ora('Building screenci image').start();
1723
+ try {
1724
+ await spawnSilent(containerRuntime, [
1725
+ 'build',
1726
+ '-f',
1727
+ screenciDockerfilePath,
1728
+ '-t',
1729
+ ghcrImage,
1730
+ screenciPackageRoot,
1731
+ ]);
1732
+ await spawnSilent(containerRuntime, [
1733
+ 'build',
1734
+ '-f',
1735
+ dockerfilePath,
1736
+ '-t',
1737
+ 'screenci',
1738
+ configDir,
1739
+ ]);
1740
+ spinner.succeed('Building screenci image');
1741
+ }
1742
+ catch (err) {
1743
+ spinner.fail('Building screenci image');
1744
+ const msg = err instanceof Error ? err.message : String(err);
1745
+ logger.error(msg);
1746
+ logger.error('Run again with --verbose to see the full build output');
1747
+ process.exit(1);
1748
+ }
1749
+ }
1750
+ }
1751
+ if (imageTag !== undefined || process.env['SCREENCI_LOCAL_IMAGE']) {
1752
+ await buildImage(containerRuntime, ['build', '-f', dockerfilePath, '-t', 'screenci', configDir], 'Building project image', verbose);
1753
+ }
799
1754
  clearDirectory(resolve(configDir, '.screenci'));
800
- logger.info('Running recording in container...');
801
- await spawnInherited(containerRuntime, [
1755
+ const secret = process.env['SCREENCI_SECRET'];
1756
+ if (secret === undefined) {
1757
+ logger.error('Error: SCREENCI_SECRET is not set');
1758
+ process.exit(1);
1759
+ }
1760
+ await spawnContainerRecording(containerRuntime, [
802
1761
  'run',
803
1762
  '--rm',
804
1763
  '-e',
805
1764
  'SCREENCI_IN_CONTAINER=true',
806
1765
  '-e',
807
1766
  'SCREENCI_RECORD=true',
1767
+ '-e',
1768
+ `SCREENCI_SECRET=${secret}`,
1769
+ '-e',
1770
+ 'SCREENCI_SIGNAL_LOGGING=silent',
808
1771
  '-v',
809
1772
  `${configDir}/.screenci:/app/.screenci`,
810
1773
  '-v',
@@ -835,9 +1798,17 @@ async function run(command, additionalArgs, customConfigPath) {
835
1798
  // For dev command: use --ui unless --headed is specified
836
1799
  const isHeaded = additionalArgs.includes('--headed');
837
1800
  const shouldUseUI = command === 'dev' && !isHeaded;
838
- const mode = command === 'dev' ? (isHeaded ? 'headed mode' : 'UI mode') : 'recorder';
839
- logger.info(`Running ScreenCI ${mode} with npx...`);
840
- logger.info(`Using config: ${configPath}`);
1801
+ const mode = command === 'dev'
1802
+ ? isHeaded
1803
+ ? 'headed mode'
1804
+ : 'UI mode'
1805
+ : command === 'test'
1806
+ ? 'tests'
1807
+ : 'recorder';
1808
+ if (process.env.SCREENCI_IN_CONTAINER !== 'true') {
1809
+ logger.info(`Running ScreenCI ${mode} with npx...`);
1810
+ logger.info(`Using config: ${configPath}`);
1811
+ }
841
1812
  const playwrightArgs = [
842
1813
  'playwright',
843
1814
  'test',
@@ -854,29 +1825,19 @@ async function run(command, additionalArgs, customConfigPath) {
854
1825
  ...(command === 'record' ? { SCREENCI_RECORD: 'true' } : {}),
855
1826
  },
856
1827
  });
857
- const forwardSignal = (signal) => {
858
- logger.info(`Received ${signal}, stopping recording...`);
859
- if (!child.killed) {
860
- child.kill(signal);
861
- }
862
- // Force-kill after 3 s if the child hasn't actually exited yet.
863
- // child.killed becomes true as soon as we send the signal, so we check
864
- // child.exitCode instead — it stays null until the process truly exits.
865
- // unref() so the timer doesn't keep the process alive on its own.
866
- const forceKill = setTimeout(() => {
867
- if (child.exitCode === null) {
868
- logger.info('Forcing kill after timeout...');
869
- child.kill('SIGKILL');
870
- }
871
- }, 3000);
872
- forceKill.unref();
873
- };
874
- process.on('SIGINT', forwardSignal);
875
- process.on('SIGTERM', forwardSignal);
1828
+ const childSignals = forwardChildSignals(child, `screenci ${command}`);
876
1829
  return new Promise((resolve, reject) => {
877
- child.on('close', (code) => {
878
- process.off('SIGINT', forwardSignal);
879
- process.off('SIGTERM', forwardSignal);
1830
+ child.on('close', (code, signal) => {
1831
+ const forwardedSignal = childSignals.getForwardedSignal();
1832
+ childSignals.cleanup();
1833
+ if (forwardedSignal) {
1834
+ process.kill(process.pid, forwardedSignal);
1835
+ return;
1836
+ }
1837
+ if (signal) {
1838
+ process.kill(process.pid, signal);
1839
+ return;
1840
+ }
880
1841
  if (code === 0) {
881
1842
  resolve();
882
1843
  }
@@ -885,6 +1846,7 @@ async function run(command, additionalArgs, customConfigPath) {
885
1846
  }
886
1847
  });
887
1848
  child.on('error', (err) => {
1849
+ childSignals.cleanup();
888
1850
  reject(err);
889
1851
  });
890
1852
  });