@reshotdev/screenshot 0.0.1-beta.13 → 0.0.1-beta.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reshotdev/screenshot",
3
- "version": "0.0.1-beta.13",
3
+ "version": "0.0.1-beta.14",
4
4
  "description": "Screenshot and video capture CLI",
5
5
  "author": "Reshot <hello@reshot.dev>",
6
6
  "license": "MIT",
@@ -22,7 +22,7 @@
22
22
  "playwright"
23
23
  ],
24
24
  "bin": {
25
- "reshot": "./src/index.js"
25
+ "reshot": "src/index.js"
26
26
  },
27
27
  "files": [
28
28
  "src/",
@@ -72,4 +72,4 @@
72
72
  "build": "pnpm run ui:build",
73
73
  "pack:check": "npm pack --dry-run"
74
74
  }
75
- }
75
+ }
@@ -52,6 +52,13 @@ async function doctorReleaseCommand(options = {}) {
52
52
  }
53
53
  }
54
54
 
55
+ const advisories = report.summary?.advisories || [];
56
+ if (advisories.length > 0) {
57
+ for (const advisory of advisories.slice(0, 10)) {
58
+ console.log(chalk.yellow(` ⚠ ${advisory.scope}: ${advisory.message}`));
59
+ }
60
+ }
61
+
55
62
  if (report.reportPath) {
56
63
  console.log(chalk.gray(`\n Report: ${report.reportPath}`));
57
64
  }
@@ -595,6 +595,11 @@ function buildPublishMetadata({
595
595
  }) {
596
596
  const scenarioDefinition = buildScenarioDefinition(scenarioConfig);
597
597
 
598
+ // Only attach git metadata when a real commit hash is available. Sending
599
+ // empty git values when the repo has no HEAD causes the platform to reject
600
+ // the batch with an opaque 400.
601
+ const hasGit = !!(gitInfo && gitInfo.commitHash);
602
+
598
603
  return {
599
604
  projectId,
600
605
  publishSessionId, // Unique ID for this CLI publish run
@@ -609,10 +614,14 @@ function buildPublishMetadata({
609
614
  publish: {
610
615
  autoApprove,
611
616
  },
612
- git: {
613
- commitHash: gitInfo.commitHash,
614
- commitMessage: gitInfo.commitMessage,
615
- },
617
+ ...(hasGit
618
+ ? {
619
+ git: {
620
+ commitHash: gitInfo.commitHash,
621
+ commitMessage: gitInfo.commitMessage,
622
+ },
623
+ }
624
+ : {}),
616
625
  cli: {
617
626
  version: pkg.version,
618
627
  captureTimestamp: new Date().toISOString(),
@@ -1285,14 +1294,28 @@ function getGitInfo() {
1285
1294
  try {
1286
1295
  const commitHash = execSync("git rev-parse HEAD", {
1287
1296
  encoding: "utf-8",
1297
+ stdio: ["ignore", "pipe", "ignore"],
1288
1298
  }).trim();
1289
1299
  const commitMessage = execSync("git log -1 --pretty=%B", {
1290
1300
  encoding: "utf-8",
1301
+ stdio: ["ignore", "pipe", "ignore"],
1291
1302
  }).trim();
1292
- return { commitHash, commitMessage };
1303
+ return { commitHash, commitMessage, hasCommit: !!commitHash };
1293
1304
  } catch (error) {
1294
- console.warn(chalk.yellow(" ⚠ Could not read git information"));
1295
- return { commitHash: "", commitMessage: "" };
1305
+ // No git HEAD — either not a git repo or a brand-new repo with no commits.
1306
+ // Proceed WITHOUT git metadata rather than sending empty values that the
1307
+ // platform rejects with an opaque 400 "Batch request failed".
1308
+ console.log(
1309
+ chalk.yellow(
1310
+ " ⚠ No git commit found — publishing without commit metadata.",
1311
+ ),
1312
+ );
1313
+ console.log(
1314
+ chalk.gray(
1315
+ " Tip: run `git commit` first to attach commit info to this publish.",
1316
+ ),
1317
+ );
1318
+ return { commitHash: "", commitMessage: "", hasCommit: false };
1296
1319
  }
1297
1320
  }
1298
1321
 
@@ -1427,9 +1450,23 @@ async function publishCommand(options = {}) {
1427
1450
  };
1428
1451
 
1429
1452
  if (!releaseDoctor.ok) {
1430
- console.log(chalk.red(" ✖ Release doctor failed. Fix the reported issues before publishing."));
1453
+ console.log(chalk.red(" ✖ Release doctor failed. Fix the issues below before publishing:\n"));
1454
+ const blockingIssues = releaseDoctor.summary?.blockingIssues || [];
1455
+ if (blockingIssues.length > 0) {
1456
+ for (const issue of blockingIssues) {
1457
+ const scope = issue.scope ? `${issue.scope}: ` : "";
1458
+ console.log(chalk.red(` ✖ ${scope}${issue.message}`));
1459
+ }
1460
+ } else {
1461
+ console.log(chalk.red(" ✖ Release gate checks failed (see report for details)."));
1462
+ }
1463
+ const advisories = releaseDoctor.summary?.advisories || [];
1464
+ for (const advisory of advisories) {
1465
+ const scope = advisory.scope ? `${advisory.scope}: ` : "";
1466
+ console.log(chalk.yellow(` ⚠ ${scope}${advisory.message}`));
1467
+ }
1431
1468
  if (releaseDoctor.reportPath) {
1432
- console.log(chalk.gray(` Report: ${releaseDoctor.reportPath}`));
1469
+ console.log(chalk.gray(`\n Full report: ${releaseDoctor.reportPath}`));
1433
1470
  }
1434
1471
  if (!noExit) process.exit(1);
1435
1472
  return {
@@ -7,6 +7,16 @@ const fs = require("fs-extra");
7
7
  const path = require("path");
8
8
  const config = require("../lib/config");
9
9
  const { normalizeConfigContract } = require("../lib/target-contract");
10
+ const { detectCI } = require("../lib/ci-detect");
11
+
12
+ /**
13
+ * Whether the current process can prompt the user interactively.
14
+ * False in CI or when stdin is not a TTY (piped / redirected), where an
15
+ * inquirer prompt would throw `ERR_USE_AFTER_CLOSE: readline`.
16
+ */
17
+ function isInteractive() {
18
+ return !!process.stdin.isTTY && !detectCI().isCI;
19
+ }
10
20
 
11
21
  /**
12
22
  * Detect if this is a Git repository and if it's GitHub
@@ -545,7 +555,18 @@ async function setupWizard(options = {}) {
545
555
  return;
546
556
  }
547
557
 
548
- // Offer to launch studio
558
+ // Offer to launch studio. In non-interactive environments (CI, piped stdin)
559
+ // an inquirer prompt throws `ERR_USE_AFTER_CLOSE: readline`, so skip the
560
+ // prompt and auto-answer the safe default (do NOT launch Studio).
561
+ if (!isInteractive()) {
562
+ console.log(
563
+ chalk.gray(
564
+ "Non-interactive environment detected — skipping Studio launch. Run `reshot studio` when you want the local UI.\n",
565
+ ),
566
+ );
567
+ return;
568
+ }
569
+
549
570
  const { launchStudio } = await inquirer.prompt([
550
571
  {
551
572
  type: "confirm",
package/src/index.js CHANGED
@@ -35,7 +35,9 @@ program
35
35
  .action(async (options) => {
36
36
  try {
37
37
  const setupWizard = require("./commands/setup-wizard");
38
- await setupWizard(options);
38
+ // Commander stores `--no-studio` as `options.studio === false`, not
39
+ // `options.noStudio`. Normalize it so the flag is honored end-to-end.
40
+ await setupWizard({ ...options, noStudio: options.studio === false });
39
41
  } catch (error) {
40
42
  console.error(chalk.red("Error:"), error.message);
41
43
  process.exit(1);
@@ -6,6 +6,7 @@ const path = require("path");
6
6
  const fs = require("fs-extra");
7
7
  const chalk = require("chalk");
8
8
  const { buildLaunchOptions } = require("./ci-detect");
9
+ const { launchChromium } = require("./ensure-browser");
9
10
  const {
10
11
  applyVariantToPage,
11
12
  applyStorageAndReload,
@@ -299,9 +300,9 @@ class CaptureEngine {
299
300
 
300
301
  const contextOptions = this._buildContextOptions();
301
302
 
302
- this.browser = await chromium.launch(buildLaunchOptions({
303
+ this.browser = await launchChromium(chromium, buildLaunchOptions({
303
304
  headless: this.headless,
304
- }));
305
+ }), this.logger);
305
306
  this.context = await this.browser.newContext(contextOptions);
306
307
  this.page = await this.context.newPage();
307
308
 
@@ -1106,8 +1106,10 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
1106
1106
  // Malformed JSON — fall through and treat as non-empty so the warning still fires.
1107
1107
  }
1108
1108
  }
1109
- if (hasSession && !sessionIsEmpty) {
1110
- // Validate session freshness with graduated warnings
1109
+ if (hasSession && !sessionIsEmpty && scenario.requiresAuth) {
1110
+ // Validate session freshness with graduated warnings.
1111
+ // Only relevant when this scenario actually requires auth — a leftover
1112
+ // session file should not trigger staleness warnings for public scenarios.
1111
1113
  const sessionStats = fs.statSync(sessionPath);
1112
1114
  const sessionAgeHours =
1113
1115
  (Date.now() - sessionStats.mtimeMs) / (1000 * 60 * 60);
@@ -1938,8 +1940,10 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1938
1940
  // Check for saved session state (auth cookies) - CRITICAL for authenticated scenarios
1939
1941
  const sessionPath = getDefaultSessionPath();
1940
1942
  const hasSession = fs.existsSync(sessionPath);
1941
- if (hasSession) {
1942
- // Validate session freshness
1943
+ if (hasSession && scenario.requiresAuth) {
1944
+ // Validate session freshness. Only relevant when this scenario actually
1945
+ // requires auth — a leftover session file should not trigger staleness
1946
+ // warnings for public scenarios.
1943
1947
  const sessionStats = fs.statSync(sessionPath);
1944
1948
  const sessionAgeHours = (Date.now() - sessionStats.mtimeMs) / (1000 * 60 * 60);
1945
1949
  if (sessionAgeHours > 24) {
@@ -1952,6 +1956,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1952
1956
  }
1953
1957
 
1954
1958
  const { chromium } = require("playwright");
1959
+ const { launchChromium } = require("./ensure-browser");
1955
1960
  // Use a unique temp directory for this recording to avoid conflicts
1956
1961
  const recordingId = `recording-${Date.now()}-${Math.random()
1957
1962
  .toString(36)
@@ -1978,7 +1983,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1978
1983
  debug("Launching browser...");
1979
1984
 
1980
1985
  // Launch browser with video recording
1981
- browser = await chromium.launch(buildLaunchOptions({ headless }));
1986
+ browser = await launchChromium(chromium, buildLaunchOptions({ headless }));
1982
1987
  debug("Browser launched successfully");
1983
1988
 
1984
1989
  // Build context options with variant support using universal injector
@@ -0,0 +1,147 @@
1
+ // ensure-browser.js - Guarantees the correct Playwright browser build is present
2
+ //
3
+ // The CLI bundles a specific version of Playwright, which is pinned to an exact
4
+ // browser build. Telling users to run a bare `npx playwright install` can resolve
5
+ // a DIFFERENT Playwright version (and therefore a different browser build),
6
+ // leaving the bundled launcher unable to find its executable. To make the build
7
+ // match 1:1, we drive the BUNDLED Playwright's own installer.
8
+
9
+ const path = require("path");
10
+ const fs = require("fs");
11
+ const { spawnSync } = require("child_process");
12
+
13
+ // Matches Playwright's "missing executable" launch error.
14
+ const MISSING_EXECUTABLE_RE = /Executable doesn't exist|please run the following command to download new browsers|browserType\.launch.*Executable/i;
15
+
16
+ let installAttempted = false;
17
+
18
+ /**
19
+ * Resolve the bundled Playwright's CLI entrypoint and version.
20
+ * We resolve relative to the package.json so the path matches whatever
21
+ * Playwright build this CLI actually depends on.
22
+ * @returns {{ cliPath: string|null, version: string|null }}
23
+ */
24
+ function resolveBundledPlaywright() {
25
+ for (const pkg of ["playwright", "playwright-core"]) {
26
+ try {
27
+ const pkgJsonPath = require.resolve(`${pkg}/package.json`);
28
+ const cliPath = path.join(path.dirname(pkgJsonPath), "cli.js");
29
+ if (fs.existsSync(cliPath)) {
30
+ let version = null;
31
+ try {
32
+ version = require(pkgJsonPath).version;
33
+ } catch (_) {
34
+ /* ignore */
35
+ }
36
+ return { cliPath, version, pkg };
37
+ }
38
+ } catch (_) {
39
+ // try next package name
40
+ }
41
+ }
42
+ return { cliPath: null, version: null, pkg: null };
43
+ }
44
+
45
+ /**
46
+ * Build the EXACT install command that matches the bundled Playwright build.
47
+ * Used both to run the install and as the fallback message shown to the user.
48
+ * @returns {string}
49
+ */
50
+ function getInstallCommandString() {
51
+ const { cliPath } = resolveBundledPlaywright();
52
+ if (cliPath) {
53
+ return `node "${cliPath}" install chromium`;
54
+ }
55
+ return "npx playwright install chromium";
56
+ }
57
+
58
+ /**
59
+ * Install the chromium browser using the BUNDLED Playwright's own installer,
60
+ * so the browser build can never mismatch the bundled Playwright version.
61
+ * @param {(msg: string) => void} logger
62
+ * @returns {boolean} whether the install command ran successfully
63
+ */
64
+ function installBundledChromium(logger = console.log) {
65
+ const { cliPath, version } = resolveBundledPlaywright();
66
+ if (!cliPath) {
67
+ return false;
68
+ }
69
+
70
+ logger(
71
+ `\n⬇️ Installing the Chromium build for Playwright${
72
+ version ? ` ${version}` : ""
73
+ } (one-time setup)...`
74
+ );
75
+
76
+ // Install both the headless shell and full chromium so any launch path works.
77
+ const result = spawnSync(
78
+ process.execPath,
79
+ [cliPath, "install", "chromium", "chromium-headless-shell"],
80
+ { stdio: "inherit" }
81
+ );
82
+
83
+ if (result.error || result.status !== 0) {
84
+ logger(
85
+ `\n⚠ Automatic browser install failed. Please run this command manually:\n ${getInstallCommandString()}\n`
86
+ );
87
+ return false;
88
+ }
89
+
90
+ return true;
91
+ }
92
+
93
+ /**
94
+ * Determine whether an error is Playwright's "missing browser executable" error.
95
+ * @param {Error} err
96
+ * @returns {boolean}
97
+ */
98
+ function isMissingExecutableError(err) {
99
+ return !!err && MISSING_EXECUTABLE_RE.test(err.message || "");
100
+ }
101
+
102
+ /**
103
+ * Launch chromium, auto-installing the matching browser build on first run if
104
+ * the executable is missing. Retries the launch exactly once after installing.
105
+ *
106
+ * @param {import('playwright').BrowserType} chromium - the bundled chromium type
107
+ * @param {Object} launchOptions - options passed to chromium.launch()
108
+ * @param {(msg: string) => void} [logger]
109
+ * @returns {Promise<import('playwright').Browser>}
110
+ */
111
+ async function launchChromium(chromium, launchOptions = {}, logger = console.log) {
112
+ try {
113
+ return await chromium.launch(launchOptions);
114
+ } catch (err) {
115
+ if (!isMissingExecutableError(err)) {
116
+ throw err;
117
+ }
118
+
119
+ // Only attempt the auto-install once per process to avoid loops.
120
+ if (installAttempted) {
121
+ const e = new Error(
122
+ `${err.message}\n\nThe Chromium build for this CLI is missing. Run:\n ${getInstallCommandString()}`
123
+ );
124
+ throw e;
125
+ }
126
+ installAttempted = true;
127
+
128
+ const installed = installBundledChromium(logger);
129
+ if (!installed) {
130
+ const e = new Error(
131
+ `${err.message}\n\nThe Chromium build for this CLI is missing. Run:\n ${getInstallCommandString()}`
132
+ );
133
+ throw e;
134
+ }
135
+
136
+ // Retry once now that the matching browser build is installed.
137
+ return await chromium.launch(launchOptions);
138
+ }
139
+ }
140
+
141
+ module.exports = {
142
+ launchChromium,
143
+ installBundledChromium,
144
+ isMissingExecutableError,
145
+ getInstallCommandString,
146
+ resolveBundledPlaywright,
147
+ };
@@ -275,13 +275,21 @@ async function runReleaseDoctor(options = {}) {
275
275
  for (const issue of targetDoctor.summary?.advisories || []) {
276
276
  advisories.push({ scope: "target-doctor", ...issue });
277
277
  }
278
- if (!docsAssetMap.skipped) {
278
+ // A stale/mismatched docs asset map (e.g. src/data/reshot-assets.json left
279
+ // behind by an earlier `reshot pull`) describes a generated artifact, not the
280
+ // config being published. It must NOT hard-block a publish of the current
281
+ // config — surface it as an advisory with a concrete remedy instead.
282
+ if (!docsAssetMap.skipped && !docsAssetMap.ok) {
283
+ const remedy =
284
+ docsAssetMap.path
285
+ ? `Re-run \`reshot pull\` to regenerate it, or delete ${docsAssetMap.path}.`
286
+ : "Re-run `reshot pull` to regenerate it, or delete the stale src/data/reshot-assets.json.";
279
287
  for (const issue of docsAssetMap.issues) {
280
- blockingIssues.push({ scope: "docs-asset-map", message: issue });
288
+ advisories.push({ scope: "docs-asset-map", message: `${issue} ${remedy}` });
281
289
  }
282
290
  }
283
291
 
284
- const ok = preflight.ok && targetDoctor.ok && (docsAssetMap.skipped || docsAssetMap.ok);
292
+ const ok = preflight.ok && targetDoctor.ok;
285
293
  const report = {
286
294
  type: "ReleaseDoctorReport",
287
295
  stage: "doctor-release",