screenci 0.0.10 → 0.0.12

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