screenci 0.0.62 → 0.0.64

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 (71) hide show
  1. package/README.md +34 -15
  2. package/bin/screenci.js +2 -2
  3. package/dist/cli.d.ts +46 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +463 -242
  6. package/dist/cli.js.map +1 -1
  7. package/dist/docs/manifest.d.ts +132 -70
  8. package/dist/docs/manifest.d.ts.map +1 -1
  9. package/dist/docs/manifest.js +46 -24
  10. package/dist/docs/manifest.js.map +1 -1
  11. package/dist/docs/videos.d.ts +1 -1
  12. package/dist/docs/videos.js +1 -1
  13. package/dist/docs/videos.js.map +1 -1
  14. package/dist/e2e/instrument.e2e.js +11 -11
  15. package/dist/e2e/instrument.e2e.js.map +1 -1
  16. package/dist/index.d.ts +6 -4
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +3 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/src/asset.d.ts +27 -0
  21. package/dist/src/asset.d.ts.map +1 -1
  22. package/dist/src/asset.js +46 -0
  23. package/dist/src/asset.js.map +1 -1
  24. package/dist/src/changeFocus.d.ts.map +1 -1
  25. package/dist/src/changeFocus.js +3 -3
  26. package/dist/src/changeFocus.js.map +1 -1
  27. package/dist/src/cue.d.ts +60 -13
  28. package/dist/src/cue.d.ts.map +1 -1
  29. package/dist/src/cue.js +153 -47
  30. package/dist/src/cue.js.map +1 -1
  31. package/dist/src/events.d.ts +56 -8
  32. package/dist/src/events.d.ts.map +1 -1
  33. package/dist/src/events.js +47 -1
  34. package/dist/src/events.js.map +1 -1
  35. package/dist/src/git.d.ts +15 -0
  36. package/dist/src/git.d.ts.map +1 -0
  37. package/dist/src/git.js +43 -0
  38. package/dist/src/git.js.map +1 -0
  39. package/dist/src/init.d.ts +9 -0
  40. package/dist/src/init.d.ts.map +1 -1
  41. package/dist/src/init.js +293 -113
  42. package/dist/src/init.js.map +1 -1
  43. package/dist/src/instrument.d.ts.map +1 -1
  44. package/dist/src/instrument.js +49 -125
  45. package/dist/src/instrument.js.map +1 -1
  46. package/dist/src/mouse.d.ts +1 -0
  47. package/dist/src/mouse.d.ts.map +1 -1
  48. package/dist/src/mouse.js +9 -3
  49. package/dist/src/mouse.js.map +1 -1
  50. package/dist/src/recording.d.ts +1 -1
  51. package/dist/src/recording.d.ts.map +1 -1
  52. package/dist/src/recordingData.d.ts +43 -1
  53. package/dist/src/recordingData.d.ts.map +1 -1
  54. package/dist/src/studio.d.ts +36 -0
  55. package/dist/src/studio.d.ts.map +1 -0
  56. package/dist/src/studio.js +39 -0
  57. package/dist/src/studio.js.map +1 -0
  58. package/dist/src/types.d.ts +141 -125
  59. package/dist/src/types.d.ts.map +1 -1
  60. package/dist/src/types.js +1 -0
  61. package/dist/src/types.js.map +1 -1
  62. package/dist/src/video.d.ts +2 -1
  63. package/dist/src/video.d.ts.map +1 -1
  64. package/dist/src/video.js.map +1 -1
  65. package/dist/src/voices.d.ts +3 -3
  66. package/dist/src/voices.d.ts.map +1 -1
  67. package/dist/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +1 -1
  69. package/skills/screenci/SKILL.md +7 -8
  70. package/skills/screenci/references/init.md +1 -2
  71. package/skills/screenci/references/record.md +3 -9
package/dist/cli.js CHANGED
@@ -1,12 +1,11 @@
1
1
  import { spawn } from 'child_process';
2
2
  import { createReadStream } from 'fs';
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, } from 'fs';
4
- import { createHash } from 'crypto';
5
- import { createServer } from 'http';
4
+ import { createHash, randomUUID } from 'crypto';
5
+ import { createRequire } from 'module';
6
6
  import { appendFile, readdir, readFile, stat, writeFile } from 'fs/promises';
7
7
  import { delimiter, dirname, relative as pathRelative, resolve } from 'path';
8
8
  import { fileURLToPath, pathToFileURL } from 'url';
9
- import { confirm } from '@inquirer/prompts';
10
9
  import { Command, CommanderError } from 'commander';
11
10
  import pc from 'picocolors';
12
11
  import { logger } from './src/logger.js';
@@ -15,8 +14,44 @@ import { SCREENCI_DISABLE_RECORDING_TIMINGS_ENV, SCREENCI_MOCK_RECORD_ENV, } fro
15
14
  import { DEFAULT_RECORD_UPLOAD_POLICY } from './src/defaults.js';
16
15
  import { findDuplicateTitles, formatDuplicateTitlesMessage, } from './src/titleValidation.js';
17
16
  const SCREENCI_MOCK_RECORD_DOCS_URL = 'https://screenci.com/docs/reference/cli/#--mock-record';
18
- const SCREENCI_LOGIN_DOCS_URL = 'https://screenci.com/docs/reference/cli/#screenci-login';
19
- const SCREENCI_SECRETS_URL = 'https://app.screenci.com/secrets';
17
+ const SCREENCI_RECORD_DOCS_URL = 'https://screenci.com/docs/reference/cli/#screenci-record';
18
+ const SCREENCI_ENVIRONMENT_VARIABLE = 'SCREENCI_ENVIRONMENT';
19
+ const SCREENCI_ENVIRONMENT_OPTION_VALUES = ['local', 'dev', 'prod'];
20
+ const SCREENCI_PRODUCTION_BACKEND_URL = 'https://api.screenci.com';
21
+ const SCREENCI_PRODUCTION_FRONTEND_URL = 'https://app.screenci.com';
22
+ const SCREENCI_DEVELOPMENT_BACKEND_URL = 'https://dev.api.screenci.com';
23
+ const SCREENCI_DEVELOPMENT_FRONTEND_URL = 'https://dev.app.screenci.com';
24
+ const SCREENCI_LINK_SESSION_FILE = 'link-session.json';
25
+ const SCREENCI_LINK_SESSION_POLL_INTERVAL_MS = 2_000;
26
+ const require = createRequire(import.meta.url);
27
+ function parseScreenCIEnvironment(value) {
28
+ if (value === undefined)
29
+ return undefined;
30
+ if (SCREENCI_ENVIRONMENT_OPTION_VALUES.includes(value)) {
31
+ return value;
32
+ }
33
+ throw new Error(`Invalid ${SCREENCI_ENVIRONMENT_VARIABLE} "${value}". Expected one of: ${SCREENCI_ENVIRONMENT_OPTION_VALUES.join(', ')}`);
34
+ }
35
+ function getScreenCIEnvironment() {
36
+ const parsed = parseScreenCIEnvironment(process.env[SCREENCI_ENVIRONMENT_VARIABLE]);
37
+ return parsed ?? 'prod';
38
+ }
39
+ /**
40
+ * Reports whether the current session can complete an interactive browser
41
+ * sign-in. A session is interactive only when both stdin and stdout are
42
+ * attached to a terminal and no signal marks the run as automated. This is the
43
+ * proxy for "a human is present to open the sign-in link" — it does not attempt
44
+ * to identify any particular caller (CI, a piped shell, or an automated tool).
45
+ *
46
+ * Dependency-injected so tests can force a value without a real terminal.
47
+ */
48
+ export function detectInteractiveSession(env = process.env, stdout = process.stdout, stdin = process.stdin) {
49
+ if (env.SCREENCI_NONINTERACTIVE === '1')
50
+ return false;
51
+ if (env.CI === 'true')
52
+ return false;
53
+ return Boolean(stdout.isTTY) && Boolean(stdin.isTTY);
54
+ }
20
55
  export function collectPlaywrightListTitles(suites) {
21
56
  const titles = [];
22
57
  const visitSuite = (suite) => {
@@ -51,7 +86,7 @@ function extractPlaywrightDiscoveryError(output) {
51
86
  }
52
87
  }
53
88
  function logScreenCISecretGuide() {
54
- logger.info(`Guide: ${pc.cyan(SCREENCI_LOGIN_DOCS_URL)}`);
89
+ logger.info(`Guide: ${pc.cyan(SCREENCI_RECORD_DOCS_URL)}`);
55
90
  }
56
91
  function getSuggestedScreenciCommand(command) {
57
92
  const pm = determinePackageManager();
@@ -70,7 +105,7 @@ async function collectDiscoveredTestTitles(configPath, additionalArgs, env) {
70
105
  '--list',
71
106
  '--reporter=json',
72
107
  ];
73
- const spawnSpec = resolveSpawnSpec('playwright', listArgs);
108
+ const spawnSpec = resolvePlaywrightSpawnSpec(listArgs, dirname(configPath));
74
109
  return await new Promise((resolve, reject) => {
75
110
  const child = spawn(spawnSpec.command, spawnSpec.args, {
76
111
  stdio: ['inherit', 'pipe', 'pipe'],
@@ -181,6 +216,28 @@ function contentTypeForPath(filePath) {
181
216
  };
182
217
  return contentTypeMap[ext] ?? 'application/octet-stream';
183
218
  }
219
+ /**
220
+ * One-line summary of Studio overrides for CLI output, e.g.
221
+ * `recording.size (1 → 0.8), narration "intro" (en)`.
222
+ */
223
+ export function formatStudioChangeSummary(changes) {
224
+ return changes
225
+ .map((change) => {
226
+ const label = change.label ?? 'selection';
227
+ if (change.kind === 'narration') {
228
+ return change.language !== undefined
229
+ ? `${label} (${change.language})`
230
+ : label;
231
+ }
232
+ return change.from !== undefined && change.to !== undefined
233
+ ? `${label} (${change.from} → ${change.to})`
234
+ : label;
235
+ })
236
+ .join(', ');
237
+ }
238
+ export function formatStudioUrl(appUrl, projectId, videoId) {
239
+ return `${appUrl}/project/${projectId}/video/${videoId}/studio`;
240
+ }
184
241
  class UploadAssetError extends Error {
185
242
  constructor(message) {
186
243
  super(message);
@@ -199,6 +256,14 @@ class PartialUploadError extends Error {
199
256
  this.name = 'PartialUploadError';
200
257
  }
201
258
  }
259
+ class RecordFailureHintError extends Error {
260
+ cause;
261
+ constructor(cause) {
262
+ super(cause.message);
263
+ this.name = cause.name;
264
+ this.cause = cause;
265
+ }
266
+ }
202
267
  function isUploadCancelledError(err) {
203
268
  return (err instanceof UploadCancelledError ||
204
269
  (err instanceof Error &&
@@ -207,12 +272,12 @@ function isUploadCancelledError(err) {
207
272
  function isPartialUploadError(err) {
208
273
  return err instanceof PartialUploadError;
209
274
  }
275
+ function isRecordFailureHintError(err) {
276
+ return err instanceof RecordFailureHintError;
277
+ }
210
278
  function isUploadAssetError(err) {
211
279
  return err instanceof UploadAssetError;
212
280
  }
213
- function supportsInPlaceUploadUpdates(verbose) {
214
- return !verbose && process.stdout.isTTY === true && !process.env.CI;
215
- }
216
281
  function formatUploadProgressLine(videoName, status) {
217
282
  switch (status) {
218
283
  case undefined:
@@ -229,41 +294,13 @@ function formatUploadProgressLine(videoName, status) {
229
294
  }
230
295
  }
231
296
  }
232
- function createUploadProgressReporter(videoNames, verbose) {
233
- const useInPlaceUpdates = supportsInPlaceUploadUpdates(verbose);
234
- if (!useInPlaceUpdates) {
235
- return {
236
- complete(index, status) {
237
- logger.info(formatUploadProgressLine(videoNames[index] ?? 'unknown', status));
238
- },
239
- info(message) {
240
- logger.info(message);
241
- },
242
- };
243
- }
244
- const statuses = new Array(videoNames.length);
245
- let hasRendered = false;
246
- const render = () => {
247
- const renderedLines = videoNames.map((videoName, index) => formatUploadProgressLine(videoName, statuses[index]));
248
- process.stdout.write(`${hasRendered && videoNames.length > 0 ? `\u001B[${videoNames.length}A` : ''}${renderedLines.map((line) => `\r\u001B[2K${line}`).join('\n')}\n`);
249
- hasRendered = true;
250
- };
251
- const clear = () => {
252
- if (!hasRendered || videoNames.length === 0)
253
- return;
254
- process.stdout.write(`\u001B[${videoNames.length}A${Array.from({ length: videoNames.length }, () => '\r\u001B[2K').join('\n')}\n`);
255
- hasRendered = false;
256
- };
257
- render();
297
+ function createUploadProgressReporter(videoNames, _verbose) {
258
298
  return {
259
299
  complete(index, status) {
260
- statuses[index] = status;
261
- render();
300
+ logger.info(formatUploadProgressLine(videoNames[index] ?? 'unknown', status));
262
301
  },
263
302
  info(message) {
264
- clear();
265
303
  logger.info(message);
266
- render();
267
304
  },
268
305
  };
269
306
  }
@@ -305,9 +342,10 @@ async function loadUploadCandidate(screenciDir, entry, verbose) {
305
342
  preparedUploadAssets,
306
343
  };
307
344
  }
308
- async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, progressIndex) {
345
+ async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, elevenLabsApiKey, verbose, uploadAbort, progressReporter, progressIndex, recordId) {
309
346
  const { entry, videoName, data, preparedUploadAssets } = candidate;
310
347
  let projectId = null;
348
+ let videoId = null;
311
349
  try {
312
350
  uploadAbort.throwIfAborted();
313
351
  const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
@@ -315,9 +353,11 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
315
353
  progressReporter.complete(progressIndex, 'failure');
316
354
  return {
317
355
  projectId: null,
356
+ videoId: null,
318
357
  hadFailure: true,
319
358
  videoName,
320
359
  failureMessage: `Missing recording.mp4 for "${videoName}"`,
360
+ recordId,
321
361
  };
322
362
  }
323
363
  const recordingHash = await hashFile(recordingPath);
@@ -326,12 +366,16 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
326
366
  headers: {
327
367
  'Content-Type': 'application/json',
328
368
  'X-ScreenCI-Secret': secret,
369
+ ...(elevenLabsApiKey
370
+ ? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
371
+ : {}),
329
372
  },
330
373
  body: JSON.stringify({
331
374
  projectName,
332
375
  videoName,
333
376
  data,
334
377
  recordingHash,
378
+ recordId,
335
379
  expectedAssets: preparedUploadAssets.map((asset) => ({
336
380
  fileHash: asset.fileHash,
337
381
  size: asset.size,
@@ -349,14 +393,18 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
349
393
  progressReporter.complete(progressIndex, 'failure');
350
394
  return {
351
395
  projectId: null,
396
+ videoId: null,
352
397
  hadFailure: true,
353
398
  videoName,
354
399
  failureMessage: formatUploadStartFailureMessage(videoName, startResponse.status, text, secret),
400
+ recordId,
355
401
  };
356
402
  }
357
403
  const startBody = (await startResponse.json());
358
404
  const { recordingId } = startBody;
359
405
  projectId = startBody.projectId;
406
+ videoId = startBody.videoId ?? null;
407
+ const studio = startBody.studio;
360
408
  if (verbose) {
361
409
  logger.info(`recordingId=${recordingId} projectId=${projectId}`);
362
410
  logger.info(`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`);
@@ -381,6 +429,9 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
381
429
  'Content-Type': 'video/mp4',
382
430
  'Content-Length': String(fileStat.size),
383
431
  'X-ScreenCI-Secret': secret,
432
+ ...(elevenLabsApiKey
433
+ ? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
434
+ : {}),
384
435
  },
385
436
  body: stream,
386
437
  signal: uploadAbort.signal,
@@ -392,9 +443,11 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
392
443
  progressReporter.complete(progressIndex, 'failure');
393
444
  return {
394
445
  projectId,
446
+ videoId,
395
447
  hadFailure: true,
396
448
  videoName,
397
449
  failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
450
+ recordId,
398
451
  };
399
452
  }
400
453
  }
@@ -403,7 +456,14 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
403
456
  }
404
457
  progressReporter.complete(progressIndex, 'success');
405
458
  cleanupUploadedRecordingDir(screenciDir, entry);
406
- return { projectId, hadFailure: false, videoName };
459
+ return {
460
+ projectId,
461
+ videoId,
462
+ hadFailure: false,
463
+ videoName,
464
+ recordId,
465
+ ...(studio !== undefined && { studio }),
466
+ };
407
467
  }
408
468
  catch (err) {
409
469
  if (isUploadCancelledError(err)) {
@@ -414,17 +474,21 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
414
474
  progressReporter.complete(progressIndex, 'failure');
415
475
  return {
416
476
  projectId,
477
+ videoId,
417
478
  hadFailure: true,
418
479
  videoName,
419
480
  failureMessage: err instanceof Error ? err.message : String(err),
481
+ recordId,
420
482
  };
421
483
  }
422
484
  progressReporter.complete(progressIndex, 'failure');
423
485
  return {
424
486
  projectId,
487
+ videoId,
425
488
  hadFailure: true,
426
489
  videoName,
427
490
  failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
491
+ recordId,
428
492
  };
429
493
  }
430
494
  }
@@ -527,6 +591,32 @@ function resolveWindowsCmdShim(cmd) {
527
591
  }
528
592
  return shimName;
529
593
  }
594
+ function isModuleNotFoundError(error) {
595
+ return (error instanceof Error &&
596
+ error.code === 'MODULE_NOT_FOUND');
597
+ }
598
+ function resolvePlaywrightCliEntrypoint(searchFrom) {
599
+ // Prefer the @playwright/test installed alongside the user's config, since it
600
+ // is declared as a peer dependency of the project being recorded.
601
+ try {
602
+ return require.resolve('@playwright/test/cli', { paths: [searchFrom] });
603
+ }
604
+ catch (error) {
605
+ if (!isModuleNotFoundError(error))
606
+ throw error;
607
+ }
608
+ // Fall back to the copy resolvable from the screenci CLI's own install. This
609
+ // keeps discovery working when Playwright is hoisted to a parent install or
610
+ // bundled with the CLI rather than next to the config file.
611
+ return require.resolve('@playwright/test/cli');
612
+ }
613
+ function resolvePlaywrightSpawnSpec(args, searchFrom) {
614
+ const cliEntrypoint = resolvePlaywrightCliEntrypoint(searchFrom);
615
+ return {
616
+ command: process.execPath,
617
+ args: [cliEntrypoint, ...args],
618
+ };
619
+ }
530
620
  function forwardChildSignals(child, activityLabel, options = {}) {
531
621
  let forwardedSignal = null;
532
622
  let forceKillTimer = null;
@@ -594,26 +684,72 @@ function forwardChildSignals(child, activityLabel, options = {}) {
594
684
  getForwardedSignal: () => forwardedSignal,
595
685
  };
596
686
  }
597
- function clearDirectory(dir) {
687
+ function clearRecordingDirectories(dir) {
598
688
  mkdirSync(dir, { recursive: true });
599
689
  for (const entry of readdirSync(dir)) {
690
+ if (entry === SCREENCI_LINK_SESSION_FILE)
691
+ continue;
600
692
  rmSync(resolve(dir, entry), { recursive: true, force: true });
601
693
  }
602
694
  }
603
695
  function findScreenCIConfig(customPath) {
604
696
  if (customPath) {
605
697
  const resolvedPath = resolve(process.cwd(), customPath);
606
- if (existsSync(resolvedPath)) {
607
- return resolvedPath;
698
+ return existsSync(resolvedPath)
699
+ ? { kind: 'found', path: resolvedPath }
700
+ : { kind: 'not-found' };
701
+ }
702
+ // Walk up from the current directory looking for a flat `screenci.config.ts`,
703
+ // which is what's present when the command runs from inside the `screenci/`
704
+ // island. We deliberately do NOT auto-use a nested
705
+ // `screenci/screenci.config.ts`: running the CLI from outside the island
706
+ // resolves the `screenci` binary from the registry (npx download) rather than
707
+ // the version-pinned island install, so it would silently run a different
708
+ // version. Instead we detect the island and ask the user to `cd` into it.
709
+ let current = process.cwd();
710
+ let islandConfigPath;
711
+ while (true) {
712
+ const flatConfig = resolve(current, 'screenci.config.ts');
713
+ if (existsSync(flatConfig)) {
714
+ return { kind: 'found', path: flatConfig };
715
+ }
716
+ if (islandConfigPath === undefined) {
717
+ const islandConfig = resolve(current, 'screenci', 'screenci.config.ts');
718
+ if (existsSync(islandConfig)) {
719
+ islandConfigPath = islandConfig;
720
+ }
721
+ }
722
+ const parent = dirname(current);
723
+ if (parent === current)
724
+ break;
725
+ current = parent;
726
+ }
727
+ if (islandConfigPath !== undefined) {
728
+ return { kind: 'island-not-entered', islandConfigPath };
729
+ }
730
+ return { kind: 'not-found' };
731
+ }
732
+ // Resolve the config path, or log a helpful message and exit. Centralizes the
733
+ // `cd screenci` guidance so every command (test/record/info/...) behaves the
734
+ // same when invoked from outside the island.
735
+ function resolveScreenCIConfigPathOrExit(customPath) {
736
+ const resolution = findScreenCIConfig(customPath);
737
+ switch (resolution.kind) {
738
+ case 'found':
739
+ return resolution.path;
740
+ case 'island-not-entered': {
741
+ const islandDir = dirname(resolution.islandConfigPath);
742
+ const relDir = pathRelative(process.cwd(), islandDir) || '.';
743
+ logger.error(`Error: no screenci.config.ts found here, but found ${pc.cyan(`${relDir}/screenci.config.ts`)}. Run ${pc.cyan(`cd ${relDir}`)} and rerun the command from there.`);
744
+ return process.exit(1);
745
+ }
746
+ case 'not-found': {
747
+ logger.error(customPath
748
+ ? `Error: Config file not found: ${customPath}`
749
+ : 'Error: screenci.config.ts not found in the current directory or any parent.');
750
+ return process.exit(1);
608
751
  }
609
- return null;
610
- }
611
- const cwd = process.cwd();
612
- const configPath = resolve(cwd, 'screenci.config.ts');
613
- if (existsSync(configPath)) {
614
- return configPath;
615
752
  }
616
- return null;
617
753
  }
618
754
  async function hashFile(filePath) {
619
755
  return new Promise((resolveHash, reject) => {
@@ -692,11 +828,15 @@ async function prepareCustomVoiceAssets(data, configDir) {
692
828
  }
693
829
  return preparedAssets;
694
830
  }
695
- async function collectUploadAssets(data, configDir) {
831
+ export async function collectUploadAssets(data, configDir) {
696
832
  const sourceFilePath = data.metadata?.sourceFilePath;
697
833
  const assets = new Map();
698
834
  for (const event of data.events) {
699
835
  if (event.type === 'assetStart') {
836
+ // Studio assets have no local file — they are uploaded from the Studio
837
+ // page and merged into the recording by the backend.
838
+ if ('studio' in event && event.studio === true)
839
+ continue;
700
840
  if (assets.has(`name:${event.name}`))
701
841
  continue;
702
842
  const resolvedFile = await readRecordingFile(event.path, configDir, sourceFilePath);
@@ -952,6 +1092,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
952
1092
  }
953
1093
  export async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specificEntry, verbose = false) {
954
1094
  const uploadAbort = createUploadAbortController('upload');
1095
+ const recordId = randomUUID();
955
1096
  let entries;
956
1097
  try {
957
1098
  entries = await readdir(screenciDir);
@@ -960,15 +1101,18 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
960
1101
  logger.warn('No .screenci directory found, skipping upload');
961
1102
  return {
962
1103
  projectId: null,
1104
+ recordId: null,
963
1105
  hadFailures: false,
964
1106
  failedVideoNames: [],
965
1107
  failedVideoMessages: [],
1108
+ studioNotices: [],
966
1109
  };
967
1110
  }
968
1111
  if (specificEntry !== undefined) {
969
1112
  entries = entries.filter((e) => e === specificEntry);
970
1113
  }
971
1114
  let firstProjectId = null;
1115
+ const elevenLabsApiKey = process.env.ELEVENLABS_API_KEY?.trim() || undefined;
972
1116
  try {
973
1117
  const candidates = (await Promise.all(entries.map(async (entry) => {
974
1118
  uploadAbort.throwIfAborted();
@@ -977,13 +1121,15 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
977
1121
  if (candidates.length === 0) {
978
1122
  return {
979
1123
  projectId: null,
1124
+ recordId: null,
980
1125
  hadFailures: false,
981
1126
  failedVideoNames: [],
982
1127
  failedVideoMessages: [],
1128
+ studioNotices: [],
983
1129
  };
984
1130
  }
985
1131
  const progressReporter = createUploadProgressReporter(candidates.map((candidate) => candidate.videoName), verbose);
986
- const results = await Promise.all(candidates.map(async (candidate, index) => await uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, index)));
1132
+ const results = await Promise.all(candidates.map(async (candidate, index) => await uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, elevenLabsApiKey, verbose, uploadAbort, progressReporter, index, recordId)));
987
1133
  firstProjectId =
988
1134
  results.find((result) => result.projectId !== null)?.projectId ?? null;
989
1135
  const hadFailures = results.some((result) => result.hadFailure);
@@ -993,11 +1139,22 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
993
1139
  const failedVideoMessages = results.flatMap((result) => result.hadFailure && typeof result.failureMessage === 'string'
994
1140
  ? [{ videoName: result.videoName, message: result.failureMessage }]
995
1141
  : []);
1142
+ const studioNotices = results.flatMap((result) => !result.hadFailure && result.studio !== undefined
1143
+ ? [
1144
+ {
1145
+ videoName: result.videoName,
1146
+ videoId: result.videoId,
1147
+ studio: result.studio,
1148
+ },
1149
+ ]
1150
+ : []);
996
1151
  return {
997
1152
  projectId: firstProjectId,
1153
+ recordId,
998
1154
  hadFailures,
999
1155
  failedVideoNames,
1000
1156
  failedVideoMessages,
1157
+ studioNotices,
1001
1158
  };
1002
1159
  }
1003
1160
  finally {
@@ -1015,16 +1172,38 @@ async function countCompletedRecordings(screenciDir) {
1015
1172
  return entries.filter((entry) => existsSync(resolve(screenciDir, entry, 'data.json'))).length;
1016
1173
  }
1017
1174
  export function getDevBackendUrl() {
1018
- const devBackendPort = process.env.DEV_BACKEND_PORT;
1019
- return devBackendPort
1020
- ? `http://localhost:${devBackendPort}`
1021
- : 'https://api.screenci.com';
1175
+ switch (getScreenCIEnvironment()) {
1176
+ case 'local': {
1177
+ const devBackendPort = process.env.DEV_BACKEND_PORT;
1178
+ return devBackendPort
1179
+ ? `http://localhost:${devBackendPort}`
1180
+ : 'http://localhost:8787';
1181
+ }
1182
+ case 'dev':
1183
+ return SCREENCI_DEVELOPMENT_BACKEND_URL;
1184
+ case 'prod':
1185
+ return SCREENCI_PRODUCTION_BACKEND_URL;
1186
+ }
1022
1187
  }
1023
1188
  export function getDevFrontendUrl() {
1024
- const devFrontendPort = process.env.DEV_FRONTEND_PORT;
1025
- return devFrontendPort
1026
- ? `http://localhost:${devFrontendPort}`
1027
- : 'https://app.screenci.com';
1189
+ switch (getScreenCIEnvironment()) {
1190
+ case 'local': {
1191
+ const devFrontendPort = process.env.DEV_FRONTEND_PORT;
1192
+ return devFrontendPort
1193
+ ? `http://localhost:${devFrontendPort}`
1194
+ : 'http://localhost:5173';
1195
+ }
1196
+ case 'dev':
1197
+ return SCREENCI_DEVELOPMENT_FRONTEND_URL;
1198
+ case 'prod':
1199
+ return SCREENCI_PRODUCTION_FRONTEND_URL;
1200
+ }
1201
+ }
1202
+ export function getCliLinkSessionApiUrl() {
1203
+ return getDevFrontendUrl();
1204
+ }
1205
+ function getScreenCISecretsUrl() {
1206
+ return `${getDevFrontendUrl()}/secrets`;
1028
1207
  }
1029
1208
  async function writeGitHubProjectOutput(projectUrl) {
1030
1209
  const githubOutput = process.env.GITHUB_OUTPUT;
@@ -1033,14 +1212,7 @@ async function writeGitHubProjectOutput(projectUrl) {
1033
1212
  await appendFile(githubOutput, `screenci_project_url=${projectUrl}\n`);
1034
1213
  }
1035
1214
  async function loadScreenCIConfigAndEnv(configPath) {
1036
- const resolvedConfigPath = findScreenCIConfig(configPath);
1037
- if (!resolvedConfigPath) {
1038
- const errorMsg = configPath
1039
- ? `Error: Config file not found: ${configPath}`
1040
- : 'Error: screenci.config.ts not found in current directory';
1041
- logger.error(errorMsg);
1042
- process.exit(1);
1043
- }
1215
+ const resolvedConfigPath = resolveScreenCIConfigPathOrExit(configPath);
1044
1216
  let screenciConfig;
1045
1217
  try {
1046
1218
  screenciConfig =
@@ -1232,12 +1404,18 @@ async function loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath) {
1232
1404
  throw err;
1233
1405
  }
1234
1406
  }
1235
- async function requireScreenCISecret(configPath) {
1407
+ async function requireScreenCISecret(configPath, opts = {}) {
1236
1408
  const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
1237
- const secret = process.env.SCREENCI_SECRET;
1409
+ const secret = process.env.SCREENCI_SECRET ??
1410
+ (await ensureScreenciSecret(resolvedConfigPath, opts));
1238
1411
  if (!secret) {
1412
+ // In a non-interactive session ensureScreenciSecret already printed the
1413
+ // sign-in link and the next step, so we exit without repeating guidance.
1414
+ if (opts.interactive === false) {
1415
+ process.exit(1);
1416
+ }
1239
1417
  const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath);
1240
- logger.error(`No SCREENCI_SECRET configured. Run ${pc.cyan(getSuggestedScreenciCommand('login'))} or add SCREENCI_SECRET manually to ${envFilePath} by following the guide at ${pc.cyan(SCREENCI_LOGIN_DOCS_URL)}.`);
1418
+ logger.error(`No SCREENCI_SECRET configured. Rerun ${pc.cyan(getSuggestedScreenciCommand('record'))} or add SCREENCI_SECRET manually to ${envFilePath} by following the guide at ${pc.cyan(SCREENCI_RECORD_DOCS_URL)}.`);
1241
1419
  process.exit(1);
1242
1420
  }
1243
1421
  return {
@@ -1248,7 +1426,7 @@ async function requireScreenCISecret(configPath) {
1248
1426
  };
1249
1427
  }
1250
1428
  async function fetchProjectInfo(configPath) {
1251
- const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath);
1429
+ const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath, { interactive: detectInteractiveSession() });
1252
1430
  const url = new URL(`${apiUrl}/cli/project-info`);
1253
1431
  url.searchParams.set('projectName', screenciConfig.projectName);
1254
1432
  const res = await fetch(url.toString(), {
@@ -1267,7 +1445,9 @@ async function printProjectInfo(configPath) {
1267
1445
  process.stdout.write(`${JSON.stringify(info, null, 2)}\n`);
1268
1446
  }
1269
1447
  async function updateVideoVisibility(videoId, isPublic, configPath) {
1270
- const { secret, apiUrl } = await requireScreenCISecret(configPath);
1448
+ const { secret, apiUrl } = await requireScreenCISecret(configPath, {
1449
+ interactive: detectInteractiveSession(),
1450
+ });
1271
1451
  const method = isPublic ? 'PUT' : 'DELETE';
1272
1452
  const res = await fetch(`${apiUrl}/cli/public-video/${videoId}`, {
1273
1453
  method,
@@ -1281,29 +1461,6 @@ async function updateVideoVisibility(videoId, isPublic, configPath) {
1281
1461
  }
1282
1462
  logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`);
1283
1463
  }
1284
- function openBrowser(url) {
1285
- try {
1286
- if (process.platform === 'win32') {
1287
- spawn('cmd', ['/c', 'start', '', url], {
1288
- detached: true,
1289
- stdio: 'ignore',
1290
- shell: true,
1291
- }).unref();
1292
- return;
1293
- }
1294
- const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
1295
- spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
1296
- }
1297
- catch (err) {
1298
- logger.warn('Failed to open browser automatically:', err);
1299
- }
1300
- }
1301
- async function promptToOpenLoginUrlWithSignal(signal) {
1302
- return await confirm({
1303
- message: 'Open this link in your browser now?',
1304
- default: false,
1305
- }, { signal });
1306
- }
1307
1464
  async function persistScreenCISecret(envFilePath, secret) {
1308
1465
  const nextLine = `SCREENCI_SECRET=${secret}`;
1309
1466
  try {
@@ -1330,147 +1487,177 @@ async function persistScreenCISecret(envFilePath, secret) {
1330
1487
  }
1331
1488
  await writeFile(envFilePath, `${nextLine}\n`);
1332
1489
  }
1333
- async function performBrowserLogin(appUrl, options) {
1334
- return new Promise((resolve, reject) => {
1335
- let settled = false;
1336
- const promptAbort = new AbortController();
1337
- const finish = (callback) => {
1338
- if (settled)
1339
- return;
1340
- settled = true;
1341
- promptAbort.abort();
1342
- callback();
1343
- };
1344
- const server = createServer((req, res) => {
1345
- try {
1346
- const reqUrl = new URL(req.url ?? '/', 'http://localhost');
1347
- const secret = reqUrl.searchParams.get('secret');
1348
- if (secret) {
1349
- res.writeHead(200, { 'Content-Type': 'text/html' });
1350
- res.end('<html><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><p style="font-size:1.2rem">Setup complete! You can close this tab.</p></body></html>');
1351
- server.close();
1352
- finish(() => resolve(secret));
1353
- }
1354
- else {
1355
- res.writeHead(400, { 'Content-Type': 'text/html' });
1356
- res.end('<html><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><p style="color:red;font-size:1.2rem">Authentication failed: no secret received. Please try again.</p></body></html>');
1357
- server.close();
1358
- finish(() => reject(new Error('No secret received in callback')));
1359
- }
1360
- }
1361
- catch (err) {
1362
- res.writeHead(500);
1363
- res.end('Internal error');
1364
- server.close();
1365
- finish(() => reject(err));
1366
- }
1367
- });
1368
- server.listen(0, '127.0.0.1', () => {
1369
- const port = server.address().port;
1370
- const callbackUrl = `http://localhost:${port}/callback`;
1371
- const loginUrl = `${appUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
1372
- logger.info(`Open this link to log in to ScreenCI:\n${loginUrl}\n`);
1373
- void (async () => {
1374
- if (options?.openBrowserImmediately) {
1375
- openBrowser(loginUrl);
1376
- return;
1377
- }
1378
- if (settled)
1379
- return;
1380
- const shouldOpen = await promptToOpenLoginUrlWithSignal(promptAbort.signal);
1381
- if (settled)
1382
- return;
1383
- if (shouldOpen) {
1384
- openBrowser(loginUrl);
1385
- return;
1386
- }
1387
- logger.info('Browser not opened. Keep this command running and open the link manually to continue.');
1388
- })().catch((err) => {
1389
- if (settled)
1390
- return;
1391
- if (err instanceof Error &&
1392
- (err.name === 'AbortPromptError' ||
1393
- err.name === 'AbortError' ||
1394
- err.message === 'Prompt was canceled')) {
1395
- return;
1396
- }
1397
- server.close();
1398
- finish(() => reject(err));
1399
- });
1400
- });
1401
- const timeout = setTimeout(() => {
1402
- server.close();
1403
- finish(() => reject(new Error('Authentication timed out after 15 minutes')));
1404
- }, 15 * 60 * 1000);
1405
- server.on('close', () => clearTimeout(timeout));
1490
+ function getLinkSessionFilePath(projectDir) {
1491
+ return resolve(projectDir, '.screenci', SCREENCI_LINK_SESSION_FILE);
1492
+ }
1493
+ async function readPersistedLinkSessionSpec(specPath) {
1494
+ try {
1495
+ const raw = await readFile(specPath, 'utf-8');
1496
+ return JSON.parse(raw);
1497
+ }
1498
+ catch (error) {
1499
+ if (!isMissingFileError(error)) {
1500
+ logger.warn(`Ignoring invalid stored link session at ${specPath}.`);
1501
+ rmSync(specPath, { force: true });
1502
+ }
1503
+ return null;
1504
+ }
1505
+ }
1506
+ async function writePersistedLinkSessionSpec(specPath, spec) {
1507
+ mkdirSync(dirname(specPath), { recursive: true });
1508
+ await writeFile(specPath, `${JSON.stringify(spec, null, 2)}\n`);
1509
+ }
1510
+ function deletePersistedLinkSessionSpec(specPath) {
1511
+ rmSync(specPath, { force: true });
1512
+ }
1513
+ function isStoredLinkSessionReusable(spec, options) {
1514
+ return (spec.environment === options.environment &&
1515
+ spec.envFilePath === options.envFilePath &&
1516
+ spec.resolvedConfigPath === options.resolvedConfigPath &&
1517
+ spec.expiresAt > new Date().toISOString());
1518
+ }
1519
+ async function createLinkSessionSpec(options) {
1520
+ const response = await fetch(`${options.apiUrl}/cli-link/session`, {
1521
+ method: 'POST',
1406
1522
  });
1523
+ if (!response.ok) {
1524
+ const text = await response.text();
1525
+ throw new Error(`Failed to create link session: ${response.status} ${text}`);
1526
+ }
1527
+ const body = (await response.json());
1528
+ return {
1529
+ token: body.token,
1530
+ appUrl: `${options.appUrl}/cli-auth?session=${encodeURIComponent(body.token)}`,
1531
+ pollUrl: `${options.apiUrl}/cli-link/session?token=${encodeURIComponent(body.token)}`,
1532
+ createdAt: body.createdAt,
1533
+ expiresAt: body.expiresAt,
1534
+ environment: options.environment,
1535
+ ...(options.resolvedConfigPath
1536
+ ? { resolvedConfigPath: options.resolvedConfigPath }
1537
+ : {}),
1538
+ envFilePath: options.envFilePath,
1539
+ };
1540
+ }
1541
+ async function pollLinkSessionOnce(spec) {
1542
+ const response = await fetch(spec.pollUrl);
1543
+ const body = (await response.json());
1544
+ const status = body.status ?? 'invalid';
1545
+ if (status === 'completed' && body.secret) {
1546
+ return { status, secret: body.secret };
1547
+ }
1548
+ if (status === 'pending' && new Date().toISOString() >= spec.expiresAt) {
1549
+ return { status: 'expired' };
1550
+ }
1551
+ return { status };
1407
1552
  }
1408
- export async function ensureScreenciSecret(resolvedConfigPath) {
1553
+ async function pollLinkSession(spec) {
1554
+ for (;;) {
1555
+ const result = await pollLinkSessionOnce(spec);
1556
+ if (result.status === 'completed' && result.secret) {
1557
+ return result;
1558
+ }
1559
+ if (result.status === 'expired' ||
1560
+ result.status === 'consumed' ||
1561
+ result.status === 'invalid') {
1562
+ return result;
1563
+ }
1564
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, SCREENCI_LINK_SESSION_POLL_INTERVAL_MS));
1565
+ }
1566
+ }
1567
+ export async function ensureScreenciSecret(resolvedConfigPath, opts = {}) {
1568
+ const interactive = opts.interactive ?? true;
1409
1569
  const existingSecret = process.env.SCREENCI_SECRET;
1410
1570
  if (existingSecret)
1411
1571
  return existingSecret;
1412
- const appUrl = getDevFrontendUrl();
1413
1572
  try {
1414
- const secret = await performBrowserLogin(appUrl, {
1415
- openBrowserImmediately: true,
1416
- });
1417
- process.env.SCREENCI_SECRET = secret;
1418
- const savePath = resolvedConfigPath
1573
+ const environment = getScreenCIEnvironment();
1574
+ const apiUrl = getCliLinkSessionApiUrl();
1575
+ const appUrl = getDevFrontendUrl();
1576
+ const envFilePath = resolvedConfigPath
1419
1577
  ? await resolveProjectEnvFilePath(resolvedConfigPath)
1420
1578
  : resolve(process.cwd(), '.env');
1421
- await persistScreenCISecret(savePath, secret);
1422
- logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
1423
- return secret;
1579
+ const projectDir = resolvedConfigPath
1580
+ ? dirname(resolvedConfigPath)
1581
+ : process.cwd();
1582
+ const specPath = getLinkSessionFilePath(projectDir);
1583
+ const linkSessionContext = {
1584
+ environment,
1585
+ envFilePath,
1586
+ ...(resolvedConfigPath ? { resolvedConfigPath } : {}),
1587
+ };
1588
+ const ensureSpec = async () => {
1589
+ const storedSpec = await readPersistedLinkSessionSpec(specPath);
1590
+ const spec = storedSpec &&
1591
+ isStoredLinkSessionReusable(storedSpec, linkSessionContext)
1592
+ ? storedSpec
1593
+ : await createLinkSessionSpec({
1594
+ apiUrl,
1595
+ appUrl,
1596
+ ...linkSessionContext,
1597
+ });
1598
+ if (spec !== storedSpec) {
1599
+ await writePersistedLinkSessionSpec(specPath, spec);
1600
+ }
1601
+ return spec;
1602
+ };
1603
+ const saveCompletedSecret = async (secret) => {
1604
+ process.env.SCREENCI_SECRET = secret;
1605
+ await persistScreenCISecret(envFilePath, secret);
1606
+ deletePersistedLinkSessionSpec(specPath);
1607
+ logger.info(`Successfully saved SCREENCI_SECRET to ${envFilePath}`);
1608
+ return secret;
1609
+ };
1610
+ if (!interactive) {
1611
+ // Non-interactive sessions cannot complete a browser sign-in here, so we
1612
+ // never block. Reuse or create the persisted session and check its status
1613
+ // once; if a stored session is stale, recreate it and check once more.
1614
+ // When the session is already completed (the sign-in happened between
1615
+ // runs) we pick up the secret; otherwise we print the link and return so
1616
+ // the caller can surface it and rerun later.
1617
+ let spec = await ensureSpec();
1618
+ let result = await pollLinkSessionOnce(spec);
1619
+ if (result.status === 'expired' ||
1620
+ result.status === 'consumed' ||
1621
+ result.status === 'invalid') {
1622
+ deletePersistedLinkSessionSpec(specPath);
1623
+ spec = await ensureSpec();
1624
+ result = await pollLinkSessionOnce(spec);
1625
+ }
1626
+ if (result.status === 'completed' && result.secret) {
1627
+ return await saveCompletedSecret(result.secret);
1628
+ }
1629
+ logger.info(`Sign-in required to record. Open this link to sign in and choose a plan:\n${pc.cyan(spec.appUrl)}\n` +
1630
+ `This session is non-interactive, so sign-in can't complete here. After signing in, rerun ${pc.cyan(getSuggestedScreenciCommand('record'))} to continue.`);
1631
+ return undefined;
1632
+ }
1633
+ for (;;) {
1634
+ const spec = await ensureSpec();
1635
+ logger.info(`Open this link to sign in and connect the CLI:\n${pc.cyan(spec.appUrl)}\n`);
1636
+ const result = await pollLinkSession(spec);
1637
+ if (result.status === 'completed' && result.secret) {
1638
+ return await saveCompletedSecret(result.secret);
1639
+ }
1640
+ deletePersistedLinkSessionSpec(specPath);
1641
+ }
1424
1642
  }
1425
1643
  catch (err) {
1426
1644
  const msg = err instanceof Error ? err.message : String(err);
1427
1645
  logger.warn(`Authentication failed: ${msg}`);
1428
- logger.info(`You can add SCREENCI_SECRET manually to .env later. Get it from ${SCREENCI_SECRETS_URL}.`);
1646
+ logger.info(`You can add SCREENCI_SECRET manually to ${resolvedConfigPath ? await resolveProjectEnvFilePath(resolvedConfigPath) : '.env'} later. Get it from ${getScreenCISecretsUrl()}.`);
1429
1647
  logScreenCISecretGuide();
1430
1648
  return undefined;
1431
1649
  }
1432
1650
  }
1433
- async function runLogin(configPath, open = false) {
1434
- const { resolvedConfigPath } = await loadScreenCIConfigAndEnv(configPath);
1435
- if (process.env.SCREENCI_SECRET) {
1436
- logger.info('SCREENCI_SECRET is already configured.');
1437
- return;
1438
- }
1439
- const savePath = await resolveProjectEnvFilePath(resolvedConfigPath);
1440
- const appUrl = getDevFrontendUrl();
1441
- try {
1442
- const secret = await performBrowserLogin(appUrl, {
1443
- openBrowserImmediately: open,
1444
- });
1445
- process.env.SCREENCI_SECRET = secret;
1446
- await persistScreenCISecret(savePath, secret);
1447
- logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
1448
- }
1449
- catch (err) {
1450
- const msg = err instanceof Error ? err.message : String(err);
1451
- logger.warn(`Authentication failed: ${msg}`);
1452
- logger.info(`You can run ${pc.cyan(getSuggestedScreenciCommand('login'))} again or add SCREENCI_SECRET manually to ${savePath}. Get it from ${SCREENCI_SECRETS_URL}.`);
1453
- logScreenCISecretGuide();
1454
- }
1455
- }
1456
1651
  export async function main() {
1457
1652
  if (process.argv.length <= 2) {
1458
1653
  logger.error('Error: No command provided');
1459
- logger.error('Available commands: login, record, test, info, make-public, make-private, init');
1654
+ logger.error('Available commands: record, test, info, make-public, make-private, init');
1460
1655
  process.exit(1);
1461
1656
  }
1462
1657
  const program = new Command();
1463
1658
  const defaultPackageManager = determinePackageManager();
1464
1659
  program.name('screenci');
1465
1660
  program.exitOverride();
1466
- program
1467
- .command('login')
1468
- .description('Authenticate and save SCREENCI_SECRET for this project')
1469
- .option('-c, --config <path>', 'path to screenci.config.ts')
1470
- .option('--open', 'open the login URL in your browser immediately')
1471
- .action(async (options) => {
1472
- await runLogin(options.config, options.open === true);
1473
- });
1474
1661
  // record command — playwright args pass through as-is
1475
1662
  program
1476
1663
  .command('record [playwrightArgs...]')
@@ -1484,20 +1671,23 @@ export async function main() {
1484
1671
  await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose);
1485
1672
  }
1486
1673
  catch (error) {
1487
- logRecordFailureHint();
1488
- if (error instanceof Error &&
1489
- error.message.startsWith('Playwright exited with code ')) {
1490
- playwrightFailure = error;
1674
+ if (!(error instanceof Error))
1675
+ throw error;
1676
+ if (error.message.startsWith('Playwright exited with code ')) {
1677
+ playwrightFailure = new RecordFailureHintError(error);
1491
1678
  }
1492
1679
  else {
1493
- throw error;
1680
+ throw new RecordFailureHintError(error);
1494
1681
  }
1495
1682
  }
1496
1683
  if (process.env.SCREENCI_RECORDING === 'true')
1497
1684
  return;
1498
- // After recording, upload results to API if configured
1499
- const resolvedConfigPath = findScreenCIConfig(parsed.configPath);
1500
- if (resolvedConfigPath) {
1685
+ // After recording, upload results to API if configured. `run` already
1686
+ // resolved the config (or exited), so this best-effort lookup only acts
1687
+ // when a flat config is present in/under the current directory.
1688
+ const resolution = findScreenCIConfig(parsed.configPath);
1689
+ if (resolution.kind === 'found') {
1690
+ const resolvedConfigPath = resolution.path;
1501
1691
  try {
1502
1692
  const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
1503
1693
  loadEnvFile(screenciConfig.envFile
@@ -1514,7 +1704,7 @@ export async function main() {
1514
1704
  logger.info('All recordings failed.');
1515
1705
  }
1516
1706
  else if (!secret) {
1517
- logger.info(`No SCREENCI_SECRET configured for uploads. Run ${getSuggestedScreenciCommand('login')} or add it to the project env file.`);
1707
+ logger.info(`No SCREENCI_SECRET configured for uploads. Rerun ${getSuggestedScreenciCommand('record')} or add it to the project env file.`);
1518
1708
  }
1519
1709
  else if (playwrightFailure !== null &&
1520
1710
  uploadPolicy === 'all-or-nothing') {
@@ -1526,9 +1716,11 @@ export async function main() {
1526
1716
  }
1527
1717
  let uploadResult = {
1528
1718
  projectId: null,
1719
+ recordId: null,
1529
1720
  hadFailures: false,
1530
1721
  failedVideoNames: [],
1531
1722
  failedVideoMessages: [],
1723
+ studioNotices: [],
1532
1724
  };
1533
1725
  try {
1534
1726
  uploadResult = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
@@ -1539,8 +1731,17 @@ export async function main() {
1539
1731
  }
1540
1732
  throw err;
1541
1733
  }
1542
- const { projectId, hadFailures, failedVideoNames, failedVideoMessages, } = uploadResult;
1543
- if (projectId !== null) {
1734
+ const { projectId, recordId, hadFailures, failedVideoNames, failedVideoMessages, studioNotices, } = uploadResult;
1735
+ if (recordId !== null && projectId !== null) {
1736
+ const recordUrl = `${appUrl}/record/${recordId}`;
1737
+ await writeGitHubProjectOutput(recordUrl);
1738
+ logger.info('');
1739
+ logger.info(playwrightFailure !== null
1740
+ ? 'Recording partially succeeded, rendering in progress. Results available at:'
1741
+ : 'Recording finished, rendering in progress. Results available at:');
1742
+ logger.info(pc.cyan(recordUrl));
1743
+ }
1744
+ else if (projectId !== null) {
1544
1745
  const projectUrl = `${appUrl}/project/${projectId}`;
1545
1746
  await writeGitHubProjectOutput(projectUrl);
1546
1747
  logger.info('');
@@ -1549,6 +1750,19 @@ export async function main() {
1549
1750
  : 'Recording finished, rendering in progress. Results available at:');
1550
1751
  logger.info(pc.cyan(projectUrl));
1551
1752
  }
1753
+ for (const notice of studioNotices) {
1754
+ if ('held' in notice.studio) {
1755
+ logger.info('');
1756
+ logger.info(`Rendering for "${notice.videoName}" is on hold — configure it in Studio:`);
1757
+ if (projectId !== null && notice.videoId !== null) {
1758
+ logger.info(pc.cyan(formatStudioUrl(appUrl, projectId, notice.videoId)));
1759
+ }
1760
+ }
1761
+ else if (notice.studio.appliedChanges.length > 0) {
1762
+ logger.info('');
1763
+ logger.info(`Selections were overridden in Studio for "${notice.videoName}": ${formatStudioChangeSummary(notice.studio.appliedChanges)}`);
1764
+ }
1765
+ }
1552
1766
  if (hadFailures) {
1553
1767
  for (const failedVideo of failedVideoMessages) {
1554
1768
  logger.warn(formatFailedVideoMessage(failedVideo.videoName, failedVideo.message));
@@ -1580,8 +1794,11 @@ export async function main() {
1580
1794
  .action(async () => {
1581
1795
  const parsed = parseConfigCliArgs(getSubcommandArgv('test'));
1582
1796
  let configMockRecord = false;
1583
- const resolvedConfigPath = findScreenCIConfig(parsed.configPath);
1584
- if (resolvedConfigPath) {
1797
+ // Best-effort env preload before handing off to `run`, which performs the
1798
+ // authoritative resolution (and emits the `cd screenci` guidance on miss).
1799
+ const resolution = findScreenCIConfig(parsed.configPath);
1800
+ if (resolution.kind === 'found') {
1801
+ const resolvedConfigPath = resolution.path;
1585
1802
  try {
1586
1803
  const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
1587
1804
  configMockRecord = screenciConfig.test?.mockRecord ?? false;
@@ -1752,25 +1969,20 @@ function validateArgs(args) {
1752
1969
  }
1753
1970
  }
1754
1971
  async function run(command, additionalArgs, customConfigPath, verbose = false, mockRecord = false) {
1755
- const configPath = findScreenCIConfig(customConfigPath);
1756
- if (!configPath) {
1757
- const errorMsg = customConfigPath
1758
- ? `Error: Config file not found: ${customConfigPath}`
1759
- : 'Error: screenci.config.ts not found in current directory';
1760
- logger.error(errorMsg);
1761
- process.exit(1);
1762
- }
1972
+ const configPath = resolveScreenCIConfigPathOrExit(customConfigPath);
1763
1973
  if (command === 'test' || process.env.SCREENCI_RECORDING !== 'true') {
1764
1974
  await loadEnvFileFromConfigSource(configPath, false);
1765
1975
  }
1766
1976
  // Only validate args for record command
1767
1977
  if (command === 'record') {
1768
1978
  if (!process.env.SCREENCI_SECRET) {
1769
- await requireScreenCISecret(configPath);
1979
+ await requireScreenCISecret(configPath, {
1980
+ interactive: detectInteractiveSession(),
1981
+ });
1770
1982
  }
1771
1983
  validateArgs(additionalArgs);
1772
1984
  const screenciDir = resolve(dirname(configPath), '.screenci');
1773
- clearDirectory(screenciDir);
1985
+ clearRecordingDirectories(screenciDir);
1774
1986
  }
1775
1987
  const envForChild = { ...process.env };
1776
1988
  await validateUniqueDiscoveredTestTitles(configPath, additionalArgs, {
@@ -1788,7 +2000,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
1788
2000
  logger.info(`Using config: ${configPath}`);
1789
2001
  }
1790
2002
  const playwrightArgs = ['test', '--config', configPath, ...additionalArgs];
1791
- const spawnSpec = resolveSpawnSpec('playwright', playwrightArgs);
2003
+ const spawnSpec = resolvePlaywrightSpawnSpec(playwrightArgs, dirname(configPath));
1792
2004
  const child = spawn(spawnSpec.command, spawnSpec.args, {
1793
2005
  stdio: 'inherit',
1794
2006
  ...(process.platform !== 'win32' ? { detached: true } : {}),
@@ -1843,9 +2055,21 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
1843
2055
  });
1844
2056
  }
1845
2057
  function logRecordFailureHint() {
2058
+ logger.info('');
1846
2059
  logger.info(`If ${pc.cyan('screenci test')} works but ${pc.cyan('screenci record')} fails, try ${pc.cyan('screenci test --mock-record')}.`);
1847
2060
  logger.info(`More info: ${pc.cyan(SCREENCI_MOCK_RECORD_DOCS_URL)}`);
1848
2061
  }
2062
+ export function logCliError(error) {
2063
+ if (isPartialUploadError(error)) {
2064
+ return;
2065
+ }
2066
+ const errorToLog = isRecordFailureHintError(error) ? error.cause : error;
2067
+ const message = errorToLog instanceof Error ? errorToLog.message : String(errorToLog);
2068
+ logger.error(message);
2069
+ if (isRecordFailureHintError(error)) {
2070
+ logRecordFailureHint();
2071
+ }
2072
+ }
1849
2073
  // Only run if this file is being executed directly
1850
2074
  // Check if this module is the main module (handles symlinks properly)
1851
2075
  const currentFile = fileURLToPath(import.meta.url);
@@ -1856,10 +2080,7 @@ if (mainFile &&
1856
2080
  currentRealFile === mainFile ||
1857
2081
  currentFile === realpathSync(mainFile))) {
1858
2082
  main().catch((error) => {
1859
- if (isPartialUploadError(error)) {
1860
- process.exit(1);
1861
- }
1862
- logger.error('Error:', error.message);
2083
+ logCliError(error);
1863
2084
  process.exit(1);
1864
2085
  });
1865
2086
  }