storemeta 0.1.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.
Files changed (92) hide show
  1. package/.env.release.example +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +94 -0
  4. package/dist/auth/apple/credential-state.js +14 -0
  5. package/dist/auth/apple/jwt.js +56 -0
  6. package/dist/auth/apple/load-credentials.js +36 -0
  7. package/dist/auth/credential-state.js +6 -0
  8. package/dist/auth/google/credential-state.js +10 -0
  9. package/dist/auth/google/load-credentials.js +18 -0
  10. package/dist/auth/google/service-account-auth.js +25 -0
  11. package/dist/auth/redact.js +17 -0
  12. package/dist/cli/auth-check.js +46 -0
  13. package/dist/cli/config-doctor.js +40 -0
  14. package/dist/cli/context.js +23 -0
  15. package/dist/cli/errors.js +10 -0
  16. package/dist/cli/exit-code.js +6 -0
  17. package/dist/cli/init.js +60 -0
  18. package/dist/cli/locales-list.js +37 -0
  19. package/dist/cli/metadata-diff.js +57 -0
  20. package/dist/cli/metadata-pull.js +52 -0
  21. package/dist/cli/metadata-push.js +95 -0
  22. package/dist/cli/render.js +57 -0
  23. package/dist/cli/result-types.js +1 -0
  24. package/dist/cli/scaffold.js +76 -0
  25. package/dist/cli/screenshots-diff.js +65 -0
  26. package/dist/cli/screenshots-pull.js +72 -0
  27. package/dist/cli/screenshots-push.js +96 -0
  28. package/dist/cli/validate.js +74 -0
  29. package/dist/cli.js +222 -0
  30. package/dist/config/apps.js +21 -0
  31. package/dist/config/load-config.js +30 -0
  32. package/dist/config/schema.js +97 -0
  33. package/dist/config/select-app.js +10 -0
  34. package/dist/config/select-platforms.js +17 -0
  35. package/dist/config/single-app.js +16 -0
  36. package/dist/config/types.js +1 -0
  37. package/dist/config/validate-base-dirs.js +33 -0
  38. package/dist/config/validate-credential-env-names.js +32 -0
  39. package/dist/config/validate-platform-identifiers.js +24 -0
  40. package/dist/formats/load-metadata.js +38 -0
  41. package/dist/formats/metadata-types.js +1 -0
  42. package/dist/formats/screenshot-types.js +1 -0
  43. package/dist/formats/serialize-metadata.js +20 -0
  44. package/dist/locales/groups.js +12 -0
  45. package/dist/locales/map.js +15 -0
  46. package/dist/locales/normalize.js +22 -0
  47. package/dist/locales/types.js +1 -0
  48. package/dist/locales/validate-groups.js +14 -0
  49. package/dist/platforms/apple/client.js +109 -0
  50. package/dist/platforms/apple/metadata/pull.js +120 -0
  51. package/dist/platforms/apple/metadata/push.js +272 -0
  52. package/dist/platforms/apple/metadata/types.js +1 -0
  53. package/dist/platforms/apple/screenshots/pull.js +160 -0
  54. package/dist/platforms/apple/screenshots/push.js +418 -0
  55. package/dist/platforms/google/client.js +42 -0
  56. package/dist/platforms/google/edits.js +38 -0
  57. package/dist/platforms/google/metadata/pull.js +23 -0
  58. package/dist/platforms/google/metadata/push.js +73 -0
  59. package/dist/platforms/google/metadata/types.js +1 -0
  60. package/dist/platforms/google/screenshots/pull.js +129 -0
  61. package/dist/platforms/google/screenshots/push.js +252 -0
  62. package/dist/platforms/google/screenshots/types.js +7 -0
  63. package/dist/validation/metadata/apple.js +31 -0
  64. package/dist/validation/metadata/files.js +58 -0
  65. package/dist/validation/metadata/google.js +46 -0
  66. package/dist/validation/screenshots/extensions.js +17 -0
  67. package/dist/validation/screenshots/files.js +55 -0
  68. package/dist/validation/screenshots/folders.js +41 -0
  69. package/dist/validation/screenshots/order.js +25 -0
  70. package/dist/writers/ensure-directory.js +16 -0
  71. package/dist/writers/order-screenshots.js +19 -0
  72. package/dist/writers/resolve-screenshot-path.js +8 -0
  73. package/dist/writers/resolve-within-base-dir.js +17 -0
  74. package/dist/writers/write-metadata.js +17 -0
  75. package/docs/API_EDITABLE_METADATA_SURFACE.md +319 -0
  76. package/docs/AUTH_SETUP.md +67 -0
  77. package/docs/DOCUMENTATION.md +285 -0
  78. package/docs/PROJECT_PLAN.md +687 -0
  79. package/docs/RELEASE_VERIFICATION.md +70 -0
  80. package/docs/TODO.md +308 -0
  81. package/examples/README.md +8 -0
  82. package/examples/metadata/apple/en-US.yml +11 -0
  83. package/examples/metadata/apple/tr.yml +11 -0
  84. package/examples/metadata/google/en-US.yml +6 -0
  85. package/examples/metadata/google/tr.yml +6 -0
  86. package/examples/screenshots/apple/en-US/APP_IPHONE_65/1.png +1 -0
  87. package/examples/screenshots/apple/tr/APP_IPHONE_65/1.png +1 -0
  88. package/examples/screenshots/google/en-US/phoneScreenshots/1.png +1 -0
  89. package/examples/screenshots/google/tr/phoneScreenshots/1.png +1 -0
  90. package/examples/storemeta.yml +29 -0
  91. package/package.json +55 -0
  92. package/storemeta.release.example.yml +27 -0
package/dist/cli.js ADDED
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+ import { Command, Option } from "commander";
3
+ import { runAuthCheckCommand } from "./cli/auth-check.js";
4
+ import { runConfigDoctorCommand } from "./cli/config-doctor.js";
5
+ import { runInitCommand } from "./cli/init.js";
6
+ import { applyCommandSummaryExitCode } from "./cli/exit-code.js";
7
+ import { runLocalesListCommand } from "./cli/locales-list.js";
8
+ import { runMetadataDiffCommand } from "./cli/metadata-diff.js";
9
+ import { runMetadataPullCommand } from "./cli/metadata-pull.js";
10
+ import { runMetadataPushCommand } from "./cli/metadata-push.js";
11
+ import { renderCommandError, renderCommandSummary } from "./cli/render.js";
12
+ import { runScaffoldCommand } from "./cli/scaffold.js";
13
+ import { runScreenshotsDiffCommand } from "./cli/screenshots-diff.js";
14
+ import { runScreenshotsPullCommand } from "./cli/screenshots-pull.js";
15
+ import { runScreenshotsPushCommand } from "./cli/screenshots-push.js";
16
+ import { runValidateCommand } from "./cli/validate.js";
17
+ export function buildCliProgram() {
18
+ const program = new Command()
19
+ .name("storemeta")
20
+ .description("CLI for pulling, validating, and pushing App Store Connect and Google Play metadata and screenshots.")
21
+ .option("--config <path>", "Path to the root storemeta config file")
22
+ .option("--app <id>", "Configured app identifier")
23
+ .addOption(new Option("--platform <platform>", "Target platform")
24
+ .choices(["apple", "google", "all"]))
25
+ .option("--locale <code>", "Target locale code")
26
+ .option("--dry-run", "Preview changes without applying them")
27
+ .option("--replace", "Delete existing remote screenshots before uploading new ones")
28
+ .option("--verbose", "Enable verbose output");
29
+ const metadataCommand = program
30
+ .command("metadata")
31
+ .description("Metadata commands");
32
+ const screenshotsCommand = program
33
+ .command("screenshots")
34
+ .description("Screenshot commands");
35
+ const authCommand = program.command("auth").description("Auth commands");
36
+ const configCommand = program.command("config").description("Config commands");
37
+ const localesCommand = program.command("locales").description("Locale commands");
38
+ program
39
+ .command("init")
40
+ .description("Create a starter storemeta config")
41
+ .action(async () => {
42
+ const options = program.opts();
43
+ const configPath = await runInitCommand(options.config);
44
+ const summary = await runScaffoldCommand({
45
+ config: configPath,
46
+ app: options.app,
47
+ platform: options.platform,
48
+ });
49
+ console.log([
50
+ "Created starter storemeta project.",
51
+ "Next steps:",
52
+ "1. Fill app identifiers in storemeta.yml.",
53
+ "2. Create Apple App Store Connect API credentials and Google Play service account credentials.",
54
+ "3. Export the credential environment variables named in storemeta.yml.",
55
+ "4. Run storemeta auth check, then storemeta validate.",
56
+ "See docs/AUTH_SETUP.md for credential setup.",
57
+ ].join("\n"));
58
+ console.log(renderCommandSummary(summary));
59
+ });
60
+ program
61
+ .command("scaffold")
62
+ .description("Create missing metadata files and screenshot folders from config")
63
+ .action(async () => {
64
+ const options = program.opts();
65
+ const summary = await runScaffoldCommand({
66
+ config: options.config,
67
+ app: options.app,
68
+ platform: options.platform,
69
+ });
70
+ console.log(renderCommandSummary(summary));
71
+ applyCommandSummaryExitCode(summary);
72
+ });
73
+ program
74
+ .command("validate")
75
+ .description("Validate the storemeta project configuration")
76
+ .action(async () => {
77
+ const options = program.opts();
78
+ const summary = await runValidateCommand({
79
+ config: options.config,
80
+ app: options.app,
81
+ platform: options.platform,
82
+ });
83
+ console.log(renderCommandSummary(summary));
84
+ applyCommandSummaryExitCode(summary);
85
+ });
86
+ metadataCommand
87
+ .command("pull")
88
+ .description("Pull remote metadata into the local project")
89
+ .action(async () => {
90
+ const options = program.opts();
91
+ await runMetadataPullCommand({
92
+ config: options.config,
93
+ app: options.app,
94
+ locale: options.locale,
95
+ platform: options.platform,
96
+ });
97
+ });
98
+ metadataCommand
99
+ .command("push")
100
+ .description("Push local metadata to the store")
101
+ .action(async () => {
102
+ const options = program.opts();
103
+ const summary = await runMetadataPushCommand({
104
+ config: options.config,
105
+ app: options.app,
106
+ platform: options.platform,
107
+ locale: options.locale,
108
+ dryRun: options.dryRun,
109
+ });
110
+ console.log(renderCommandSummary(summary));
111
+ applyCommandSummaryExitCode(summary);
112
+ });
113
+ metadataCommand
114
+ .command("diff")
115
+ .description("Compare configured metadata locales with local metadata files")
116
+ .action(async () => {
117
+ const options = program.opts();
118
+ const summary = await runMetadataDiffCommand({
119
+ config: options.config,
120
+ app: options.app,
121
+ platform: options.platform,
122
+ });
123
+ console.log(renderCommandSummary(summary));
124
+ applyCommandSummaryExitCode(summary);
125
+ });
126
+ authCommand
127
+ .command("check")
128
+ .description("Check configured credential environment variables")
129
+ .action(async () => {
130
+ const options = program.opts();
131
+ const summary = await runAuthCheckCommand({
132
+ config: options.config,
133
+ app: options.app,
134
+ platform: options.platform,
135
+ });
136
+ console.log(renderCommandSummary(summary));
137
+ applyCommandSummaryExitCode(summary);
138
+ });
139
+ configCommand
140
+ .command("doctor")
141
+ .description("Inspect resolved config, app, directories, and platforms")
142
+ .action(async () => {
143
+ const options = program.opts();
144
+ const summary = await runConfigDoctorCommand({
145
+ config: options.config,
146
+ app: options.app,
147
+ platform: options.platform,
148
+ });
149
+ console.log(renderCommandSummary(summary));
150
+ applyCommandSummaryExitCode(summary);
151
+ });
152
+ localesCommand
153
+ .command("list")
154
+ .description("List configured default locales")
155
+ .action(async () => {
156
+ const options = program.opts();
157
+ const summary = await runLocalesListCommand({
158
+ config: options.config,
159
+ app: options.app,
160
+ platform: options.platform,
161
+ });
162
+ console.log(renderCommandSummary(summary));
163
+ applyCommandSummaryExitCode(summary);
164
+ });
165
+ screenshotsCommand
166
+ .command("pull")
167
+ .description("Pull remote screenshots into the local project")
168
+ .action(async () => {
169
+ const options = program.opts();
170
+ const summary = await runScreenshotsPullCommand({
171
+ config: options.config,
172
+ app: options.app,
173
+ platform: options.platform,
174
+ locale: options.locale,
175
+ });
176
+ console.log(renderCommandSummary(summary));
177
+ applyCommandSummaryExitCode(summary);
178
+ });
179
+ screenshotsCommand
180
+ .command("diff")
181
+ .description("Compare configured screenshot locales with local screenshot folders")
182
+ .action(async () => {
183
+ const options = program.opts();
184
+ const summary = await runScreenshotsDiffCommand({
185
+ config: options.config,
186
+ app: options.app,
187
+ platform: options.platform,
188
+ });
189
+ console.log(renderCommandSummary(summary));
190
+ applyCommandSummaryExitCode(summary);
191
+ });
192
+ screenshotsCommand
193
+ .command("push")
194
+ .description("Push local screenshots to the store")
195
+ .action(async () => {
196
+ const options = program.opts();
197
+ const summary = await runScreenshotsPushCommand({
198
+ config: options.config,
199
+ app: options.app,
200
+ platform: options.platform,
201
+ locale: options.locale,
202
+ dryRun: options.dryRun,
203
+ replace: options.replace,
204
+ });
205
+ console.log(renderCommandSummary(summary));
206
+ applyCommandSummaryExitCode(summary);
207
+ });
208
+ return program;
209
+ }
210
+ export async function runCli(argv = process.argv) {
211
+ const program = buildCliProgram();
212
+ await program.parseAsync(argv);
213
+ }
214
+ export function hasVerboseFlag(argv) {
215
+ return argv.includes("--verbose");
216
+ }
217
+ void runCli().catch((error) => {
218
+ console.error(renderCommandError(error, {
219
+ verbose: hasVerboseFlag(process.argv),
220
+ }));
221
+ process.exitCode = 1;
222
+ });
@@ -0,0 +1,21 @@
1
+ export function listConfiguredApps(config) {
2
+ return Object.entries(config.apps)
3
+ .sort(([leftId], [rightId]) => leftId.localeCompare(rightId))
4
+ .map(([id, settings]) => ({
5
+ id,
6
+ settings,
7
+ }));
8
+ }
9
+ export function getConfiguredApp(config, appId) {
10
+ const settings = config.apps[appId];
11
+ if (settings === undefined) {
12
+ return undefined;
13
+ }
14
+ return {
15
+ id: appId,
16
+ settings,
17
+ };
18
+ }
19
+ export function hasConfiguredApp(config, appId) {
20
+ return getConfiguredApp(config, appId) !== undefined;
21
+ }
@@ -0,0 +1,30 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import YAML from "yaml";
4
+ import { StoremetaError } from "../cli/errors.js";
5
+ export const DEFAULT_CONFIG_FILE = "storemeta.yml";
6
+ export function resolveConfigPath(configPath) {
7
+ return resolve(configPath ?? DEFAULT_CONFIG_FILE);
8
+ }
9
+ export async function loadConfigFile(configPath) {
10
+ const resolvedPath = resolveConfigPath(configPath);
11
+ let raw;
12
+ try {
13
+ raw = await readFile(resolvedPath, "utf8");
14
+ }
15
+ catch (cause) {
16
+ throw new StoremetaError("FILESYSTEM_ERROR", `Failed to read config file at ${resolvedPath}`, { cause });
17
+ }
18
+ let parsed;
19
+ try {
20
+ parsed = YAML.parse(raw);
21
+ }
22
+ catch (cause) {
23
+ throw new StoremetaError("CONFIG_ERROR", `Failed to parse YAML config at ${resolvedPath}`, { cause });
24
+ }
25
+ return {
26
+ path: resolvedPath,
27
+ raw,
28
+ parsed,
29
+ };
30
+ }
@@ -0,0 +1,97 @@
1
+ import { z } from "zod";
2
+ import { StoremetaError } from "../cli/errors.js";
3
+ const metadataFormatSchema = z.literal("yaml");
4
+ const projectSettingsSchema = z
5
+ .object({
6
+ name: z.string().min(1),
7
+ defaultApp: z.string().min(1),
8
+ })
9
+ .strict();
10
+ const metadataSettingsSchema = z
11
+ .object({
12
+ baseDir: z.string().min(1),
13
+ format: metadataFormatSchema,
14
+ })
15
+ .strict();
16
+ const screenshotSettingsSchema = z
17
+ .object({
18
+ baseDir: z.string().min(1),
19
+ })
20
+ .strict();
21
+ const appleCredentialsSettingsSchema = z
22
+ .object({
23
+ issuerIdEnv: z.string().min(1),
24
+ keyIdEnv: z.string().min(1),
25
+ privateKeyPathEnv: z.string().min(1),
26
+ })
27
+ .strict();
28
+ const googleCredentialsSettingsSchema = z
29
+ .object({
30
+ serviceAccountPathEnv: z.string().min(1),
31
+ })
32
+ .strict();
33
+ const localeSettingsSchema = z
34
+ .object({
35
+ default: z.array(z.string().min(1)).optional(),
36
+ map: z.record(z.string(), z.string().min(1)).optional(),
37
+ })
38
+ .strict();
39
+ const screenshotGroupSettingsSchema = z
40
+ .object({
41
+ locales: z.array(z.string().min(1)).min(1),
42
+ })
43
+ .strict();
44
+ const googleScreenshotSettingsSchema = z
45
+ .object({
46
+ groups: z.record(z.string(), screenshotGroupSettingsSchema).optional(),
47
+ })
48
+ .strict();
49
+ const appleAppSettingsSchema = z
50
+ .object({
51
+ appId: z.string().min(1),
52
+ credentials: appleCredentialsSettingsSchema,
53
+ locales: localeSettingsSchema.optional(),
54
+ })
55
+ .strict();
56
+ const googleAppSettingsSchema = z
57
+ .object({
58
+ packageName: z.string().min(1),
59
+ credentials: googleCredentialsSettingsSchema,
60
+ locales: localeSettingsSchema.optional(),
61
+ screenshots: googleScreenshotSettingsSchema.optional(),
62
+ })
63
+ .strict();
64
+ const appSettingsSchema = z
65
+ .object({
66
+ metadata: metadataSettingsSchema,
67
+ screenshots: screenshotSettingsSchema,
68
+ apple: appleAppSettingsSchema.optional(),
69
+ google: googleAppSettingsSchema.optional(),
70
+ })
71
+ .strict();
72
+ export const storemetaConfigSchema = z
73
+ .object({
74
+ version: z.literal(1),
75
+ project: projectSettingsSchema,
76
+ apps: z
77
+ .record(z.string().min(1), appSettingsSchema)
78
+ .refine((apps) => Object.keys(apps).length > 0, {
79
+ message: "At least one app must be configured",
80
+ }),
81
+ })
82
+ .strict();
83
+ function formatSchemaIssues(error) {
84
+ return error.issues
85
+ .map((issue) => {
86
+ const path = issue.path.length > 0 ? issue.path.join(".") : "root";
87
+ return `${path}: ${issue.message}`;
88
+ })
89
+ .join("; ");
90
+ }
91
+ export function validateRootConfig(config) {
92
+ const result = storemetaConfigSchema.safeParse(config);
93
+ if (!result.success) {
94
+ throw new StoremetaError("CONFIG_ERROR", `Config schema validation failed: ${formatSchemaIssues(result.error)}`, { cause: result.error });
95
+ }
96
+ return result.data;
97
+ }
@@ -0,0 +1,10 @@
1
+ import { StoremetaError } from "../cli/errors.js";
2
+ import { getConfiguredApp } from "./apps.js";
3
+ export function selectConfiguredApp(config, selectedAppId) {
4
+ const targetAppId = selectedAppId ?? config.project.defaultApp;
5
+ const app = getConfiguredApp(config, targetAppId);
6
+ if (app === undefined) {
7
+ throw new StoremetaError("CONFIG_ERROR", `Configured app "${targetAppId}" was not found`);
8
+ }
9
+ return app;
10
+ }
@@ -0,0 +1,17 @@
1
+ import { StoremetaError } from "../cli/errors.js";
2
+ export function resolveSelectedPlatforms(app, selectedPlatform) {
3
+ const configuredPlatforms = [];
4
+ if (app.settings.apple !== undefined) {
5
+ configuredPlatforms.push("apple");
6
+ }
7
+ if (app.settings.google !== undefined) {
8
+ configuredPlatforms.push("google");
9
+ }
10
+ if (selectedPlatform === undefined || selectedPlatform === "all") {
11
+ return configuredPlatforms;
12
+ }
13
+ if (!configuredPlatforms.includes(selectedPlatform)) {
14
+ throw new StoremetaError("CONFIG_ERROR", `Configured app "${app.id}" does not support platform "${selectedPlatform}"`);
15
+ }
16
+ return [selectedPlatform];
17
+ }
@@ -0,0 +1,16 @@
1
+ import { StoremetaError } from "../cli/errors.js";
2
+ import { listConfiguredApps } from "./apps.js";
3
+ export function hasSingleConfiguredApp(config) {
4
+ return listConfiguredApps(config).length === 1;
5
+ }
6
+ export function getSingleConfiguredApp(config) {
7
+ const [app] = listConfiguredApps(config);
8
+ return hasSingleConfiguredApp(config) ? app : undefined;
9
+ }
10
+ export function requireSingleConfiguredApp(config) {
11
+ const app = getSingleConfiguredApp(config);
12
+ if (app === undefined) {
13
+ throw new StoremetaError("CONFIG_ERROR", "Expected exactly one configured app");
14
+ }
15
+ return app;
16
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { posix, win32 } from "node:path";
2
+ import { StoremetaError } from "../cli/errors.js";
3
+ import { listConfiguredApps } from "./apps.js";
4
+ function isAbsolutePath(value) {
5
+ return posix.isAbsolute(value) || win32.isAbsolute(value);
6
+ }
7
+ function normalizeRelativePath(value) {
8
+ return posix.normalize(value.replaceAll("\\", "/"));
9
+ }
10
+ function isSafeRelativeDirectoryPath(value) {
11
+ if (value.trim().length === 0 || isAbsolutePath(value)) {
12
+ return false;
13
+ }
14
+ const normalized = normalizeRelativePath(value);
15
+ if (normalized === "." || normalized === "..") {
16
+ return false;
17
+ }
18
+ return !normalized.startsWith("../");
19
+ }
20
+ export function validateBaseDirectoryPaths(config) {
21
+ const issues = [];
22
+ for (const app of listConfiguredApps(config)) {
23
+ if (!isSafeRelativeDirectoryPath(app.settings.metadata.baseDir)) {
24
+ issues.push(`apps.${app.id}.metadata.baseDir: must be a safe relative path`);
25
+ }
26
+ if (!isSafeRelativeDirectoryPath(app.settings.screenshots.baseDir)) {
27
+ issues.push(`apps.${app.id}.screenshots.baseDir: must be a safe relative path`);
28
+ }
29
+ }
30
+ if (issues.length > 0) {
31
+ throw new StoremetaError("CONFIG_ERROR", `Base directory validation failed: ${issues.join("; ")}`);
32
+ }
33
+ }
@@ -0,0 +1,32 @@
1
+ import { StoremetaError } from "../cli/errors.js";
2
+ import { listConfiguredApps } from "./apps.js";
3
+ const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
4
+ function isValidEnvVarName(value) {
5
+ return ENV_VAR_NAME_PATTERN.test(value);
6
+ }
7
+ export function validateCredentialEnvVarNames(config) {
8
+ const issues = [];
9
+ for (const app of listConfiguredApps(config)) {
10
+ if (app.settings.apple !== undefined) {
11
+ const { credentials } = app.settings.apple;
12
+ if (!isValidEnvVarName(credentials.issuerIdEnv)) {
13
+ issues.push(`apps.${app.id}.apple.credentials.issuerIdEnv: invalid env var name`);
14
+ }
15
+ if (!isValidEnvVarName(credentials.keyIdEnv)) {
16
+ issues.push(`apps.${app.id}.apple.credentials.keyIdEnv: invalid env var name`);
17
+ }
18
+ if (!isValidEnvVarName(credentials.privateKeyPathEnv)) {
19
+ issues.push(`apps.${app.id}.apple.credentials.privateKeyPathEnv: invalid env var name`);
20
+ }
21
+ }
22
+ if (app.settings.google !== undefined) {
23
+ const { credentials } = app.settings.google;
24
+ if (!isValidEnvVarName(credentials.serviceAccountPathEnv)) {
25
+ issues.push(`apps.${app.id}.google.credentials.serviceAccountPathEnv: invalid env var name`);
26
+ }
27
+ }
28
+ }
29
+ if (issues.length > 0) {
30
+ throw new StoremetaError("CONFIG_ERROR", `Credential environment variable validation failed: ${issues.join("; ")}`);
31
+ }
32
+ }
@@ -0,0 +1,24 @@
1
+ import { StoremetaError } from "../cli/errors.js";
2
+ import { listConfiguredApps } from "./apps.js";
3
+ function hasText(value) {
4
+ return value !== undefined && value.trim().length > 0;
5
+ }
6
+ export function validateRequiredPlatformIdentifiers(config) {
7
+ const issues = [];
8
+ for (const app of listConfiguredApps(config)) {
9
+ const { apple, google } = app.settings;
10
+ if (apple === undefined && google === undefined) {
11
+ issues.push(`apps.${app.id}: at least one platform must be configured`);
12
+ continue;
13
+ }
14
+ if (apple !== undefined && !hasText(apple.appId)) {
15
+ issues.push(`apps.${app.id}.apple.appId: required`);
16
+ }
17
+ if (google !== undefined && !hasText(google.packageName)) {
18
+ issues.push(`apps.${app.id}.google.packageName: required`);
19
+ }
20
+ }
21
+ if (issues.length > 0) {
22
+ throw new StoremetaError("CONFIG_ERROR", `Platform identifier validation failed: ${issues.join("; ")}`);
23
+ }
24
+ }
@@ -0,0 +1,38 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { extname, resolve } from "node:path";
3
+ import YAML from "yaml";
4
+ import { StoremetaError } from "../cli/errors.js";
5
+ async function loadMetadataFileForExtension(filePath, expectedExtension) {
6
+ const resolvedPath = resolve(filePath);
7
+ if (extname(resolvedPath).toLowerCase() !== expectedExtension) {
8
+ throw new StoremetaError("FILESYSTEM_ERROR", `Expected a ${expectedExtension} metadata file: ${resolvedPath}`);
9
+ }
10
+ let raw;
11
+ try {
12
+ raw = await readFile(resolvedPath, "utf8");
13
+ }
14
+ catch (cause) {
15
+ throw new StoremetaError("FILESYSTEM_ERROR", `Failed to read metadata file at ${resolvedPath}`, { cause });
16
+ }
17
+ let parsed;
18
+ try {
19
+ parsed = YAML.parse(raw);
20
+ }
21
+ catch (cause) {
22
+ throw new StoremetaError("CONFIG_ERROR", `Failed to parse YAML metadata at ${resolvedPath}`, { cause });
23
+ }
24
+ return {
25
+ path: resolvedPath,
26
+ raw,
27
+ parsed,
28
+ };
29
+ }
30
+ export async function loadYmlMetadataFile(filePath) {
31
+ return loadMetadataFileForExtension(filePath, ".yml");
32
+ }
33
+ export async function loadYamlMetadataFile(filePath) {
34
+ return loadMetadataFileForExtension(filePath, ".yaml");
35
+ }
36
+ export async function loadMarkdownMetadataFile(filePath) {
37
+ return loadMetadataFileForExtension(filePath, ".md");
38
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ import YAML from "yaml";
2
+ function sortMetadataValue(value) {
3
+ if (Array.isArray(value)) {
4
+ return value.map(sortMetadataValue);
5
+ }
6
+ if (value !== null && typeof value === "object") {
7
+ return Object.fromEntries(Object.entries(value)
8
+ .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
9
+ .map(([key, childValue]) => [key, sortMetadataValue(childValue)]));
10
+ }
11
+ return value;
12
+ }
13
+ export function serializeMetadataDocument(document) {
14
+ const serialized = YAML.stringify(sortMetadataValue(document), {
15
+ indent: 2,
16
+ lineWidth: 0,
17
+ minContentWidth: 0,
18
+ });
19
+ return serialized.endsWith("\n") ? serialized : `${serialized}\n`;
20
+ }
@@ -0,0 +1,12 @@
1
+ import { normalizeLocaleCodes } from "./normalize.js";
2
+ export function listScreenshotGroups(settings) {
3
+ return Object.entries(settings?.groups ?? {})
4
+ .sort(([leftName], [rightName]) => leftName.localeCompare(rightName))
5
+ .map(([name, group]) => ({
6
+ name,
7
+ locales: normalizeLocaleCodes(group.locales),
8
+ }));
9
+ }
10
+ export function getScreenshotGroup(settings, groupName) {
11
+ return listScreenshotGroups(settings).find((group) => group.name === groupName);
12
+ }
@@ -0,0 +1,15 @@
1
+ import { normalizeLocaleCode } from "./normalize.js";
2
+ function normalizeLocaleMap(localeSettings) {
3
+ return Object.fromEntries(Object.entries(localeSettings?.map ?? {}).map(([sourceLocale, targetLocale]) => [
4
+ normalizeLocaleCode(sourceLocale),
5
+ normalizeLocaleCode(targetLocale),
6
+ ]));
7
+ }
8
+ export function mapLocaleCode(locale, localeSettings) {
9
+ const normalizedLocale = normalizeLocaleCode(locale);
10
+ const localeMap = normalizeLocaleMap(localeSettings);
11
+ return localeMap[normalizedLocale] ?? normalizedLocale;
12
+ }
13
+ export function mapLocaleCodes(locales, localeSettings) {
14
+ return locales.map((locale) => mapLocaleCode(locale, localeSettings));
15
+ }
@@ -0,0 +1,22 @@
1
+ function normalizeLocaleSegment(segment, index) {
2
+ if (index === 0) {
3
+ return segment.toLowerCase();
4
+ }
5
+ if (segment.length === 4) {
6
+ return `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1).toLowerCase()}`;
7
+ }
8
+ if (/^[A-Za-z]{2}$/.test(segment) || /^\d{3}$/.test(segment)) {
9
+ return segment.toUpperCase();
10
+ }
11
+ return segment.toLowerCase();
12
+ }
13
+ export function normalizeLocaleCode(locale) {
14
+ const trimmed = locale.trim().replaceAll("_", "-");
15
+ const segments = trimmed.split("-").filter((segment) => segment.length > 0);
16
+ return segments
17
+ .map((segment, index) => normalizeLocaleSegment(segment, index))
18
+ .join("-");
19
+ }
20
+ export function normalizeLocaleCodes(locales) {
21
+ return locales.map(normalizeLocaleCode);
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import { StoremetaError } from "../cli/errors.js";
2
+ import { listScreenshotGroups } from "./groups.js";
3
+ export function validateScreenshotGroups(settings) {
4
+ const issues = [];
5
+ for (const group of listScreenshotGroups(settings)) {
6
+ const uniqueLocales = new Set(group.locales);
7
+ if (uniqueLocales.size !== group.locales.length) {
8
+ issues.push(`screenshots.groups.${group.name}: contains duplicate locales after normalization`);
9
+ }
10
+ }
11
+ if (issues.length > 0) {
12
+ throw new StoremetaError("VALIDATION_ERROR", `Screenshot group validation failed: ${issues.join("; ")}`);
13
+ }
14
+ }