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.
- package/.env.release.example +8 -0
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/dist/auth/apple/credential-state.js +14 -0
- package/dist/auth/apple/jwt.js +56 -0
- package/dist/auth/apple/load-credentials.js +36 -0
- package/dist/auth/credential-state.js +6 -0
- package/dist/auth/google/credential-state.js +10 -0
- package/dist/auth/google/load-credentials.js +18 -0
- package/dist/auth/google/service-account-auth.js +25 -0
- package/dist/auth/redact.js +17 -0
- package/dist/cli/auth-check.js +46 -0
- package/dist/cli/config-doctor.js +40 -0
- package/dist/cli/context.js +23 -0
- package/dist/cli/errors.js +10 -0
- package/dist/cli/exit-code.js +6 -0
- package/dist/cli/init.js +60 -0
- package/dist/cli/locales-list.js +37 -0
- package/dist/cli/metadata-diff.js +57 -0
- package/dist/cli/metadata-pull.js +52 -0
- package/dist/cli/metadata-push.js +95 -0
- package/dist/cli/render.js +57 -0
- package/dist/cli/result-types.js +1 -0
- package/dist/cli/scaffold.js +76 -0
- package/dist/cli/screenshots-diff.js +65 -0
- package/dist/cli/screenshots-pull.js +72 -0
- package/dist/cli/screenshots-push.js +96 -0
- package/dist/cli/validate.js +74 -0
- package/dist/cli.js +222 -0
- package/dist/config/apps.js +21 -0
- package/dist/config/load-config.js +30 -0
- package/dist/config/schema.js +97 -0
- package/dist/config/select-app.js +10 -0
- package/dist/config/select-platforms.js +17 -0
- package/dist/config/single-app.js +16 -0
- package/dist/config/types.js +1 -0
- package/dist/config/validate-base-dirs.js +33 -0
- package/dist/config/validate-credential-env-names.js +32 -0
- package/dist/config/validate-platform-identifiers.js +24 -0
- package/dist/formats/load-metadata.js +38 -0
- package/dist/formats/metadata-types.js +1 -0
- package/dist/formats/screenshot-types.js +1 -0
- package/dist/formats/serialize-metadata.js +20 -0
- package/dist/locales/groups.js +12 -0
- package/dist/locales/map.js +15 -0
- package/dist/locales/normalize.js +22 -0
- package/dist/locales/types.js +1 -0
- package/dist/locales/validate-groups.js +14 -0
- package/dist/platforms/apple/client.js +109 -0
- package/dist/platforms/apple/metadata/pull.js +120 -0
- package/dist/platforms/apple/metadata/push.js +272 -0
- package/dist/platforms/apple/metadata/types.js +1 -0
- package/dist/platforms/apple/screenshots/pull.js +160 -0
- package/dist/platforms/apple/screenshots/push.js +418 -0
- package/dist/platforms/google/client.js +42 -0
- package/dist/platforms/google/edits.js +38 -0
- package/dist/platforms/google/metadata/pull.js +23 -0
- package/dist/platforms/google/metadata/push.js +73 -0
- package/dist/platforms/google/metadata/types.js +1 -0
- package/dist/platforms/google/screenshots/pull.js +129 -0
- package/dist/platforms/google/screenshots/push.js +252 -0
- package/dist/platforms/google/screenshots/types.js +7 -0
- package/dist/validation/metadata/apple.js +31 -0
- package/dist/validation/metadata/files.js +58 -0
- package/dist/validation/metadata/google.js +46 -0
- package/dist/validation/screenshots/extensions.js +17 -0
- package/dist/validation/screenshots/files.js +55 -0
- package/dist/validation/screenshots/folders.js +41 -0
- package/dist/validation/screenshots/order.js +25 -0
- package/dist/writers/ensure-directory.js +16 -0
- package/dist/writers/order-screenshots.js +19 -0
- package/dist/writers/resolve-screenshot-path.js +8 -0
- package/dist/writers/resolve-within-base-dir.js +17 -0
- package/dist/writers/write-metadata.js +17 -0
- package/docs/API_EDITABLE_METADATA_SURFACE.md +319 -0
- package/docs/AUTH_SETUP.md +67 -0
- package/docs/DOCUMENTATION.md +285 -0
- package/docs/PROJECT_PLAN.md +687 -0
- package/docs/RELEASE_VERIFICATION.md +70 -0
- package/docs/TODO.md +308 -0
- package/examples/README.md +8 -0
- package/examples/metadata/apple/en-US.yml +11 -0
- package/examples/metadata/apple/tr.yml +11 -0
- package/examples/metadata/google/en-US.yml +6 -0
- package/examples/metadata/google/tr.yml +6 -0
- package/examples/screenshots/apple/en-US/APP_IPHONE_65/1.png +1 -0
- package/examples/screenshots/apple/tr/APP_IPHONE_65/1.png +1 -0
- package/examples/screenshots/google/en-US/phoneScreenshots/1.png +1 -0
- package/examples/screenshots/google/tr/phoneScreenshots/1.png +1 -0
- package/examples/storemeta.yml +29 -0
- package/package.json +55 -0
- package/storemeta.release.example.yml +27 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Copy this file to .env.release.local and fill real local values.
|
|
2
|
+
# .env.release.local is ignored by .gitignore through the .env.* rule.
|
|
3
|
+
|
|
4
|
+
STORE_APPLE_ISSUER_ID=00000000-0000-0000-0000-000000000000
|
|
5
|
+
STORE_APPLE_KEY_ID=XXXXXXXXXX
|
|
6
|
+
STORE_APPLE_PRIVATE_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
|
|
7
|
+
|
|
8
|
+
STORE_GOOGLE_SERVICE_ACCOUNT_PATH=/absolute/path/to/google-service-account.json
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# storemeta
|
|
2
|
+
|
|
3
|
+
`storemeta` is a TypeScript CLI for pulling, validating, and pushing App Store Connect and Google Play metadata and screenshots.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g storemeta
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requirements:
|
|
12
|
+
- Node.js 20+
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
Create a starter project:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
storemeta init
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Validate local config, metadata, and screenshot layout:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
storemeta validate
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Create missing locale metadata files and screenshot folders from config:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
storemeta scaffold
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Pull metadata from one platform:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
storemeta metadata pull --platform apple
|
|
38
|
+
storemeta metadata pull --platform google --locale en-US
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Pull screenshots from one platform:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
storemeta screenshots pull --platform apple
|
|
45
|
+
storemeta screenshots pull --platform google --locale en-US
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Push local metadata or screenshots:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
storemeta metadata push --platform google --dry-run
|
|
52
|
+
storemeta screenshots push --platform apple --replace
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Implemented Commands
|
|
56
|
+
|
|
57
|
+
- `storemeta init`
|
|
58
|
+
- `storemeta validate`
|
|
59
|
+
- `storemeta auth check`
|
|
60
|
+
- `storemeta config doctor`
|
|
61
|
+
- `storemeta locales list`
|
|
62
|
+
- `storemeta scaffold`
|
|
63
|
+
- `storemeta metadata pull`
|
|
64
|
+
- `storemeta metadata push`
|
|
65
|
+
- `storemeta metadata diff`
|
|
66
|
+
- `storemeta screenshots pull`
|
|
67
|
+
- `storemeta screenshots push`
|
|
68
|
+
- `storemeta screenshots diff`
|
|
69
|
+
|
|
70
|
+
## Example Project
|
|
71
|
+
|
|
72
|
+
A safe, fake sample project lives under [`examples/`](examples).
|
|
73
|
+
|
|
74
|
+
## Documentation
|
|
75
|
+
|
|
76
|
+
The current user and config documentation is here:
|
|
77
|
+
|
|
78
|
+
- [DOCUMENTATION.md](docs/DOCUMENTATION.md)
|
|
79
|
+
|
|
80
|
+
The original implementation plan is kept here:
|
|
81
|
+
|
|
82
|
+
- [PROJECT_PLAN.md](docs/PROJECT_PLAN.md)
|
|
83
|
+
|
|
84
|
+
Release verification notes:
|
|
85
|
+
|
|
86
|
+
- [RELEASE_VERIFICATION.md](docs/RELEASE_VERIFICATION.md)
|
|
87
|
+
|
|
88
|
+
Credential setup:
|
|
89
|
+
|
|
90
|
+
- [AUTH_SETUP.md](docs/AUTH_SETUP.md)
|
|
91
|
+
|
|
92
|
+
Security notes:
|
|
93
|
+
|
|
94
|
+
- [SECURITY.md](SECURITY.md)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createCredentialFieldState, } from "../credential-state.js";
|
|
2
|
+
import { loadAppleCredentials } from "./load-credentials.js";
|
|
3
|
+
export function getAppleCredentialState(credentials, env = process.env) {
|
|
4
|
+
const loaded = loadAppleCredentials(credentials, env);
|
|
5
|
+
const issuerId = createCredentialFieldState(credentials.issuerIdEnv, loaded.issuerId);
|
|
6
|
+
const keyId = createCredentialFieldState(credentials.keyIdEnv, loaded.keyId);
|
|
7
|
+
const privateKeyPath = createCredentialFieldState(credentials.privateKeyPathEnv, loaded.privateKeyPath);
|
|
8
|
+
return {
|
|
9
|
+
issuerId,
|
|
10
|
+
keyId,
|
|
11
|
+
privateKeyPath,
|
|
12
|
+
allPresent: issuerId.present && keyId.present && privateKeyPath.present,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { importPKCS8, SignJWT } from "jose";
|
|
3
|
+
import { StoremetaError } from "../../cli/errors.js";
|
|
4
|
+
import { requireAppleCredentials } from "./load-credentials.js";
|
|
5
|
+
export const APPLE_APP_STORE_CONNECT_AUDIENCE = "appstoreconnect-v1";
|
|
6
|
+
const DEFAULT_APPLE_TOKEN_TTL_SECONDS = 15 * 60;
|
|
7
|
+
const MAX_APPLE_TOKEN_TTL_SECONDS = 20 * 60;
|
|
8
|
+
function resolveAppleTokenIssuedAt(now) {
|
|
9
|
+
if (now instanceof Date) {
|
|
10
|
+
return Math.floor(now.getTime() / 1000);
|
|
11
|
+
}
|
|
12
|
+
if (typeof now === "number") {
|
|
13
|
+
return Math.floor(now);
|
|
14
|
+
}
|
|
15
|
+
return Math.floor(Date.now() / 1000);
|
|
16
|
+
}
|
|
17
|
+
function resolveAppleTokenTtlSeconds(expiresInSeconds) {
|
|
18
|
+
const ttlSeconds = expiresInSeconds ?? DEFAULT_APPLE_TOKEN_TTL_SECONDS;
|
|
19
|
+
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
|
|
20
|
+
throw new StoremetaError("AUTH_ERROR", "Apple JWT expiration must be a positive number of seconds");
|
|
21
|
+
}
|
|
22
|
+
if (ttlSeconds > MAX_APPLE_TOKEN_TTL_SECONDS) {
|
|
23
|
+
throw new StoremetaError("AUTH_ERROR", `Apple JWT expiration must not exceed ${MAX_APPLE_TOKEN_TTL_SECONDS} seconds`);
|
|
24
|
+
}
|
|
25
|
+
return Math.floor(ttlSeconds);
|
|
26
|
+
}
|
|
27
|
+
export async function createAppleJwtToken(credentials, env = process.env, options) {
|
|
28
|
+
const resolvedCredentials = requireAppleCredentials(credentials, env);
|
|
29
|
+
const issuedAt = resolveAppleTokenIssuedAt(options?.now);
|
|
30
|
+
const ttlSeconds = resolveAppleTokenTtlSeconds(options?.expiresInSeconds);
|
|
31
|
+
let privateKeyPem;
|
|
32
|
+
try {
|
|
33
|
+
privateKeyPem = await readFile(resolvedCredentials.privateKeyPath, "utf8");
|
|
34
|
+
}
|
|
35
|
+
catch (cause) {
|
|
36
|
+
throw new StoremetaError("AUTH_ERROR", `Failed to read Apple private key file at ${resolvedCredentials.privateKeyPath}`, { cause });
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const privateKey = await importPKCS8(privateKeyPem, "ES256");
|
|
40
|
+
return await new SignJWT({
|
|
41
|
+
iss: resolvedCredentials.issuerId,
|
|
42
|
+
aud: APPLE_APP_STORE_CONNECT_AUDIENCE,
|
|
43
|
+
})
|
|
44
|
+
.setProtectedHeader({
|
|
45
|
+
alg: "ES256",
|
|
46
|
+
kid: resolvedCredentials.keyId,
|
|
47
|
+
typ: "JWT",
|
|
48
|
+
})
|
|
49
|
+
.setIssuedAt(issuedAt)
|
|
50
|
+
.setExpirationTime(issuedAt + ttlSeconds)
|
|
51
|
+
.sign(privateKey);
|
|
52
|
+
}
|
|
53
|
+
catch (cause) {
|
|
54
|
+
throw new StoremetaError("AUTH_ERROR", "Failed to sign Apple JWT from the configured private key", { cause });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { StoremetaError } from "../../cli/errors.js";
|
|
2
|
+
function hasText(value) {
|
|
3
|
+
return value !== undefined && value.trim().length > 0;
|
|
4
|
+
}
|
|
5
|
+
export function loadAppleCredentials(credentials, env = process.env) {
|
|
6
|
+
return {
|
|
7
|
+
issuerId: env[credentials.issuerIdEnv],
|
|
8
|
+
keyId: env[credentials.keyIdEnv],
|
|
9
|
+
privateKeyPath: env[credentials.privateKeyPathEnv],
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function requireAppleCredentials(credentials, env = process.env) {
|
|
13
|
+
const loaded = loadAppleCredentials(credentials, env);
|
|
14
|
+
const missing = [];
|
|
15
|
+
if (!hasText(loaded.issuerId)) {
|
|
16
|
+
missing.push(credentials.issuerIdEnv);
|
|
17
|
+
}
|
|
18
|
+
if (!hasText(loaded.keyId)) {
|
|
19
|
+
missing.push(credentials.keyIdEnv);
|
|
20
|
+
}
|
|
21
|
+
if (!hasText(loaded.privateKeyPath)) {
|
|
22
|
+
missing.push(credentials.privateKeyPathEnv);
|
|
23
|
+
}
|
|
24
|
+
if (missing.length > 0) {
|
|
25
|
+
throw new StoremetaError("AUTH_ERROR", `Missing Apple credentials in environment: ${missing.join(", ")}`);
|
|
26
|
+
}
|
|
27
|
+
const { issuerId, keyId, privateKeyPath } = loaded;
|
|
28
|
+
if (!hasText(issuerId) || !hasText(keyId) || !hasText(privateKeyPath)) {
|
|
29
|
+
throw new StoremetaError("AUTH_ERROR", "Apple credential resolution failed unexpectedly after validation");
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
issuerId,
|
|
33
|
+
keyId,
|
|
34
|
+
privateKeyPath,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createCredentialFieldState, } from "../credential-state.js";
|
|
2
|
+
import { loadGoogleCredentials } from "./load-credentials.js";
|
|
3
|
+
export function getGoogleCredentialState(credentials, env = process.env) {
|
|
4
|
+
const loaded = loadGoogleCredentials(credentials, env);
|
|
5
|
+
const serviceAccountPath = createCredentialFieldState(credentials.serviceAccountPathEnv, loaded.serviceAccountPath);
|
|
6
|
+
return {
|
|
7
|
+
serviceAccountPath,
|
|
8
|
+
allPresent: serviceAccountPath.present,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { StoremetaError } from "../../cli/errors.js";
|
|
2
|
+
function hasText(value) {
|
|
3
|
+
return value !== undefined && value.trim().length > 0;
|
|
4
|
+
}
|
|
5
|
+
export function loadGoogleCredentials(credentials, env = process.env) {
|
|
6
|
+
return {
|
|
7
|
+
serviceAccountPath: env[credentials.serviceAccountPathEnv],
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export function requireGoogleCredentials(credentials, env = process.env) {
|
|
11
|
+
const loaded = loadGoogleCredentials(credentials, env);
|
|
12
|
+
if (!hasText(loaded.serviceAccountPath)) {
|
|
13
|
+
throw new StoremetaError("AUTH_ERROR", `Missing Google credentials in environment: ${credentials.serviceAccountPathEnv}`);
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
serviceAccountPath: loaded.serviceAccountPath,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { GoogleAuth } from "google-auth-library";
|
|
2
|
+
import { StoremetaError } from "../../cli/errors.js";
|
|
3
|
+
import { requireGoogleCredentials } from "./load-credentials.js";
|
|
4
|
+
export const GOOGLE_PLAY_SCOPE = "https://www.googleapis.com/auth/androidpublisher";
|
|
5
|
+
export function createGoogleServiceAccountAuth(credentials, env = process.env) {
|
|
6
|
+
const resolvedCredentials = requireGoogleCredentials(credentials, env);
|
|
7
|
+
return new GoogleAuth({
|
|
8
|
+
keyFile: resolvedCredentials.serviceAccountPath,
|
|
9
|
+
scopes: [GOOGLE_PLAY_SCOPE],
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export async function getGoogleAccessToken(credentials, env = process.env) {
|
|
13
|
+
const auth = createGoogleServiceAccountAuth(credentials, env);
|
|
14
|
+
const client = await auth.getClient();
|
|
15
|
+
const accessTokenResult = await client.getAccessToken();
|
|
16
|
+
const accessToken = typeof accessTokenResult === "string"
|
|
17
|
+
? accessTokenResult
|
|
18
|
+
: accessTokenResult?.token;
|
|
19
|
+
if (accessToken === undefined ||
|
|
20
|
+
accessToken === null ||
|
|
21
|
+
accessToken.trim().length === 0) {
|
|
22
|
+
throw new StoremetaError("AUTH_ERROR", "Failed to obtain a Google Play access token from the service account");
|
|
23
|
+
}
|
|
24
|
+
return accessToken;
|
|
25
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const REDACTED_SECRET = "[REDACTED]";
|
|
2
|
+
function hasPresentValue(value) {
|
|
3
|
+
if (typeof value === "string") {
|
|
4
|
+
return value.trim().length > 0;
|
|
5
|
+
}
|
|
6
|
+
return value !== undefined && value !== null;
|
|
7
|
+
}
|
|
8
|
+
export function redactSecretValue(value, replacement = REDACTED_SECRET) {
|
|
9
|
+
return hasPresentValue(value) ? replacement : value;
|
|
10
|
+
}
|
|
11
|
+
export function redactSecretFields(record, secretKeys, replacement = REDACTED_SECRET) {
|
|
12
|
+
const redacted = { ...record };
|
|
13
|
+
for (const key of secretKeys) {
|
|
14
|
+
redacted[key] = redactSecretValue(record[key], replacement);
|
|
15
|
+
}
|
|
16
|
+
return redacted;
|
|
17
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { getAppleCredentialState } from "../auth/apple/credential-state.js";
|
|
2
|
+
import { getGoogleCredentialState } from "../auth/google/credential-state.js";
|
|
3
|
+
import { resolveSelectedPlatforms } from "../config/select-platforms.js";
|
|
4
|
+
import { createCommandContext } from "./context.js";
|
|
5
|
+
function createSummary(results) {
|
|
6
|
+
const failureCount = results.filter((result) => !result.success).length;
|
|
7
|
+
const successCount = results.length - failureCount;
|
|
8
|
+
return {
|
|
9
|
+
status: failureCount === 0 ? "success" : successCount === 0 ? "failure" : "partial",
|
|
10
|
+
successCount,
|
|
11
|
+
failureCount,
|
|
12
|
+
skippedCount: 0,
|
|
13
|
+
results,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export async function runAuthCheckCommand(options) {
|
|
17
|
+
const context = await createCommandContext(options);
|
|
18
|
+
const platforms = resolveSelectedPlatforms(context.app, context.platform);
|
|
19
|
+
const results = [];
|
|
20
|
+
for (const platform of platforms) {
|
|
21
|
+
if (platform === "apple" && context.app.settings.apple !== undefined) {
|
|
22
|
+
const state = getAppleCredentialState(context.app.settings.apple.credentials);
|
|
23
|
+
const missing = [state.issuerId, state.keyId, state.privateKeyPath]
|
|
24
|
+
.filter((field) => !field.present)
|
|
25
|
+
.map((field) => field.envVar);
|
|
26
|
+
results.push({
|
|
27
|
+
target: "auth.apple",
|
|
28
|
+
success: missing.length === 0,
|
|
29
|
+
message: missing.length === 0
|
|
30
|
+
? "Apple credential environment variables are present"
|
|
31
|
+
: `Missing Apple credential environment variables: ${missing.join(", ")}`,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (platform === "google" && context.app.settings.google !== undefined) {
|
|
35
|
+
const state = getGoogleCredentialState(context.app.settings.google.credentials);
|
|
36
|
+
results.push({
|
|
37
|
+
target: "auth.google",
|
|
38
|
+
success: state.allPresent,
|
|
39
|
+
message: state.allPresent
|
|
40
|
+
? "Google credential environment variables are present"
|
|
41
|
+
: `Missing Google credential environment variable: ${state.serviceAccountPath.envVar}`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return createSummary(results);
|
|
46
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { resolveSelectedPlatforms } from "../config/select-platforms.js";
|
|
2
|
+
import { createCommandContext } from "./context.js";
|
|
3
|
+
export async function runConfigDoctorCommand(options) {
|
|
4
|
+
const context = await createCommandContext(options);
|
|
5
|
+
const platforms = resolveSelectedPlatforms(context.app, context.platform);
|
|
6
|
+
const results = [
|
|
7
|
+
{
|
|
8
|
+
target: "config",
|
|
9
|
+
success: true,
|
|
10
|
+
message: `Loaded ${context.configPath}`,
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
target: "project",
|
|
14
|
+
success: true,
|
|
15
|
+
message: `${context.config.project.name}; default app ${context.config.project.defaultApp}`,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
target: `app.${context.app.id}`,
|
|
19
|
+
success: true,
|
|
20
|
+
message: `Selected platforms: ${platforms.join(", ") || "none"}`,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
target: "metadata.baseDir",
|
|
24
|
+
success: true,
|
|
25
|
+
message: context.app.settings.metadata.baseDir,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
target: "screenshots.baseDir",
|
|
29
|
+
success: true,
|
|
30
|
+
message: context.app.settings.screenshots.baseDir,
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
return {
|
|
34
|
+
status: "success",
|
|
35
|
+
successCount: results.length,
|
|
36
|
+
failureCount: 0,
|
|
37
|
+
skippedCount: 0,
|
|
38
|
+
results,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { loadConfigFile } from "../config/load-config.js";
|
|
2
|
+
import { validateRootConfig } from "../config/schema.js";
|
|
3
|
+
import { selectConfiguredApp } from "../config/select-app.js";
|
|
4
|
+
import { validateBaseDirectoryPaths } from "../config/validate-base-dirs.js";
|
|
5
|
+
import { validateCredentialEnvVarNames } from "../config/validate-credential-env-names.js";
|
|
6
|
+
import { validateRequiredPlatformIdentifiers } from "../config/validate-platform-identifiers.js";
|
|
7
|
+
import { normalizeLocaleCode } from "../locales/normalize.js";
|
|
8
|
+
export async function createCommandContext(options) {
|
|
9
|
+
const loadedConfig = await loadConfigFile(options.config);
|
|
10
|
+
const config = validateRootConfig(loadedConfig.parsed);
|
|
11
|
+
validateRequiredPlatformIdentifiers(config);
|
|
12
|
+
validateCredentialEnvVarNames(config);
|
|
13
|
+
validateBaseDirectoryPaths(config);
|
|
14
|
+
return {
|
|
15
|
+
configPath: loadedConfig.path,
|
|
16
|
+
config,
|
|
17
|
+
app: selectConfiguredApp(config, options.app),
|
|
18
|
+
platform: options.platform,
|
|
19
|
+
locale: options.locale === undefined ? undefined : normalizeLocaleCode(options.locale),
|
|
20
|
+
dryRun: options.dryRun ?? false,
|
|
21
|
+
verbose: options.verbose ?? false,
|
|
22
|
+
};
|
|
23
|
+
}
|
package/dist/cli/init.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { access, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { StoremetaError } from "./errors.js";
|
|
4
|
+
import { DEFAULT_CONFIG_FILE } from "../config/load-config.js";
|
|
5
|
+
export function renderStarterConfig() {
|
|
6
|
+
return `version: 1
|
|
7
|
+
|
|
8
|
+
project:
|
|
9
|
+
name: example-project
|
|
10
|
+
defaultApp: example-app
|
|
11
|
+
|
|
12
|
+
apps:
|
|
13
|
+
example-app:
|
|
14
|
+
metadata:
|
|
15
|
+
baseDir: metadata
|
|
16
|
+
format: yaml
|
|
17
|
+
screenshots:
|
|
18
|
+
baseDir: screenshots
|
|
19
|
+
|
|
20
|
+
apple:
|
|
21
|
+
appId: "0000000000"
|
|
22
|
+
credentials:
|
|
23
|
+
issuerIdEnv: STORE_APPLE_ISSUER_ID
|
|
24
|
+
keyIdEnv: STORE_APPLE_KEY_ID
|
|
25
|
+
privateKeyPathEnv: STORE_APPLE_PRIVATE_KEY_PATH
|
|
26
|
+
locales:
|
|
27
|
+
default: [en-US]
|
|
28
|
+
|
|
29
|
+
google:
|
|
30
|
+
packageName: com.example.app
|
|
31
|
+
credentials:
|
|
32
|
+
serviceAccountPathEnv: STORE_GOOGLE_SERVICE_ACCOUNT_PATH
|
|
33
|
+
locales:
|
|
34
|
+
default: [en-US]
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
37
|
+
export async function runInitCommand(configPath) {
|
|
38
|
+
const resolvedPath = resolve(configPath ?? DEFAULT_CONFIG_FILE);
|
|
39
|
+
const projectRoot = dirname(resolvedPath);
|
|
40
|
+
try {
|
|
41
|
+
await access(resolvedPath);
|
|
42
|
+
throw new StoremetaError("FILESYSTEM_ERROR", `Refusing to overwrite existing config file at ${resolvedPath}`);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error instanceof StoremetaError ||
|
|
46
|
+
(typeof error === "object" &&
|
|
47
|
+
error !== null &&
|
|
48
|
+
"code" in error &&
|
|
49
|
+
error.code !== "ENOENT")) {
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await mkdir(projectRoot, { recursive: true });
|
|
54
|
+
await writeFile(resolvedPath, renderStarterConfig(), "utf8");
|
|
55
|
+
await mkdir(join(projectRoot, "metadata", "apple"), { recursive: true });
|
|
56
|
+
await mkdir(join(projectRoot, "metadata", "google"), { recursive: true });
|
|
57
|
+
await mkdir(join(projectRoot, "screenshots", "apple", "en-US", "APP_IPHONE_65"), { recursive: true });
|
|
58
|
+
await mkdir(join(projectRoot, "screenshots", "google", "en-US", "phoneScreenshots"), { recursive: true });
|
|
59
|
+
return resolvedPath;
|
|
60
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { resolveSelectedPlatforms } from "../config/select-platforms.js";
|
|
2
|
+
import { normalizeLocaleCode } from "../locales/normalize.js";
|
|
3
|
+
import { createCommandContext } from "./context.js";
|
|
4
|
+
function formatLocales(locales) {
|
|
5
|
+
if (locales === undefined || locales.length === 0) {
|
|
6
|
+
return "none configured";
|
|
7
|
+
}
|
|
8
|
+
return locales.map(normalizeLocaleCode).join(", ");
|
|
9
|
+
}
|
|
10
|
+
export async function runLocalesListCommand(options) {
|
|
11
|
+
const context = await createCommandContext(options);
|
|
12
|
+
const platforms = resolveSelectedPlatforms(context.app, context.platform);
|
|
13
|
+
const results = [];
|
|
14
|
+
for (const platform of platforms) {
|
|
15
|
+
if (platform === "apple" && context.app.settings.apple !== undefined) {
|
|
16
|
+
results.push({
|
|
17
|
+
target: "locales.apple",
|
|
18
|
+
success: true,
|
|
19
|
+
message: formatLocales(context.app.settings.apple.locales?.default),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
if (platform === "google" && context.app.settings.google !== undefined) {
|
|
23
|
+
results.push({
|
|
24
|
+
target: "locales.google",
|
|
25
|
+
success: true,
|
|
26
|
+
message: formatLocales(context.app.settings.google.locales?.default),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
status: "success",
|
|
32
|
+
successCount: results.length,
|
|
33
|
+
failureCount: 0,
|
|
34
|
+
skippedCount: 0,
|
|
35
|
+
results,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, extname, resolve } from "node:path";
|
|
3
|
+
import { resolveSelectedPlatforms } from "../config/select-platforms.js";
|
|
4
|
+
import { normalizeLocaleCode } from "../locales/normalize.js";
|
|
5
|
+
import { createCommandContext } from "./context.js";
|
|
6
|
+
async function listMetadataLocales(platformDir) {
|
|
7
|
+
try {
|
|
8
|
+
const entries = await readdir(platformDir, { withFileTypes: true });
|
|
9
|
+
return entries
|
|
10
|
+
.filter((entry) => entry.isFile())
|
|
11
|
+
.map((entry) => entry.name)
|
|
12
|
+
.filter((fileName) => [".yml", ".yaml", ".md"].includes(extname(fileName)))
|
|
13
|
+
.map((fileName) => normalizeLocaleCode(fileName.slice(0, -extname(fileName).length)))
|
|
14
|
+
.sort((left, right) => left.localeCompare(right));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function expectedLocalesForPlatform(context, platform) {
|
|
21
|
+
const locales = platform === "apple"
|
|
22
|
+
? context.app.settings.apple?.locales?.default
|
|
23
|
+
: context.app.settings.google?.locales?.default;
|
|
24
|
+
return (locales ?? []).map(normalizeLocaleCode).sort((left, right) => left.localeCompare(right));
|
|
25
|
+
}
|
|
26
|
+
export async function runMetadataDiffCommand(options) {
|
|
27
|
+
const context = await createCommandContext(options);
|
|
28
|
+
const platforms = resolveSelectedPlatforms(context.app, context.platform);
|
|
29
|
+
const metadataBaseDir = resolve(dirname(context.configPath), context.app.settings.metadata.baseDir);
|
|
30
|
+
const results = [];
|
|
31
|
+
for (const platform of platforms) {
|
|
32
|
+
const expected = expectedLocalesForPlatform(context, platform);
|
|
33
|
+
const actual = await listMetadataLocales(resolve(metadataBaseDir, platform));
|
|
34
|
+
const missing = expected.filter((locale) => !actual.includes(locale));
|
|
35
|
+
const extra = actual.filter((locale) => expected.length > 0 && !expected.includes(locale));
|
|
36
|
+
results.push({
|
|
37
|
+
target: `metadata.${platform}`,
|
|
38
|
+
success: missing.length === 0,
|
|
39
|
+
message: [
|
|
40
|
+
`local: ${actual.length === 0 ? "none" : actual.join(", ")}`,
|
|
41
|
+
expected.length === 0 ? "expected: not configured" : `expected: ${expected.join(", ")}`,
|
|
42
|
+
missing.length === 0 ? undefined : `missing: ${missing.join(", ")}`,
|
|
43
|
+
extra.length === 0 ? undefined : `extra: ${extra.join(", ")}`,
|
|
44
|
+
]
|
|
45
|
+
.filter((part) => part !== undefined)
|
|
46
|
+
.join("; "),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const failureCount = results.filter((result) => !result.success).length;
|
|
50
|
+
return {
|
|
51
|
+
status: failureCount === 0 ? "success" : "partial",
|
|
52
|
+
successCount: results.length - failureCount,
|
|
53
|
+
failureCount,
|
|
54
|
+
skippedCount: 0,
|
|
55
|
+
results,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { dirname, resolve } from "node:path";
|
|
2
|
+
import { StoremetaError } from "./errors.js";
|
|
3
|
+
import { resolveSelectedPlatforms } from "../config/select-platforms.js";
|
|
4
|
+
import { normalizeLocaleCode } from "../locales/normalize.js";
|
|
5
|
+
import { createAppStoreConnectClient } from "../platforms/apple/client.js";
|
|
6
|
+
import { fetchAppleAppInfoLocalizations, fetchAppleAppStoreVersionLocalizations, mergeAppleLocalizations, normalizeMergedAppleLocalizations, writeAppleMetadataDocuments, } from "../platforms/apple/metadata/pull.js";
|
|
7
|
+
import { createGooglePlayClient } from "../platforms/google/client.js";
|
|
8
|
+
import { withGoogleEditSession } from "../platforms/google/edits.js";
|
|
9
|
+
import { fetchGoogleListingsForLocales, normalizeGoogleListing, writeGoogleListingDocuments, } from "../platforms/google/metadata/pull.js";
|
|
10
|
+
import { createCommandContext } from "./context.js";
|
|
11
|
+
function filterLocalizedDocumentsByLocale(documents, locale) {
|
|
12
|
+
if (locale === undefined) {
|
|
13
|
+
return documents;
|
|
14
|
+
}
|
|
15
|
+
const normalizedLocale = normalizeLocaleCode(locale);
|
|
16
|
+
return documents.filter((document) => normalizeLocaleCode(document.locale) === normalizedLocale);
|
|
17
|
+
}
|
|
18
|
+
export async function runMetadataPullCommand(options) {
|
|
19
|
+
const context = await createCommandContext(options);
|
|
20
|
+
const app = context.app;
|
|
21
|
+
const selectedPlatforms = resolveSelectedPlatforms(app, context.platform);
|
|
22
|
+
const metadataBaseDir = resolve(dirname(context.configPath), app.settings.metadata.baseDir);
|
|
23
|
+
for (const platform of selectedPlatforms) {
|
|
24
|
+
if (platform === "apple") {
|
|
25
|
+
const appleSettings = app.settings.apple;
|
|
26
|
+
if (appleSettings === undefined) {
|
|
27
|
+
throw new StoremetaError("CONFIG_ERROR", `Configured app "${app.id}" does not define App Store Connect settings`);
|
|
28
|
+
}
|
|
29
|
+
const client = createAppStoreConnectClient(appleSettings.credentials);
|
|
30
|
+
const appInfoLocalizations = await fetchAppleAppInfoLocalizations(client, appleSettings.appId);
|
|
31
|
+
const appStoreVersionLocalizations = await fetchAppleAppStoreVersionLocalizations(client, appleSettings.appId);
|
|
32
|
+
const documents = filterLocalizedDocumentsByLocale(normalizeMergedAppleLocalizations(mergeAppleLocalizations(appInfoLocalizations, appStoreVersionLocalizations)), context.locale);
|
|
33
|
+
await writeAppleMetadataDocuments(metadataBaseDir, documents);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const googleSettings = app.settings.google;
|
|
37
|
+
if (googleSettings === undefined) {
|
|
38
|
+
throw new StoremetaError("CONFIG_ERROR", `Configured app "${app.id}" does not define Google Play settings`);
|
|
39
|
+
}
|
|
40
|
+
const locales = context.locale
|
|
41
|
+
? [normalizeLocaleCode(context.locale)]
|
|
42
|
+
: (googleSettings.locales?.default ?? []).map(normalizeLocaleCode);
|
|
43
|
+
const client = createGooglePlayClient(googleSettings.credentials);
|
|
44
|
+
await withGoogleEditSession(client, googleSettings.packageName, async (edit) => {
|
|
45
|
+
const listings = await fetchGoogleListingsForLocales(client, googleSettings.packageName, edit.id, locales);
|
|
46
|
+
const documents = listings.map(normalizeGoogleListing);
|
|
47
|
+
await writeGoogleListingDocuments(metadataBaseDir, documents);
|
|
48
|
+
}, {
|
|
49
|
+
autoCommit: false,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|