screenci 0.0.54 → 0.0.55

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/README.md CHANGED
@@ -79,11 +79,13 @@ Playwright without starting the final recording and upload path.
79
79
  ## Record the final output
80
80
 
81
81
  ```bash
82
+ npx screenci login
82
83
  npx screenci record
83
84
  ```
84
85
 
85
- `record` writes local artifacts into `.screenci/<video-name>/` and uploads them
86
- when `SCREENCI_SECRET` is configured.
86
+ `login` saves `SCREENCI_SECRET` into the project env file. `record` writes local
87
+ artifacts into `.screenci/<video-name>/` and uploads them when that secret is
88
+ configured.
87
89
 
88
90
  ## Configure
89
91
 
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../cli.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../cli.ts"],"names":[],"mappings":"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"}
package/dist/cli.js CHANGED
@@ -3,9 +3,10 @@ import { createReadStream } from 'fs';
3
3
  import { existsSync, mkdirSync, 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,8 +1010,10 @@ 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
  }
@@ -995,10 +1036,9 @@ function isMissingFileError(err) {
995
1036
  async function loadEnvFileFromConfigSource(resolvedConfigPath, warnOnFailure) {
996
1037
  try {
997
1038
  const screenciConfig = await tryReadConfigFromSource(resolvedConfigPath);
998
- if (screenciConfig.envFile) {
999
- const envFilePath = resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
1000
- loadEnvFile(envFilePath, warnOnFailure);
1001
- }
1039
+ loadEnvFile(screenciConfig.envFile
1040
+ ? resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
1041
+ : resolve(dirname(resolvedConfigPath), '.env'), warnOnFailure);
1002
1042
  }
1003
1043
  catch {
1004
1044
  // Config import may require Playwright context or dynamic values. Continue with
@@ -1016,6 +1056,10 @@ async function resolveConfiguredEnvFilePath(resolvedConfigPath) {
1016
1056
  return undefined;
1017
1057
  }
1018
1058
  }
1059
+ async function resolveProjectEnvFilePath(resolvedConfigPath) {
1060
+ return ((await resolveConfiguredEnvFilePath(resolvedConfigPath)) ??
1061
+ resolve(dirname(resolvedConfigPath), '.env'));
1062
+ }
1019
1063
  export function extractConfigStringLiteral(configSource, property) {
1020
1064
  const singleQuoteMatch = configSource.match(new RegExp(property + "\\s*:\\s*'([^'\\n]+)'"));
1021
1065
  if (singleQuoteMatch)
@@ -1094,7 +1138,9 @@ async function requireScreenCISecret(configPath) {
1094
1138
  const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
1095
1139
  const secret = process.env.SCREENCI_SECRET;
1096
1140
  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).');
1141
+ const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath);
1142
+ 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}.`);
1143
+ logScreenCISecretGuide();
1098
1144
  process.exit(1);
1099
1145
  }
1100
1146
  return {
@@ -1155,8 +1201,47 @@ function openBrowser(url) {
1155
1201
  logger.warn('Failed to open browser automatically:', err);
1156
1202
  }
1157
1203
  }
1158
- async function performBrowserLogin(appUrl) {
1204
+ async function promptToOpenLoginUrl() {
1205
+ return await confirm({
1206
+ message: 'Open this link in your browser now?',
1207
+ default: false,
1208
+ });
1209
+ }
1210
+ async function persistScreenCISecret(envFilePath, secret) {
1211
+ const nextLine = `SCREENCI_SECRET=${secret}`;
1212
+ try {
1213
+ const existing = await readFile(envFilePath, 'utf-8');
1214
+ const lines = existing === '' ? [] : existing.split(/\r?\n/);
1215
+ const firstSecretIndex = lines.findIndex((line) => line.startsWith('SCREENCI_SECRET='));
1216
+ const linesWithoutSecret = lines.filter((line) => !line.startsWith('SCREENCI_SECRET='));
1217
+ const finalLines = firstSecretIndex >= 0
1218
+ ? [
1219
+ ...linesWithoutSecret.slice(0, firstSecretIndex),
1220
+ nextLine,
1221
+ ...linesWithoutSecret.slice(firstSecretIndex),
1222
+ ]
1223
+ : [...linesWithoutSecret, nextLine];
1224
+ let nextContent = finalLines.join('\n');
1225
+ if (!nextContent.endsWith('\n'))
1226
+ nextContent += '\n';
1227
+ await writeFile(envFilePath, nextContent);
1228
+ return;
1229
+ }
1230
+ catch (err) {
1231
+ if (!isMissingFileError(err))
1232
+ throw err;
1233
+ }
1234
+ await writeFile(envFilePath, `${nextLine}\n`);
1235
+ }
1236
+ async function performBrowserLogin(appUrl, options) {
1159
1237
  return new Promise((resolve, reject) => {
1238
+ let settled = false;
1239
+ const finish = (callback) => {
1240
+ if (settled)
1241
+ return;
1242
+ settled = true;
1243
+ callback();
1244
+ };
1160
1245
  const server = createServer((req, res) => {
1161
1246
  try {
1162
1247
  const reqUrl = new URL(req.url ?? '/', 'http://localhost');
@@ -1165,34 +1250,48 @@ async function performBrowserLogin(appUrl) {
1165
1250
  res.writeHead(200, { 'Content-Type': 'text/html' });
1166
1251
  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
1252
  server.close();
1168
- resolve(secret);
1253
+ finish(() => resolve(secret));
1169
1254
  }
1170
1255
  else {
1171
1256
  res.writeHead(400, { 'Content-Type': 'text/html' });
1172
1257
  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
1258
  server.close();
1174
- reject(new Error('No secret received in callback'));
1259
+ finish(() => reject(new Error('No secret received in callback')));
1175
1260
  }
1176
1261
  }
1177
1262
  catch (err) {
1178
1263
  res.writeHead(500);
1179
1264
  res.end('Internal error');
1180
1265
  server.close();
1181
- reject(err);
1266
+ finish(() => reject(err));
1182
1267
  }
1183
1268
  });
1184
1269
  server.listen(0, '127.0.0.1', () => {
1185
1270
  const port = server.address().port;
1186
1271
  const callbackUrl = `http://localhost:${port}/callback`;
1187
1272
  const loginUrl = `${appUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
1188
- logger.info(`If the browser does not open automatically, visit:`);
1273
+ logger.info('Open this link to log in to ScreenCI:');
1189
1274
  logger.info(pc.cyan(loginUrl));
1190
1275
  logger.info('');
1191
- openBrowser(loginUrl);
1276
+ void (async () => {
1277
+ if (options?.openBrowserImmediately) {
1278
+ openBrowser(loginUrl);
1279
+ return;
1280
+ }
1281
+ const shouldOpen = await promptToOpenLoginUrl();
1282
+ if (shouldOpen) {
1283
+ openBrowser(loginUrl);
1284
+ return;
1285
+ }
1286
+ logger.info('Browser not opened. Keep this command running and open the link manually to continue.');
1287
+ })().catch((err) => {
1288
+ server.close();
1289
+ finish(() => reject(err));
1290
+ });
1192
1291
  });
1193
1292
  const timeout = setTimeout(() => {
1194
1293
  server.close();
1195
- reject(new Error('Authentication timed out after 5 minutes'));
1294
+ finish(() => reject(new Error('Authentication timed out after 15 minutes')));
1196
1295
  }, 15 * 60 * 1000);
1197
1296
  server.on('close', () => clearTimeout(timeout));
1198
1297
  });
@@ -1201,36 +1300,68 @@ export async function ensureScreenciSecret(resolvedConfigPath) {
1201
1300
  const existingSecret = process.env.SCREENCI_SECRET;
1202
1301
  if (existingSecret)
1203
1302
  return existingSecret;
1204
- logger.info('Opening browser for authentication to get your SCREENCI_SECRET...');
1205
1303
  const appUrl = getDevFrontendUrl();
1206
1304
  try {
1207
- const secret = await performBrowserLogin(appUrl);
1305
+ const secret = await performBrowserLogin(appUrl, {
1306
+ openBrowserImmediately: true,
1307
+ });
1208
1308
  process.env.SCREENCI_SECRET = secret;
1209
1309
  const savePath = resolvedConfigPath
1210
- ? ((await resolveConfiguredEnvFilePath(resolvedConfigPath)) ??
1211
- resolve(process.cwd(), '.env'))
1310
+ ? await resolveProjectEnvFilePath(resolvedConfigPath)
1212
1311
  : resolve(process.cwd(), '.env');
1213
- await appendFile(savePath, `SCREENCI_SECRET=${secret}\n`);
1312
+ await persistScreenCISecret(savePath, secret);
1214
1313
  logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
1215
1314
  return secret;
1216
1315
  }
1217
1316
  catch (err) {
1218
1317
  const msg = err instanceof Error ? err.message : String(err);
1219
1318
  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).');
1319
+ logger.info(`You can add SCREENCI_SECRET manually to .env later. Get it from ${SCREENCI_SECRETS_URL}.`);
1320
+ logScreenCISecretGuide();
1221
1321
  return undefined;
1222
1322
  }
1223
1323
  }
1324
+ async function runLogin(configPath, open = false) {
1325
+ const { resolvedConfigPath } = await loadScreenCIConfigAndEnv(configPath);
1326
+ if (process.env.SCREENCI_SECRET) {
1327
+ logger.info('SCREENCI_SECRET is already configured.');
1328
+ return;
1329
+ }
1330
+ const savePath = await resolveProjectEnvFilePath(resolvedConfigPath);
1331
+ const appUrl = getDevFrontendUrl();
1332
+ try {
1333
+ const secret = await performBrowserLogin(appUrl, {
1334
+ openBrowserImmediately: open,
1335
+ });
1336
+ process.env.SCREENCI_SECRET = secret;
1337
+ await persistScreenCISecret(savePath, secret);
1338
+ logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
1339
+ }
1340
+ catch (err) {
1341
+ const msg = err instanceof Error ? err.message : String(err);
1342
+ logger.warn(`Authentication failed: ${msg}`);
1343
+ logger.info(`You can run ${pc.cyan('screenci login')} again or add SCREENCI_SECRET manually to ${savePath}. Get it from ${SCREENCI_SECRETS_URL}.`);
1344
+ logScreenCISecretGuide();
1345
+ }
1346
+ }
1224
1347
  export async function main() {
1225
1348
  if (process.argv.length <= 2) {
1226
1349
  logger.error('Error: No command provided');
1227
- logger.error('Available commands: record, test, info, make-public, make-private, init');
1350
+ logger.error('Available commands: login, record, test, info, make-public, make-private, init');
1228
1351
  process.exit(1);
1229
1352
  }
1230
1353
  const program = new Command();
1231
1354
  const defaultPackageManager = determinePackageManager();
1232
1355
  program.name('screenci');
1233
1356
  program.exitOverride();
1357
+ program
1358
+ .command('login')
1359
+ .description('Authenticate and save SCREENCI_SECRET for this project')
1360
+ .option('-c, --config <path>', 'path to screenci.config.ts')
1361
+ .option('--open', 'open the login URL in your browser immediately')
1362
+ .action(async (options) => {
1363
+ await runLogin(options.config, options.open === true);
1364
+ });
1234
1365
  // record command — playwright args pass through as-is
1235
1366
  program
1236
1367
  .command('record [playwrightArgs...]')
@@ -1260,10 +1391,9 @@ export async function main() {
1260
1391
  if (resolvedConfigPath) {
1261
1392
  try {
1262
1393
  const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
1263
- if (screenciConfig.envFile) {
1264
- const envFilePath = resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
1265
- loadEnvFile(envFilePath, true);
1266
- }
1394
+ loadEnvFile(screenciConfig.envFile
1395
+ ? resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
1396
+ : resolve(dirname(resolvedConfigPath), '.env'), true);
1267
1397
  const apiUrl = getDevBackendUrl();
1268
1398
  const appUrl = getDevFrontendUrl();
1269
1399
  const secret = process.env.SCREENCI_SECRET;
@@ -1275,7 +1405,7 @@ export async function main() {
1275
1405
  logger.info('All recordings failed.');
1276
1406
  }
1277
1407
  else if (!secret) {
1278
- logger.info('No secret configured, skipping upload. Set SCREENCI_SECRET in your .env file.');
1408
+ logger.info('No SCREENCI_SECRET configured for uploads. Run screenci login or add it to the project env file.');
1279
1409
  }
1280
1410
  else if (playwrightFailure !== null &&
1281
1411
  uploadPolicy === 'all-or-nothing') {
@@ -1530,7 +1660,9 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
1530
1660
  }
1531
1661
  // Only validate args for record command
1532
1662
  if (command === 'record') {
1533
- await ensureScreenciSecret(configPath);
1663
+ if (!process.env.SCREENCI_SECRET) {
1664
+ await requireScreenCISecret(configPath);
1665
+ }
1534
1666
  validateArgs(additionalArgs);
1535
1667
  const screenciDir = resolve(dirname(configPath), '.screenci');
1536
1668
  clearDirectory(screenciDir);
@@ -1538,6 +1670,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
1538
1670
  const envForChild = { ...process.env };
1539
1671
  await validateUniqueDiscoveredTestTitles(configPath, additionalArgs, {
1540
1672
  ...envForChild,
1673
+ SCREENCI_CONFIG_DIR: dirname(configPath),
1541
1674
  ...(command === 'record' ? { SCREENCI_RECORDING: 'true' } : {}),
1542
1675
  ...(command === 'test' && !mockRecord
1543
1676
  ? { [SCREENCI_DISABLE_RECORDING_TIMINGS_ENV]: 'true' }
@@ -1562,6 +1695,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
1562
1695
  : {}),
1563
1696
  env: {
1564
1697
  ...envForChild,
1698
+ SCREENCI_CONFIG_DIR: dirname(configPath),
1565
1699
  // Enable recording only for record command
1566
1700
  ...(command === 'record' ? { SCREENCI_RECORDING: 'true' } : {}),
1567
1701
  ...(command === 'test' && !mockRecord