heroshot 0.0.5 → 0.0.6

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,17 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { existsSync as existsSync3, rmSync } from "fs";
4
+ import { existsSync as existsSync4, readFileSync as readFileSync4, rmSync } from "fs";
5
+ import path5 from "path";
5
6
  import { Command } from "commander";
6
7
 
7
8
  // src/browser.ts
8
- import { readFileSync as readFileSync2 } from "fs";
9
- import { homedir } from "os";
10
- import path2 from "path";
11
- import { chromium } from "playwright";
9
+ import { readFileSync as readFileSync3 } from "fs";
10
+ import path3 from "path";
11
+ import {
12
+ chromium
13
+ } from "playwright";
12
14
 
13
15
  // src/configFile.ts
14
- import { existsSync, readFileSync, writeFileSync } from "fs";
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
15
17
  import path from "path";
16
18
 
17
19
  // src/schema.ts
@@ -48,11 +50,13 @@ var screenshotSchema = z.object({
48
50
  });
49
51
  var browserSchema = z.object({
50
52
  viewport: viewportSchema.optional(),
51
- colorScheme: colorSchemeSchema.optional()
53
+ colorScheme: colorSchemeSchema.optional(),
54
+ /** Device scale factor for retina/high-DPI screenshots (1 = standard, 2 = retina) */
55
+ deviceScaleFactor: z.number().min(1).max(3).optional()
52
56
  });
53
57
  var configSchema = z.object({
54
58
  /** Output directory for screenshots (relative to config file) */
55
- outputDirectory: z.string().default("."),
59
+ outputDirectory: z.string().default("heroshots"),
56
60
  /** Output format for screenshots (png or jpeg) */
57
61
  outputFormat: outputFormatSchema.optional(),
58
62
  /** JPEG quality (1-100), only used when outputFormat is 'jpeg' */
@@ -69,9 +73,52 @@ function parseConfig(input) {
69
73
  }
70
74
 
71
75
  // src/configFile.ts
72
- var CONFIG_FILENAME = "heroshot.json";
76
+ var HEROSHOT_DIRECTORY_NAME = ".heroshot";
77
+ var CONFIG_FILENAME = "config.json";
78
+ function getHeroshotDirectory(directory = process.cwd()) {
79
+ return path.join(directory, HEROSHOT_DIRECTORY_NAME);
80
+ }
81
+ var README_CONTENT = `# Heroshot
82
+
83
+ This folder contains heroshot configuration and encrypted session data.
84
+
85
+ ## Files
86
+
87
+ - \`config.json\` - Screenshot definitions (URLs, selectors, output settings)
88
+ - \`session.enc\` - Encrypted browser session (cookies, localStorage)
89
+
90
+ ## Safe to commit
91
+
92
+ This folder is safe to commit to source control:
93
+
94
+ - \`config.json\` contains no secrets
95
+ - \`session.enc\` is encrypted with AES-256-GCM
96
+
97
+ ## CI/CD Usage
98
+
99
+ To use heroshot in CI, add your session key as a secret:
100
+
101
+ \`\`\`yaml
102
+ - run: npx heroshot --session-key=\${{ secrets.HEROSHOT_SESSION_KEY }}
103
+ \`\`\`
104
+
105
+ To get your session key, run: \`heroshot session-key\`
106
+
107
+ Learn more: https://heroshot.sh/docs
108
+ `;
109
+ function ensureHeroshotDirectory(directory = process.cwd()) {
110
+ const heroshotPath = getHeroshotDirectory(directory);
111
+ const readmePath = path.join(heroshotPath, "README.md");
112
+ if (!existsSync(heroshotPath)) {
113
+ mkdirSync(heroshotPath, { recursive: true });
114
+ writeFileSync(readmePath, README_CONTENT, "utf8");
115
+ } else if (!existsSync(readmePath)) {
116
+ writeFileSync(readmePath, README_CONTENT, "utf8");
117
+ }
118
+ return heroshotPath;
119
+ }
73
120
  function getConfigPath(directory = process.cwd()) {
74
- return path.join(directory, CONFIG_FILENAME);
121
+ return path.join(directory, HEROSHOT_DIRECTORY_NAME, CONFIG_FILENAME);
75
122
  }
76
123
  function loadConfig(configPath) {
77
124
  if (!existsSync(configPath)) {
@@ -82,6 +129,10 @@ function loadConfig(configPath) {
82
129
  return parseConfig(json);
83
130
  }
84
131
  function saveConfig(configPath, config) {
132
+ const parentDirectory = path.dirname(configPath);
133
+ if (!existsSync(parentDirectory)) {
134
+ mkdirSync(parentDirectory, { recursive: true });
135
+ }
85
136
  const content = JSON.stringify(config, null, 2);
86
137
  writeFileSync(configPath, content, "utf8");
87
138
  }
@@ -103,45 +154,156 @@ log.error = (message) => {
103
154
  console.error(message);
104
155
  };
105
156
 
106
- // src/browser.ts
107
- var PROFILE_DIR = path2.join(homedir(), ".heroshot", "browser-profile");
108
- var TOOLBAR_DIR = path2.join(import.meta.dirname, "..", "toolbar");
109
- function getProfilePath() {
110
- return PROFILE_DIR;
157
+ // src/session.ts
158
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from "crypto";
159
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
160
+ import { homedir } from "os";
161
+ import path2 from "path";
162
+ var SESSION_FILENAME = "session.enc";
163
+ var KEYS_DIRECTORY = path2.join(homedir(), ".heroshot", "keys");
164
+ var ALGORITHM = "aes-256-gcm";
165
+ var KEY_LENGTH = 32;
166
+ var IV_LENGTH = 16;
167
+ var AUTH_TAG_LENGTH = 16;
168
+ var SALT_LENGTH = 16;
169
+ var SESSION_KEY_LENGTH = 20;
170
+ function generateSessionKey() {
171
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
172
+ const bytes = randomBytes(SESSION_KEY_LENGTH);
173
+ return [...bytes].map((byte) => alphabet[byte % alphabet.length]).join("");
174
+ }
175
+ function deriveKey(sessionKey, salt) {
176
+ return scryptSync(sessionKey, salt, KEY_LENGTH);
177
+ }
178
+ function encrypt(data, sessionKey) {
179
+ const salt = randomBytes(SALT_LENGTH);
180
+ const key = deriveKey(sessionKey, salt);
181
+ const iv = randomBytes(IV_LENGTH);
182
+ const cipher = createCipheriv(ALGORITHM, key, iv);
183
+ const encrypted = Buffer.concat([cipher.update(data, "utf8"), cipher.final()]);
184
+ const authTag = cipher.getAuthTag();
185
+ return Buffer.concat([salt, iv, authTag, encrypted]);
186
+ }
187
+ function decrypt(encryptedData, sessionKey) {
188
+ const salt = encryptedData.subarray(0, SALT_LENGTH);
189
+ const iv = encryptedData.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
190
+ const authTag = encryptedData.subarray(
191
+ SALT_LENGTH + IV_LENGTH,
192
+ SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH
193
+ );
194
+ const ciphertext = encryptedData.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
195
+ const key = deriveKey(sessionKey, salt);
196
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
197
+ decipher.setAuthTag(authTag);
198
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
199
+ return decrypted.toString("utf8");
200
+ }
201
+ function getProjectId(directory = process.cwd()) {
202
+ const hash = createHash("sha256").update(directory).digest("hex");
203
+ return hash.slice(0, 16);
204
+ }
205
+ function getSessionPath(directory = process.cwd()) {
206
+ return path2.join(getHeroshotDirectory(directory), SESSION_FILENAME);
207
+ }
208
+ function getLocalKeyPath(directory = process.cwd()) {
209
+ const projectId = getProjectId(directory);
210
+ return path2.join(KEYS_DIRECTORY, projectId);
211
+ }
212
+ function saveLocalKey(sessionKey, directory = process.cwd()) {
213
+ if (!existsSync2(KEYS_DIRECTORY)) {
214
+ mkdirSync2(KEYS_DIRECTORY, { recursive: true, mode: 448 });
215
+ }
216
+ const keyPath = getLocalKeyPath(directory);
217
+ writeFileSync2(keyPath, sessionKey, { encoding: "utf8", mode: 384 });
218
+ }
219
+ function loadLocalKey(directory = process.cwd()) {
220
+ const keyPath = getLocalKeyPath(directory);
221
+ if (!existsSync2(keyPath)) {
222
+ return null;
223
+ }
224
+ return readFileSync2(keyPath, "utf8").trim();
225
+ }
226
+ function saveSession(storageState, sessionKey, directory = process.cwd()) {
227
+ const heroshotPath = getHeroshotDirectory(directory);
228
+ if (!existsSync2(heroshotPath)) {
229
+ mkdirSync2(heroshotPath, { recursive: true });
230
+ }
231
+ const json = JSON.stringify(storageState);
232
+ const encrypted = encrypt(json, sessionKey);
233
+ const sessionPath = getSessionPath(directory);
234
+ writeFileSync2(sessionPath, encrypted, { mode: 420 });
235
+ }
236
+ function loadSession(sessionKey, directory = process.cwd()) {
237
+ const sessionPath = getSessionPath(directory);
238
+ if (!existsSync2(sessionPath)) {
239
+ return null;
240
+ }
241
+ try {
242
+ const encrypted = readFileSync2(sessionPath);
243
+ const json = decrypt(encrypted, sessionKey);
244
+ const parsed = JSON.parse(json);
245
+ if (parsed && typeof parsed === "object") {
246
+ return parsed;
247
+ }
248
+ return null;
249
+ } catch {
250
+ return null;
251
+ }
111
252
  }
253
+ function sessionExists(directory = process.cwd()) {
254
+ return existsSync2(getSessionPath(directory));
255
+ }
256
+ function getSessionKey(cliKey, directory = process.cwd()) {
257
+ if (cliKey) {
258
+ return cliKey;
259
+ }
260
+ const environmentKey = process.env["HEROSHOT_SESSION_KEY"];
261
+ if (environmentKey) {
262
+ return environmentKey;
263
+ }
264
+ return loadLocalKey(directory);
265
+ }
266
+
267
+ // src/browser.ts
268
+ var TOOLBAR_DIR = path3.join(import.meta.dirname, "..", "toolbar");
112
269
  var DEFAULT_VIEWPORT = { width: 1280, height: 800 };
113
270
  var BROWSER_CHANNELS = ["chrome", "chromium"];
114
- async function launchPersistentBrowser(options = {}) {
271
+ async function launchBrowser(options = {}) {
115
272
  const viewport = options.viewport ?? DEFAULT_VIEWPORT;
116
- const baseOptions = {
117
- headless: options.headless ?? false,
118
- viewport
119
- };
273
+ let browser = null;
120
274
  for (const channel of BROWSER_CHANNELS) {
121
275
  try {
122
- const context = await chromium.launchPersistentContext(PROFILE_DIR, {
123
- ...baseOptions,
276
+ browser = await chromium.launch({
277
+ headless: options.headless ?? false,
124
278
  channel
125
279
  });
126
- return context;
280
+ break;
127
281
  } catch {
128
282
  continue;
129
283
  }
130
284
  }
131
- const message = [
132
- "",
133
- "Error: No browser found.",
134
- "",
135
- "Heroshot needs a browser to capture screenshots. Options:",
136
- "",
137
- " 1. Install Chrome (recommended):",
138
- " https://www.google.com/chrome/",
139
- "",
140
- " 2. Or install Playwright browsers:",
141
- " npx playwright install chromium",
142
- ""
143
- ].join("\n");
144
- throw new Error(message);
285
+ if (!browser) {
286
+ const message = [
287
+ "",
288
+ "Error: No browser found.",
289
+ "",
290
+ "Heroshot needs a browser to capture screenshots. Options:",
291
+ "",
292
+ " 1. Install Chrome (recommended):",
293
+ " https://www.google.com/chrome/",
294
+ "",
295
+ " 2. Or install Playwright browsers:",
296
+ " npx playwright install chromium",
297
+ ""
298
+ ].join("\n");
299
+ throw new Error(message);
300
+ }
301
+ const context = await browser.newContext({
302
+ viewport,
303
+ ...options.deviceScaleFactor && { deviceScaleFactor: options.deviceScaleFactor },
304
+ ...options.storageState && { storageState: options.storageState }
305
+ });
306
+ return { browser, context };
145
307
  }
146
308
  var exposedPages = /* @__PURE__ */ new WeakSet();
147
309
  async function injectToolbar(page, options) {
@@ -177,16 +339,30 @@ async function injectToolbar(page, options) {
177
339
  },
178
340
  };
179
341
  `);
180
- const scriptPath = path2.join(TOOLBAR_DIR, "dist", "toolbar.js");
181
- const script = readFileSync2(scriptPath, "utf8");
342
+ const scriptPath = path3.join(TOOLBAR_DIR, "dist", "toolbar.js");
343
+ const script = readFileSync3(scriptPath, "utf8");
182
344
  await page.addScriptTag({ content: script });
183
345
  }
184
346
  async function setup() {
185
347
  log.verbose("Opening browser...");
186
- log.verbose(`Profile: ${PROFILE_DIR}`);
348
+ ensureHeroshotDirectory();
187
349
  const configPath = getConfigPath();
188
350
  const config = loadConfig(configPath);
189
351
  const viewport = config.browser?.viewport ?? DEFAULT_VIEWPORT;
352
+ let sessionKey = loadLocalKey();
353
+ const isNewKey = !sessionKey;
354
+ if (!sessionKey) {
355
+ sessionKey = generateSessionKey();
356
+ saveLocalKey(sessionKey);
357
+ }
358
+ let storageState;
359
+ if (sessionExists()) {
360
+ const state = loadSession(sessionKey);
361
+ if (state) {
362
+ storageState = state;
363
+ log.verbose("Loaded existing session.");
364
+ }
365
+ }
190
366
  const allScreenshots = config.screenshots.map((screenshot, index) => ({
191
367
  id: screenshot.id,
192
368
  name: screenshot.name,
@@ -201,7 +377,7 @@ async function setup() {
201
377
  let pendingJob = null;
202
378
  let selectedId = null;
203
379
  let sidebarExpanded = false;
204
- const context = await launchPersistentBrowser({ headless: false, viewport });
380
+ const { browser, context } = await launchBrowser({ headless: false, viewport, storageState });
205
381
  const handleEvent = (event) => {
206
382
  switch (event.type) {
207
383
  case "screenshot-added": {
@@ -248,7 +424,15 @@ async function setup() {
248
424
  break;
249
425
  }
250
426
  case "done": {
251
- void context.close();
427
+ void (async () => {
428
+ try {
429
+ const currentStorageState = await context.storageState();
430
+ saveSession(currentStorageState, sessionKey);
431
+ log.verbose("Session saved.");
432
+ } catch {
433
+ }
434
+ await browser.close();
435
+ })();
252
436
  break;
253
437
  }
254
438
  }
@@ -278,40 +462,46 @@ async function setup() {
278
462
  await page.goto("https://heroshot.sh/welcome", { waitUntil: "domcontentloaded" });
279
463
  log("Pick elements to screenshot. Close browser or click Done when finished.");
280
464
  await new Promise((resolve) => {
281
- context.once("close", () => resolve());
465
+ browser.once("disconnected", () => resolve());
282
466
  });
283
- if (newlyAddedIds.size > 0) {
284
- const latestConfig = loadConfig(configPath);
285
- for (const element of allScreenshots) {
286
- if (!newlyAddedIds.has(element.id)) continue;
287
- const filename = element.name.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/(?:^-|-$)/g, "") + ".png";
288
- const screenshot = {
289
- id: element.id,
290
- name: element.name,
291
- url: element.url,
292
- selector: element.selector,
293
- filename,
294
- ...element.padding && { padding: element.padding },
295
- ...element.scroll && { scroll: element.scroll }
296
- };
297
- const existingIndex = latestConfig.screenshots.findIndex((item) => item.id === element.id);
298
- if (existingIndex === -1) {
299
- latestConfig.screenshots.push(screenshot);
300
- log.verbose(`+ ${element.name}`);
301
- } else {
302
- latestConfig.screenshots[existingIndex] = screenshot;
303
- log.verbose(`~ ${element.name} (updated)`);
304
- }
467
+ const latestConfig = loadConfig(configPath);
468
+ for (const element of allScreenshots) {
469
+ if (!newlyAddedIds.has(element.id)) continue;
470
+ const filename = element.name.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/(?:^-|-$)/g, "") + ".png";
471
+ const screenshot = {
472
+ id: element.id,
473
+ name: element.name,
474
+ url: element.url,
475
+ selector: element.selector,
476
+ filename,
477
+ ...element.padding && { padding: element.padding },
478
+ ...element.scroll && { scroll: element.scroll }
479
+ };
480
+ const existingIndex = latestConfig.screenshots.findIndex((item) => item.id === element.id);
481
+ if (existingIndex === -1) {
482
+ latestConfig.screenshots.push(screenshot);
483
+ log.verbose(`+ ${element.name}`);
484
+ } else {
485
+ latestConfig.screenshots[existingIndex] = screenshot;
486
+ log.verbose(`~ ${element.name} (updated)`);
305
487
  }
306
- saveConfig(configPath, latestConfig);
307
- log.verbose(`Config saved: ${configPath}`);
488
+ }
489
+ saveConfig(configPath, latestConfig);
490
+ log.verbose(`Config saved: ${configPath}`);
491
+ if (isNewKey) {
492
+ log("");
493
+ log("Session encrypted and saved to .heroshot/session.enc");
494
+ log("");
495
+ log("To print your session key, run: heroshot session-key");
496
+ log("");
497
+ log("For CI, add HEROSHOT_SESSION_KEY as a repository secret.");
308
498
  }
309
499
  return { hasScreenshots: allScreenshots.length > 0 };
310
500
  }
311
501
 
312
502
  // src/sync.ts
313
- import { existsSync as existsSync2, mkdirSync } from "fs";
314
- import path3 from "path";
503
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3 } from "fs";
504
+ import path4 from "path";
315
505
  async function findElement(page, selector, maxAttempts = 10, intervalMs = 500) {
316
506
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
317
507
  const handle = await page.evaluateHandle(`
@@ -348,10 +538,10 @@ async function findElement(page, selector, maxAttempts = 10, intervalMs = 500) {
348
538
  return null;
349
539
  }
350
540
  function addFilenameSuffix(filename, suffix) {
351
- const extension = path3.extname(filename);
352
- const base = path3.basename(filename, extension);
353
- const directory = path3.dirname(filename);
354
- return path3.join(directory, `${base}${suffix}${extension}`);
541
+ const extension = path4.extname(filename);
542
+ const base = path4.basename(filename, extension);
543
+ const directory = path4.dirname(filename);
544
+ return path4.join(directory, `${base}${suffix}${extension}`);
355
545
  }
356
546
  async function takeScreenshot(target, outputPath, format, quality, clip) {
357
547
  const isPage = "goto" in target;
@@ -387,10 +577,10 @@ async function captureScreenshot(page, screenshot, outputDirectory, captureOptio
387
577
  await page.evaluate(`window.scrollTo(${scroll.x}, ${scroll.y})`);
388
578
  await page.waitForTimeout(100);
389
579
  }
390
- const outputPath = path3.join(outputDirectory, finalFilename);
391
- const outputDirectoryPath = path3.dirname(outputPath);
392
- if (!existsSync2(outputDirectoryPath)) {
393
- mkdirSync(outputDirectoryPath, { recursive: true });
580
+ const outputPath = path4.join(outputDirectory, finalFilename);
581
+ const outputDirectoryPath = path4.dirname(outputPath);
582
+ if (!existsSync3(outputDirectoryPath)) {
583
+ mkdirSync3(outputDirectoryPath, { recursive: true });
394
584
  }
395
585
  try {
396
586
  if (selector) {
@@ -443,8 +633,21 @@ async function captureAndLog(page, screenshot, outputDirectory, captureOptions,
443
633
  error: result.error
444
634
  };
445
635
  }
636
+ function loadEncryptedSession(sessionKeyOption) {
637
+ const sessionKey = getSessionKey(sessionKeyOption);
638
+ if (!sessionKey || !sessionExists()) {
639
+ return void 0;
640
+ }
641
+ const state = loadSession(sessionKey);
642
+ if (state) {
643
+ log.verbose("Using encrypted session.");
644
+ return state;
645
+ }
646
+ log.verbose("Failed to decrypt session - using fresh browser.");
647
+ return void 0;
648
+ }
446
649
  async function sync(options = {}) {
447
- const configPath = getConfigPath();
650
+ const configPath = options.configPath ?? getConfigPath();
448
651
  const config = loadConfig(configPath);
449
652
  if (config.screenshots.length === 0) {
450
653
  log("No screenshots defined.");
@@ -457,12 +660,17 @@ async function sync(options = {}) {
457
660
  return { total: 0, success: 0, failed: 0, results: [] };
458
661
  }
459
662
  log.verbose(`Syncing ${screenshots.length} screenshot(s)...`);
460
- const configDirectory = path3.dirname(configPath);
461
- const outputDirectory = path3.resolve(configDirectory, config.outputDirectory);
663
+ const configDirectory = path4.dirname(configPath);
664
+ const projectRoot = path4.dirname(configDirectory);
665
+ const outputDirectory = path4.resolve(projectRoot, config.outputDirectory);
666
+ const storageState = loadEncryptedSession(options.sessionKey);
462
667
  const viewport = config.browser?.viewport ?? { width: 1280, height: 800 };
463
- const context = await launchPersistentBrowser({
668
+ const deviceScaleFactor = config.browser?.deviceScaleFactor;
669
+ const { browser, context } = await launchBrowser({
464
670
  headless: true,
465
- viewport
671
+ viewport,
672
+ deviceScaleFactor,
673
+ storageState
466
674
  });
467
675
  const page = await context.newPage();
468
676
  const colorSchemeSetting = config.browser?.colorScheme;
@@ -491,7 +699,7 @@ async function sync(options = {}) {
491
699
  }
492
700
  }
493
701
  }
494
- await context.close();
702
+ await browser.close();
495
703
  const { length: totalCount } = results;
496
704
  const successfulResults = results.filter(({ success }) => success);
497
705
  const { length: successCount } = successfulResults;
@@ -510,19 +718,28 @@ async function sync(options = {}) {
510
718
  }
511
719
 
512
720
  // src/cli.ts
721
+ var packageJsonPath = path5.join(import.meta.dirname, "..", "package.json");
722
+ var packageJson = JSON.parse(readFileSync4(packageJsonPath, "utf8"));
723
+ var version = packageJson && typeof packageJson === "object" && "version" in packageJson ? String(packageJson.version) : "0.0.0";
513
724
  var program = new Command();
514
- program.name("heroshot").description("Define your screenshots once, update them forever with one command").version("0.0.2-alpha.2").option("-v, --verbose", "Show detailed output").hook("preAction", () => {
725
+ program.name("heroshot").description("Define your screenshots once, update them forever with one command").version(version).option("-v, --verbose", "Show detailed output").option("-c, --config <path>", "Path to config file").option("-s, --session-key <key>", "Session key for encrypted auth (or set HEROSHOT_SESSION_KEY)").hook("preAction", () => {
515
726
  const options = program.opts();
516
727
  setVerbose(options.verbose ?? false);
517
728
  });
518
729
  program.command("run", { isDefault: true, hidden: true }).description("Run heroshot (setup if no config, otherwise sync)").action(async () => {
519
- const configPath = getConfigPath();
520
- if (existsSync3(configPath)) {
521
- const result = await sync({});
730
+ const options = program.opts();
731
+ const configPath = options.config ? path5.resolve(options.config) : getConfigPath();
732
+ if (existsSync4(configPath)) {
733
+ const result = await sync({ configPath, sessionKey: options.sessionKey });
522
734
  if (result.failed > 0) {
523
735
  process.exitCode = 1;
524
736
  }
525
737
  } else {
738
+ if (options.config) {
739
+ log(`Config file not found: ${configPath}`);
740
+ process.exitCode = 1;
741
+ return;
742
+ }
526
743
  const { hasScreenshots } = await setup();
527
744
  if (hasScreenshots) {
528
745
  log("");
@@ -533,21 +750,32 @@ program.command("run", { isDefault: true, hidden: true }).description("Run heros
533
750
  }
534
751
  }
535
752
  });
536
- program.command("config").description("Open browser to add/edit screenshot definitions").option("--reset", "Clear existing browser profile and start fresh").option("--only", "Only run config, skip sync afterwards").action(async (options) => {
537
- if (options.reset) {
538
- const profilePath = getProfilePath();
539
- if (existsSync3(profilePath)) {
540
- rmSync(profilePath, { recursive: true });
541
- log.verbose("Browser profile cleared.");
753
+ program.command("config").description("Open browser to add/edit screenshot definitions").option("--reset", "Clear existing session and start fresh").option("--only", "Only run config, skip sync afterwards").action(async (commandOptions) => {
754
+ const globalOptions = program.opts();
755
+ if (commandOptions.reset) {
756
+ const sessionPath = getSessionPath();
757
+ if (existsSync4(sessionPath)) {
758
+ rmSync(sessionPath);
759
+ log.verbose("Session cleared.");
542
760
  }
543
761
  }
544
762
  const { hasScreenshots } = await setup();
545
- if (hasScreenshots && !options.only) {
763
+ if (hasScreenshots && !commandOptions.only) {
546
764
  log("");
547
- const result = await sync({});
765
+ const configPath = globalOptions.config ? path5.resolve(globalOptions.config) : void 0;
766
+ const result = await sync({ configPath, sessionKey: globalOptions.sessionKey });
548
767
  if (result.failed > 0) {
549
768
  process.exitCode = 1;
550
769
  }
551
770
  }
552
771
  });
772
+ program.command("session-key").description("Print the session key for this project (for CI setup)").action(() => {
773
+ const sessionKey = loadLocalKey();
774
+ if (sessionKey) {
775
+ log(sessionKey);
776
+ } else {
777
+ log('No session key found. Run "heroshot config" first to generate one.');
778
+ process.exitCode = 1;
779
+ }
780
+ });
553
781
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heroshot",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "Define your screenshots once, update them forever with one command",
5
5
  "type": "module",
6
6
  "author": "Ondrej Machala",
@@ -3949,14 +3949,6 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
3949
3949
  function isEditingNewElement() {
3950
3950
  return get(isNewElement);
3951
3951
  }
3952
- function getSelectedElementSide() {
3953
- const element = get(selectedElement) ?? get(currentElement);
3954
- if (!element) return null;
3955
- const rect = element.getBoundingClientRect();
3956
- const viewportCenter = globalThis.innerWidth / 2;
3957
- const elementCenter = rect.left + rect.width / 2;
3958
- return elementCenter < viewportCenter ? "left" : "right";
3959
- }
3960
3952
  function getOverlayRects(element, _scrollX, _scrollY, padding) {
3961
3953
  if (!element) return null;
3962
3954
  const rect = element.getBoundingClientRect();
@@ -4082,8 +4074,7 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
4082
4074
  confirmDraft,
4083
4075
  getCurrentPadding,
4084
4076
  getCurrentScroll,
4085
- isEditingNewElement,
4086
- getSelectedElementSide
4077
+ isEditingNewElement
4087
4078
  };
4088
4079
  var fragment = comment();
4089
4080
  event("scroll", $window, handleScroll);
@@ -4299,23 +4290,26 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
4299
4290
  }
4300
4291
  delegate(["click", "mousedown"]);
4301
4292
  var root_2 = /* @__PURE__ */ from_html(`<p class="text-xs text-slate-500 mt-2">Will capture two screenshots: -light and -dark variants</p>`);
4302
- var root_1$2 = /* @__PURE__ */ from_html(`<div class="fixed inset-0 bg-black/50 z-[2147483647] flex items-center justify-center pointer-events-auto" role="button" tabindex="0"><div class="bg-slate-800 rounded-lg p-6 w-80 shadow-2xl" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1"><h2 class="text-lg font-semibold text-white mb-4">Settings</h2> <div class="mb-4"><span class="block text-sm text-slate-400 mb-2">Viewport Size</span> <div class="flex gap-2 items-center"><label class="sr-only" for="viewport-width">Width</label> <input id="viewport-width" type="number" class="w-20 px-2 py-1 bg-slate-700 text-white rounded border border-slate-600 focus:border-blue-500 focus:outline-none" min="320" max="3840"/> <span class="text-slate-400">x</span> <label class="sr-only" for="viewport-height">Height</label> <input id="viewport-height" type="number" class="w-20 px-2 py-1 bg-slate-700 text-white rounded border border-slate-600 focus:border-blue-500 focus:outline-none" min="200" max="2160"/> <span class="text-slate-500 text-sm">px</span></div></div> <div class="mb-6"><span class="block text-sm text-slate-400 mb-2">Color Scheme</span> <div class="flex gap-2"><button type="button">Auto</button> <button type="button">Light</button> <button type="button">Dark</button> <button type="button" title="Capture both light and dark versions">Both</button></div> <!></div> <div class="flex justify-end gap-2"><button type="button" class="px-4 py-2 rounded bg-slate-700 text-white hover:bg-slate-600 transition-colors">Cancel</button> <button type="button" class="px-4 py-2 rounded bg-green-500 text-white hover:bg-green-600 transition-colors">Save</button></div></div></div>`);
4293
+ var root_1$2 = /* @__PURE__ */ from_html(`<div class="fixed inset-0 bg-black/50 z-[2147483647] flex items-center justify-center pointer-events-auto" role="button" tabindex="0"><div class="bg-slate-800 rounded-lg p-6 w-80 shadow-2xl" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1"><h2 class="text-lg font-semibold text-white mb-4">Settings</h2> <div class="mb-4"><span class="block text-sm text-slate-400 mb-2">Viewport Size</span> <div class="flex gap-2 items-center"><label class="sr-only" for="viewport-width">Width</label> <input id="viewport-width" type="number" class="w-20 px-2 py-1 bg-slate-700 text-white rounded border border-slate-600 focus:border-blue-500 focus:outline-none" min="320" max="3840"/> <span class="text-slate-400">x</span> <label class="sr-only" for="viewport-height">Height</label> <input id="viewport-height" type="number" class="w-20 px-2 py-1 bg-slate-700 text-white rounded border border-slate-600 focus:border-blue-500 focus:outline-none" min="200" max="2160"/> <span class="text-slate-500 text-sm">px</span></div></div> <div class="mb-4"><span class="block text-sm text-slate-400 mb-2">Scale (Retina)</span> <div class="flex gap-2"><button type="button">1x</button> <button type="button">2x</button> <button type="button">3x</button></div> <p class="text-xs text-slate-500 mt-2">Higher scale = sharper images, larger file size</p></div> <div class="mb-6"><span class="block text-sm text-slate-400 mb-2">Color Scheme</span> <div class="flex gap-2"><button type="button">Auto</button> <button type="button">Light</button> <button type="button">Dark</button> <button type="button" title="Capture both light and dark versions">Both</button></div> <!></div> <div class="flex justify-end gap-2"><button type="button" class="px-4 py-2 rounded bg-slate-700 text-white hover:bg-slate-600 transition-colors">Cancel</button> <button type="button" class="px-4 py-2 rounded bg-green-500 text-white hover:bg-green-600 transition-colors">Save</button></div></div></div>`);
4303
4294
  function SettingsModal($$anchor, $$props) {
4304
4295
  push($$props, true);
4305
4296
  let width = /* @__PURE__ */ state(0);
4306
4297
  let height = /* @__PURE__ */ state(0);
4307
4298
  let colorScheme = /* @__PURE__ */ state(void 0);
4299
+ let deviceScaleFactor = /* @__PURE__ */ state(void 0);
4308
4300
  user_effect(() => {
4309
4301
  if ($$props.visible) {
4310
4302
  set(width, $$props.settings.viewport.width, true);
4311
4303
  set(height, $$props.settings.viewport.height, true);
4312
4304
  set(colorScheme, $$props.settings.colorScheme, true);
4305
+ set(deviceScaleFactor, $$props.settings.deviceScaleFactor, true);
4313
4306
  }
4314
4307
  });
4315
4308
  function handleSave() {
4316
4309
  $$props.onSave({
4317
4310
  viewport: { width: get(width), height: get(height) },
4318
- colorScheme: get(colorScheme)
4311
+ colorScheme: get(colorScheme),
4312
+ deviceScaleFactor: get(deviceScaleFactor)
4319
4313
  });
4320
4314
  $$props.onClose();
4321
4315
  }
@@ -4353,23 +4347,31 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
4353
4347
  var div_4 = sibling(div_2, 2);
4354
4348
  var div_5 = sibling(child(div_4), 2);
4355
4349
  var button = child(div_5);
4356
- button.__click = () => {
4350
+ button.__click = () => set(deviceScaleFactor, void 0);
4351
+ var button_1 = sibling(button, 2);
4352
+ button_1.__click = () => set(deviceScaleFactor, 2);
4353
+ var button_2 = sibling(button_1, 2);
4354
+ button_2.__click = () => set(deviceScaleFactor, 3);
4355
+ var div_6 = sibling(div_4, 2);
4356
+ var div_7 = sibling(child(div_6), 2);
4357
+ var button_3 = child(div_7);
4358
+ button_3.__click = () => {
4357
4359
  set(colorScheme, void 0);
4358
4360
  applyColorScheme();
4359
4361
  };
4360
- var button_1 = sibling(button, 2);
4361
- button_1.__click = () => {
4362
+ var button_4 = sibling(button_3, 2);
4363
+ button_4.__click = () => {
4362
4364
  set(colorScheme, "light");
4363
4365
  applyColorScheme("light");
4364
4366
  };
4365
- var button_2 = sibling(button_1, 2);
4366
- button_2.__click = () => {
4367
+ var button_5 = sibling(button_4, 2);
4368
+ button_5.__click = () => {
4367
4369
  set(colorScheme, "dark");
4368
4370
  applyColorScheme("dark");
4369
4371
  };
4370
- var button_3 = sibling(button_2, 2);
4371
- button_3.__click = () => set(colorScheme, "both");
4372
- var node_1 = sibling(div_5, 2);
4372
+ var button_6 = sibling(button_5, 2);
4373
+ button_6.__click = () => set(colorScheme, "both");
4374
+ var node_1 = sibling(div_7, 2);
4373
4375
  {
4374
4376
  var consequent = ($$anchor3) => {
4375
4377
  var p = root_2();
@@ -4379,19 +4381,22 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
4379
4381
  if (get(colorScheme) === "both") $$render(consequent);
4380
4382
  });
4381
4383
  }
4382
- var div_6 = sibling(div_4, 2);
4383
- var button_4 = child(div_6);
4384
- button_4.__click = function(...$$args) {
4384
+ var div_8 = sibling(div_6, 2);
4385
+ var button_7 = child(div_8);
4386
+ button_7.__click = function(...$$args) {
4385
4387
  var _a2;
4386
4388
  (_a2 = $$props.onClose) == null ? void 0 : _a2.apply(this, $$args);
4387
4389
  };
4388
- var button_5 = sibling(button_4, 2);
4389
- button_5.__click = handleSave;
4390
+ var button_8 = sibling(button_7, 2);
4391
+ button_8.__click = handleSave;
4390
4392
  template_effect(() => {
4391
- set_class(button, 1, `px-3 py-1.5 rounded text-sm transition-colors ${get(colorScheme) === void 0 ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-300 hover:bg-slate-600"}`);
4392
- set_class(button_1, 1, `px-3 py-1.5 rounded text-sm transition-colors ${get(colorScheme) === "light" ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-300 hover:bg-slate-600"}`);
4393
- set_class(button_2, 1, `px-3 py-1.5 rounded text-sm transition-colors ${get(colorScheme) === "dark" ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-300 hover:bg-slate-600"}`);
4394
- set_class(button_3, 1, `px-3 py-1.5 rounded text-sm transition-colors ${get(colorScheme) === "both" ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-300 hover:bg-slate-600"}`);
4393
+ set_class(button, 1, `px-3 py-1.5 rounded text-sm transition-colors ${get(deviceScaleFactor) === void 0 || get(deviceScaleFactor) === 1 ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-300 hover:bg-slate-600"}`);
4394
+ set_class(button_1, 1, `px-3 py-1.5 rounded text-sm transition-colors ${get(deviceScaleFactor) === 2 ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-300 hover:bg-slate-600"}`);
4395
+ set_class(button_2, 1, `px-3 py-1.5 rounded text-sm transition-colors ${get(deviceScaleFactor) === 3 ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-300 hover:bg-slate-600"}`);
4396
+ set_class(button_3, 1, `px-3 py-1.5 rounded text-sm transition-colors ${get(colorScheme) === void 0 ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-300 hover:bg-slate-600"}`);
4397
+ set_class(button_4, 1, `px-3 py-1.5 rounded text-sm transition-colors ${get(colorScheme) === "light" ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-300 hover:bg-slate-600"}`);
4398
+ set_class(button_5, 1, `px-3 py-1.5 rounded text-sm transition-colors ${get(colorScheme) === "dark" ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-300 hover:bg-slate-600"}`);
4399
+ set_class(button_6, 1, `px-3 py-1.5 rounded text-sm transition-colors ${get(colorScheme) === "both" ? "bg-blue-600 text-white" : "bg-slate-700 text-slate-300 hover:bg-slate-600"}`);
4395
4400
  });
4396
4401
  bind_value(input, () => get(width), ($$value) => set(width, $$value));
4397
4402
  bind_value(input_1, () => get(height), ($$value) => set(height, $$value));
@@ -4465,7 +4470,6 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
4465
4470
  function Sidebar($$anchor, $$props) {
4466
4471
  push($$props, true);
4467
4472
  append_styles($$anchor, $$css$1);
4468
- let side = /* @__PURE__ */ user_derived(() => $$props.selectedElementPosition === "right" ? "left" : "right");
4469
4473
  let localEditingId = /* @__PURE__ */ state(null);
4470
4474
  let editValue = /* @__PURE__ */ state("");
4471
4475
  let inputElement = /* @__PURE__ */ state(null);
@@ -4562,7 +4566,7 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
4562
4566
  return "bg-slate-700/50 hover:bg-slate-600";
4563
4567
  }
4564
4568
  let dragTransform = /* @__PURE__ */ user_derived(() => get(dragOffsetX) !== 0 || get(dragOffsetY) !== 0 ? `transform: translate(${get(dragOffsetX)}px, ${get(dragOffsetY)}px);` : "");
4565
- let positionStyle = /* @__PURE__ */ user_derived(() => get(side) === "right" ? `right: 16px; left: auto; ${get(dragTransform)}` : `left: 16px; right: auto; ${get(dragTransform)}`);
4569
+ let positionStyle = /* @__PURE__ */ user_derived(() => `right: 16px; left: auto; ${get(dragTransform)}`);
4566
4570
  var div = root$1();
4567
4571
  div.__keydown = stopKeyboardEvent;
4568
4572
  div.__keyup = (event2) => event2.stopPropagation();
@@ -4754,7 +4758,6 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
4754
4758
  // ID of draft item (not yet saved)
4755
4759
  );
4756
4760
  let selectedScreenshotId = /* @__PURE__ */ state(proxy($$props.initialSelectedId ?? null));
4757
- let selectedElementPosition = /* @__PURE__ */ state(null);
4758
4761
  let elementPicker;
4759
4762
  let screenshotCount = /* @__PURE__ */ user_derived(() => get(screenshots).length);
4760
4763
  let sidebarButtonClass = /* @__PURE__ */ user_derived(() => get(sidebarExpanded) ? "bg-blue-600" : "bg-slate-700 hover:bg-slate-600");
@@ -4782,7 +4785,6 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
4782
4785
  // Focus the name input
4783
4786
  true
4784
4787
  );
4785
- set(selectedElementPosition, elementPicker.getSelectedElementSide(), true);
4786
4788
  }
4787
4789
  function handlePaddingUpdate(id, padding) {
4788
4790
  const hasPadding = padding.top > 0 || padding.right > 0 || padding.bottom > 0 || padding.left > 0;
@@ -4813,11 +4815,9 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
4813
4815
  set(editingId, null);
4814
4816
  }
4815
4817
  set(selectedScreenshotId, null);
4816
- set(selectedElementPosition, null);
4817
4818
  }
4818
4819
  function handleDeselect() {
4819
4820
  set(selectedScreenshotId, null);
4820
- set(selectedElementPosition, null);
4821
4821
  }
4822
4822
  function handleDraftConfirm(id) {
4823
4823
  if (id === get(draftId)) {
@@ -4864,12 +4864,6 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
4864
4864
  const currentUrl = globalThis.location.href;
4865
4865
  if (screenshot.url === currentUrl) {
4866
4866
  elementPicker.highlightElement(screenshot.selector, screenshot.id);
4867
- globalThis.setTimeout(
4868
- () => {
4869
- set(selectedElementPosition, elementPicker.getSelectedElementSide(), true);
4870
- },
4871
- 200
4872
- );
4873
4867
  } else {
4874
4868
  emit({
4875
4869
  type: "screenshot-selected",
@@ -4881,12 +4875,6 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
4881
4875
  }
4882
4876
  function executePendingJob(job) {
4883
4877
  elementPicker.highlightElement(job.selector, job.screenshotId);
4884
- globalThis.setTimeout(
4885
- () => {
4886
- set(selectedElementPosition, elementPicker.getSelectedElementSide(), true);
4887
- },
4888
- 200
4889
- );
4890
4878
  emit({ type: "job-complete" });
4891
4879
  }
4892
4880
  function isToolbarJob(value) {
@@ -4984,9 +4972,6 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
4984
4972
  get selectedId() {
4985
4973
  return get(selectedScreenshotId);
4986
4974
  },
4987
- get selectedElementPosition() {
4988
- return get(selectedElementPosition);
4989
- },
4990
4975
  onToggle: () => set(sidebarExpanded, !get(sidebarExpanded)),
4991
4976
  onSelect: handleSelectScreenshot,
4992
4977
  onRemove: handleRemoveScreenshot,