screenci 0.0.68 → 0.0.70

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 (161) hide show
  1. package/README.md +45 -122
  2. package/dist/cli.d.ts +3 -18
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +117 -158
  5. package/dist/cli.js.map +1 -1
  6. package/dist/docs/manifest.d.ts +66 -35
  7. package/dist/docs/manifest.d.ts.map +1 -1
  8. package/dist/docs/manifest.js +22 -11
  9. package/dist/docs/manifest.js.map +1 -1
  10. package/dist/docs/video-sources/cli.screenci.d.ts +2 -0
  11. package/dist/docs/video-sources/cli.screenci.d.ts.map +1 -0
  12. package/dist/docs/video-sources/{cli.video.js → cli.screenci.js} +1 -1
  13. package/dist/docs/video-sources/cli.screenci.js.map +1 -0
  14. package/dist/docs/video-sources/installation.screenci.d.ts +2 -0
  15. package/dist/docs/video-sources/installation.screenci.d.ts.map +1 -0
  16. package/dist/docs/video-sources/{installation.video.js → installation.screenci.js} +1 -1
  17. package/dist/docs/video-sources/installation.screenci.js.map +1 -0
  18. package/dist/docs/video-sources/landing.screenci.d.ts +2 -0
  19. package/dist/docs/video-sources/landing.screenci.d.ts.map +1 -0
  20. package/dist/docs/video-sources/{landing.video.js → landing.screenci.js} +1 -1
  21. package/dist/docs/video-sources/landing.screenci.js.map +1 -0
  22. package/dist/docs/video-sources/locator-highlight-animated.screenci.d.ts +2 -0
  23. package/dist/docs/video-sources/locator-highlight-animated.screenci.d.ts.map +1 -0
  24. package/dist/docs/video-sources/locator-highlight-animated.screenci.js +53 -0
  25. package/dist/docs/video-sources/locator-highlight-animated.screenci.js.map +1 -0
  26. package/dist/docs/video-sources/locator-highlight-still.screenci.d.ts +2 -0
  27. package/dist/docs/video-sources/locator-highlight-still.screenci.d.ts.map +1 -0
  28. package/dist/docs/video-sources/locator-highlight-still.screenci.js +24 -0
  29. package/dist/docs/video-sources/locator-highlight-still.screenci.js.map +1 -0
  30. package/dist/docs/video-sources/product-pitch.screenci.d.ts +2 -0
  31. package/dist/docs/video-sources/product-pitch.screenci.d.ts.map +1 -0
  32. package/dist/docs/video-sources/product-pitch.screenci.js +136 -0
  33. package/dist/docs/video-sources/product-pitch.screenci.js.map +1 -0
  34. package/dist/docs/video-sources/public-urls.screenci.d.ts +2 -0
  35. package/dist/docs/video-sources/public-urls.screenci.d.ts.map +1 -0
  36. package/dist/docs/video-sources/public-urls.screenci.js +34 -0
  37. package/dist/docs/video-sources/public-urls.screenci.js.map +1 -0
  38. package/dist/docs/video-sources/studio.screenci.d.ts +2 -0
  39. package/dist/docs/video-sources/studio.screenci.d.ts.map +1 -0
  40. package/dist/docs/video-sources/studio.screenci.js +57 -0
  41. package/dist/docs/video-sources/studio.screenci.js.map +1 -0
  42. package/dist/docs/videos.d.ts +36 -15
  43. package/dist/docs/videos.d.ts.map +1 -1
  44. package/dist/docs/videos.js +27 -2
  45. package/dist/docs/videos.js.map +1 -1
  46. package/dist/e2e/htmlRasterizer.e2e.d.ts +2 -0
  47. package/dist/e2e/htmlRasterizer.e2e.d.ts.map +1 -0
  48. package/dist/e2e/htmlRasterizer.e2e.js +107 -0
  49. package/dist/e2e/htmlRasterizer.e2e.js.map +1 -0
  50. package/dist/e2e/screenshot.e2e.d.ts +2 -0
  51. package/dist/e2e/screenshot.e2e.d.ts.map +1 -0
  52. package/dist/e2e/screenshot.e2e.js +73 -0
  53. package/dist/e2e/screenshot.e2e.js.map +1 -0
  54. package/dist/index.d.ts +10 -4
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +5 -2
  57. package/dist/index.js.map +1 -1
  58. package/dist/src/asset.d.ts +245 -53
  59. package/dist/src/asset.d.ts.map +1 -1
  60. package/dist/src/asset.js +718 -77
  61. package/dist/src/asset.js.map +1 -1
  62. package/dist/src/audio.d.ts +127 -0
  63. package/dist/src/audio.d.ts.map +1 -0
  64. package/dist/src/audio.js +207 -0
  65. package/dist/src/audio.js.map +1 -0
  66. package/dist/src/config.d.ts.map +1 -1
  67. package/dist/src/config.js +9 -3
  68. package/dist/src/config.js.map +1 -1
  69. package/dist/src/contextOptions.d.ts +48 -0
  70. package/dist/src/contextOptions.d.ts.map +1 -0
  71. package/dist/src/contextOptions.js +71 -0
  72. package/dist/src/contextOptions.js.map +1 -0
  73. package/dist/src/crop.d.ts +93 -0
  74. package/dist/src/crop.d.ts.map +1 -0
  75. package/dist/src/crop.js +133 -0
  76. package/dist/src/crop.js.map +1 -0
  77. package/dist/src/cue.d.ts +17 -4
  78. package/dist/src/cue.d.ts.map +1 -1
  79. package/dist/src/cue.js +31 -3
  80. package/dist/src/cue.js.map +1 -1
  81. package/dist/src/defaults.d.ts +13 -1
  82. package/dist/src/defaults.d.ts.map +1 -1
  83. package/dist/src/defaults.js +13 -0
  84. package/dist/src/defaults.js.map +1 -1
  85. package/dist/src/dimensions.d.ts +6 -0
  86. package/dist/src/dimensions.d.ts.map +1 -1
  87. package/dist/src/dimensions.js +15 -0
  88. package/dist/src/dimensions.js.map +1 -1
  89. package/dist/src/events.d.ts +283 -13
  90. package/dist/src/events.d.ts.map +1 -1
  91. package/dist/src/events.js +134 -5
  92. package/dist/src/events.js.map +1 -1
  93. package/dist/src/htmlRasterizer.d.ts +153 -0
  94. package/dist/src/htmlRasterizer.d.ts.map +1 -0
  95. package/dist/src/htmlRasterizer.js +386 -0
  96. package/dist/src/htmlRasterizer.js.map +1 -0
  97. package/dist/src/init.d.ts +17 -0
  98. package/dist/src/init.d.ts.map +1 -1
  99. package/dist/src/init.js +131 -11
  100. package/dist/src/init.js.map +1 -1
  101. package/dist/src/linkSession.d.ts +36 -0
  102. package/dist/src/linkSession.d.ts.map +1 -0
  103. package/dist/src/linkSession.js +122 -0
  104. package/dist/src/linkSession.js.map +1 -0
  105. package/dist/src/overlayFlush.d.ts +18 -0
  106. package/dist/src/overlayFlush.d.ts.map +1 -0
  107. package/dist/src/overlayFlush.js +56 -0
  108. package/dist/src/overlayFlush.js.map +1 -0
  109. package/dist/src/overlayRect.d.ts +69 -0
  110. package/dist/src/overlayRect.d.ts.map +1 -0
  111. package/dist/src/overlayRect.js +55 -0
  112. package/dist/src/overlayRect.js.map +1 -0
  113. package/dist/src/performance.d.ts +1 -3
  114. package/dist/src/performance.d.ts.map +1 -1
  115. package/dist/src/performance.js +0 -12
  116. package/dist/src/performance.js.map +1 -1
  117. package/dist/src/recording.d.ts +3 -2
  118. package/dist/src/recording.d.ts.map +1 -1
  119. package/dist/src/recordingData.d.ts +22 -2
  120. package/dist/src/recordingData.d.ts.map +1 -1
  121. package/dist/src/runtimeContext.d.ts +57 -1
  122. package/dist/src/runtimeContext.d.ts.map +1 -1
  123. package/dist/src/runtimeContext.js +40 -0
  124. package/dist/src/runtimeContext.js.map +1 -1
  125. package/dist/src/screenshot.d.ts +105 -0
  126. package/dist/src/screenshot.d.ts.map +1 -0
  127. package/dist/src/screenshot.js +188 -0
  128. package/dist/src/screenshot.js.map +1 -0
  129. package/dist/src/stillCapture.d.ts +68 -0
  130. package/dist/src/stillCapture.d.ts.map +1 -0
  131. package/dist/src/stillCapture.js +119 -0
  132. package/dist/src/stillCapture.js.map +1 -0
  133. package/dist/src/studio.d.ts +8 -11
  134. package/dist/src/studio.d.ts.map +1 -1
  135. package/dist/src/studio.js +2 -25
  136. package/dist/src/studio.js.map +1 -1
  137. package/dist/src/types.d.ts +125 -15
  138. package/dist/src/types.d.ts.map +1 -1
  139. package/dist/src/types.js.map +1 -1
  140. package/dist/src/video.d.ts +54 -1
  141. package/dist/src/video.d.ts.map +1 -1
  142. package/dist/src/video.js +190 -42
  143. package/dist/src/video.js.map +1 -1
  144. package/dist/src/voices.d.ts +22 -9
  145. package/dist/src/voices.d.ts.map +1 -1
  146. package/dist/src/voices.js +6 -24
  147. package/dist/src/voices.js.map +1 -1
  148. package/dist/tsconfig.tsbuildinfo +1 -1
  149. package/package.json +16 -2
  150. package/skills/screenci/SKILL.md +37 -15
  151. package/skills/screenci/references/init.md +3 -1
  152. package/skills/screenci/references/record.md +8 -3
  153. package/dist/docs/video-sources/cli.video.d.ts +0 -2
  154. package/dist/docs/video-sources/cli.video.d.ts.map +0 -1
  155. package/dist/docs/video-sources/cli.video.js.map +0 -1
  156. package/dist/docs/video-sources/installation.video.d.ts +0 -2
  157. package/dist/docs/video-sources/installation.video.d.ts.map +0 -1
  158. package/dist/docs/video-sources/installation.video.js.map +0 -1
  159. package/dist/docs/video-sources/landing.video.d.ts +0 -2
  160. package/dist/docs/video-sources/landing.video.d.ts.map +0 -1
  161. package/dist/docs/video-sources/landing.video.js.map +0 -1
package/dist/cli.js CHANGED
@@ -13,33 +13,26 @@ import { determinePackageManager, parsePackageManager, runInit, } from './src/in
13
13
  import { SCREENCI_DISABLE_RECORDING_TIMINGS_ENV, SCREENCI_MOCK_RECORD_ENV, } from './src/runtimeMode.js';
14
14
  import { DEFAULT_RECORD_UPLOAD_POLICY } from './src/defaults.js';
15
15
  import { findDuplicateTitles, formatDuplicateTitlesMessage, } from './src/titleValidation.js';
16
+ import { createLinkSessionSpec, deletePersistedLinkSessionSpec, getCliLinkSessionApiUrl, getDevBackendUrl, getDevFrontendUrl, getLinkSessionFilePath, getScreenCIEnvironment, isStoredLinkSessionReusable, readPersistedLinkSessionSpec, SCREENCI_LINK_SESSION_FILE, writePersistedLinkSessionSpec, } from './src/linkSession.js';
17
+ // Re-export the environment-aware URL helpers so existing importers (and tests)
18
+ // can keep importing them from the CLI entrypoint.
19
+ export { getCliLinkSessionApiUrl, getDevBackendUrl, getDevFrontendUrl };
16
20
  const SCREENCI_MOCK_RECORD_DOCS_URL = 'https://screenci.com/docs/reference/cli/#--mock-record';
17
21
  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';
22
+ // Records the recordId of the most recent `screenci record` upload so
23
+ // `screenci info` can report exactly the run that was just made.
24
+ const SCREENCI_LAST_RECORD_FILE = 'last-record.json';
25
25
  const SCREENCI_LINK_SESSION_POLL_INTERVAL_MS = 2_000;
26
26
  // `record --poll` keeps a non-interactive session (an agent or CI) waiting for
27
27
  // sign-in. We poll on a slower cadence than the interactive loop so a long wait
28
28
  // for a human to click the link does not hammer the backend.
29
29
  const SCREENCI_LINK_SESSION_POLL_FLAG_INTERVAL_MS = 5_000;
30
+ // `record --poll-auth` does not wait forever: after this long without a
31
+ // completed sign-in we stop polling and exit cleanly so an agent or CI step does
32
+ // not hang indefinitely. The link stays valid, so the command can be rerun. The
33
+ // default can be overridden with SCREENCI_POLL_AUTH_TIMEOUT_MS (milliseconds).
34
+ const SCREENCI_LINK_SESSION_POLL_FLAG_TIMEOUT_MS = 5 * 60 * 1_000;
30
35
  const require = createRequire(import.meta.url);
31
- function parseScreenCIEnvironment(value) {
32
- if (value === undefined)
33
- return undefined;
34
- if (SCREENCI_ENVIRONMENT_OPTION_VALUES.includes(value)) {
35
- return value;
36
- }
37
- throw new Error(`Invalid ${SCREENCI_ENVIRONMENT_VARIABLE} "${value}". Expected one of: ${SCREENCI_ENVIRONMENT_OPTION_VALUES.join(', ')}`);
38
- }
39
- function getScreenCIEnvironment() {
40
- const parsed = parseScreenCIEnvironment(process.env[SCREENCI_ENVIRONMENT_VARIABLE]);
41
- return parsed ?? 'prod';
42
- }
43
36
  /**
44
37
  * Reports whether the current session can complete an interactive browser
45
38
  * sign-in. A session is interactive only when both stdin and stdout are
@@ -221,25 +214,6 @@ function contentTypeForPath(filePath) {
221
214
  };
222
215
  return contentTypeMap[ext] ?? 'application/octet-stream';
223
216
  }
224
- /**
225
- * One-line summary of Studio overrides for CLI output, e.g.
226
- * `recording.size (1 → 0.8), narration "intro" (en)`.
227
- */
228
- export function formatStudioChangeSummary(changes) {
229
- return changes
230
- .map((change) => {
231
- const label = change.label ?? 'selection';
232
- if (change.kind === 'narration') {
233
- return change.language !== undefined
234
- ? `${label} (${change.language})`
235
- : label;
236
- }
237
- return change.from !== undefined && change.to !== undefined
238
- ? `${label} (${change.from} → ${change.to})`
239
- : label;
240
- })
241
- .join(', ');
242
- }
243
217
  export function formatStudioUrl(appUrl, projectId, videoId) {
244
218
  return `${appUrl}/project/${projectId}/video/${videoId}/studio`;
245
219
  }
@@ -368,7 +342,15 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
368
342
  let plan = null;
369
343
  try {
370
344
  uploadAbort.throwIfAborted();
371
- const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
345
+ // A screenshot recording uploads its raw page capture (always a PNG) through
346
+ // the same recording endpoint a video uses; the renderer reads those bytes as
347
+ // the capture. Videos upload recording.mp4. Output kind defaults to 'video'.
348
+ const isScreenshot = data.output === 'screenshot';
349
+ const recordingFileName = isScreenshot
350
+ ? (data.screenshot?.path ?? 'screenshot.png')
351
+ : 'recording.mp4';
352
+ const recordingContentType = isScreenshot ? 'image/png' : 'video/mp4';
353
+ const recordingPath = resolve(screenciDir, entry, recordingFileName);
372
354
  if (!existsSync(recordingPath)) {
373
355
  progressReporter.complete(progressIndex, 'failure');
374
356
  return {
@@ -376,7 +358,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
376
358
  videoId: null,
377
359
  hadFailure: true,
378
360
  videoName,
379
- failureMessage: `Missing recording.mp4 for "${videoName}"`,
361
+ failureMessage: `Missing ${recordingFileName} for "${videoName}"`,
380
362
  recordId,
381
363
  };
382
364
  }
@@ -434,7 +416,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
434
416
  uploadAbort.throwIfAborted();
435
417
  const fileStat = await stat(recordingPath);
436
418
  if (verbose) {
437
- logger.info(`Uploading recording.mp4 size=${(fileStat.size / 1024 / 1024).toFixed(1)}MB`);
419
+ logger.info(`Uploading ${recordingFileName} size=${(fileStat.size / 1024 / 1024).toFixed(1)}MB`);
438
420
  }
439
421
  const recordingResponse = await withUploadRetry(async () => {
440
422
  const stream = createReadStream(recordingPath);
@@ -446,7 +428,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
446
428
  return await fetch(`${apiUrl}/cli/upload/${recordingId}/recording`, {
447
429
  method: 'PUT',
448
430
  headers: {
449
- 'Content-Type': 'video/mp4',
431
+ 'Content-Type': recordingContentType,
450
432
  'Content-Length': String(fileStat.size),
451
433
  'X-ScreenCI-Secret': secret,
452
434
  ...(elevenLabsApiKey
@@ -1201,43 +1183,6 @@ async function countCompletedRecordings(screenciDir) {
1201
1183
  }
1202
1184
  return entries.filter((entry) => existsSync(resolve(screenciDir, entry, 'data.json'))).length;
1203
1185
  }
1204
- export function getDevBackendUrl() {
1205
- switch (getScreenCIEnvironment()) {
1206
- case 'local': {
1207
- const devBackendPort = process.env.DEV_BACKEND_PORT;
1208
- return devBackendPort
1209
- ? `http://localhost:${devBackendPort}`
1210
- : 'http://localhost:8787';
1211
- }
1212
- case 'dev':
1213
- return SCREENCI_DEVELOPMENT_BACKEND_URL;
1214
- case 'prod':
1215
- return SCREENCI_PRODUCTION_BACKEND_URL;
1216
- }
1217
- }
1218
- export function getDevFrontendUrl() {
1219
- switch (getScreenCIEnvironment()) {
1220
- case 'local': {
1221
- const devFrontendPort = process.env.DEV_FRONTEND_PORT;
1222
- return devFrontendPort
1223
- ? `http://localhost:${devFrontendPort}`
1224
- : 'http://localhost:5173';
1225
- }
1226
- case 'dev':
1227
- return SCREENCI_DEVELOPMENT_FRONTEND_URL;
1228
- case 'prod':
1229
- return SCREENCI_PRODUCTION_FRONTEND_URL;
1230
- }
1231
- }
1232
- export function getCliLinkSessionApiUrl() {
1233
- // The `/cli-link/session` routes are Convex HTTP actions served from the same
1234
- // backend host as the `/cli/*` upload endpoints. We hit that host directly
1235
- // rather than the frontend: the frontend only forwards these routes via the
1236
- // vite dev-server proxy, which does not exist on the hosted dev/prod frontend
1237
- // (a POST there returns 405). The CLI runs under Node, so there is no CORS
1238
- // constraint that would require going through the frontend origin.
1239
- return getDevBackendUrl();
1240
- }
1241
1186
  function getScreenCISecretsUrl() {
1242
1187
  return `${getDevFrontendUrl()}/secrets`;
1243
1188
  }
@@ -1463,25 +1408,6 @@ async function requireScreenCISecret(configPath, opts = {}) {
1463
1408
  apiUrl: getDevBackendUrl(),
1464
1409
  };
1465
1410
  }
1466
- async function fetchProjectInfo(configPath) {
1467
- const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath, { interactive: detectInteractiveSession() });
1468
- const url = new URL(`${apiUrl}/cli/project-info`);
1469
- url.searchParams.set('projectName', screenciConfig.projectName);
1470
- const res = await fetch(url.toString(), {
1471
- headers: {
1472
- 'X-ScreenCI-Secret': secret,
1473
- },
1474
- });
1475
- if (!res.ok) {
1476
- const text = await res.text();
1477
- throw new Error(`Failed to fetch project info: ${res.status} ${text}${hint401(res.status, secret)}`);
1478
- }
1479
- return (await res.json());
1480
- }
1481
- async function printProjectInfo(configPath) {
1482
- const info = await fetchProjectInfo(configPath);
1483
- process.stdout.write(`${JSON.stringify(info, null, 2)}\n`);
1484
- }
1485
1411
  async function updateVideoVisibility(videoId, isPublic, configPath) {
1486
1412
  const { secret, apiUrl } = await requireScreenCISecret(configPath, {
1487
1413
  interactive: detectInteractiveSession(),
@@ -1499,6 +1425,62 @@ async function updateVideoVisibility(videoId, isPublic, configPath) {
1499
1425
  }
1500
1426
  logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`);
1501
1427
  }
1428
+ function getLastRecordFilePath(screenciDir) {
1429
+ return resolve(screenciDir, SCREENCI_LAST_RECORD_FILE);
1430
+ }
1431
+ /**
1432
+ * Persists the recordId of the just-completed `screenci record` upload so a
1433
+ * later `screenci info` can report exactly that run. Best-effort: a
1434
+ * failure to write must not fail the record command.
1435
+ */
1436
+ async function saveLastRecordId(screenciDir, recordId) {
1437
+ try {
1438
+ mkdirSync(screenciDir, { recursive: true });
1439
+ await writeFile(getLastRecordFilePath(screenciDir), `${JSON.stringify({ recordId, savedAt: new Date().toISOString() }, null, 2)}\n`);
1440
+ }
1441
+ catch (err) {
1442
+ logger.warn(`Failed to record run id for info: ${err instanceof Error ? err.message : String(err)}`);
1443
+ }
1444
+ }
1445
+ async function readLastRecordId(screenciDir) {
1446
+ try {
1447
+ const raw = await readFile(getLastRecordFilePath(screenciDir), 'utf-8');
1448
+ const parsed = JSON.parse(raw);
1449
+ return typeof parsed.recordId === 'string' ? parsed.recordId : null;
1450
+ }
1451
+ catch (err) {
1452
+ if (!isMissingFileError(err)) {
1453
+ logger.warn(`Ignoring invalid stored record at ${getLastRecordFilePath(screenciDir)}.`);
1454
+ }
1455
+ return null;
1456
+ }
1457
+ }
1458
+ // `screenci info` prints every project video and its public URLs as JSON. When
1459
+ // this machine has recorded a run (a recordId is stored in
1460
+ // .screenci/last-record.json), the backend also attaches, to the videos from
1461
+ // that run, a per-language `latestRecord` with render status and record-pinned
1462
+ // URLs. Without a local run, only the project-wide listing with `static` URLs is
1463
+ // returned. The server does the merge; the CLI just passes the recordId.
1464
+ async function printInfo(configPath) {
1465
+ const { resolvedConfigPath, screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath, {
1466
+ interactive: detectInteractiveSession(),
1467
+ });
1468
+ const screenciDir = resolve(dirname(resolvedConfigPath), '.screenci');
1469
+ const recordId = await readLastRecordId(screenciDir);
1470
+ const url = new URL(`${apiUrl}/cli/info`);
1471
+ url.searchParams.set('projectName', screenciConfig.projectName);
1472
+ if (recordId)
1473
+ url.searchParams.set('record', recordId);
1474
+ const res = await fetch(url.toString(), {
1475
+ headers: { 'X-ScreenCI-Secret': secret },
1476
+ });
1477
+ if (!res.ok) {
1478
+ const text = await res.text();
1479
+ throw new Error(`Failed to fetch info: ${res.status} ${text}${hint401(res.status, secret)}`);
1480
+ }
1481
+ const data = await res.json();
1482
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
1483
+ }
1502
1484
  async function persistScreenCISecret(envFilePath, secret) {
1503
1485
  const nextLine = `SCREENCI_SECRET=${secret}`;
1504
1486
  try {
@@ -1525,57 +1507,6 @@ async function persistScreenCISecret(envFilePath, secret) {
1525
1507
  }
1526
1508
  await writeFile(envFilePath, `${nextLine}\n`);
1527
1509
  }
1528
- function getLinkSessionFilePath(projectDir) {
1529
- return resolve(projectDir, '.screenci', SCREENCI_LINK_SESSION_FILE);
1530
- }
1531
- async function readPersistedLinkSessionSpec(specPath) {
1532
- try {
1533
- const raw = await readFile(specPath, 'utf-8');
1534
- return JSON.parse(raw);
1535
- }
1536
- catch (error) {
1537
- if (!isMissingFileError(error)) {
1538
- logger.warn(`Ignoring invalid stored link session at ${specPath}.`);
1539
- rmSync(specPath, { force: true });
1540
- }
1541
- return null;
1542
- }
1543
- }
1544
- async function writePersistedLinkSessionSpec(specPath, spec) {
1545
- mkdirSync(dirname(specPath), { recursive: true });
1546
- await writeFile(specPath, `${JSON.stringify(spec, null, 2)}\n`);
1547
- }
1548
- function deletePersistedLinkSessionSpec(specPath) {
1549
- rmSync(specPath, { force: true });
1550
- }
1551
- function isStoredLinkSessionReusable(spec, options) {
1552
- return (spec.environment === options.environment &&
1553
- spec.envFilePath === options.envFilePath &&
1554
- spec.resolvedConfigPath === options.resolvedConfigPath &&
1555
- spec.expiresAt > new Date().toISOString());
1556
- }
1557
- async function createLinkSessionSpec(options) {
1558
- const response = await fetch(`${options.apiUrl}/cli-link/session`, {
1559
- method: 'POST',
1560
- });
1561
- if (!response.ok) {
1562
- const text = await response.text();
1563
- throw new Error(`Failed to create link session: ${response.status} ${text}`);
1564
- }
1565
- const body = (await response.json());
1566
- return {
1567
- token: body.token,
1568
- appUrl: `${options.appUrl}/cli-auth?session=${encodeURIComponent(body.token)}`,
1569
- pollUrl: `${options.apiUrl}/cli-link/session?token=${encodeURIComponent(body.token)}`,
1570
- createdAt: body.createdAt,
1571
- expiresAt: body.expiresAt,
1572
- environment: options.environment,
1573
- ...(options.resolvedConfigPath
1574
- ? { resolvedConfigPath: options.resolvedConfigPath }
1575
- : {}),
1576
- envFilePath: options.envFilePath,
1577
- };
1578
- }
1579
1510
  async function pollLinkSessionOnce(spec) {
1580
1511
  const response = await fetch(spec.pollUrl);
1581
1512
  const body = (await response.json());
@@ -1588,7 +1519,16 @@ async function pollLinkSessionOnce(spec) {
1588
1519
  }
1589
1520
  return { status };
1590
1521
  }
1591
- async function pollLinkSession(spec, pollIntervalMs = SCREENCI_LINK_SESSION_POLL_INTERVAL_MS) {
1522
+ function getPollAuthTimeoutMs() {
1523
+ const raw = process.env.SCREENCI_POLL_AUTH_TIMEOUT_MS;
1524
+ if (raw === undefined)
1525
+ return SCREENCI_LINK_SESSION_POLL_FLAG_TIMEOUT_MS;
1526
+ const parsed = Number(raw);
1527
+ return Number.isFinite(parsed) && parsed >= 0
1528
+ ? parsed
1529
+ : SCREENCI_LINK_SESSION_POLL_FLAG_TIMEOUT_MS;
1530
+ }
1531
+ async function pollLinkSession(spec, pollIntervalMs = SCREENCI_LINK_SESSION_POLL_INTERVAL_MS, deadlineEpochMs) {
1592
1532
  for (;;) {
1593
1533
  const result = await pollLinkSessionOnce(spec);
1594
1534
  if (result.status === 'completed' && result.secret) {
@@ -1599,6 +1539,11 @@ async function pollLinkSession(spec, pollIntervalMs = SCREENCI_LINK_SESSION_POLL
1599
1539
  result.status === 'invalid') {
1600
1540
  return result;
1601
1541
  }
1542
+ // Stop before sleeping again once the optional deadline has passed so
1543
+ // `--poll-auth` cannot block forever waiting for a human to sign in.
1544
+ if (deadlineEpochMs !== undefined && Date.now() >= deadlineEpochMs) {
1545
+ return { status: 'timed-out' };
1546
+ }
1602
1547
  await new Promise((resolveDelay) => setTimeout(resolveDelay, pollIntervalMs));
1603
1548
  }
1604
1549
  }
@@ -1672,14 +1617,24 @@ export async function ensureScreenciSecret(resolvedConfigPath, opts = {}) {
1672
1617
  // a slow cadence until the human finishes signing in, and continue
1673
1618
  // recording automatically once the secret lands. We re-print the link
1674
1619
  // on every (re)created session so the latest valid link is always
1675
- // visible, including after a stale session is recreated.
1620
+ // visible, including after a stale session is recreated. We do not wait
1621
+ // forever: after a default timeout we stop polling and exit cleanly so
1622
+ // an agent or CI step does not hang. The link stays valid for a rerun.
1623
+ const timeoutMs = getPollAuthTimeoutMs();
1624
+ const timeoutMinutes = Math.round(timeoutMs / 60_000);
1625
+ const deadlineEpochMs = Date.now() + timeoutMs;
1676
1626
  for (;;) {
1677
1627
  logger.info(`Sign-in required to record. Open this link to sign in:\n${pc.cyan(spec.appUrl)}\n` +
1678
- `Waiting for sign-in (checking every 5 seconds). Recording continues automatically once you finish.`);
1679
- const polled = await pollLinkSession(spec, SCREENCI_LINK_SESSION_POLL_FLAG_INTERVAL_MS);
1628
+ `Waiting for sign-in (checking every 5 seconds, up to ${timeoutMinutes} minutes). Recording continues automatically once you finish.`);
1629
+ const polled = await pollLinkSession(spec, SCREENCI_LINK_SESSION_POLL_FLAG_INTERVAL_MS, deadlineEpochMs);
1680
1630
  if (polled.status === 'completed' && polled.secret) {
1681
1631
  return await saveCompletedSecret(polled.secret);
1682
1632
  }
1633
+ if (polled.status === 'timed-out' || Date.now() >= deadlineEpochMs) {
1634
+ logger.info(`Timed out after ${timeoutMinutes} minutes waiting for sign-in. The link is still valid:\n${pc.cyan(spec.appUrl)}\n` +
1635
+ `After signing in, rerun ${pc.cyan(getSuggestedScreenciCommand('record'))} to continue, or ${pc.cyan(getSuggestedScreenciCommand('record', '--poll-auth'))} to wait again.`);
1636
+ return undefined;
1637
+ }
1683
1638
  deletePersistedLinkSessionSpec(specPath);
1684
1639
  spec = await ensureSpec();
1685
1640
  }
@@ -1721,7 +1676,7 @@ export async function main() {
1721
1676
  .command('record [playwrightArgs...]')
1722
1677
  .description('Record videos using Playwright')
1723
1678
  .option('-v, --verbose', 'verbose output')
1724
- .option('--poll-auth', 'wait for sign-in to complete (polling every 5s) instead of exiting, then continue recording')
1679
+ .option('--poll-auth', 'wait for sign-in to complete (polling every 5s, up to 5 minutes) instead of exiting, then continue recording')
1725
1680
  .allowUnknownOption(true)
1726
1681
  .action(async () => {
1727
1682
  const parsed = parseRecordCliArgs(getSubcommandArgv('record'));
@@ -1792,6 +1747,10 @@ export async function main() {
1792
1747
  throw err;
1793
1748
  }
1794
1749
  const { projectId, recordId, hadFailures, failedVideoNames, failedVideoMessages, studioNotices, plan, } = uploadResult;
1750
+ // Remember this run so `screenci info` can report exactly it.
1751
+ if (recordId !== null) {
1752
+ await saveLastRecordId(screenciDir, recordId);
1753
+ }
1795
1754
  if (recordId !== null && projectId !== null) {
1796
1755
  const recordUrl = `${appUrl}/record/${recordId}`;
1797
1756
  await writeGitHubProjectOutput(recordUrl);
@@ -1823,9 +1782,9 @@ export async function main() {
1823
1782
  logger.info(pc.cyan(formatStudioUrl(appUrl, projectId, notice.videoId)));
1824
1783
  }
1825
1784
  }
1826
- else if (notice.studio.appliedChanges.length > 0) {
1785
+ else if (notice.studio.applied) {
1827
1786
  logger.info('');
1828
- logger.info(`Selections were overridden in Studio for "${notice.videoName}": ${formatStudioChangeSummary(notice.studio.appliedChanges)}`);
1787
+ logger.info(`Studio configuration applied for "${notice.videoName}".`);
1829
1788
  }
1830
1789
  }
1831
1790
  if (hadFailures) {
@@ -1884,10 +1843,10 @@ export async function main() {
1884
1843
  });
1885
1844
  program
1886
1845
  .command('info')
1887
- .description('Print remote project info as JSON')
1846
+ .description("Print the latest record run's video URLs and render status as JSON")
1888
1847
  .option('-c, --config <path>', 'path to screenci.config.ts')
1889
1848
  .action(async (options) => {
1890
- await printProjectInfo(options['config']);
1849
+ await printInfo(options['config']);
1891
1850
  });
1892
1851
  program
1893
1852
  .command('make-public <id>')