screenci 0.0.10 → 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 +16 -2
  3. package/dist/cli.d.ts +21 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +1199 -420
  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 +34 -4
  53. package/skills/playwright-cli/SKILL.md +348 -0
  54. package/skills/screenci/SKILL.md +55 -0
  55. package/skills/screenci/references/init.md +47 -0
  56. package/skills/screenci/references/record.md +41 -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,99 @@ 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
+ ];
14
21
  }
15
- function completeInline(msg) {
16
- process.stdout.write(`\r\x1b[K${msg}\n`);
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
+ };
17
102
  }
18
103
  function parseDockerfileVersion(dockerfilePath) {
19
104
  let content;
@@ -31,20 +116,183 @@ function parseDockerfileVersion(dockerfilePath) {
31
116
  const match = fromLine.match(/:([^\s@]+)/);
32
117
  return match?.[1] ?? 'unknown';
33
118
  }
34
- function spawnSilent(cmd, args) {
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) {
35
167
  return new Promise((resolve, reject) => {
36
- const child = spawn(cmd, args, { stdio: 'pipe' });
37
- child.on('close', (code) => {
38
- if (code === 0) {
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) {
39
182
  resolve();
40
183
  }
41
184
  else {
42
185
  reject(new Error(`${cmd} exited with code ${code}`));
43
186
  }
44
187
  });
45
- child.on('error', reject);
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
+ });
46
223
  });
47
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
+ }
48
296
  const CONTAINER_LOG_FILTER = [
49
297
  /^Running ScreenCI /,
50
298
  /^Using config:/,
@@ -60,6 +308,7 @@ const CONTAINER_LOG_FILTER = [
60
308
  function spawnContainerRecording(cmd, args) {
61
309
  return new Promise((resolve, reject) => {
62
310
  const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'pipe'] });
311
+ const childSignals = forwardChildSignals(child, 'screenci record');
63
312
  function forwardFiltered(chunk, out) {
64
313
  const lines = chunk.toString().split('\n');
65
314
  for (const line of lines) {
@@ -72,15 +321,28 @@ function spawnContainerRecording(cmd, args) {
72
321
  }
73
322
  child.stdout?.on('data', (chunk) => forwardFiltered(chunk, process.stdout));
74
323
  child.stderr?.on('data', (chunk) => forwardFiltered(chunk, process.stderr));
75
- child.on('close', (code) => {
76
- if (code === 0) {
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) {
77
336
  resolve();
78
337
  }
79
338
  else {
80
339
  reject(new Error(`${cmd} exited with code ${code}`));
81
340
  }
82
341
  });
83
- child.on('error', reject);
342
+ child.on('error', (err) => {
343
+ childSignals.cleanup();
344
+ reject(err);
345
+ });
84
346
  });
85
347
  }
86
348
  function clearDirectory(dir) {
@@ -119,54 +381,6 @@ function findRepoRoot(startDir) {
119
381
  current = parent;
120
382
  }
121
383
  }
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
384
  async function findLatestEntry(screenciDir) {
171
385
  let entries;
172
386
  try {
@@ -192,58 +406,260 @@ async function findLatestEntry(screenciDir) {
192
406
  }
193
407
  return latestEntry;
194
408
  }
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;
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
+ }
224
432
  }
225
- catch {
226
- // 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
+ }
227
446
  }
228
447
  }
229
- if (fileBuffer === undefined) {
230
- logger.warn(`Asset file not found, skipping upload: ${assetPath}`);
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}`);
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;
468
+ }
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
+ });
231
505
  continue;
232
506
  }
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';
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();
246
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();
247
663
  const res = await fetch(`${apiUrl}/cli/upload/${recordingId}/asset`, {
248
664
  method: 'PUT',
249
665
  headers: {
@@ -251,26 +667,38 @@ async function uploadAssets(data, apiUrl, secret, recordingId, configDir) {
251
667
  'X-ScreenCI-Secret': secret,
252
668
  },
253
669
  body: JSON.stringify({
254
- sha256,
255
- fileBase64: fileBuffer.toString('base64'),
256
- contentType,
257
- 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 } : {}),
258
676
  }),
677
+ signal,
259
678
  });
260
679
  if (!res.ok) {
261
680
  const text = await res.text();
262
- 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
+ }
263
687
  }
264
688
  else {
265
- logger.info(`Asset uploaded: ${assetPath}`);
689
+ logger.info(`Asset uploaded: ${asset.path}`);
266
690
  }
267
691
  }
268
692
  catch (err) {
269
- 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);
270
697
  }
271
698
  }
272
699
  }
273
700
  async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specificEntry) {
701
+ const uploadAbort = createUploadAbortController('upload');
274
702
  let entries;
275
703
  try {
276
704
  entries = await readdir(screenciDir);
@@ -283,76 +711,165 @@ async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specif
283
711
  entries = entries.filter((e) => e === specificEntry);
284
712
  }
285
713
  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}`);
714
+ try {
715
+ for (const entry of entries) {
716
+ uploadAbort.throwIfAborted();
717
+ const dataJsonPath = resolve(screenciDir, entry, 'data.json');
718
+ if (!existsSync(dataJsonPath))
315
719
  continue;
720
+ let data;
721
+ try {
722
+ const raw = await readFile(dataJsonPath, 'utf-8');
723
+ data = JSON.parse(raw);
316
724
  }
317
- const { recordingId, projectId } = (await startResponse.json());
318
- if (firstProjectId === null) {
319
- firstProjectId = projectId;
320
- }
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',
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',
330
742
  headers: {
331
- 'Content-Type': 'video/mp4',
332
- 'Content-Length': String(fileStat.size),
743
+ 'Content-Type': 'application/json',
333
744
  'X-ScreenCI-Secret': secret,
334
745
  },
335
- body: stream,
336
- // @ts-expect-error Node.js fetch supports duplex for streaming
337
- 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,
338
762
  });
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}`);
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)}`);
343
767
  continue;
344
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);
345
819
  }
346
- completeInline(`Uploading "${videoName}" ✓`);
347
- }
348
- catch (err) {
349
- process.stdout.write('\n');
350
- logger.warn(`Network error uploading "${videoName}":`, err);
351
820
  }
821
+ return firstProjectId;
352
822
  }
353
- return firstProjectId;
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';
354
838
  }
355
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) {
356
873
  const resolvedConfigPath = findScreenCIConfig(configPath);
357
874
  if (!resolvedConfigPath) {
358
875
  const errorMsg = configPath
@@ -379,33 +896,55 @@ async function uploadLatest(configPath) {
379
896
  logger.warn(`Failed to load env file ${envFilePath}:`, err);
380
897
  }
381
898
  }
382
- const apiUrl = process.env.DEV_PORT
383
- ? `http://localhost:${process.env.DEV_PORT}`
384
- : 'https://api.screenci.com';
899
+ return { resolvedConfigPath, screenciConfig };
900
+ }
901
+ async function requireScreenCISecret(configPath) {
902
+ const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
385
903
  const secret = process.env.SCREENCI_SECRET;
386
904
  if (!secret) {
387
905
  logger.error('No secret configured. Set SCREENCI_SECRET in your .env file (get it from the API Key page in the dashboard).');
388
906
  process.exit(1);
389
907
  }
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;
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)}`);
396
927
  }
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}`);
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)}`);
408
946
  }
947
+ logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`);
409
948
  }
410
949
  function generateConfig(projectName) {
411
950
  return `import { defineConfig } from 'screenci'
@@ -433,11 +972,18 @@ export default defineConfig({
433
972
  })
434
973
  `;
435
974
  }
436
- function generatePackageJson(projectName, localPackagePath) {
975
+ function generatePackageJson(projectName, localPackagePath, includePlaywrightCli = false) {
437
976
  const npmName = projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
438
977
  const screenciVersion = localPackagePath
439
978
  ? `file:${localPackagePath}`
440
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
+ }
441
987
  return (JSON.stringify({
442
988
  name: npmName,
443
989
  version: '1.0.0',
@@ -445,16 +991,13 @@ function generatePackageJson(projectName, localPackagePath) {
445
991
  type: 'module',
446
992
  scripts: {
447
993
  record: 'screenci record',
448
- 'upload-latest': 'screenci upload-latest',
994
+ retry: 'screenci retry',
449
995
  dev: 'screenci dev',
450
996
  },
451
997
  dependencies: {
452
998
  screenci: screenciVersion,
453
999
  },
454
- devDependencies: {
455
- '@types/node': '^25.0.0',
456
- tsx: '^4.21.0',
457
- },
1000
+ devDependencies,
458
1001
  }, null, 2) + '\n');
459
1002
  }
460
1003
  function generateDockerfile() {
@@ -549,7 +1092,7 @@ async function performBrowserLogin(appUrl) {
549
1092
  const callbackUrl = `http://localhost:${port}/callback`;
550
1093
  const loginUrl = `${appUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
551
1094
  logger.info(`If the browser does not open automatically, visit:`);
552
- logger.info(loginUrl);
1095
+ logger.info(pc.cyan(loginUrl));
553
1096
  logger.info('');
554
1097
  openBrowser(loginUrl);
555
1098
  });
@@ -569,34 +1112,14 @@ video('Example video', async ({ page }) => {
569
1112
  })
570
1113
  `;
571
1114
  }
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
1115
  async function promptProjectName() {
586
- return promptLine('Project name: ');
1116
+ return input({ message: 'Project name:' });
587
1117
  }
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, '');
1118
+ function getProjectDirName(name) {
1119
+ return name.toLowerCase().replace(/\s+/g, '-');
595
1120
  }
596
1121
  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');
1122
+ const appUrl = getDevFrontendUrl();
600
1123
  try {
601
1124
  const secret = await performBrowserLogin(appUrl);
602
1125
  const savePath = resolve(process.cwd(), '.env');
@@ -616,8 +1139,9 @@ function checkNodeVersion() {
616
1139
  process.exit(1);
617
1140
  }
618
1141
  }
619
- async function runInit(projectNameArg, localPackagePath) {
1142
+ async function runInit(projectNameArg, localPackagePath, verbose = false) {
620
1143
  checkNodeVersion();
1144
+ checkContainerRuntimeForInit();
621
1145
  let projectName = projectNameArg?.trim();
622
1146
  if (!projectName) {
623
1147
  projectName = await promptProjectName();
@@ -626,21 +1150,25 @@ async function runInit(projectNameArg, localPackagePath) {
626
1150
  logger.error('Error: Project name is required');
627
1151
  process.exit(1);
628
1152
  }
629
- const dirName = toKebabCase(projectName);
1153
+ const dirName = getProjectDirName(projectName);
630
1154
  const projectDir = resolve(process.cwd(), dirName);
631
1155
  if (existsSync(projectDir)) {
632
1156
  logger.error(`Error: Directory "${dirName}" already exists`);
633
1157
  process.exit(1);
634
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
+ });
635
1163
  await mkdir(resolve(projectDir, 'videos'), { recursive: true });
636
1164
  await mkdir(resolve(projectDir, '.github', 'workflows'), { recursive: true });
637
1165
  await writeFile(resolve(projectDir, 'screenci.config.ts'), generateConfig(projectName));
638
- await writeFile(resolve(projectDir, 'package.json'), generatePackageJson(dirName, localPackagePath));
1166
+ await writeFile(resolve(projectDir, 'package.json'), generatePackageJson(dirName, localPackagePath, shouldAddPlaywrightCli));
639
1167
  await writeFile(resolve(projectDir, 'Dockerfile'), generateDockerfile());
640
1168
  await writeFile(resolve(projectDir, '.gitignore'), generateGitignore());
641
1169
  await writeFile(resolve(projectDir, 'videos', 'example.video.ts'), generateExampleVideo());
642
1170
  await writeFile(resolve(projectDir, '.github', 'workflows', 'record.yml'), generateGithubAction());
643
- logger.info(`Initialized screenci project "${projectName}" in ${dirName}/`);
1171
+ logger.info(`Initialized screenci project "${projectName}" in ${projectDir}/`);
644
1172
  logger.info('Files created:');
645
1173
  logger.info(' screenci.config.ts');
646
1174
  logger.info(' package.json');
@@ -649,149 +1177,401 @@ async function runInit(projectNameArg, localPackagePath) {
649
1177
  logger.info(' videos/example.video.ts');
650
1178
  logger.info(' .github/workflows/record.yml');
651
1179
  logger.info('');
652
- logger.info('Running npm install...');
653
- 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
+ }
654
1264
  logger.info('');
655
1265
  logger.info('Next steps:');
656
1266
  logger.info(` cd ${dirName}`);
657
- 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');
658
1277
  }
659
1278
  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) {
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;
724
1308
  try {
725
- const configModule = await import(resolvedConfigPath);
1309
+ const configModule = await import(resolvedConfigForSecret);
726
1310
  const screenciConfig = configModule.default;
727
- if (screenciConfig.envFile) {
728
- const envFilePath = resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
1311
+ envFilePath = screenciConfig.envFile
1312
+ ? resolve(dirname(resolvedConfigForSecret), screenciConfig.envFile)
1313
+ : null;
1314
+ if (envFilePath) {
729
1315
  try {
730
1316
  process.loadEnvFile(envFilePath);
731
1317
  }
732
- catch (err) {
733
- logger.warn(`Failed to load env file ${envFilePath}:`, err);
1318
+ catch {
1319
+ // env file may not exist yet
734
1320
  }
735
1321
  }
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;
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);
748
1361
  }
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}`);
1362
+ catch (err) {
1363
+ logger.warn(`Failed to load env file ${envFilePath}:`, err);
756
1364
  }
757
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);
1378
+ }
758
1379
  catch (err) {
759
- logger.warn('Failed to load config for upload:', err);
1380
+ if (isUploadCancelledError(err)) {
1381
+ process.exit(130);
1382
+ }
1383
+ throw err;
760
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, '..');
761
1460
  }
762
- break;
1461
+ await runInit(name, localPackagePath, options['verbose'] ?? false);
763
1462
  }
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();
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);
773
1473
  }
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);
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);
785
1480
  }
786
- await runInit(initArgs[0], localPackagePath);
1481
+ logger.error(`Error: ${err.message}`);
1482
+ process.exit(1);
787
1483
  }
788
- break;
789
- }
790
- default:
791
- logger.error(`Unknown command: ${command}`);
792
- 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}`);
793
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
+ }
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
+ }
794
1573
  }
1574
+ return { configPath, otherArgs };
795
1575
  }
796
1576
  function validateArgs(args) {
797
1577
  const disallowedFlags = ['--fully-parallel', '--workers', '-j', '--retries'];
@@ -812,28 +1592,22 @@ function validateArgs(args) {
812
1592
  }
813
1593
  }
814
1594
  }
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);
1595
+ function spawnInherited(cmd, args, cwd, activityLabel = cmd) {
1596
+ const child = spawn(cmd, args, { stdio: 'inherit', ...(cwd ? { cwd } : {}) });
1597
+ const childSignals = forwardChildSignals(child, activityLabel);
832
1598
  return new Promise((resolve, reject) => {
833
- child.on('close', (code) => {
834
- process.off('SIGINT', forwardSignal);
835
- process.off('SIGTERM', forwardSignal);
836
- 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) {
837
1611
  resolve();
838
1612
  }
839
1613
  else {
@@ -841,44 +1615,55 @@ function spawnInherited(cmd, args) {
841
1615
  }
842
1616
  });
843
1617
  child.on('error', (err) => {
844
- process.off('SIGINT', forwardSignal);
845
- process.off('SIGTERM', forwardSignal);
1618
+ childSignals.cleanup();
846
1619
  reject(err);
847
1620
  });
848
1621
  });
849
1622
  }
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
- }
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);
856
1633
  }
857
1634
  logger.error('Error: Neither podman nor docker found.');
858
1635
  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/');
1636
+ logger.error(prerequisitesMessage());
861
1637
  process.exit(1);
862
1638
  }
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
+ }
863
1648
  async function buildImage(cmd, args, label, verbose) {
864
1649
  if (verbose) {
865
1650
  await spawnInherited(cmd, args);
866
1651
  return;
867
1652
  }
868
- writeInline(`${label}...`);
1653
+ const spinner = ora(label).start();
869
1654
  try {
870
1655
  await spawnSilent(cmd, args);
871
- completeInline(`${label} ✓`);
1656
+ spinner.succeed(label);
872
1657
  }
873
1658
  catch (err) {
874
- process.stdout.write('\n');
1659
+ spinner.fail(label);
875
1660
  const msg = err instanceof Error ? err.message : String(err);
876
1661
  logger.error(msg);
877
1662
  logger.error('Run again with --verbose to see the full build output');
878
1663
  process.exit(1);
879
1664
  }
880
1665
  }
881
- async function runWithContainer(additionalArgs, customConfigPath, imageTag, verbose = false) {
1666
+ async function runWithContainer(additionalArgs, customConfigPath, imageTag, verbose = false, forcedRuntime) {
882
1667
  const configPath = findScreenCIConfig(customConfigPath);
883
1668
  if (!configPath) {
884
1669
  const errorMsg = customConfigPath
@@ -899,7 +1684,7 @@ async function runWithContainer(additionalArgs, customConfigPath, imageTag, verb
899
1684
  logger.error('Error: Could not find repository root (.git or pnpm-workspace.yaml)');
900
1685
  process.exit(1);
901
1686
  }
902
- const containerRuntime = detectContainerRuntime();
1687
+ const containerRuntime = detectContainerRuntime(forcedRuntime);
903
1688
  const ghcrImage = 'ghcr.io/screenci/record:latest';
904
1689
  const dockerfileVersion = parseDockerfileVersion(dockerfilePath);
905
1690
  if (process.env['SCREENCI_LOCAL_IMAGE']) {
@@ -912,13 +1697,15 @@ async function runWithContainer(additionalArgs, customConfigPath, imageTag, verb
912
1697
  }).status === 0;
913
1698
  logger.info(`Using image tag ${imageTag} instead of the version ${dockerfileVersion} from Dockerfile`);
914
1699
  if (!imageExists) {
915
- await buildImage(containerRuntime, ['pull', remoteImage], 'Pulling image', verbose);
1700
+ await buildImage(containerRuntime, ['pull', remoteImage], 'Pulling screenci image', verbose);
916
1701
  }
917
1702
  await spawnSilent(containerRuntime, ['tag', remoteImage, ghcrImage]);
918
1703
  }
919
1704
  else {
920
1705
  const cliDir = dirname(fileURLToPath(import.meta.url));
921
- const screenciPackageRoot = resolve(cliDir, '..');
1706
+ const screenciPackageRoot = existsSync(resolve(cliDir, 'package.json'))
1707
+ ? cliDir
1708
+ : resolve(cliDir, '..');
922
1709
  const screenciDockerfilePath = resolve(cliDir, 'Dockerfile');
923
1710
  if (verbose) {
924
1711
  await spawnInherited(containerRuntime, [
@@ -928,18 +1715,11 @@ async function runWithContainer(additionalArgs, customConfigPath, imageTag, verb
928
1715
  '-t',
929
1716
  ghcrImage,
930
1717
  screenciPackageRoot,
931
- ]);
932
- await spawnInherited(containerRuntime, [
933
- 'build',
934
- '-f',
935
- dockerfilePath,
936
- '-t',
937
- 'screenci',
938
- configDir,
939
- ]);
1718
+ ], undefined, 'building screenci image');
1719
+ await spawnInherited(containerRuntime, ['build', '-f', dockerfilePath, '-t', 'screenci', configDir], undefined, 'building project image');
940
1720
  }
941
1721
  else {
942
- writeInline('Building image...');
1722
+ const spinner = ora('Building screenci image').start();
943
1723
  try {
944
1724
  await spawnSilent(containerRuntime, [
945
1725
  'build',
@@ -957,10 +1737,10 @@ async function runWithContainer(additionalArgs, customConfigPath, imageTag, verb
957
1737
  'screenci',
958
1738
  configDir,
959
1739
  ]);
960
- completeInline('Building image');
1740
+ spinner.succeed('Building screenci image');
961
1741
  }
962
1742
  catch (err) {
963
- process.stdout.write('\n');
1743
+ spinner.fail('Building screenci image');
964
1744
  const msg = err instanceof Error ? err.message : String(err);
965
1745
  logger.error(msg);
966
1746
  logger.error('Run again with --verbose to see the full build output');
@@ -969,7 +1749,7 @@ async function runWithContainer(additionalArgs, customConfigPath, imageTag, verb
969
1749
  }
970
1750
  }
971
1751
  if (imageTag !== undefined || process.env['SCREENCI_LOCAL_IMAGE']) {
972
- await buildImage(containerRuntime, ['build', '-f', dockerfilePath, '-t', 'screenci', configDir], 'Building image', verbose);
1752
+ await buildImage(containerRuntime, ['build', '-f', dockerfilePath, '-t', 'screenci', configDir], 'Building project image', verbose);
973
1753
  }
974
1754
  clearDirectory(resolve(configDir, '.screenci'));
975
1755
  const secret = process.env['SCREENCI_SECRET'];
@@ -986,6 +1766,8 @@ async function runWithContainer(additionalArgs, customConfigPath, imageTag, verb
986
1766
  'SCREENCI_RECORD=true',
987
1767
  '-e',
988
1768
  `SCREENCI_SECRET=${secret}`,
1769
+ '-e',
1770
+ 'SCREENCI_SIGNAL_LOGGING=silent',
989
1771
  '-v',
990
1772
  `${configDir}/.screenci:/app/.screenci`,
991
1773
  '-v',
@@ -1016,7 +1798,13 @@ async function run(command, additionalArgs, customConfigPath) {
1016
1798
  // For dev command: use --ui unless --headed is specified
1017
1799
  const isHeaded = additionalArgs.includes('--headed');
1018
1800
  const shouldUseUI = command === 'dev' && !isHeaded;
1019
- const mode = command === 'dev' ? (isHeaded ? 'headed mode' : 'UI mode') : 'recorder';
1801
+ const mode = command === 'dev'
1802
+ ? isHeaded
1803
+ ? 'headed mode'
1804
+ : 'UI mode'
1805
+ : command === 'test'
1806
+ ? 'tests'
1807
+ : 'recorder';
1020
1808
  if (process.env.SCREENCI_IN_CONTAINER !== 'true') {
1021
1809
  logger.info(`Running ScreenCI ${mode} with npx...`);
1022
1810
  logger.info(`Using config: ${configPath}`);
@@ -1037,29 +1825,19 @@ async function run(command, additionalArgs, customConfigPath) {
1037
1825
  ...(command === 'record' ? { SCREENCI_RECORD: 'true' } : {}),
1038
1826
  },
1039
1827
  });
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);
1828
+ const childSignals = forwardChildSignals(child, `screenci ${command}`);
1059
1829
  return new Promise((resolve, reject) => {
1060
- child.on('close', (code) => {
1061
- process.off('SIGINT', forwardSignal);
1062
- 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
+ }
1063
1841
  if (code === 0) {
1064
1842
  resolve();
1065
1843
  }
@@ -1068,6 +1846,7 @@ async function run(command, additionalArgs, customConfigPath) {
1068
1846
  }
1069
1847
  });
1070
1848
  child.on('error', (err) => {
1849
+ childSignals.cleanup();
1071
1850
  reject(err);
1072
1851
  });
1073
1852
  });