device-shots 0.1.0 → 0.2.1

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 (3) hide show
  1. package/README.md +180 -0
  2. package/dist/index.js +179 -49
  3. package/package.json +1 -1
package/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # device-shots
2
+
3
+ A CLI tool that captures screenshots from running iOS simulators and Android emulators, then optionally frames iOS screenshots with device bezels. Built for developers who need store-ready screenshots without manual work.
4
+
5
+ ## What it does
6
+
7
+ 1. **Discovers devices** — Finds all running iOS simulators and Android emulators that have your app installed
8
+ 2. **Cleans up status bars** — Sets a uniform status bar (9:41, full battery, full signal) on all devices before capturing
9
+ 3. **Captures screenshots** — Takes screenshots from every discovered device in one go
10
+ 4. **Transparent Android status bar** — Automatically makes the Android status bar area transparent using ImageMagick
11
+ 5. **Frames iOS screenshots** — Wraps iOS screenshots in device bezels (iPhone/iPad frames) using [device-frames-core](https://pypi.org/project/device-frames-core/)
12
+ 6. **Organizes by screen size** — Saves screenshots into store-aligned size buckets (e.g. `6.9`, `6.7`, `phone`) instead of device names
13
+
14
+ ## Use cases
15
+
16
+ - Generating App Store and Play Store screenshots across multiple device sizes at once
17
+ - Keeping consistent, reproducible screenshot sets for your app listing
18
+ - Automating screenshot capture in CI/CD pipelines
19
+ - Quickly re-capturing screenshots after UI changes
20
+
21
+ ## Prerequisites
22
+
23
+ ### Required
24
+
25
+ - **Node.js 18+**
26
+ - **Xcode Command Line Tools** (for iOS) — provides `xcrun simctl`
27
+ - **Android SDK** (for Android) — provides `adb`. The tool looks for it at `$ANDROID_HOME/platform-tools/adb` or `~/Library/Android/sdk/platform-tools/adb`
28
+
29
+ ### Optional
30
+
31
+ - **ImageMagick** — needed to make Android status bars transparent. Install with `brew install imagemagick`
32
+ - **Python 3** — needed for iOS screenshot framing. The tool auto-creates a virtual environment at `~/.device-shots/.venv` and installs `device-frames-core` and `Pillow` automatically on first use
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ npm install -g device-shots
38
+ ```
39
+
40
+ Or run directly with `npx`:
41
+
42
+ ```bash
43
+ npx device-shots capture --bundle-id com.example.myapp
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ### Capture screenshots
49
+
50
+ ```bash
51
+ # Interactive — prompts for screenshot name
52
+ device-shots capture --bundle-id com.example.myapp
53
+
54
+ # Non-interactive — provide name directly
55
+ device-shots capture dashboard --bundle-id com.example.myapp
56
+
57
+ # iOS only, custom output directory
58
+ device-shots capture home -b com.example.myapp -p ios -o ./.store-assets
59
+
60
+ # Skip framing
61
+ device-shots capture login -b com.example.myapp --no-frame
62
+
63
+ # Custom status bar time
64
+ device-shots capture checkout -b com.example.myapp --time "10:30"
65
+ ```
66
+
67
+ ### Frame existing screenshots
68
+
69
+ ```bash
70
+ # Frame all unframed iOS screenshots in ./.screenshots
71
+ device-shots frame
72
+
73
+ # Frame from a specific directory
74
+ device-shots frame ./.my-screenshots
75
+
76
+ # Re-frame everything (overwrite existing framed images)
77
+ device-shots frame --force
78
+ ```
79
+
80
+ ### Initialize config
81
+
82
+ ```bash
83
+ device-shots init
84
+ ```
85
+
86
+ Creates a `.device-shotsrc.json` in the current directory so you don't have to pass `--bundle-id` every time:
87
+
88
+ ```json
89
+ {
90
+ "bundleId": "com.example.myapp",
91
+ "output": "./.screenshots",
92
+ "platform": "both",
93
+ "time": "9:41",
94
+ "frame": true
95
+ }
96
+ ```
97
+
98
+ ## Config file
99
+
100
+ The tool uses [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig), so you can configure it in any of these ways:
101
+
102
+ - `.device-shotsrc.json`
103
+ - `.device-shotsrc.yaml`
104
+ - `.device-shotsrc.js`
105
+ - `device-shots.config.js`
106
+ - `"device-shots"` key in `package.json`
107
+
108
+ ## Output structure
109
+
110
+ Screenshots are organized by platform and screen size bucket, matching App Store and Play Store requirements:
111
+
112
+ ```
113
+ .screenshots/
114
+ ├── ios/
115
+ │ ├── 6.9/ # iPhone 16 Pro Max
116
+ │ │ ├── dashboard.png
117
+ │ │ ├── dashboard_framed.png
118
+ │ │ ├── settings.png
119
+ │ │ └── settings_framed.png
120
+ │ ├── 6.5/ # iPhone 14 Plus, 13 Pro Max
121
+ │ │ ├── dashboard.png
122
+ │ │ └── dashboard_framed.png
123
+ │ └── 6.3/ # iPhone 16 Pro, 16, 15 Pro, 15
124
+ │ └── ...
125
+ ├── android/
126
+ │ └── phone/ # Pixel 9 Pro, etc.
127
+ │ ├── dashboard.png
128
+ │ └── settings.png
129
+ └── metadata.json
130
+ ```
131
+
132
+ ### Screen size buckets
133
+
134
+ **iOS** — Maps to App Store Connect display size categories:
135
+
136
+ | Bucket | Devices |
137
+ |--------|---------|
138
+ | `6.9` | iPhone 16 Pro Max, 16 Plus, 15 Pro Max, 15 Plus, 14 Pro Max |
139
+ | `6.5` | iPhone 14 Plus, 13 Pro Max, 12 Pro Max, 11 Pro Max, XS Max |
140
+ | `6.3` | iPhone 16 Pro, 16, 15 Pro, 15, 14 Pro |
141
+ | `6.1` | iPhone 14, 13, 12, X, XS, 12 mini, 13 mini |
142
+ | `5.5` | iPhone 8 Plus, 7 Plus |
143
+ | `4.7` | iPhone SE (3rd/2nd), iPhone 8 |
144
+ | `13` | iPad Pro 13", iPad Air 13" |
145
+ | `11` | iPad Pro 11", iPad Air 11", iPad mini 6th gen |
146
+
147
+ **Android** — Categorized by form factor:
148
+
149
+ | Bucket | Criteria |
150
+ |--------|----------|
151
+ | `phone` | Shorter screen dimension < 1200px |
152
+ | `tablet-7` | Shorter dimension 1200–1799px |
153
+ | `tablet-10` | Shorter dimension 1800px+ |
154
+
155
+ ### metadata.json
156
+
157
+ Tracks which physical device was used for each size bucket:
158
+
159
+ ```json
160
+ {
161
+ "ios": {
162
+ "6.9": {
163
+ "device": "iPhone 16 Pro Max",
164
+ "id": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
165
+ "resolution": "1320x2868"
166
+ }
167
+ },
168
+ "android": {
169
+ "phone": {
170
+ "device": "Pixel 9 Pro",
171
+ "id": "emulator-5554",
172
+ "resolution": "1080x2340"
173
+ }
174
+ }
175
+ }
176
+ ```
177
+
178
+ ## License
179
+
180
+ MIT
package/dist/index.js CHANGED
@@ -2,12 +2,19 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
- import { writeFileSync, existsSync as existsSync5 } from "fs";
5
+ import { writeFileSync as writeFileSync2, existsSync as existsSync5 } from "fs";
6
6
  import pc3 from "picocolors";
7
7
  import prompts2 from "prompts";
8
8
 
9
9
  // src/commands/capture.ts
10
- import { existsSync as existsSync4, mkdirSync, readdirSync as readdirSync2, renameSync } from "fs";
10
+ import {
11
+ existsSync as existsSync4,
12
+ mkdirSync,
13
+ readdirSync as readdirSync2,
14
+ renameSync,
15
+ readFileSync,
16
+ writeFileSync
17
+ } from "fs";
11
18
  import { join as join4 } from "path";
12
19
  import { tmpdir } from "os";
13
20
  import { mkdtempSync } from "fs";
@@ -20,7 +27,7 @@ import { cosmiconfig } from "cosmiconfig";
20
27
  var MODULE_NAME = "device-shots";
21
28
  var DEFAULTS = {
22
29
  bundleId: "",
23
- output: "./screenshots",
30
+ output: "./.screenshots",
24
31
  platform: "both",
25
32
  time: "9:41",
26
33
  frame: true
@@ -40,7 +47,7 @@ function createDefaultConfig(bundleId) {
40
47
  return JSON.stringify(
41
48
  {
42
49
  bundleId,
43
- output: "./screenshots",
50
+ output: "./.screenshots",
44
51
  platform: "both",
45
52
  time: "9:41",
46
53
  frame: true
@@ -87,6 +94,55 @@ async function runOrFail(cmd, args, options) {
87
94
  return result.stdout ?? "";
88
95
  }
89
96
 
97
+ // src/devices/screen-sizes.ts
98
+ var IOS_RESOLUTION_MAP = {
99
+ // 6.9" — iPhone 16 Pro Max, 16 Plus, 15 Pro Max, 15 Plus, 14 Pro Max
100
+ "1320x2868": "6.9",
101
+ "1290x2796": "6.9",
102
+ "1260x2736": "6.9",
103
+ // 6.5" — iPhone 14 Plus, 13 Pro Max, 12 Pro Max, 11 Pro Max, XS Max, XR
104
+ "1284x2778": "6.5",
105
+ "1242x2688": "6.5",
106
+ // 6.3" — iPhone 16 Pro, 16, 15 Pro, 15, 14 Pro
107
+ "1206x2622": "6.3",
108
+ "1179x2556": "6.3",
109
+ // 6.1" — iPhone 14, 13, 13 Pro, 12, 12 Pro, X, XS, 11 Pro, 12 mini, 13 mini
110
+ "1170x2532": "6.1",
111
+ "1125x2436": "6.1",
112
+ "1080x2340": "6.1",
113
+ // 5.5" — iPhone 8 Plus, 7 Plus, 6s Plus
114
+ "1242x2208": "5.5",
115
+ // 4.7" — iPhone SE (3rd/2nd), iPhone 8, 7, 6s
116
+ "750x1334": "4.7",
117
+ // 4.0" — iPhone SE (1st), iPhone 5/5s/5c
118
+ "640x1136": "4.0",
119
+ "640x1096": "4.0",
120
+ // iPad 13" — iPad Pro 13" (M4/M3/M2/M1), iPad Air 13", iPad Pro 12.9" (older)
121
+ "2064x2752": "13",
122
+ "2048x2732": "13",
123
+ // iPad 11" — iPad Pro 11", iPad Air 11", iPad 10th gen, iPad mini 6th gen
124
+ "1488x2266": "11",
125
+ "1668x2420": "11",
126
+ "1668x2388": "11",
127
+ "1640x2360": "11",
128
+ // iPad 10.5" — iPad Pro 10.5", iPad Air 3rd gen, iPad 7th-9th gen
129
+ "1668x2224": "10.5",
130
+ // iPad 9.7" — iPad Pro 9.7", iPad Air 1-2, older iPad mini
131
+ "1536x2048": "9.7",
132
+ "1536x2008": "9.7"
133
+ };
134
+ function getIosScreenSize(width, height) {
135
+ const w = Math.min(width, height);
136
+ const h = Math.max(width, height);
137
+ return IOS_RESOLUTION_MAP[`${w}x${h}`] ?? `${w}x${h}`;
138
+ }
139
+ function getAndroidScreenSize(width, height) {
140
+ const shorter = Math.min(width, height);
141
+ if (shorter >= 1800) return "tablet-10";
142
+ if (shorter >= 1200) return "tablet-7";
143
+ return "phone";
144
+ }
145
+
90
146
  // src/devices/ios.ts
91
147
  function sanitizeName(name) {
92
148
  return name.replace(/[ (),]/g, "_").replace(/[^A-Za-z0-9_-]/g, "");
@@ -115,17 +171,63 @@ async function discoverIosDevices(bundleId) {
115
171
  bundleId
116
172
  ]);
117
173
  if (container) {
174
+ const resolution = await getResolutionViaTestCapture(d.udid);
175
+ const screenSize = resolution ? getIosScreenSize(resolution.width, resolution.height) : sanitizeName(d.name);
118
176
  devices.push({
119
177
  platform: "ios",
120
178
  safeName: sanitizeName(d.name),
121
179
  captureId: d.udid,
122
- displayName: d.name
180
+ displayName: d.name,
181
+ screenSize,
182
+ resolution: resolution ?? { width: 0, height: 0 }
123
183
  });
124
184
  }
125
185
  }
126
186
  }
127
187
  return devices;
128
188
  }
189
+ async function getResolutionViaTestCapture(udid) {
190
+ try {
191
+ const tmpPath = `/tmp/device-shots-probe-${udid}.png`;
192
+ await runOrFail("xcrun", ["simctl", "io", udid, "screenshot", tmpPath]);
193
+ if (await commandExists("magick")) {
194
+ const { stdout } = await run("magick", [
195
+ "identify",
196
+ "-format",
197
+ "%wx%h",
198
+ tmpPath
199
+ ]);
200
+ const match = stdout.match(/(\d+)x(\d+)/);
201
+ await run("rm", ["-f", tmpPath]);
202
+ if (match) {
203
+ return {
204
+ width: parseInt(match[1], 10),
205
+ height: parseInt(match[2], 10)
206
+ };
207
+ }
208
+ } else {
209
+ const { stdout } = await run("sips", [
210
+ "-g",
211
+ "pixelWidth",
212
+ "-g",
213
+ "pixelHeight",
214
+ tmpPath
215
+ ]);
216
+ await run("rm", ["-f", tmpPath]);
217
+ const wMatch = stdout.match(/pixelWidth:\s*(\d+)/);
218
+ const hMatch = stdout.match(/pixelHeight:\s*(\d+)/);
219
+ if (wMatch && hMatch) {
220
+ return {
221
+ width: parseInt(wMatch[1], 10),
222
+ height: parseInt(hMatch[1], 10)
223
+ };
224
+ }
225
+ }
226
+ await run("rm", ["-f", tmpPath]);
227
+ } catch {
228
+ }
229
+ return null;
230
+ }
129
231
  async function setIosStatusBar(time) {
130
232
  if (!await commandExists("xcrun")) return;
131
233
  await run("xcrun", [
@@ -164,6 +266,16 @@ async function captureIosScreenshot(udid, outputPath) {
164
266
  function sanitizeName2(name) {
165
267
  return name.replace(/[ (),]/g, "_").replace(/[^A-Za-z0-9_-]/g, "");
166
268
  }
269
+ async function getAndroidResolution(serial) {
270
+ const adb = getAdbPath();
271
+ const { stdout } = await run(adb, ["-s", serial, "shell", "wm", "size"]);
272
+ const match = stdout.match(/(\d+)x(\d+)/);
273
+ if (!match) return null;
274
+ return {
275
+ width: parseInt(match[1], 10),
276
+ height: parseInt(match[2], 10)
277
+ };
278
+ }
167
279
  async function discoverAndroidDevices(bundleId) {
168
280
  const adb = getAdbPath();
169
281
  const { stdout } = await run(adb, ["devices"]);
@@ -203,11 +315,15 @@ async function discoverAndroidDevices(bundleId) {
203
315
  avdName = model.trim();
204
316
  }
205
317
  const safeName = sanitizeName2(avdName) || serial;
318
+ const resolution = await getAndroidResolution(serial);
319
+ const screenSize = resolution ? getAndroidScreenSize(resolution.width, resolution.height) : "phone";
206
320
  devices.push({
207
321
  platform: "android",
208
322
  safeName,
209
323
  captureId: serial,
210
- displayName: avdName || serial
324
+ displayName: avdName || serial,
325
+ screenSize,
326
+ resolution: resolution ?? { width: 0, height: 0 }
211
327
  });
212
328
  }
213
329
  return devices;
@@ -358,11 +474,11 @@ function getFramePyPath() {
358
474
  "Could not find vendored frame.py. Ensure the vendor/ directory is present."
359
475
  );
360
476
  }
361
- async function frameScreenshots(rawDir, framedDir, force = false) {
477
+ async function frameScreenshots(inputDir, outputDir, force = false) {
362
478
  await ensureVenv();
363
479
  const framePy = getFramePyPath();
364
480
  const python = getVenvPython();
365
- const args = [framePy, rawDir, framedDir];
481
+ const args = [framePy, inputDir, outputDir];
366
482
  if (force) {
367
483
  args.push("--force");
368
484
  }
@@ -370,22 +486,24 @@ async function frameScreenshots(rawDir, framedDir, force = false) {
370
486
  if (output) {
371
487
  process.stdout.write(output + "\n");
372
488
  }
373
- const framedFiles = existsSync3(framedDir) ? readdirSync(framedDir).filter((f) => f.endsWith(".png")).length : 0;
374
- const rawFiles = existsSync3(rawDir) ? readdirSync(rawDir).filter((f) => f.endsWith(".png")).length : 0;
489
+ const framedFiles = existsSync3(outputDir) ? readdirSync(outputDir).filter((f) => f.endsWith(".png")).length : 0;
490
+ const rawFiles = existsSync3(inputDir) ? readdirSync(inputDir).filter((f) => f.endsWith(".png")).length : 0;
375
491
  return { framed: framedFiles, skipped: rawFiles - framedFiles };
376
492
  }
377
493
  async function frameAllIosScreenshots(screenshotsDir, force = false) {
378
494
  const iosDir = join3(screenshotsDir, "ios");
379
495
  if (!existsSync3(iosDir)) return 0;
380
496
  let totalFramed = 0;
381
- const deviceDirs = readdirSync(iosDir, { withFileTypes: true }).filter((d) => d.isDirectory());
382
- for (const deviceDir of deviceDirs) {
383
- const rawDir = join3(iosDir, deviceDir.name, "raw");
384
- if (!existsSync3(rawDir)) continue;
385
- const pngFiles = readdirSync(rawDir).filter((f) => f.endsWith(".png"));
497
+ const sizeDirs = readdirSync(iosDir, { withFileTypes: true }).filter(
498
+ (d) => d.isDirectory()
499
+ );
500
+ for (const sizeDir of sizeDirs) {
501
+ const dirPath = join3(iosDir, sizeDir.name);
502
+ const pngFiles = readdirSync(dirPath).filter(
503
+ (f) => f.endsWith(".png") && !f.includes("_framed")
504
+ );
386
505
  if (pngFiles.length === 0) continue;
387
- const framedDir = join3(iosDir, deviceDir.name, "framed");
388
- const { framed } = await frameScreenshots(rawDir, framedDir, force);
506
+ const { framed } = await frameScreenshots(dirPath, dirPath, force);
389
507
  totalFramed += framed;
390
508
  }
391
509
  return totalFramed;
@@ -418,18 +536,17 @@ async function captureCommand(options) {
418
536
  Detected ${devices.length} device(s) with ${bundleId}:`)
419
537
  );
420
538
  for (const device of devices) {
421
- const deviceDir = join4(outputDir, device.platform, device.safeName);
422
- const rawDir = join4(deviceDir, "raw");
423
- if (existsSync4(rawDir)) {
424
- const count = readdirSync2(rawDir).filter(
425
- (f) => f.endsWith(".png")
539
+ const sizeDir = join4(outputDir, device.platform, device.screenSize);
540
+ if (existsSync4(sizeDir)) {
541
+ const count = readdirSync2(sizeDir).filter(
542
+ (f) => f.endsWith(".png") && !f.includes("_framed")
426
543
  ).length;
427
544
  console.log(
428
- ` ${pc.dim(device.platform + "/")}${device.safeName} (${device.displayName}) - ${count} screenshot(s)`
545
+ ` ${pc.dim(device.platform + "/")}${device.screenSize} ${pc.dim("(" + device.displayName + ")")} - ${count} screenshot(s)`
429
546
  );
430
547
  } else {
431
548
  console.log(
432
- ` ${pc.dim(device.platform + "/")}${device.safeName} (${device.displayName}) - ${pc.green("new")}`
549
+ ` ${pc.dim(device.platform + "/")}${device.screenSize} ${pc.dim("(" + device.displayName + ")")} - ${pc.green("new")}`
433
550
  );
434
551
  }
435
552
  }
@@ -458,9 +575,8 @@ Detected ${devices.length} device(s) with ${bundleId}:`)
458
575
  const existingFile = join4(
459
576
  outputDir,
460
577
  firstDevice.platform,
461
- firstDevice.safeName,
462
- "raw",
463
- `${screenshotName}_${firstDevice.safeName}.png`
578
+ firstDevice.screenSize,
579
+ `${screenshotName}.png`
464
580
  );
465
581
  if (existsSync4(existingFile)) {
466
582
  const response = await prompts({
@@ -492,10 +608,12 @@ Detected ${devices.length} device(s) with ${bundleId}:`)
492
608
  console.log("");
493
609
  const captured = [];
494
610
  for (const device of devices) {
495
- const filename = `${screenshotName}_${device.safeName}.png`;
496
- const tmpPath = join4(tmpDir, filename);
611
+ const filename = `${screenshotName}.png`;
612
+ const tmpPath = join4(tmpDir, `${device.platform}_${device.screenSize}_${filename}`);
497
613
  const icon = device.platform === "ios" ? "iOS" : "Android";
498
- const s = ora(`${icon}: Capturing from ${device.displayName}...`).start();
614
+ const s = ora(
615
+ `${icon}: Capturing from ${device.displayName}...`
616
+ ).start();
499
617
  let success = false;
500
618
  if (device.platform === "ios") {
501
619
  success = await captureIosScreenshot(device.captureId, tmpPath);
@@ -514,25 +632,21 @@ Detected ${devices.length} device(s) with ${bundleId}:`)
514
632
  if (success) {
515
633
  captured.push({
516
634
  platform: device.platform,
517
- safeName: device.safeName,
635
+ screenSize: device.screenSize,
518
636
  filename,
519
637
  tmpPath
520
638
  });
521
- s.succeed(`${icon}: ${device.displayName}`);
639
+ s.succeed(`${icon}: ${device.screenSize} (${device.displayName})`);
522
640
  } else {
523
641
  s.fail(`${icon}: Failed to capture from ${device.displayName}`);
524
642
  }
525
643
  }
526
644
  for (const file of captured) {
527
- const destDir = join4(outputDir, file.platform, file.safeName, "raw");
645
+ const destDir = join4(outputDir, file.platform, file.screenSize);
528
646
  mkdirSync(destDir, { recursive: true });
529
- if (file.platform === "ios") {
530
- mkdirSync(join4(outputDir, file.platform, file.safeName, "framed"), {
531
- recursive: true
532
- });
533
- }
534
647
  renameSync(file.tmpPath, join4(destDir, file.filename));
535
648
  }
649
+ updateMetadata(outputDir, devices);
536
650
  if (iosDevices.length > 0) {
537
651
  await clearIosStatusBar();
538
652
  }
@@ -561,19 +675,35 @@ Captured ${captured.length} screenshot(s) as '${screenshotName}'.`
561
675
  function getExistingScreenshotNames(outputDir, devices) {
562
676
  const names = /* @__PURE__ */ new Set();
563
677
  for (const device of devices) {
564
- const rawDir = join4(outputDir, device.platform, device.safeName, "raw");
565
- if (!existsSync4(rawDir)) continue;
566
- for (const file of readdirSync2(rawDir)) {
567
- if (!file.endsWith(".png")) continue;
568
- const base = file.replace(".png", "");
569
- const suffix = `_${device.safeName}`;
570
- if (base.endsWith(suffix)) {
571
- names.add(base.slice(0, -suffix.length));
572
- }
678
+ const sizeDir = join4(outputDir, device.platform, device.screenSize);
679
+ if (!existsSync4(sizeDir)) continue;
680
+ for (const file of readdirSync2(sizeDir)) {
681
+ if (!file.endsWith(".png") || file.includes("_framed")) continue;
682
+ names.add(file.replace(".png", ""));
573
683
  }
574
684
  }
575
685
  return [...names].sort();
576
686
  }
687
+ function updateMetadata(outputDir, devices) {
688
+ const metaPath = join4(outputDir, "metadata.json");
689
+ let metadata = { ios: {}, android: {} };
690
+ if (existsSync4(metaPath)) {
691
+ try {
692
+ metadata = JSON.parse(readFileSync(metaPath, "utf-8"));
693
+ } catch {
694
+ }
695
+ }
696
+ for (const device of devices) {
697
+ const entry = {
698
+ device: device.displayName,
699
+ id: device.captureId,
700
+ resolution: `${device.resolution.width}x${device.resolution.height}`
701
+ };
702
+ metadata[device.platform][device.screenSize] = entry;
703
+ }
704
+ mkdirSync(outputDir, { recursive: true });
705
+ writeFileSync(metaPath, JSON.stringify(metadata, null, 2) + "\n");
706
+ }
577
707
 
578
708
  // src/commands/frame.ts
579
709
  import ora2 from "ora";
@@ -605,7 +735,7 @@ async function frameCommand(dir, options) {
605
735
  var program = new Command();
606
736
  program.name("device-shots").description(
607
737
  "Capture and frame mobile app screenshots from iOS simulators and Android emulators"
608
- ).version("0.1.0");
738
+ ).version("0.2.1");
609
739
  program.command("capture").description("Capture screenshots from running devices").argument("[name]", "Screenshot name").option("-b, --bundle-id <id>", "App bundle ID").option("-o, --output <dir>", "Output directory").option("-p, --platform <platform>", "ios, android, or both").option("--no-frame", "Skip framing after capture").option("--time <time>", "Status bar time", "9:41").action(async (name, opts) => {
610
740
  await captureCommand({ name, ...opts });
611
741
  });
@@ -628,7 +758,7 @@ program.command("init").description("Create a .device-shotsrc.json config file")
628
758
  return;
629
759
  }
630
760
  const config = createDefaultConfig(response.bundleId);
631
- writeFileSync(configPath, config + "\n");
761
+ writeFileSync2(configPath, config + "\n");
632
762
  console.log(pc3.green(`Created ${configPath}`));
633
763
  });
634
764
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "device-shots",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Capture and frame mobile app screenshots from iOS simulators and Android emulators",
5
5
  "type": "module",
6
6
  "bin": {