screenci 0.0.54 → 0.0.56

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.
package/dist/cli.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { spawn } from 'child_process';
2
2
  import { createReadStream } from 'fs';
3
- import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from 'fs';
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, } from 'fs';
4
4
  import { createHash } from 'crypto';
5
5
  import { createServer } from 'http';
6
- import { appendFile, readdir, readFile, stat } from 'fs/promises';
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';
9
10
  import { Command, CommanderError } from 'commander';
10
11
  import pc from 'picocolors';
11
12
  import { logger } from './src/logger.js';
@@ -14,6 +15,8 @@ import { SCREENCI_DISABLE_RECORDING_TIMINGS_ENV, SCREENCI_MOCK_RECORD_ENV, } fro
14
15
  import { DEFAULT_RECORD_UPLOAD_POLICY } from './src/defaults.js';
15
16
  import { findDuplicateTitles, formatDuplicateTitlesMessage, } from './src/titleValidation.js';
16
17
  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
20
  export function collectPlaywrightListTitles(suites) {
18
21
  const titles = [];
19
22
  const visitSuite = (suite) => {
@@ -32,6 +35,9 @@ export function collectPlaywrightListTitles(suites) {
32
35
  function parsePlaywrightListReport(stdout) {
33
36
  return JSON.parse(stdout);
34
37
  }
38
+ function logScreenCISecretGuide() {
39
+ logger.info(`Guide: ${pc.cyan(SCREENCI_LOGIN_DOCS_URL)}`);
40
+ }
35
41
  async function collectDiscoveredTestTitles(configPath, additionalArgs, env) {
36
42
  const listArgs = [
37
43
  'test',
@@ -111,15 +117,19 @@ async function validateUniqueDiscoveredTestTitles(configPath, additionalArgs, en
111
117
  throw new Error(formatDuplicateTitlesMessage(duplicates));
112
118
  }
113
119
  }
114
- function resolveRecordingFileCandidates(filePath, configDir) {
120
+ function resolveRecordingFileCandidates(filePath, configDir, sourceFilePath) {
121
+ const sourceFileCandidate = typeof sourceFilePath === 'string'
122
+ ? resolve(configDir, dirname(sourceFilePath), filePath)
123
+ : null;
115
124
  return [
116
125
  filePath,
126
+ ...(sourceFileCandidate ? [sourceFileCandidate] : []),
117
127
  resolve(configDir, 'videos', filePath),
118
128
  resolve(configDir, pathRelative('/app', filePath)),
119
129
  ];
120
130
  }
121
- async function readRecordingFile(filePath, configDir) {
122
- for (const candidate of resolveRecordingFileCandidates(filePath, configDir)) {
131
+ async function readRecordingFile(filePath, configDir, sourceFilePath) {
132
+ for (const candidate of resolveRecordingFileCandidates(filePath, configDir, sourceFilePath)) {
123
133
  try {
124
134
  return { buffer: await readFile(candidate), resolvedPath: candidate };
125
135
  }
@@ -144,6 +154,12 @@ function contentTypeForPath(filePath) {
144
154
  };
145
155
  return contentTypeMap[ext] ?? 'application/octet-stream';
146
156
  }
157
+ class UploadAssetError extends Error {
158
+ constructor(message) {
159
+ super(message);
160
+ this.name = 'UploadAssetError';
161
+ }
162
+ }
147
163
  class UploadCancelledError extends Error {
148
164
  constructor(message = 'Upload cancelled') {
149
165
  super(message);
@@ -164,6 +180,9 @@ function isUploadCancelledError(err) {
164
180
  function isPartialUploadError(err) {
165
181
  return err instanceof PartialUploadError;
166
182
  }
183
+ function isUploadAssetError(err) {
184
+ return err instanceof UploadAssetError;
185
+ }
167
186
  function supportsInPlaceUploadUpdates(verbose) {
168
187
  return !verbose && process.stdout.isTTY === true && !process.env.CI;
169
188
  }
@@ -248,12 +267,20 @@ async function loadUploadCandidate(screenciDir, entry, verbose) {
248
267
  }
249
268
  async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, progressIndex) {
250
269
  const { entry, videoName, data, preparedUploadAssets } = candidate;
270
+ let projectId = null;
251
271
  try {
252
272
  uploadAbort.throwIfAborted();
253
273
  const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
254
- const recordingHash = existsSync(recordingPath)
255
- ? await hashFile(recordingPath)
256
- : undefined;
274
+ if (!existsSync(recordingPath)) {
275
+ progressReporter.complete(progressIndex, 'failure');
276
+ return {
277
+ projectId: null,
278
+ hadFailure: true,
279
+ videoName,
280
+ failureMessage: `Missing recording.mp4 for "${videoName}"`,
281
+ };
282
+ }
283
+ const recordingHash = await hashFile(recordingPath);
257
284
  const startResponse = await fetch(`${apiUrl}/cli/upload/start`, {
258
285
  method: 'POST',
259
286
  headers: {
@@ -264,7 +291,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
264
291
  projectName,
265
292
  videoName,
266
293
  data,
267
- ...(recordingHash !== undefined ? { recordingHash } : {}),
294
+ recordingHash,
268
295
  expectedAssets: preparedUploadAssets.map((asset) => ({
269
296
  fileHash: asset.fileHash,
270
297
  size: asset.size,
@@ -287,53 +314,53 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
287
314
  failureMessage: formatUploadStartFailureMessage(videoName, startResponse.status, text, secret),
288
315
  };
289
316
  }
290
- const { recordingId, projectId } = (await startResponse.json());
317
+ const startBody = (await startResponse.json());
318
+ const { recordingId } = startBody;
319
+ projectId = startBody.projectId;
291
320
  if (verbose) {
292
321
  logger.info(`recordingId=${recordingId} projectId=${projectId}`);
293
322
  logger.info(`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`);
294
323
  }
295
324
  await uploadAssets(preparedUploadAssets, apiUrl, secret, recordingId, uploadAbort.signal, uploadAbort.throwIfAborted, progressReporter);
296
- if (existsSync(recordingPath)) {
297
- uploadAbort.throwIfAborted();
298
- const fileStat = await stat(recordingPath);
299
- if (verbose) {
300
- logger.info(`Uploading recording.mp4 size=${(fileStat.size / 1024 / 1024).toFixed(1)}MB`);
301
- }
302
- const stream = createReadStream(recordingPath);
303
- const abortStream = () => {
304
- stream.destroy(new UploadCancelledError(`Upload cancelled for "${videoName}"`));
305
- };
306
- uploadAbort.signal.addEventListener('abort', abortStream, {
307
- once: true,
325
+ uploadAbort.throwIfAborted();
326
+ const fileStat = await stat(recordingPath);
327
+ if (verbose) {
328
+ logger.info(`Uploading recording.mp4 size=${(fileStat.size / 1024 / 1024).toFixed(1)}MB`);
329
+ }
330
+ const stream = createReadStream(recordingPath);
331
+ const abortStream = () => {
332
+ stream.destroy(new UploadCancelledError(`Upload cancelled for "${videoName}"`));
333
+ };
334
+ uploadAbort.signal.addEventListener('abort', abortStream, {
335
+ once: true,
336
+ });
337
+ try {
338
+ const recordingResponse = await fetch(`${apiUrl}/cli/upload/${recordingId}/recording`, {
339
+ method: 'PUT',
340
+ headers: {
341
+ 'Content-Type': 'video/mp4',
342
+ 'Content-Length': String(fileStat.size),
343
+ 'X-ScreenCI-Secret': secret,
344
+ },
345
+ body: stream,
346
+ signal: uploadAbort.signal,
347
+ // @ts-expect-error Node.js fetch supports duplex for streaming
348
+ duplex: 'half',
308
349
  });
309
- try {
310
- const recordingResponse = await fetch(`${apiUrl}/cli/upload/${recordingId}/recording`, {
311
- method: 'PUT',
312
- headers: {
313
- 'Content-Type': 'video/mp4',
314
- 'Content-Length': String(fileStat.size),
315
- 'X-ScreenCI-Secret': secret,
316
- },
317
- body: stream,
318
- signal: uploadAbort.signal,
319
- // @ts-expect-error Node.js fetch supports duplex for streaming
320
- duplex: 'half',
321
- });
322
- if (!recordingResponse.ok) {
323
- const text = await recordingResponse.text();
324
- progressReporter.complete(progressIndex, 'failure');
325
- return {
326
- projectId,
327
- hadFailure: true,
328
- videoName,
329
- failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
330
- };
331
- }
332
- }
333
- finally {
334
- uploadAbort.signal.removeEventListener('abort', abortStream);
350
+ if (!recordingResponse.ok) {
351
+ const text = await recordingResponse.text();
352
+ progressReporter.complete(progressIndex, 'failure');
353
+ return {
354
+ projectId,
355
+ hadFailure: true,
356
+ videoName,
357
+ failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
358
+ };
335
359
  }
336
360
  }
361
+ finally {
362
+ uploadAbort.signal.removeEventListener('abort', abortStream);
363
+ }
337
364
  progressReporter.complete(progressIndex, 'success');
338
365
  return { projectId, hadFailure: false, videoName };
339
366
  }
@@ -342,9 +369,18 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
342
369
  progressReporter.complete(progressIndex, 'cancelled');
343
370
  throw err;
344
371
  }
372
+ if (isUploadAssetError(err)) {
373
+ progressReporter.complete(progressIndex, 'failure');
374
+ return {
375
+ projectId,
376
+ hadFailure: true,
377
+ videoName,
378
+ failureMessage: err instanceof Error ? err.message : String(err),
379
+ };
380
+ }
345
381
  progressReporter.complete(progressIndex, 'failure');
346
382
  return {
347
- projectId: null,
383
+ projectId,
348
384
  hadFailure: true,
349
385
  videoName,
350
386
  failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
@@ -548,6 +584,7 @@ async function hashFile(filePath) {
548
584
  });
549
585
  }
550
586
  async function prepareCustomVoiceAssets(data, configDir) {
587
+ const sourceFilePath = data.metadata?.sourceFilePath;
551
588
  const customVoiceRefsByPath = new Map();
552
589
  for (const event of data.events) {
553
590
  if (event.type === 'cueStart' && event.translations) {
@@ -580,7 +617,7 @@ async function prepareCustomVoiceAssets(data, configDir) {
580
617
  }
581
618
  const preparedAssets = [];
582
619
  for (const [voicePath, refs] of customVoiceRefsByPath) {
583
- const resolvedFile = await readRecordingFile(voicePath, configDir);
620
+ const resolvedFile = await readRecordingFile(voicePath, configDir, sourceFilePath);
584
621
  if (resolvedFile === null) {
585
622
  const existingHash = refs.find((ref) => typeof ref.assetHash === 'string')?.assetHash;
586
623
  if (!existingHash) {
@@ -615,12 +652,13 @@ async function prepareCustomVoiceAssets(data, configDir) {
615
652
  return preparedAssets;
616
653
  }
617
654
  async function collectUploadAssets(data, configDir) {
655
+ const sourceFilePath = data.metadata?.sourceFilePath;
618
656
  const assets = new Map();
619
657
  for (const event of data.events) {
620
658
  if (event.type === 'assetStart') {
621
659
  if (assets.has(`name:${event.name}`))
622
660
  continue;
623
- const resolvedFile = await readRecordingFile(event.path, configDir);
661
+ const resolvedFile = await readRecordingFile(event.path, configDir, sourceFilePath);
624
662
  if (resolvedFile === null) {
625
663
  logger.warn(`Asset file not found, skipping upload: ${event.path}`);
626
664
  continue;
@@ -641,7 +679,7 @@ async function collectUploadAssets(data, configDir) {
641
679
  if (typeof event.assetHash === 'string' &&
642
680
  !assets.has(`hash:${event.assetHash}`)) {
643
681
  const resolvedFile = typeof event.assetPath === 'string'
644
- ? await readRecordingFile(event.assetPath, configDir)
682
+ ? await readRecordingFile(event.assetPath, configDir, sourceFilePath)
645
683
  : null;
646
684
  assets.set(`hash:${event.assetHash}`, {
647
685
  fileHash: event.assetHash,
@@ -663,7 +701,7 @@ async function collectUploadAssets(data, configDir) {
663
701
  !assets.has(`hash:${translation.assetHash}`)) {
664
702
  const resolvedFile = 'assetPath' in translation &&
665
703
  typeof translation.assetPath === 'string'
666
- ? await readRecordingFile(translation.assetPath, configDir)
704
+ ? await readRecordingFile(translation.assetPath, configDir, sourceFilePath)
667
705
  : null;
668
706
  assets.set(`hash:${translation.assetHash}`, {
669
707
  fileHash: translation.assetHash,
@@ -820,8 +858,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
820
858
  });
821
859
  if (!checkRes.ok) {
822
860
  const text = await checkRes.text();
823
- logger.warn(`Failed to check asset ${asset.path}: ${checkRes.status} ${text}${hint401(checkRes.status, secret)}`);
824
- continue;
861
+ throw new UploadAssetError(`Failed to check asset ${asset.path}: ${checkRes.status} ${text}${hint401(checkRes.status, secret)}`);
825
862
  }
826
863
  const checkBody = (await checkRes.json());
827
864
  if (checkBody.exists) {
@@ -829,8 +866,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
829
866
  continue;
830
867
  }
831
868
  if (!asset.fileBuffer || !asset.contentType) {
832
- logger.warn(`Asset bytes not available for upload and backend does not have it yet: ${asset.path}`);
833
- continue;
869
+ throw new UploadAssetError(`Asset bytes not available for upload and backend does not have it yet: ${asset.path}`);
834
870
  }
835
871
  throwIfAborted();
836
872
  const res = await fetch(`${apiUrl}/cli/upload/${recordingId}/asset`, {
@@ -855,7 +891,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
855
891
  logInfo(`Asset already exists: ${asset.path}`);
856
892
  }
857
893
  else {
858
- logger.warn(`Failed to upload asset ${asset.path}: ${res.status} ${text}${hint401(res.status, secret)}`);
894
+ throw new UploadAssetError(`Failed to upload asset ${asset.path}: ${res.status} ${text}${hint401(res.status, secret)}`);
859
895
  }
860
896
  }
861
897
  else {
@@ -866,7 +902,10 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
866
902
  if (isUploadCancelledError(err)) {
867
903
  throw err;
868
904
  }
869
- logger.warn(`Network error uploading asset ${asset.path}:`, err);
905
+ if (isUploadAssetError(err)) {
906
+ throw err;
907
+ }
908
+ throw new UploadAssetError(`Network error uploading asset ${asset.path}: ${err instanceof Error ? err.message : String(err)}`);
870
909
  }
871
910
  }
872
911
  }
@@ -971,14 +1010,21 @@ async function loadScreenCIConfigAndEnv(configPath) {
971
1010
  process.exit(1);
972
1011
  }
973
1012
  if (screenciConfig.envFile) {
974
- const envFilePath = resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
975
- loadEnvFile(envFilePath, true);
1013
+ loadEnvFile(resolve(dirname(resolvedConfigPath), screenciConfig.envFile), true);
1014
+ }
1015
+ else {
1016
+ loadEnvFile(resolve(dirname(resolvedConfigPath), '.env'), false);
976
1017
  }
977
1018
  return { resolvedConfigPath, screenciConfig };
978
1019
  }
979
1020
  function loadEnvFile(envFilePath, warnOnFailure) {
980
1021
  try {
981
- process.loadEnvFile(envFilePath);
1022
+ const loadEnvFileCompat = process.loadEnvFile;
1023
+ if (typeof loadEnvFileCompat === 'function') {
1024
+ loadEnvFileCompat(envFilePath);
1025
+ return;
1026
+ }
1027
+ loadEnvFileFallback(envFilePath);
982
1028
  }
983
1029
  catch (err) {
984
1030
  if (warnOnFailure && !isMissingFileError(err)) {
@@ -986,6 +1032,58 @@ function loadEnvFile(envFilePath, warnOnFailure) {
986
1032
  }
987
1033
  }
988
1034
  }
1035
+ function loadEnvFileFallback(envFilePath) {
1036
+ const envSource = readFileSync(envFilePath, 'utf8');
1037
+ for (const [key, value] of parseEnvFile(envSource)) {
1038
+ if (process.env[key] === undefined) {
1039
+ process.env[key] = value;
1040
+ }
1041
+ }
1042
+ }
1043
+ function parseEnvFile(envSource) {
1044
+ const parsed = new Map();
1045
+ const lines = envSource.replace(/^\uFEFF/, '').split(/\r?\n/);
1046
+ for (const rawLine of lines) {
1047
+ const line = rawLine.trim();
1048
+ if (line === '' || line.startsWith('#'))
1049
+ continue;
1050
+ const normalizedLine = line.startsWith('export ')
1051
+ ? line.slice('export '.length).trimStart()
1052
+ : line;
1053
+ const separatorIndex = normalizedLine.indexOf('=');
1054
+ if (separatorIndex === -1)
1055
+ continue;
1056
+ const key = normalizedLine.slice(0, separatorIndex).trim();
1057
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
1058
+ continue;
1059
+ const rawValue = normalizedLine.slice(separatorIndex + 1).trim();
1060
+ parsed.set(key, parseEnvValue(rawValue));
1061
+ }
1062
+ return parsed;
1063
+ }
1064
+ function parseEnvValue(rawValue) {
1065
+ if (rawValue === '')
1066
+ return '';
1067
+ if ((rawValue.startsWith('"') && rawValue.endsWith('"')) ||
1068
+ (rawValue.startsWith("'") && rawValue.endsWith("'"))) {
1069
+ const quote = rawValue[0];
1070
+ const quotedValue = rawValue.slice(1, -1);
1071
+ if (quote === '"') {
1072
+ return quotedValue
1073
+ .replace(/\\n/g, '\n')
1074
+ .replace(/\\r/g, '\r')
1075
+ .replace(/\\t/g, '\t')
1076
+ .replace(/\\"/g, '"')
1077
+ .replace(/\\\\/g, '\\');
1078
+ }
1079
+ return quotedValue;
1080
+ }
1081
+ const inlineCommentIndex = rawValue.search(/\s#/);
1082
+ if (inlineCommentIndex >= 0) {
1083
+ return rawValue.slice(0, inlineCommentIndex).trimEnd();
1084
+ }
1085
+ return rawValue;
1086
+ }
989
1087
  function isMissingFileError(err) {
990
1088
  return (typeof err === 'object' &&
991
1089
  err !== null &&
@@ -995,10 +1093,9 @@ function isMissingFileError(err) {
995
1093
  async function loadEnvFileFromConfigSource(resolvedConfigPath, warnOnFailure) {
996
1094
  try {
997
1095
  const screenciConfig = await tryReadConfigFromSource(resolvedConfigPath);
998
- if (screenciConfig.envFile) {
999
- const envFilePath = resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
1000
- loadEnvFile(envFilePath, warnOnFailure);
1001
- }
1096
+ loadEnvFile(screenciConfig.envFile
1097
+ ? resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
1098
+ : resolve(dirname(resolvedConfigPath), '.env'), warnOnFailure);
1002
1099
  }
1003
1100
  catch {
1004
1101
  // Config import may require Playwright context or dynamic values. Continue with
@@ -1016,6 +1113,10 @@ async function resolveConfiguredEnvFilePath(resolvedConfigPath) {
1016
1113
  return undefined;
1017
1114
  }
1018
1115
  }
1116
+ async function resolveProjectEnvFilePath(resolvedConfigPath) {
1117
+ return ((await resolveConfiguredEnvFilePath(resolvedConfigPath)) ??
1118
+ resolve(dirname(resolvedConfigPath), '.env'));
1119
+ }
1019
1120
  export function extractConfigStringLiteral(configSource, property) {
1020
1121
  const singleQuoteMatch = configSource.match(new RegExp(property + "\\s*:\\s*'([^'\\n]+)'"));
1021
1122
  if (singleQuoteMatch)
@@ -1094,7 +1195,9 @@ async function requireScreenCISecret(configPath) {
1094
1195
  const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
1095
1196
  const secret = process.env.SCREENCI_SECRET;
1096
1197
  if (!secret) {
1097
- logger.error('No secret configured. Set SCREENCI_SECRET in your .env file (get it from the API Key page in the dashboard).');
1198
+ const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath);
1199
+ logger.error(`No SCREENCI_SECRET configured. Run ${pc.cyan('screenci login')} or add SCREENCI_SECRET to ${envFilePath}. You can get the secret manually from ${SCREENCI_SECRETS_URL}.`);
1200
+ logScreenCISecretGuide();
1098
1201
  process.exit(1);
1099
1202
  }
1100
1203
  return {
@@ -1155,8 +1258,47 @@ function openBrowser(url) {
1155
1258
  logger.warn('Failed to open browser automatically:', err);
1156
1259
  }
1157
1260
  }
1158
- async function performBrowserLogin(appUrl) {
1261
+ async function promptToOpenLoginUrl() {
1262
+ return await confirm({
1263
+ message: 'Open this link in your browser now?',
1264
+ default: false,
1265
+ });
1266
+ }
1267
+ async function persistScreenCISecret(envFilePath, secret) {
1268
+ const nextLine = `SCREENCI_SECRET=${secret}`;
1269
+ try {
1270
+ const existing = await readFile(envFilePath, 'utf-8');
1271
+ const lines = existing === '' ? [] : existing.split(/\r?\n/);
1272
+ const firstSecretIndex = lines.findIndex((line) => line.startsWith('SCREENCI_SECRET='));
1273
+ const linesWithoutSecret = lines.filter((line) => !line.startsWith('SCREENCI_SECRET='));
1274
+ const finalLines = firstSecretIndex >= 0
1275
+ ? [
1276
+ ...linesWithoutSecret.slice(0, firstSecretIndex),
1277
+ nextLine,
1278
+ ...linesWithoutSecret.slice(firstSecretIndex),
1279
+ ]
1280
+ : [...linesWithoutSecret, nextLine];
1281
+ let nextContent = finalLines.join('\n');
1282
+ if (!nextContent.endsWith('\n'))
1283
+ nextContent += '\n';
1284
+ await writeFile(envFilePath, nextContent);
1285
+ return;
1286
+ }
1287
+ catch (err) {
1288
+ if (!isMissingFileError(err))
1289
+ throw err;
1290
+ }
1291
+ await writeFile(envFilePath, `${nextLine}\n`);
1292
+ }
1293
+ async function performBrowserLogin(appUrl, options) {
1159
1294
  return new Promise((resolve, reject) => {
1295
+ let settled = false;
1296
+ const finish = (callback) => {
1297
+ if (settled)
1298
+ return;
1299
+ settled = true;
1300
+ callback();
1301
+ };
1160
1302
  const server = createServer((req, res) => {
1161
1303
  try {
1162
1304
  const reqUrl = new URL(req.url ?? '/', 'http://localhost');
@@ -1165,34 +1307,48 @@ async function performBrowserLogin(appUrl) {
1165
1307
  res.writeHead(200, { 'Content-Type': 'text/html' });
1166
1308
  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>');
1167
1309
  server.close();
1168
- resolve(secret);
1310
+ finish(() => resolve(secret));
1169
1311
  }
1170
1312
  else {
1171
1313
  res.writeHead(400, { 'Content-Type': 'text/html' });
1172
1314
  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>');
1173
1315
  server.close();
1174
- reject(new Error('No secret received in callback'));
1316
+ finish(() => reject(new Error('No secret received in callback')));
1175
1317
  }
1176
1318
  }
1177
1319
  catch (err) {
1178
1320
  res.writeHead(500);
1179
1321
  res.end('Internal error');
1180
1322
  server.close();
1181
- reject(err);
1323
+ finish(() => reject(err));
1182
1324
  }
1183
1325
  });
1184
1326
  server.listen(0, '127.0.0.1', () => {
1185
1327
  const port = server.address().port;
1186
1328
  const callbackUrl = `http://localhost:${port}/callback`;
1187
1329
  const loginUrl = `${appUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
1188
- logger.info(`If the browser does not open automatically, visit:`);
1330
+ logger.info('Open this link to log in to ScreenCI:');
1189
1331
  logger.info(pc.cyan(loginUrl));
1190
1332
  logger.info('');
1191
- openBrowser(loginUrl);
1333
+ void (async () => {
1334
+ if (options?.openBrowserImmediately) {
1335
+ openBrowser(loginUrl);
1336
+ return;
1337
+ }
1338
+ const shouldOpen = await promptToOpenLoginUrl();
1339
+ if (shouldOpen) {
1340
+ openBrowser(loginUrl);
1341
+ return;
1342
+ }
1343
+ logger.info('Browser not opened. Keep this command running and open the link manually to continue.');
1344
+ })().catch((err) => {
1345
+ server.close();
1346
+ finish(() => reject(err));
1347
+ });
1192
1348
  });
1193
1349
  const timeout = setTimeout(() => {
1194
1350
  server.close();
1195
- reject(new Error('Authentication timed out after 5 minutes'));
1351
+ finish(() => reject(new Error('Authentication timed out after 15 minutes')));
1196
1352
  }, 15 * 60 * 1000);
1197
1353
  server.on('close', () => clearTimeout(timeout));
1198
1354
  });
@@ -1201,36 +1357,68 @@ export async function ensureScreenciSecret(resolvedConfigPath) {
1201
1357
  const existingSecret = process.env.SCREENCI_SECRET;
1202
1358
  if (existingSecret)
1203
1359
  return existingSecret;
1204
- logger.info('Opening browser for authentication to get your SCREENCI_SECRET...');
1205
1360
  const appUrl = getDevFrontendUrl();
1206
1361
  try {
1207
- const secret = await performBrowserLogin(appUrl);
1362
+ const secret = await performBrowserLogin(appUrl, {
1363
+ openBrowserImmediately: true,
1364
+ });
1208
1365
  process.env.SCREENCI_SECRET = secret;
1209
1366
  const savePath = resolvedConfigPath
1210
- ? ((await resolveConfiguredEnvFilePath(resolvedConfigPath)) ??
1211
- resolve(process.cwd(), '.env'))
1367
+ ? await resolveProjectEnvFilePath(resolvedConfigPath)
1212
1368
  : resolve(process.cwd(), '.env');
1213
- await appendFile(savePath, `SCREENCI_SECRET=${secret}\n`);
1369
+ await persistScreenCISecret(savePath, secret);
1214
1370
  logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
1215
1371
  return secret;
1216
1372
  }
1217
1373
  catch (err) {
1218
1374
  const msg = err instanceof Error ? err.message : String(err);
1219
1375
  logger.warn(`Authentication failed: ${msg}`);
1220
- logger.info('You can add SCREENCI_SECRET manually to .env later (get it from the API Key page in the dashboard).');
1376
+ logger.info(`You can add SCREENCI_SECRET manually to .env later. Get it from ${SCREENCI_SECRETS_URL}.`);
1377
+ logScreenCISecretGuide();
1221
1378
  return undefined;
1222
1379
  }
1223
1380
  }
1381
+ async function runLogin(configPath, open = false) {
1382
+ const { resolvedConfigPath } = await loadScreenCIConfigAndEnv(configPath);
1383
+ if (process.env.SCREENCI_SECRET) {
1384
+ logger.info('SCREENCI_SECRET is already configured.');
1385
+ return;
1386
+ }
1387
+ const savePath = await resolveProjectEnvFilePath(resolvedConfigPath);
1388
+ const appUrl = getDevFrontendUrl();
1389
+ try {
1390
+ const secret = await performBrowserLogin(appUrl, {
1391
+ openBrowserImmediately: open,
1392
+ });
1393
+ process.env.SCREENCI_SECRET = secret;
1394
+ await persistScreenCISecret(savePath, secret);
1395
+ logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
1396
+ }
1397
+ catch (err) {
1398
+ const msg = err instanceof Error ? err.message : String(err);
1399
+ logger.warn(`Authentication failed: ${msg}`);
1400
+ logger.info(`You can run ${pc.cyan('screenci login')} again or add SCREENCI_SECRET manually to ${savePath}. Get it from ${SCREENCI_SECRETS_URL}.`);
1401
+ logScreenCISecretGuide();
1402
+ }
1403
+ }
1224
1404
  export async function main() {
1225
1405
  if (process.argv.length <= 2) {
1226
1406
  logger.error('Error: No command provided');
1227
- logger.error('Available commands: record, test, info, make-public, make-private, init');
1407
+ logger.error('Available commands: login, record, test, info, make-public, make-private, init');
1228
1408
  process.exit(1);
1229
1409
  }
1230
1410
  const program = new Command();
1231
1411
  const defaultPackageManager = determinePackageManager();
1232
1412
  program.name('screenci');
1233
1413
  program.exitOverride();
1414
+ program
1415
+ .command('login')
1416
+ .description('Authenticate and save SCREENCI_SECRET for this project')
1417
+ .option('-c, --config <path>', 'path to screenci.config.ts')
1418
+ .option('--open', 'open the login URL in your browser immediately')
1419
+ .action(async (options) => {
1420
+ await runLogin(options.config, options.open === true);
1421
+ });
1234
1422
  // record command — playwright args pass through as-is
1235
1423
  program
1236
1424
  .command('record [playwrightArgs...]')
@@ -1260,10 +1448,9 @@ export async function main() {
1260
1448
  if (resolvedConfigPath) {
1261
1449
  try {
1262
1450
  const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
1263
- if (screenciConfig.envFile) {
1264
- const envFilePath = resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
1265
- loadEnvFile(envFilePath, true);
1266
- }
1451
+ loadEnvFile(screenciConfig.envFile
1452
+ ? resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
1453
+ : resolve(dirname(resolvedConfigPath), '.env'), true);
1267
1454
  const apiUrl = getDevBackendUrl();
1268
1455
  const appUrl = getDevFrontendUrl();
1269
1456
  const secret = process.env.SCREENCI_SECRET;
@@ -1275,7 +1462,7 @@ export async function main() {
1275
1462
  logger.info('All recordings failed.');
1276
1463
  }
1277
1464
  else if (!secret) {
1278
- logger.info('No secret configured, skipping upload. Set SCREENCI_SECRET in your .env file.');
1465
+ logger.info('No SCREENCI_SECRET configured for uploads. Run screenci login or add it to the project env file.');
1279
1466
  }
1280
1467
  else if (playwrightFailure !== null &&
1281
1468
  uploadPolicy === 'all-or-nothing') {
@@ -1530,7 +1717,9 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
1530
1717
  }
1531
1718
  // Only validate args for record command
1532
1719
  if (command === 'record') {
1533
- await ensureScreenciSecret(configPath);
1720
+ if (!process.env.SCREENCI_SECRET) {
1721
+ await requireScreenCISecret(configPath);
1722
+ }
1534
1723
  validateArgs(additionalArgs);
1535
1724
  const screenciDir = resolve(dirname(configPath), '.screenci');
1536
1725
  clearDirectory(screenciDir);
@@ -1538,6 +1727,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
1538
1727
  const envForChild = { ...process.env };
1539
1728
  await validateUniqueDiscoveredTestTitles(configPath, additionalArgs, {
1540
1729
  ...envForChild,
1730
+ SCREENCI_CONFIG_DIR: dirname(configPath),
1541
1731
  ...(command === 'record' ? { SCREENCI_RECORDING: 'true' } : {}),
1542
1732
  ...(command === 'test' && !mockRecord
1543
1733
  ? { [SCREENCI_DISABLE_RECORDING_TIMINGS_ENV]: 'true' }
@@ -1562,6 +1752,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
1562
1752
  : {}),
1563
1753
  env: {
1564
1754
  ...envForChild,
1755
+ SCREENCI_CONFIG_DIR: dirname(configPath),
1565
1756
  // Enable recording only for record command
1566
1757
  ...(command === 'record' ? { SCREENCI_RECORDING: 'true' } : {}),
1567
1758
  ...(command === 'test' && !mockRecord