storeshot 0.1.0 → 0.2.0

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,258 +1,32 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ ASSET_CATEGORIES,
4
+ BUILT_IN_ARTWORK,
5
+ MOCKUP_BUNDLE_FILENAME,
6
+ SCREENSHOT_DEVICE_TYPES,
7
+ bundleAssetPaths,
8
+ emptyDeviceMockupCatalog,
9
+ isSafeBundlePath,
10
+ mergeDeviceMockupCatalogs,
11
+ parseMockupBundleManifest,
12
+ resolveMockupBundle,
13
+ resolvePackagePublicDirectory
14
+ } from "./chunk-I7JOE7XJ.js";
2
15
 
3
16
  // src/cli.ts
4
- import path3 from "path";
17
+ import { constants as constants2 } from "fs";
18
+ import { access as access2, readFile as readFile4 } from "fs/promises";
19
+ import path4 from "path";
5
20
  import { fileURLToPath } from "url";
6
21
  import { Command, Option } from "commander";
7
22
  import open from "open";
8
23
 
9
- // src/server.ts
10
- import { createReadStream } from "fs";
11
- import { readFile as readFile2, stat as stat2 } from "fs/promises";
12
- import { createServer } from "http";
13
- import path2 from "path";
14
-
15
24
  // src/project-store.ts
16
25
  import { createHash, randomUUID } from "crypto";
17
26
  import { constants } from "fs";
18
27
  import { access, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "fs/promises";
19
28
  import path from "path";
20
29
 
21
- // src/device-mockups.ts
22
- var MOCKUP_BUNDLE_FILENAME = "storeshot-mockups.json";
23
- var MOCKUP_BUNDLE_FORMAT = "storeshot-mockup-bundle";
24
- var identifierPattern = /^[a-z0-9][a-z0-9-]*$/;
25
- var styles = /* @__PURE__ */ new Set([
26
- "3d",
27
- "colored",
28
- "colored-3d",
29
- "handheld",
30
- "handheld-dim",
31
- "handheld-silhouette",
32
- "handheld-styles",
33
- "standard",
34
- "textured"
35
- ]);
36
- var platforms = /* @__PURE__ */ new Set(["iphone", "ipad", "mac", "watch"]);
37
- var frameExtensions = /* @__PURE__ */ new Set([".jpeg", ".jpg", ".png", ".svg", ".webp"]);
38
- function emptyDeviceMockupCatalog() {
39
- return { bundles: [], groups: [], mockups: [] };
40
- }
41
- function parseMockupBundleManifest(value) {
42
- if (!isRecord(value)) throw new Error(`${MOCKUP_BUNDLE_FILENAME} must contain a JSON object`);
43
- if (value.format !== MOCKUP_BUNDLE_FORMAT || value.version !== 1) {
44
- throw new Error("Unsupported StoreShot mockup bundle format");
45
- }
46
- if (!Array.isArray(value.mockups) || value.mockups.length === 0) {
47
- throw new Error("A mockup bundle must contain at least one mockup");
48
- }
49
- const id = readIdentifier(value.id, "Bundle id");
50
- const mockups = value.mockups.map(parseMockupEntry);
51
- const entryIds = /* @__PURE__ */ new Set();
52
- for (const mockup of mockups) {
53
- if (entryIds.has(mockup.id)) throw new Error(`Duplicate mockup id: ${mockup.id}`);
54
- entryIds.add(mockup.id);
55
- }
56
- return {
57
- format: MOCKUP_BUNDLE_FORMAT,
58
- version: 1,
59
- id,
60
- name: readString(value.name, "Bundle name"),
61
- author: readString(value.author, "Bundle author"),
62
- license: parseLicense(value.license),
63
- ...value.source === void 0 ? {} : { source: parseSource(value.source) },
64
- mockups
65
- };
66
- }
67
- function resolveMockupBundle(manifest, baseUrl, origin) {
68
- const bundlePrefix = `${manifest.id}/`;
69
- const mockups = manifest.mockups.map((entry) => ({
70
- ...entry,
71
- id: `${bundlePrefix}${entry.id}`,
72
- bundleId: manifest.id,
73
- groupId: `${bundlePrefix}${entry.groupId}`,
74
- frameUrl: `${baseUrl}${encodeBundlePath(entry.frame)}`,
75
- thumbnailUrl: `${baseUrl}${encodeBundlePath(entry.thumbnail ?? entry.frame)}`
76
- }));
77
- const groupEntries = /* @__PURE__ */ new Map();
78
- for (const mockup of mockups) {
79
- const entries = groupEntries.get(mockup.groupId) ?? [];
80
- entries.push(mockup);
81
- groupEntries.set(mockup.groupId, entries);
82
- }
83
- return {
84
- bundles: [{
85
- id: manifest.id,
86
- name: manifest.name,
87
- author: manifest.author,
88
- license: manifest.license,
89
- ...manifest.source ? { source: manifest.source } : {},
90
- mockupCount: mockups.length,
91
- origin
92
- }],
93
- groups: [...groupEntries.entries()].map(([id, entries]) => ({
94
- id,
95
- bundleId: manifest.id,
96
- name: manifest.mockups.find((entry) => `${bundlePrefix}${entry.groupId}` === id)?.groupName ?? entries[0].name,
97
- platform: entries[0].platform,
98
- style: entries[0].style,
99
- thumbnailUrl: entries[0].thumbnailUrl,
100
- count: entries.length
101
- })),
102
- mockups
103
- };
104
- }
105
- function mergeDeviceMockupCatalogs(...catalogs) {
106
- const result = emptyDeviceMockupCatalog();
107
- const bundleIds = /* @__PURE__ */ new Set();
108
- const groupIds = /* @__PURE__ */ new Set();
109
- const mockupIds = /* @__PURE__ */ new Set();
110
- for (const catalog of catalogs) {
111
- for (const bundle of catalog.bundles) {
112
- if (bundleIds.has(bundle.id)) continue;
113
- bundleIds.add(bundle.id);
114
- result.bundles.push(bundle);
115
- }
116
- for (const group of catalog.groups) {
117
- if (groupIds.has(group.id)) continue;
118
- groupIds.add(group.id);
119
- result.groups.push(group);
120
- }
121
- for (const mockup of catalog.mockups) {
122
- if (mockupIds.has(mockup.id)) continue;
123
- mockupIds.add(mockup.id);
124
- result.mockups.push(mockup);
125
- }
126
- }
127
- return result;
128
- }
129
- function bundleAssetPaths(manifest) {
130
- const paths = /* @__PURE__ */ new Set();
131
- if (manifest.license.file) paths.add(manifest.license.file);
132
- for (const mockup of manifest.mockups) {
133
- paths.add(mockup.frame);
134
- if (mockup.thumbnail) paths.add(mockup.thumbnail);
135
- }
136
- return [...paths];
137
- }
138
- function isSafeBundlePath(value) {
139
- if (!value || value.startsWith("/") || value.includes("\\")) return false;
140
- const parts = value.split("/");
141
- return parts.every((part) => part.length > 0 && part !== "." && part !== "..");
142
- }
143
- function parseMockupEntry(value) {
144
- if (!isRecord(value)) throw new Error("Mockup entry must be an object");
145
- const platform = readString(value.platform, "Mockup platform");
146
- const style = readString(value.style, "Mockup style");
147
- if (!platforms.has(platform)) throw new Error(`Unsupported mockup platform: ${platform}`);
148
- if (!styles.has(style)) throw new Error(`Unsupported mockup style: ${style}`);
149
- const frame = readFramePath(value.frame, "Mockup frame");
150
- const thumbnail = value.thumbnail === void 0 ? void 0 : readFramePath(value.thumbnail, "Mockup thumbnail");
151
- return {
152
- id: readIdentifier(value.id, "Mockup id"),
153
- groupId: readIdentifier(value.groupId, "Mockup group id"),
154
- groupName: readString(value.groupName, "Mockup group name"),
155
- name: readString(value.name, "Mockup name"),
156
- description: readString(value.description, "Mockup description"),
157
- platform,
158
- style,
159
- frame,
160
- ...thumbnail ? { thumbnail } : {},
161
- width: readDimension(value.width, "Mockup width"),
162
- height: readDimension(value.height, "Mockup height"),
163
- screen: parseScreen(value.screen)
164
- };
165
- }
166
- function parseScreen(value) {
167
- if (!isRecord(value)) throw new Error("Mockup screen must be an object");
168
- if (value.kind === "rect") {
169
- return {
170
- kind: "rect",
171
- x: readNumber(value.x, "Screen x"),
172
- y: readNumber(value.y, "Screen y"),
173
- width: readDimension(value.width, "Screen width"),
174
- height: readDimension(value.height, "Screen height"),
175
- cornerRadius: readNonNegative(value.cornerRadius, "Screen corner radius")
176
- };
177
- }
178
- if (value.kind === "projective") {
179
- if (!Array.isArray(value.transform) || value.transform.length !== 3) throw new Error("Projective transform must have three rows");
180
- const transform = value.transform.map((row) => {
181
- if (!Array.isArray(row) || row.length !== 3) throw new Error("Each projective transform row must have three numbers");
182
- return row.map((entry) => readNumber(entry, "Projective transform value"));
183
- });
184
- if (!isRecord(value.sourceCornerRadius)) throw new Error("Projective source corner radius must be an object");
185
- return {
186
- kind: "projective",
187
- transform,
188
- sourceCornerRadius: {
189
- x: readNonNegative(value.sourceCornerRadius.x, "Source corner radius x"),
190
- y: readNonNegative(value.sourceCornerRadius.y, "Source corner radius y")
191
- }
192
- };
193
- }
194
- throw new Error("Unsupported mockup screen geometry");
195
- }
196
- function parseLicense(value) {
197
- if (!isRecord(value)) throw new Error("Bundle license must be an object");
198
- const file = value.file === void 0 ? void 0 : readBundlePath(value.file, "License file");
199
- return {
200
- name: readString(value.name, "License name"),
201
- ...value.url === void 0 ? {} : { url: readString(value.url, "License URL") },
202
- ...file ? { file } : {}
203
- };
204
- }
205
- function parseSource(value) {
206
- if (!isRecord(value)) throw new Error("Bundle source must be an object");
207
- return {
208
- ...value.name === void 0 ? {} : { name: readString(value.name, "Source name") },
209
- url: readString(value.url, "Source URL"),
210
- ...value.revision === void 0 ? {} : { revision: readString(value.revision, "Source revision") }
211
- };
212
- }
213
- function readFramePath(value, name) {
214
- const result = readBundlePath(value, name);
215
- const extensionIndex = result.lastIndexOf(".");
216
- const extension = extensionIndex >= 0 ? result.slice(extensionIndex).toLowerCase() : "";
217
- if (!frameExtensions.has(extension)) throw new Error(`${name} has an unsupported file type`);
218
- return result;
219
- }
220
- function readBundlePath(value, name) {
221
- const result = readString(value, name);
222
- if (!isSafeBundlePath(result)) throw new Error(`${name} must be a relative path inside the bundle`);
223
- return result;
224
- }
225
- function readIdentifier(value, name) {
226
- const result = readString(value, name);
227
- if (!identifierPattern.test(result)) throw new Error(`${name} must use lowercase letters, numbers, and hyphens`);
228
- return result;
229
- }
230
- function readString(value, name) {
231
- const result = typeof value === "string" ? value.trim() : "";
232
- if (!result) throw new Error(`${name} must be a non-empty string`);
233
- return result;
234
- }
235
- function readNumber(value, name) {
236
- if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${name} must be a number`);
237
- return value;
238
- }
239
- function readDimension(value, name) {
240
- const result = readNumber(value, name);
241
- if (result <= 0 || result > 2e4) throw new Error(`${name} is outside the supported range`);
242
- return result;
243
- }
244
- function readNonNegative(value, name) {
245
- const result = readNumber(value, name);
246
- if (result < 0 || result > 2e4) throw new Error(`${name} is outside the supported range`);
247
- return result;
248
- }
249
- function encodeBundlePath(value) {
250
- return value.split("/").map(encodeURIComponent).join("/");
251
- }
252
- function isRecord(value) {
253
- return typeof value === "object" && value !== null && !Array.isArray(value);
254
- }
255
-
256
30
  // src/image-metadata.ts
257
31
  function readImageMetadata(contents) {
258
32
  return readPngMetadata(contents) ?? readJpegMetadata(contents) ?? readWebpMetadata(contents) ?? readSvgMetadata(contents);
@@ -385,10 +159,6 @@ function cloneScreenshotArea(area, { idFactory = defaultIdFactory, name = area.n
385
159
  };
386
160
  }
387
161
 
388
- // src/shared.ts
389
- var ASSET_CATEGORIES = ["screenshots", "brand", "logos", "other"];
390
- var SCREENSHOT_DEVICE_TYPES = ["iphone", "ipad", "mac", "watch"];
391
-
392
162
  // src/project-store.ts
393
163
  var CONFIG_FILENAME = "storeshot.json";
394
164
  var ASSETS_DIRECTORY = "assets";
@@ -444,10 +214,10 @@ var ProjectStore = class {
444
214
  }
445
215
  async readConfig() {
446
216
  const value = JSON.parse(await this.readManagedFile(this.configPath, this.root, "utf8"));
447
- return parseConfig(value);
217
+ return parseStoreShotConfig(value);
448
218
  }
449
219
  async writeConfig(config) {
450
- const value = parseConfig(config);
220
+ const value = parseStoreShotConfig(config);
451
221
  await this.runExclusive(this.configPath, async () => {
452
222
  await this.assertSafeWriteTarget(this.configPath, this.root);
453
223
  await writeJson(this.configPath, value);
@@ -523,7 +293,7 @@ var ProjectStore = class {
523
293
  async listSets() {
524
294
  const entries = await readdir(this.setsPath, { withFileTypes: true });
525
295
  const sets = await Promise.all(
526
- entries.filter((entry) => entry.isFile() && path.extname(entry.name) === ".json").map(async (entry) => parseSet(JSON.parse(await this.readManagedFile(path.join(this.setsPath, entry.name), this.setsPath, "utf8"))))
296
+ entries.filter((entry) => entry.isFile() && path.extname(entry.name) === ".json").map(async (entry) => parseScreenshotSet(JSON.parse(await this.readManagedFile(path.join(this.setsPath, entry.name), this.setsPath, "utf8"))))
527
297
  );
528
298
  return sets.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
529
299
  }
@@ -598,7 +368,7 @@ var ProjectStore = class {
598
368
  if (id !== set.id) throw new Error("Set id cannot be changed");
599
369
  const target = this.resolveSet(id);
600
370
  return this.runExclusive(target, async () => {
601
- const value = parseSet({ ...set, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
371
+ const value = parseScreenshotSet({ ...set, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
602
372
  await this.assertSafeWriteTarget(target, this.setsPath);
603
373
  await writeJson(target, value);
604
374
  return value;
@@ -608,8 +378,8 @@ var ProjectStore = class {
608
378
  const metadata = parseSetMetadataInput(input);
609
379
  const target = this.resolveSet(id);
610
380
  return this.runExclusive(target, async () => {
611
- const current = parseSet(JSON.parse(await this.readManagedFile(target, this.setsPath, "utf8")));
612
- const value = parseSet({ ...current, ...metadata, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
381
+ const current = parseScreenshotSet(JSON.parse(await this.readManagedFile(target, this.setsPath, "utf8")));
382
+ const value = parseScreenshotSet({ ...current, ...metadata, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
613
383
  await writeJson(target, value);
614
384
  return value;
615
385
  });
@@ -775,9 +545,9 @@ var ProjectStore = class {
775
545
  const target = path.join(this.assetsPath, ASSET_METADATA_FILENAME);
776
546
  try {
777
547
  const value = JSON.parse(await this.readManagedFile(target, this.assetsPath, "utf8"));
778
- if (!isRecord2(value) || value.version !== 1 || !isRecord2(value.assets)) return {};
548
+ if (!isRecord(value) || value.version !== 1 || !isRecord(value.assets)) return {};
779
549
  return Object.fromEntries(Object.entries(value.assets).flatMap(([id, entry]) => {
780
- if (!isRecord2(entry) || !SCREENSHOT_DEVICE_TYPES.includes(entry.deviceType)) return [];
550
+ if (!isRecord(entry) || !SCREENSHOT_DEVICE_TYPES.includes(entry.deviceType)) return [];
781
551
  return [[id, { deviceType: entry.deviceType }]];
782
552
  }));
783
553
  } catch (error) {
@@ -808,7 +578,7 @@ var ProjectStore = class {
808
578
  }
809
579
  async readSet(id) {
810
580
  const target = this.resolveSet(id);
811
- return parseSet(JSON.parse(await this.readManagedFile(target, this.setsPath, "utf8")));
581
+ return parseScreenshotSet(JSON.parse(await this.readManagedFile(target, this.setsPath, "utf8")));
812
582
  }
813
583
  async runExclusive(key, operation) {
814
584
  const previous = this.operations.get(key) ?? Promise.resolve();
@@ -888,21 +658,24 @@ var DuplicateAssetError = class extends Error {
888
658
  function hash(contents) {
889
659
  return createHash("sha256").update(contents).digest("hex");
890
660
  }
891
- function parseConfig(value) {
892
- if (!isRecord2(value)) throw new Error("storeshot.json must contain a JSON object");
893
- const appName = readString2(value.appName, "appName");
894
- const platforms2 = Array.isArray(value.platforms) ? value.platforms.filter((platform) => platform === "ios" || platform === "android") : [];
895
- if (platforms2.length === 0) throw new Error("platforms must include ios or android");
896
- return { version: 1, appName, platforms: [...new Set(platforms2)] };
661
+ function parseStoreShotConfig(value) {
662
+ if (!isRecord(value)) throw new Error("storeshot.json must contain a JSON object");
663
+ if (value.version !== 1) throw new Error("Unsupported storeshot.json version");
664
+ const appName = readString(value.appName, "appName");
665
+ if (!Array.isArray(value.platforms) || value.platforms.length === 0 || value.platforms.some((platform) => platform !== "ios" && platform !== "android")) {
666
+ throw new Error("platforms must contain only ios or android");
667
+ }
668
+ const platforms = value.platforms;
669
+ return { version: 1, appName, platforms: [...new Set(platforms)] };
897
670
  }
898
671
  function parseCreateSetInput(value) {
899
- if (!isRecord2(value)) throw new Error("Set details must be an object");
672
+ if (!isRecord(value)) throw new Error("Set details must be an object");
900
673
  return {
901
- name: readString2(value.name, "Set name"),
902
- locale: readString2(value.locale, "Locale"),
903
- device: readString2(value.device, "Device"),
904
- width: readDimension2(value.width, "Canvas width"),
905
- height: readDimension2(value.height, "Canvas height")
674
+ name: readString(value.name, "Set name"),
675
+ locale: readString(value.locale, "Locale"),
676
+ device: readString(value.device, "Device"),
677
+ width: readDimension(value.width, "Canvas width"),
678
+ height: readDimension(value.height, "Canvas height")
906
679
  };
907
680
  }
908
681
  function duplicateName(sourceName, existingNames) {
@@ -914,70 +687,73 @@ function duplicateName(sourceName, existingNames) {
914
687
  return `${baseName} ${copyNumber}`;
915
688
  }
916
689
  function parseSetMetadataInput(value) {
917
- if (!isRecord2(value)) throw new Error("Set settings must be an object");
690
+ if (!isRecord(value)) throw new Error("Set settings must be an object");
918
691
  return {
919
- name: readString2(value.name, "Set name"),
920
- locale: readString2(value.locale, "Locale"),
921
- device: readString2(value.device, "Device")
692
+ name: readString(value.name, "Set name"),
693
+ locale: readString(value.locale, "Locale"),
694
+ device: readString(value.device, "Device")
922
695
  };
923
696
  }
924
- function parseSet(value) {
925
- if (!isRecord2(value)) throw new Error("Set file must contain an object");
926
- const id = readString2(value.id, "Set id");
697
+ function parseScreenshotSet(value) {
698
+ if (!isRecord(value)) throw new Error("Set file must contain an object");
699
+ if (value.version !== 1) throw new Error("Unsupported set file version");
700
+ const id = readString(value.id, "Set id");
927
701
  if (!safeIdentifierPattern.test(id)) throw new Error("Set id is invalid");
928
- if (!isRecord2(value.canvas)) throw new Error("Set canvas is invalid");
702
+ if (!isRecord(value.canvas)) throw new Error("Set canvas is invalid");
929
703
  if (!Array.isArray(value.areas) || value.areas.length === 0) throw new Error("A set needs at least one screenshot area");
930
704
  return {
931
705
  version: 1,
932
706
  id,
933
- name: readString2(value.name, "Set name"),
934
- locale: readString2(value.locale, "Locale"),
935
- device: readString2(value.device, "Device"),
707
+ name: readString(value.name, "Set name"),
708
+ locale: readString(value.locale, "Locale"),
709
+ device: readString(value.device, "Device"),
936
710
  canvas: {
937
- width: readDimension2(value.canvas.width, "Canvas width"),
938
- height: readDimension2(value.canvas.height, "Canvas height")
711
+ width: readDimension(value.canvas.width, "Canvas width"),
712
+ height: readDimension(value.canvas.height, "Canvas height")
939
713
  },
940
714
  areas: value.areas.map(parseArea),
941
- createdAt: readString2(value.createdAt, "Created time"),
942
- updatedAt: readString2(value.updatedAt, "Updated time")
715
+ createdAt: readString(value.createdAt, "Created time"),
716
+ updatedAt: readString(value.updatedAt, "Updated time")
943
717
  };
944
718
  }
945
719
  function parseArea(value) {
946
- if (!isRecord2(value)) throw new Error("Screenshot area is invalid");
947
- const background = readString2(value.background, "Area background");
720
+ if (!isRecord(value)) throw new Error("Screenshot area is invalid");
721
+ const background = readString(value.background, "Area background");
948
722
  if (!hexColorPattern.test(background)) throw new Error("Area background must be a hex color");
949
723
  if (!Array.isArray(value.elements)) throw new Error("Area elements must be an array");
950
724
  return {
951
- id: readString2(value.id, "Area id"),
952
- name: readString2(value.name, "Area name"),
725
+ id: readString(value.id, "Area id"),
726
+ name: readString(value.name, "Area name"),
953
727
  background,
954
728
  elements: value.elements.map(parseElement)
955
729
  };
956
730
  }
957
731
  function parseImageSource(value, legacyAssetId) {
958
- if (isRecord2(value)) {
959
- if (value.kind === "builtin") return { kind: "builtin", id: readString2(value.id, "Built-in artwork id") };
960
- if (value.kind === "asset") return { kind: "asset", assetId: readString2(value.assetId, "Asset id") };
732
+ if (isRecord(value)) {
733
+ if (value.kind === "builtin") return { kind: "builtin", id: readString(value.id, "Built-in artwork id") };
734
+ if (value.kind === "asset") return { kind: "asset", assetId: readString(value.assetId, "Asset id") };
961
735
  throw new Error("Image source type is invalid");
962
736
  }
963
- if (legacyAssetId !== void 0) return { kind: "asset", assetId: readString2(legacyAssetId, "Asset id") };
737
+ if (legacyAssetId !== void 0) return { kind: "asset", assetId: readString(legacyAssetId, "Asset id") };
964
738
  throw new Error("Image source is invalid");
965
739
  }
966
740
  function parseElement(value) {
967
- if (!isRecord2(value)) throw new Error("Canvas element is invalid");
741
+ if (!isRecord(value)) throw new Error("Canvas element is invalid");
968
742
  const base = {
969
- id: readString2(value.id, "Element id"),
970
- x: readNumber2(value.x, "Element x"),
971
- y: readNumber2(value.y, "Element y"),
972
- width: readDimension2(value.width, "Element width"),
973
- height: readDimension2(value.height, "Element height"),
974
- rotation: readNumber2(value.rotation, "Element rotation"),
975
- opacity: readOptionalRange(value.opacity, 1, 0, 1, "Element opacity")
743
+ id: readString(value.id, "Element id"),
744
+ x: readNumber(value.x, "Element x"),
745
+ y: readNumber(value.y, "Element y"),
746
+ width: readDimension(value.width, "Element width"),
747
+ height: readDimension(value.height, "Element height"),
748
+ rotation: readNumber(value.rotation, "Element rotation"),
749
+ opacity: readOptionalRange(value.opacity, 1, 0, 1, "Element opacity"),
750
+ flipX: readOptionalBoolean(value.flipX, false, "Element horizontal flip"),
751
+ flipY: readOptionalBoolean(value.flipY, false, "Element vertical flip")
976
752
  };
977
753
  if (value.type === "image") {
978
754
  if (value.fit !== "contain" && value.fit !== "cover") throw new Error("Image fit is invalid");
979
755
  const source = parseImageSource(value.source, value.assetId);
980
- const fill = value.fill === void 0 ? void 0 : readString2(value.fill, "Image fill");
756
+ const fill = value.fill === void 0 ? void 0 : readString(value.fill, "Image fill");
981
757
  if (fill !== void 0 && !hexColorPattern.test(fill)) throw new Error("Image fill must be a hex color");
982
758
  return { ...base, type: "image", source, fit: value.fit, ...fill === void 0 ? {} : { fill } };
983
759
  }
@@ -985,13 +761,13 @@ function parseElement(value) {
985
761
  return {
986
762
  ...base,
987
763
  type: "mockup",
988
- mockupId: readString2(value.mockupId, "Device mockup id"),
989
- assetId: readString2(value.assetId, "Asset id")
764
+ mockupId: readString(value.mockupId, "Device mockup id"),
765
+ assetId: readString(value.assetId, "Asset id")
990
766
  };
991
767
  }
992
768
  if (value.type === "element") {
993
769
  const source = parseImageSource(value.source);
994
- const fill = value.fill === void 0 ? void 0 : readString2(value.fill, "Image fill");
770
+ const fill = value.fill === void 0 ? void 0 : readString(value.fill, "Image fill");
995
771
  if (fill !== void 0 && !hexColorPattern.test(fill)) throw new Error("Image fill must be a hex color");
996
772
  return {
997
773
  ...base,
@@ -1009,15 +785,15 @@ function parseElement(value) {
1009
785
  if (value.textAlign !== "left" && value.textAlign !== "center" && value.textAlign !== "right") {
1010
786
  throw new Error("Text alignment is invalid");
1011
787
  }
1012
- const color = readString2(value.color, "Text color");
788
+ const color = readString(value.color, "Text color");
1013
789
  if (!hexColorPattern.test(color)) throw new Error("Text color must be a hex color");
1014
- const lineHeight = value.lineHeight === void 0 ? void 0 : readDimension2(value.lineHeight, "Text line height");
790
+ const lineHeight = value.lineHeight === void 0 ? void 0 : readDimension(value.lineHeight, "Text line height");
1015
791
  return {
1016
792
  ...base,
1017
793
  type: "text",
1018
794
  text: typeof value.text === "string" ? value.text : "",
1019
795
  fontFamily: readOptionalString(value.fontFamily, "Geist Variable"),
1020
- fontSize: readDimension2(value.fontSize, "Font size"),
796
+ fontSize: readDimension(value.fontSize, "Font size"),
1021
797
  fontWeight,
1022
798
  ...lineHeight === void 0 ? {} : { lineHeight },
1023
799
  color,
@@ -1026,7 +802,7 @@ function parseElement(value) {
1026
802
  }
1027
803
  if (value.type === "shape") {
1028
804
  if (value.shape !== "rectangle") throw new Error("Shape type is invalid");
1029
- const fill = readString2(value.fill, "Shape fill");
805
+ const fill = readString(value.fill, "Shape fill");
1030
806
  if (!hexColorPattern.test(fill)) throw new Error("Shape fill must be a hex color");
1031
807
  return {
1032
808
  ...base,
@@ -1038,33 +814,38 @@ function parseElement(value) {
1038
814
  }
1039
815
  throw new Error("Unsupported canvas element type");
1040
816
  }
1041
- function readString2(value, name) {
817
+ function readString(value, name) {
1042
818
  const result = typeof value === "string" ? value.trim() : "";
1043
819
  if (!result) throw new Error(`${name} must be a non-empty string`);
1044
820
  return result;
1045
821
  }
1046
- function readNumber2(value, name) {
822
+ function readNumber(value, name) {
1047
823
  if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${name} must be a number`);
1048
824
  return value;
1049
825
  }
1050
826
  function readOptionalRange(value, fallback, min, max, name) {
1051
827
  if (value === void 0) return fallback;
1052
- const result = readNumber2(value, name);
828
+ const result = readNumber(value, name);
1053
829
  if (result < min || result > max) throw new Error(`${name} is outside the supported range`);
1054
830
  return result;
1055
831
  }
832
+ function readOptionalBoolean(value, fallback, name) {
833
+ if (value === void 0) return fallback;
834
+ if (typeof value !== "boolean") throw new Error(`${name} must be a boolean`);
835
+ return value;
836
+ }
1056
837
  function readOptionalString(value, fallback) {
1057
838
  return typeof value === "string" && value.trim() ? value.trim() : fallback;
1058
839
  }
1059
- function readDimension2(value, name) {
1060
- const result = readNumber2(value, name);
840
+ function readDimension(value, name) {
841
+ const result = readNumber(value, name);
1061
842
  if (result <= 0 || result > 2e4) throw new Error(`${name} is outside the supported range`);
1062
843
  return result;
1063
844
  }
1064
845
  function isAssetCategory(value) {
1065
846
  return ASSET_CATEGORIES.includes(value);
1066
847
  }
1067
- function isRecord2(value) {
848
+ function isRecord(value) {
1068
849
  return typeof value === "object" && value !== null && !Array.isArray(value);
1069
850
  }
1070
851
  function slugify(value) {
@@ -1094,10 +875,15 @@ function assertValidImage(filename, contents) {
1094
875
  }
1095
876
 
1096
877
  // src/server.ts
878
+ import { createReadStream, watch } from "fs";
879
+ import { readFile as readFile2, stat as stat2 } from "fs/promises";
880
+ import { createServer } from "http";
881
+ import path2 from "path";
1097
882
  var MAX_REQUEST_BYTES = 25 * 1024 * 1024;
1098
883
  async function startServer(options) {
1099
884
  const store = new ProjectStore(options.projectDirectory);
1100
885
  await store.initialize();
886
+ const projectEvents = new ProjectEventHub(store.root);
1101
887
  let vite;
1102
888
  if (options.useVite) {
1103
889
  const { createServer: createViteServer } = await import("vite");
@@ -1110,7 +896,7 @@ async function startServer(options) {
1110
896
  const server = createServer(async (request, response) => {
1111
897
  try {
1112
898
  if ((request.url ?? "").startsWith("/api/")) assertAllowedBrowserRequest(request, options.host);
1113
- if (await handleApiRequest(request, response, store)) return;
899
+ if (await handleApiRequest(request, response, store, projectEvents)) return;
1114
900
  if (vite) {
1115
901
  vite.middlewares(request, response);
1116
902
  return;
@@ -1131,6 +917,7 @@ async function startServer(options) {
1131
917
  store,
1132
918
  port: server.address().port,
1133
919
  close: async () => {
920
+ projectEvents.close();
1134
921
  await vite?.close();
1135
922
  await new Promise((resolve, reject) => {
1136
923
  server.close((error) => error ? reject(error) : resolve());
@@ -1138,8 +925,12 @@ async function startServer(options) {
1138
925
  }
1139
926
  };
1140
927
  }
1141
- async function handleApiRequest(request, response, store) {
928
+ async function handleApiRequest(request, response, store, projectEvents) {
1142
929
  const url = new URL(request.url ?? "/", "http://localhost");
930
+ if (request.method === "GET" && url.pathname === "/api/events") {
931
+ projectEvents.subscribe(request, response);
932
+ return true;
933
+ }
1143
934
  if (request.method === "GET" && url.pathname === "/api/project") {
1144
935
  sendJson(response, 200, await store.readProject());
1145
936
  return true;
@@ -1418,41 +1209,577 @@ function contentTypeFor(filename) {
1418
1209
  return "application/octet-stream";
1419
1210
  }
1420
1211
  }
1212
+ var ProjectEventHub = class {
1213
+ clients = /* @__PURE__ */ new Set();
1214
+ scopes = /* @__PURE__ */ new Set();
1215
+ watcher;
1216
+ heartbeat;
1217
+ broadcastTimer;
1218
+ revision = 0;
1219
+ constructor(root) {
1220
+ this.watcher = watch(root, { recursive: true, encoding: "utf8" }, (_event, filename) => {
1221
+ const relativePath = typeof filename === "string" ? filename.split(path2.sep).join("/") : "";
1222
+ const scope = changeScope(relativePath);
1223
+ if (!scope) return;
1224
+ this.scopes.add(scope);
1225
+ if (this.broadcastTimer) clearTimeout(this.broadcastTimer);
1226
+ this.broadcastTimer = setTimeout(() => this.broadcast(), 75);
1227
+ });
1228
+ this.watcher.on("error", (error) => console.error("StoreShot project watcher failed", error));
1229
+ this.heartbeat = setInterval(() => {
1230
+ for (const response of this.clients) response.write(": keep-alive\n\n");
1231
+ }, 15e3);
1232
+ this.heartbeat.unref();
1233
+ }
1234
+ subscribe(request, response) {
1235
+ response.writeHead(200, {
1236
+ "Content-Type": "text/event-stream; charset=utf-8",
1237
+ "Cache-Control": "no-cache, no-transform",
1238
+ Connection: "keep-alive",
1239
+ "X-Accel-Buffering": "no"
1240
+ });
1241
+ response.write(`event: ready
1242
+ data: ${JSON.stringify({ revision: this.revision })}
1243
+
1244
+ `);
1245
+ this.clients.add(response);
1246
+ request.once("close", () => this.clients.delete(response));
1247
+ }
1248
+ close() {
1249
+ if (this.broadcastTimer) clearTimeout(this.broadcastTimer);
1250
+ clearInterval(this.heartbeat);
1251
+ this.watcher.close();
1252
+ for (const response of this.clients) response.end();
1253
+ this.clients.clear();
1254
+ }
1255
+ broadcast() {
1256
+ this.broadcastTimer = void 0;
1257
+ if (this.scopes.size === 0) return;
1258
+ this.revision += 1;
1259
+ const data = JSON.stringify({ revision: this.revision, scopes: [...this.scopes].sort() });
1260
+ this.scopes.clear();
1261
+ for (const response of this.clients) response.write(`event: project
1262
+ data: ${data}
1263
+
1264
+ `);
1265
+ }
1266
+ };
1267
+ function changeScope(relativePath) {
1268
+ if (!relativePath) return "project";
1269
+ if (relativePath === "storeshot.json" || relativePath.startsWith(".storeshot.json.")) return "config";
1270
+ if (relativePath === "sets" || relativePath.startsWith("sets/")) return "sets";
1271
+ if (relativePath === "assets" || relativePath.startsWith("assets/")) return "assets";
1272
+ if (relativePath.startsWith("mockup-bundles/.imports/")) return void 0;
1273
+ if (relativePath === "mockup-bundles" || relativePath.startsWith("mockup-bundles/")) return "mockups";
1274
+ if (relativePath === "renders" || relativePath.startsWith("renders/")) return void 0;
1275
+ return "project";
1276
+ }
1277
+
1278
+ // src/validation.ts
1279
+ import { lstat as lstat2, readFile as readFile3, readdir as readdir2 } from "fs/promises";
1280
+ import path3 from "path";
1281
+ async function validateProject(directory, packageRoot2) {
1282
+ const root = path3.resolve(directory);
1283
+ const issues = [];
1284
+ const store = new ProjectStore(root);
1285
+ let config;
1286
+ let assets;
1287
+ let mockupCatalog;
1288
+ const sets = [];
1289
+ if (!await checkDirectory(root, ".", issues)) return validationResult(root, issues, sets);
1290
+ const configPath = path3.join(root, CONFIG_FILENAME);
1291
+ try {
1292
+ config = parseStoreShotConfig(JSON.parse(await readRegularFile(configPath, root)));
1293
+ } catch (error) {
1294
+ issues.push(issue("error", "config.invalid", CONFIG_FILENAME, messageFor(error)));
1295
+ }
1296
+ const assetsReady = await checkDirectory(path3.join(root, ASSETS_DIRECTORY), ASSETS_DIRECTORY, issues);
1297
+ const setsReady = await checkDirectory(path3.join(root, SETS_DIRECTORY), SETS_DIRECTORY, issues);
1298
+ const mockupsReady = await checkDirectory(path3.join(root, MOCKUP_BUNDLES_DIRECTORY), MOCKUP_BUNDLES_DIRECTORY, issues);
1299
+ if (setsReady) {
1300
+ const entries = await readdir2(store.setsPath, { withFileTypes: true });
1301
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
1302
+ if (!entry.name.endsWith(".json")) continue;
1303
+ const relativePath = `${SETS_DIRECTORY}/${entry.name}`;
1304
+ if (!entry.isFile() || entry.isSymbolicLink()) {
1305
+ issues.push(issue("error", "set.notRegularFile", relativePath, "Set documents must be regular JSON files"));
1306
+ continue;
1307
+ }
1308
+ try {
1309
+ const set = parseScreenshotSet(JSON.parse(await readRegularFile(path3.join(store.setsPath, entry.name), store.setsPath)));
1310
+ sets.push(set);
1311
+ if (entry.name !== `${set.id}.json`) {
1312
+ issues.push(issue("error", "set.filenameMismatch", relativePath, `Set id ${set.id} must be stored in ${set.id}.json`));
1313
+ }
1314
+ } catch (error) {
1315
+ issues.push(issue("error", "set.invalid", relativePath, messageFor(error)));
1316
+ }
1317
+ }
1318
+ }
1319
+ if (assetsReady) {
1320
+ try {
1321
+ assets = await store.listAssets();
1322
+ for (const asset of Object.values(assets).flat()) {
1323
+ if (!asset.width || !asset.height) {
1324
+ issues.push(issue("error", "asset.invalidImage", `${ASSETS_DIRECTORY}/${asset.id}`, "Asset is not a valid supported image"));
1325
+ }
1326
+ }
1327
+ } catch (error) {
1328
+ issues.push(issue("error", "assets.invalid", ASSETS_DIRECTORY, messageFor(error)));
1329
+ }
1330
+ }
1331
+ if (mockupsReady) {
1332
+ try {
1333
+ const [builtInCatalog, projectCatalog] = await Promise.all([
1334
+ loadBuiltInMockupCatalog(packageRoot2),
1335
+ store.listMockupCatalog()
1336
+ ]);
1337
+ mockupCatalog = mergeDeviceMockupCatalogs(builtInCatalog, projectCatalog);
1338
+ } catch (error) {
1339
+ issues.push(issue("error", "mockups.invalid", MOCKUP_BUNDLES_DIRECTORY, messageFor(error)));
1340
+ }
1341
+ }
1342
+ validateSetSemantics(sets, assets, mockupCatalog, issues);
1343
+ return validationResult(root, issues, sets, config, assets, mockupCatalog);
1344
+ }
1345
+ async function loadBuiltInMockupCatalog(packageRoot2) {
1346
+ const publicDirectory = await resolvePackagePublicDirectory(packageRoot2);
1347
+ const bundleDirectory = path3.join(publicDirectory, "mockup-bundles/frameup-free");
1348
+ const manifest = parseMockupBundleManifest(JSON.parse(await readRegularFile(
1349
+ path3.join(bundleDirectory, MOCKUP_BUNDLE_FILENAME),
1350
+ bundleDirectory
1351
+ )));
1352
+ return resolveMockupBundle(manifest, "/mockup-bundles/frameup-free/", "built-in");
1353
+ }
1354
+ function validateSetSemantics(sets, assets, mockupCatalog, issues) {
1355
+ const setIds = /* @__PURE__ */ new Set();
1356
+ const assetIds = new Set(Object.values(assets ?? {}).flat().map((asset) => asset.id));
1357
+ const mockupIds = new Set(mockupCatalog?.mockups.map((mockup) => mockup.id) ?? []);
1358
+ const artworkIds = new Set(BUILT_IN_ARTWORK.map((artwork) => artwork.id));
1359
+ for (const set of sets) {
1360
+ const setPath = `${SETS_DIRECTORY}/${set.id}.json`;
1361
+ if (setIds.has(set.id)) issues.push(issue("error", "set.duplicateId", setPath, `Duplicate set id: ${set.id}`));
1362
+ setIds.add(set.id);
1363
+ try {
1364
+ Intl.getCanonicalLocales(set.locale);
1365
+ } catch {
1366
+ issues.push(issue("warning", "set.locale", setPath, `${set.locale} is not a recognized locale identifier`));
1367
+ }
1368
+ if (!isIsoTimestamp(set.createdAt)) issues.push(issue("warning", "set.createdAt", setPath, "createdAt should be an ISO 8601 timestamp"));
1369
+ if (!isIsoTimestamp(set.updatedAt)) issues.push(issue("warning", "set.updatedAt", setPath, "updatedAt should be an ISO 8601 timestamp"));
1370
+ if (set.areas.length > 10) issues.push(issue("warning", "set.areaCount", setPath, "Apple App Store sets normally contain at most 10 screenshots"));
1371
+ const ids = /* @__PURE__ */ new Set();
1372
+ for (const area of set.areas) {
1373
+ if (ids.has(area.id)) issues.push(issue("error", "set.duplicateObjectId", setPath, `Duplicate area or element id: ${area.id}`));
1374
+ ids.add(area.id);
1375
+ for (const element of area.elements) {
1376
+ if (ids.has(element.id)) issues.push(issue("error", "set.duplicateObjectId", setPath, `Duplicate area or element id: ${element.id}`));
1377
+ ids.add(element.id);
1378
+ if (element.type === "mockup") {
1379
+ if (!assetIds.has(element.assetId)) issues.push(issue("error", "reference.asset", setPath, `Element ${element.id} references missing asset ${element.assetId}`));
1380
+ if (!mockupIds.has(element.mockupId)) issues.push(issue("error", "reference.mockup", setPath, `Element ${element.id} references missing mockup ${element.mockupId}`));
1381
+ } else if (element.type === "image" && element.source.kind === "asset" && !assetIds.has(element.source.assetId)) {
1382
+ issues.push(issue("error", "reference.asset", setPath, `Element ${element.id} references missing asset ${element.source.assetId}`));
1383
+ } else if (element.type === "image" && element.source.kind === "builtin" && !artworkIds.has(element.source.id)) {
1384
+ issues.push(issue("error", "reference.artwork", setPath, `Element ${element.id} references missing built-in artwork ${element.source.id}`));
1385
+ }
1386
+ }
1387
+ }
1388
+ }
1389
+ }
1390
+ async function checkDirectory(directory, displayPath, issues) {
1391
+ try {
1392
+ const metadata = await lstat2(directory);
1393
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("must be a regular directory");
1394
+ return true;
1395
+ } catch (error) {
1396
+ issues.push(issue("error", "project.directory", displayPath, messageFor(error)));
1397
+ return false;
1398
+ }
1399
+ }
1400
+ async function readRegularFile(filename, boundary) {
1401
+ const metadata = await lstat2(filename);
1402
+ if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("must be a regular file");
1403
+ const relative = path3.relative(boundary, filename);
1404
+ if (relative.startsWith("..") || path3.isAbsolute(relative)) throw new Error("is outside the project boundary");
1405
+ return readFile3(filename, "utf8");
1406
+ }
1407
+ function validationResult(directory, issues, sets, config, assets, mockupCatalog) {
1408
+ const errors = issues.filter((entry) => entry.severity === "error");
1409
+ const warnings = issues.filter((entry) => entry.severity === "warning");
1410
+ const allAssets = Object.values(assets ?? {}).flat();
1411
+ return {
1412
+ report: {
1413
+ ok: errors.length === 0,
1414
+ directory,
1415
+ errors,
1416
+ warnings,
1417
+ summary: {
1418
+ assets: allAssets.length,
1419
+ sets: sets.length,
1420
+ screenshots: assets?.screenshots.length ?? 0,
1421
+ areas: sets.reduce((total, set) => total + set.areas.length, 0)
1422
+ }
1423
+ },
1424
+ ...config ? { config } : {},
1425
+ ...assets ? { assets } : {},
1426
+ sets,
1427
+ ...mockupCatalog ? { mockupCatalog } : {}
1428
+ };
1429
+ }
1430
+ function issue(severity, code, issuePath, message) {
1431
+ return { severity, code, path: issuePath, message };
1432
+ }
1433
+ function messageFor(error) {
1434
+ return error instanceof Error ? error.message : String(error);
1435
+ }
1436
+ function isIsoTimestamp(value) {
1437
+ const date = new Date(value);
1438
+ return !Number.isNaN(date.getTime()) && date.toISOString() === value;
1439
+ }
1421
1440
 
1422
1441
  // src/cli.ts
1423
1442
  var sourceFile = fileURLToPath(import.meta.url);
1424
- var packageRoot = path3.resolve(path3.dirname(sourceFile), "..");
1425
- var isSourceExecution = path3.extname(sourceFile) === ".ts";
1426
- var program = new Command().name("storeshot").description("Manage app store screenshots locally").version("0.1.0");
1427
- program.command("dev").description("Open a StoreShot workspace for a local directory").argument("[directory]", "project directory", ".").addOption(new Option("-p, --port <port>", "local server port").default("4173")).addOption(new Option("--host <host>", "local server host").default("127.0.0.1")).option("--no-open", "do not open a browser").action(async (directory, options) => {
1428
- const port = Number(options.port);
1429
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
1430
- throw new Error("Port must be an integer between 1 and 65535");
1431
- }
1432
- const projectDirectory = path3.resolve(directory);
1433
- const service = await startServer({
1434
- host: options.host,
1435
- port,
1436
- packageRoot,
1437
- projectDirectory,
1438
- useVite: isSourceExecution
1443
+ var packageRoot = path4.resolve(path4.dirname(sourceFile), "..");
1444
+ var isSourceExecution = path4.extname(sourceFile) === ".ts";
1445
+ var DEVICE_PRESETS = {
1446
+ iphone: { device: "iPhone", width: 1320, height: 2868 },
1447
+ ipad: { device: "iPad", width: 2064, height: 2752 },
1448
+ watch: { device: "Apple Watch", width: 422, height: 514 },
1449
+ mac: { device: "Mac", width: 2880, height: 1800 }
1450
+ };
1451
+ function createCli() {
1452
+ const program2 = new Command().name("storeshot").description("Manage app store screenshots locally").version("0.1.0").option("-C, --project <directory>", "project directory", ".").option("--json", "write machine-readable JSON").showSuggestionAfterError().addHelpText("after", `
1453
+ Agent workflow:
1454
+ storeshot -C ./screenshots status --json
1455
+ storeshot -C ./screenshots validate --json
1456
+ storeshot -C ./screenshots set show <id>
1457
+ storeshot -C ./screenshots render --set <id> --scale 0.25 --json
1458
+
1459
+ Complex canvas edits can be written as complete set JSON documents with
1460
+ "set write", or edited directly in sets/ and checked with "validate".`);
1461
+ program2.command("init").description("Initialize a StoreShot project").argument("[directory]", "project directory").option("--app-name <name>", "application name").option("--platform <platform...>", "ios, android, or both").action(async (directory, options) => {
1462
+ const target = path4.resolve(directory ?? projectDirectory(program2));
1463
+ const configPath = path4.join(target, CONFIG_FILENAME);
1464
+ const created = !await fileExists(configPath);
1465
+ const store = new ProjectStore(target);
1466
+ await store.initialize();
1467
+ const current = await store.readConfig();
1468
+ const platforms = options.platform ? parsePlatforms(options.platform) : current.platforms;
1469
+ const config = options.appName || options.platform ? await store.writeConfig({ ...current, appName: options.appName ?? current.appName, platforms }) : current;
1470
+ emit(program2, `Initialized ${store.root}
1471
+ Config: ${store.configPath}`, { ok: true, created, directory: store.root, config });
1472
+ });
1473
+ program2.command("status").alias("inspect").description("Summarize the project for a human or agent").action(async () => {
1474
+ const store = await openProject(program2);
1475
+ const project = await store.readProject();
1476
+ const assets = Object.values(project.assets).flat();
1477
+ const value = {
1478
+ ok: true,
1479
+ project: {
1480
+ directory: project.directory,
1481
+ configPath: store.configPath,
1482
+ appName: project.config.appName,
1483
+ platforms: project.config.platforms
1484
+ },
1485
+ summary: {
1486
+ assets: assets.length,
1487
+ screenshots: project.assets.screenshots.length,
1488
+ sets: project.sets.length,
1489
+ areas: project.sets.reduce((total, set) => total + set.areas.length, 0)
1490
+ },
1491
+ sets: project.sets.map((set) => ({
1492
+ id: set.id,
1493
+ name: set.name,
1494
+ locale: set.locale,
1495
+ device: set.device,
1496
+ canvas: set.canvas,
1497
+ areas: set.areas.length,
1498
+ path: path4.join(store.setsPath, `${set.id}.json`)
1499
+ }))
1500
+ };
1501
+ const lines = [
1502
+ `${project.config.appName} \u2014 ${project.directory}`,
1503
+ `${value.summary.sets} sets, ${value.summary.areas} screenshots, ${value.summary.assets} assets`,
1504
+ ...value.sets.map((set) => ` ${set.id} ${set.locale} \xB7 ${set.device} \xB7 ${set.areas} screenshots`)
1505
+ ];
1506
+ emit(program2, lines.join("\n"), value);
1507
+ });
1508
+ program2.command("validate").description("Validate project files and cross-references").option("--strict", "treat warnings as failures").action(async (options) => {
1509
+ const validation = await validateProject(projectDirectory(program2), packageRoot);
1510
+ const failed = !validation.report.ok || Boolean(options.strict && validation.report.warnings.length > 0);
1511
+ emit(program2, formatValidation(validation.report), { ...validation.report, ok: !failed });
1512
+ if (failed) process.exitCode = 1;
1513
+ });
1514
+ program2.command("render").description("Render screenshot sets to PNG files").option("--set <id>", "render one set (repeatable)", collect, []).option("--area <id-or-number>", "render one area by id, exact name, or 1-based number").option("-o, --output <directory>", "output directory (default: <project>/renders)").option("--scale <scale>", "render scale from 0 to 1", "1").option("--clean", "remove each selected set's existing output directory first").action(async (options) => {
1515
+ const directory = projectDirectory(program2);
1516
+ const validation = await validateProject(directory, packageRoot);
1517
+ if (!validation.report.ok || !validation.assets) {
1518
+ emit(program2, formatValidation(validation.report), { ...validation.report, ok: false });
1519
+ process.exitCode = 1;
1520
+ return;
1521
+ }
1522
+ const selectedSets = options.set.length === 0 ? validation.sets : options.set.map((id) => {
1523
+ const set = validation.sets.find((candidate) => candidate.id === id);
1524
+ if (!set) throw new Error(`Unknown set id: ${id}`);
1525
+ return set;
1526
+ });
1527
+ if (selectedSets.length === 0) throw new Error("The project has no screenshot sets to render");
1528
+ const scale = parseNumber(options.scale, "Scale");
1529
+ const store = await openProject(program2);
1530
+ const { renderScreenshotSets } = await import("./node-renderer-FMVPIQAG.js");
1531
+ const result = await renderScreenshotSets({
1532
+ clean: Boolean(options.clean),
1533
+ outputDirectory: path4.resolve(options.output ?? path4.join(directory, "renders")),
1534
+ packageRoot,
1535
+ scale,
1536
+ sets: selectedSets,
1537
+ store,
1538
+ ...options.area ? { area: options.area } : {}
1539
+ });
1540
+ emit(
1541
+ program2,
1542
+ [`Rendered ${result.files.length} PNG file${result.files.length === 1 ? "" : "s"} to ${result.outputDirectory}`, ...result.files.map((file) => ` ${file.path}`)].join("\n"),
1543
+ { ok: true, render: result, warnings: validation.report.warnings }
1544
+ );
1545
+ });
1546
+ configureConfigCommands(program2);
1547
+ configureSetCommands(program2);
1548
+ configureAssetCommands(program2);
1549
+ configureDevCommand(program2);
1550
+ return program2;
1551
+ }
1552
+ function configureConfigCommands(program2) {
1553
+ const config = program2.command("config").description("Inspect or update storeshot.json");
1554
+ config.command("get").description("Print the project configuration").action(async () => {
1555
+ const store = await openProject(program2);
1556
+ const value = await store.readConfig();
1557
+ emitDocument(program2, value, { ok: true, config: value });
1558
+ });
1559
+ config.command("set").description("Update project configuration").option("--app-name <name>", "application name").option("--platform <platform...>", "ios, android, or both").action(async (options) => {
1560
+ if (!options.appName && !options.platform) throw new Error("Provide --app-name or --platform");
1561
+ const store = await openProject(program2);
1562
+ const current = await store.readConfig();
1563
+ const value = await store.writeConfig({
1564
+ ...current,
1565
+ appName: options.appName ?? current.appName,
1566
+ platforms: options.platform ? parsePlatforms(options.platform) : current.platforms
1567
+ });
1568
+ emit(program2, `Updated ${store.configPath}`, { ok: true, config: value });
1569
+ });
1570
+ }
1571
+ function configureSetCommands(program2) {
1572
+ const set = program2.command("set").alias("sets").description("Create, inspect, edit, duplicate, or delete screenshot sets");
1573
+ set.command("list").description("List screenshot sets").action(async () => {
1574
+ const store = await openProject(program2);
1575
+ const sets = await store.listSets();
1576
+ emit(
1577
+ program2,
1578
+ sets.length === 0 ? "No screenshot sets" : sets.map((value) => `${value.id} ${value.locale} ${value.device} ${value.name}`).join("\n"),
1579
+ { ok: true, sets }
1580
+ );
1581
+ });
1582
+ set.command("show").description("Print one complete set document").argument("<id>", "set id").action(async (id) => {
1583
+ const store = await openProject(program2);
1584
+ const value = await store.readSet(id);
1585
+ emitDocument(program2, value, { ok: true, set: value, path: path4.join(store.setsPath, `${id}.json`) });
1439
1586
  });
1440
- const url = `http://${options.host}:${port}`;
1441
- console.log(`
1442
- StoreShot is ready at ${url}`);
1443
- console.log(` Project: ${service.store.root}
1587
+ set.command("create").description("Create a screenshot set").requiredOption("--name <name>", "set name").requiredOption("--locale <locale>", "locale identifier").addOption(new Option("--preset <preset>", "iphone, ipad, watch, or mac").choices(Object.keys(DEVICE_PRESETS)).default("iphone")).option("--device <name>", "device label").option("--width <pixels>", "custom canvas width").option("--height <pixels>", "custom canvas height").action(async (options) => {
1588
+ if (options.width && !options.height || !options.width && options.height) throw new Error("Provide both --width and --height");
1589
+ const preset = DEVICE_PRESETS[options.preset];
1590
+ const store = await openProject(program2);
1591
+ const value = await store.createSet({
1592
+ name: options.name,
1593
+ locale: options.locale,
1594
+ device: options.device ?? preset.device,
1595
+ width: options.width ? parseInteger(options.width, "Canvas width") : preset.width,
1596
+ height: options.height ? parseInteger(options.height, "Canvas height") : preset.height
1597
+ });
1598
+ emit(program2, `Created ${value.id} at ${path4.join(store.setsPath, `${value.id}.json`)}`, { ok: true, set: value });
1599
+ });
1600
+ set.command("update").description("Update common set metadata").argument("<id>", "set id").option("--name <name>", "set name").option("--locale <locale>", "locale identifier").option("--device <name>", "device label").action(async (id, options) => {
1601
+ if (!options.name && !options.locale && !options.device) throw new Error("Provide --name, --locale, or --device");
1602
+ const store = await openProject(program2);
1603
+ const current = await store.readSet(id);
1604
+ const value = await store.updateSetMetadata(id, {
1605
+ name: options.name ?? current.name,
1606
+ locale: options.locale ?? current.locale,
1607
+ device: options.device ?? current.device
1608
+ });
1609
+ emit(program2, `Updated ${id}`, { ok: true, set: value });
1610
+ });
1611
+ set.command("write").description("Replace a complete set document with schema-validated JSON").argument("<id>", "set id").requiredOption("--file <file>", "JSON file, or - for stdin").action(async (id, options) => {
1612
+ const input = options.file === "-" ? await readStandardInput() : await readFile4(path4.resolve(options.file), "utf8");
1613
+ let document;
1614
+ try {
1615
+ document = JSON.parse(input);
1616
+ } catch {
1617
+ throw new Error("Set input must be valid JSON");
1618
+ }
1619
+ const value = parseScreenshotSet(document);
1620
+ if (value.id !== id) throw new Error(`Set input id ${value.id} does not match ${id}`);
1621
+ const store = await openProject(program2);
1622
+ const saved = await store.writeSet(id, value);
1623
+ emit(program2, `Wrote ${path4.join(store.setsPath, `${id}.json`)}`, { ok: true, set: saved });
1624
+ });
1625
+ set.command("duplicate").description("Duplicate a set with fresh object ids").argument("<id>", "set id").action(async (id) => {
1626
+ const store = await openProject(program2);
1627
+ const value = await store.duplicateSet(id);
1628
+ emit(program2, `Created duplicate ${value.id}`, { ok: true, set: value });
1629
+ });
1630
+ set.command("delete").description("Delete a screenshot set").argument("<id>", "set id").requiredOption("--yes", "confirm deletion").action(async (id) => {
1631
+ const store = await openProject(program2);
1632
+ await store.deleteSet(id);
1633
+ emit(program2, `Deleted ${id}`, { ok: true, deleted: id });
1634
+ });
1635
+ }
1636
+ function configureAssetCommands(program2) {
1637
+ const asset = program2.command("asset").alias("assets").description("List, add, classify, or remove project assets");
1638
+ asset.command("list").description("List project assets").addOption(new Option("--category <category>", "filter by category").choices([...ASSET_CATEGORIES])).action(async (options) => {
1639
+ const store = await openProject(program2);
1640
+ const catalog = await store.listAssets();
1641
+ const assets = options.category ? catalog[options.category] : Object.values(catalog).flat();
1642
+ emit(
1643
+ program2,
1644
+ assets.length === 0 ? "No assets" : assets.map((value) => `${value.id} ${value.width ?? "?"}x${value.height ?? "?"} ${value.size} bytes`).join("\n"),
1645
+ { ok: true, assets }
1646
+ );
1647
+ });
1648
+ asset.command("add").description("Copy image files into the project asset catalog").argument("<files...>", "image files").addOption(new Option("--category <category>", "asset category").choices([...ASSET_CATEGORIES]).default("screenshots")).option("--replace", "allow replacement of an existing same-name asset").action(async (files, options) => {
1649
+ const store = await openProject(program2);
1650
+ const added = [];
1651
+ for (const source of files) {
1652
+ const filename = path4.basename(source);
1653
+ const target = store.resolveAsset(options.category, filename);
1654
+ if (!options.replace && await fileExists(target)) throw new Error(`${options.category}/${filename} already exists; pass --replace to overwrite it`);
1655
+ const result = await store.addAsset(options.category, filename, await readFile4(path4.resolve(source)));
1656
+ added.push({ id: `${options.category}/${filename}`, replaced: result.replaced });
1657
+ }
1658
+ emit(program2, added.map((entry) => `${entry.replaced ? "Replaced" : "Added"} ${entry.id}`).join("\n"), { ok: true, assets: added });
1659
+ });
1660
+ asset.command("set-device").description("Override or clear raw screenshot device classification").argument("<id>", "asset id such as screenshots/home.png").argument("<device>", "iphone, ipad, mac, watch, or auto").action(async (id, device) => {
1661
+ if (device !== "auto" && !SCREENSHOT_DEVICE_TYPES.includes(device)) throw new Error("Device must be iphone, ipad, mac, watch, or auto");
1662
+ const { category, filename } = parseAssetId(id);
1663
+ const store = await openProject(program2);
1664
+ await store.updateAssetMetadata(category, filename, { deviceType: device === "auto" ? null : device });
1665
+ emit(program2, `Updated ${id}`, { ok: true, assetId: id, deviceType: device === "auto" ? null : device });
1666
+ });
1667
+ asset.command("remove").description("Delete assets that are not referenced by screenshot sets").argument("<ids...>", "asset ids").requiredOption("--yes", "confirm deletion").option("--force", "delete even when a set references the asset").action(async (ids, options) => {
1668
+ const store = await openProject(program2);
1669
+ const sets = await store.listSets();
1670
+ for (const id of ids) {
1671
+ const references = assetReferences(id, sets);
1672
+ if (references.length > 0 && !options.force) throw new Error(`${id} is referenced by ${references.join(", ")}; pass --force to delete it anyway`);
1673
+ }
1674
+ for (const id of ids) {
1675
+ const { category, filename } = parseAssetId(id);
1676
+ await store.deleteAsset(category, filename);
1677
+ }
1678
+ emit(program2, ids.map((id) => `Deleted ${id}`).join("\n"), { ok: true, deleted: ids });
1679
+ });
1680
+ }
1681
+ function configureDevCommand(program2) {
1682
+ program2.command("dev").description("Open a live StoreShot workspace for a local directory").argument("[directory]", "project directory").addOption(new Option("-p, --port <port>", "local server port").default("4173")).addOption(new Option("--host <host>", "local server host").default("127.0.0.1")).option("--no-open", "do not open a browser").action(async (directory, options) => {
1683
+ const port = parseInteger(options.port, "Port");
1684
+ if (port > 65535) throw new Error("Port must be an integer between 1 and 65535");
1685
+ const projectDirectory2 = path4.resolve(directory ?? globalOptions(program2).project);
1686
+ const service = await startServer({ host: options.host, port, packageRoot, projectDirectory: projectDirectory2, useVite: isSourceExecution });
1687
+ const url = `http://${options.host}:${service.port}`;
1688
+ if (globalOptions(program2).json) console.log(JSON.stringify({ ok: true, url, directory: service.store.root }));
1689
+ else console.log(`
1690
+ StoreShot is ready at ${url}
1691
+ Project: ${service.store.root}
1692
+ Filesystem changes refresh the preview automatically.
1444
1693
  `);
1445
- if (options.open) await open(url);
1446
- const shutdown = async () => {
1447
- await service.close();
1448
- process.exit(0);
1449
- };
1450
- process.once("SIGINT", shutdown);
1451
- process.once("SIGTERM", shutdown);
1452
- });
1694
+ if (options.open) await open(url);
1695
+ const shutdown = async () => {
1696
+ await service.close();
1697
+ process.exit(0);
1698
+ };
1699
+ process.once("SIGINT", shutdown);
1700
+ process.once("SIGTERM", shutdown);
1701
+ });
1702
+ }
1703
+ async function openProject(program2) {
1704
+ const store = new ProjectStore(projectDirectory(program2));
1705
+ try {
1706
+ await access2(store.configPath, constants2.R_OK);
1707
+ } catch {
1708
+ throw new Error(`No StoreShot project found at ${store.root}; run storeshot init first`);
1709
+ }
1710
+ await store.readConfig();
1711
+ return store;
1712
+ }
1713
+ function globalOptions(program2) {
1714
+ return program2.opts();
1715
+ }
1716
+ function projectDirectory(program2) {
1717
+ return path4.resolve(globalOptions(program2).project);
1718
+ }
1719
+ function emit(program2, human, json) {
1720
+ console.log(globalOptions(program2).json ? JSON.stringify(json, null, 2) : human);
1721
+ }
1722
+ function emitDocument(program2, document, json) {
1723
+ console.log(JSON.stringify(globalOptions(program2).json ? json : document, null, 2));
1724
+ }
1725
+ function formatValidation(report) {
1726
+ const lines = [
1727
+ report.ok ? `Valid StoreShot project: ${report.directory}` : `Invalid StoreShot project: ${report.directory}`,
1728
+ `${report.summary.sets} sets, ${report.summary.areas} screenshots, ${report.summary.assets} assets`
1729
+ ];
1730
+ for (const entry of [...report.errors, ...report.warnings]) {
1731
+ lines.push(`${entry.severity === "error" ? "error" : "warning"} [${entry.code}] ${entry.path}: ${entry.message}`);
1732
+ }
1733
+ return lines.join("\n");
1734
+ }
1735
+ function parsePlatforms(values) {
1736
+ const platforms = [...new Set(values.flatMap((value) => value.split(",")).map((value) => value.trim().toLowerCase()))];
1737
+ if (platforms.length === 0 || platforms.some((value) => value !== "ios" && value !== "android")) {
1738
+ throw new Error("Platforms must be ios, android, or both");
1739
+ }
1740
+ return platforms;
1741
+ }
1742
+ function parseInteger(value, name) {
1743
+ const result = Number(value);
1744
+ if (!Number.isInteger(result) || result <= 0) throw new Error(`${name} must be a positive integer`);
1745
+ return result;
1746
+ }
1747
+ function parseNumber(value, name) {
1748
+ const result = Number(value);
1749
+ if (!Number.isFinite(result)) throw new Error(`${name} must be a number`);
1750
+ return result;
1751
+ }
1752
+ function parseAssetId(id) {
1753
+ const separator = id.indexOf("/");
1754
+ const category = id.slice(0, separator);
1755
+ const filename = id.slice(separator + 1);
1756
+ if (separator < 1 || !ASSET_CATEGORIES.includes(category) || !filename || filename.includes("/")) throw new Error(`Invalid asset id: ${id}`);
1757
+ return { category, filename };
1758
+ }
1759
+ function assetReferences(assetId, sets) {
1760
+ return sets.flatMap((set) => set.areas.some((area) => area.elements.some(
1761
+ (element) => element.type === "mockup" && element.assetId === assetId || element.type === "image" && element.source.kind === "asset" && element.source.assetId === assetId
1762
+ )) ? [set.id] : []);
1763
+ }
1764
+ async function readStandardInput() {
1765
+ const chunks = [];
1766
+ for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1767
+ return Buffer.concat(chunks).toString("utf8");
1768
+ }
1769
+ async function fileExists(filename) {
1770
+ return access2(filename, constants2.F_OK).then(() => true).catch(() => false);
1771
+ }
1772
+ function collect(value, previous) {
1773
+ return [...previous, value];
1774
+ }
1775
+ var program = createCli();
1453
1776
  program.parseAsync().catch((error) => {
1454
1777
  const message = error instanceof Error ? error.message : String(error);
1455
- console.error(`storeshot: ${message}`);
1778
+ if (globalOptions(program).json) console.error(JSON.stringify({ ok: false, error: { message } }, null, 2));
1779
+ else console.error(`storeshot: ${message}`);
1456
1780
  process.exitCode = 1;
1457
1781
  });
1782
+ export {
1783
+ createCli
1784
+ };
1458
1785
  //# sourceMappingURL=cli.js.map