Skip to content

Commit

Permalink
[eas-cli] Add js metadata config file support (#1270)
Browse files Browse the repository at this point in the history
* [eas-cli] Add js metadata config file support

* [eas-cli] Add js metadata config file download support

* update CHANGELOG.md

* [eas-cli] Only save static files when pulling metadata

* [eas-cli] Support config functions and async functions in metadata

* [eas-cli] Tweak the metadata logs

* [eas-cli] Improve the dynamic config return validation

* [eas-cli] Apply improved language suggestions from the pros

Co-authored-by: Dominik Sokal <dominik.sokal@swmansion.com>

* [eas-cli] Apply code changes from review

Co-authored-by: Dominik Sokal <dominik.sokal@swmansion.com>
Co-authored-by: Wojciech Kozyra <wojciech.kozyra@swmansion.com>

* [eas-cli] Split out load config logging and move validation to single method

* [eas-cli] Fix missed renamed function

Co-authored-by: Dominik Sokal <dominik.sokal@swmansion.com>
Co-authored-by: Wojciech Kozyra <wojciech.kozyra@swmansion.com>
  • Loading branch information
3 people authored Aug 17, 2022
1 parent c8935e1 commit faf1e90
Show file tree
Hide file tree
Showing 18 changed files with 290 additions and 35 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages.

### 🎉 New features

- Add metadata support for dynamic store.config.js files. ([#1270](https://github.com/expo/eas-cli/pull/1270) by [@byCedric](https://github.com/byCedric))

### 🐛 Bug fixes

- Rebind `console.info` correctly after `ora` instance stops. ([#1113](https://github.com/expo/eas-cli/pull/1113) by [@EvanBacon](https://github.com/EvanBacon))
Expand Down
4 changes: 2 additions & 2 deletions packages/eas-cli/src/commands/metadata/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@ export default class MetadataPull extends EasCommand {
const relativePath = path.relative(process.cwd(), filePath);

Log.addNewLineIfNone();
Log.log(`🎉 Your store configuration is ready.
Log.log(`🎉 Your store config is ready.
- Update the ${chalk.bold(relativePath)} file to prepare the app information.
- Run ${chalk.bold('eas submit')} or manually upload a new app version to the app stores.
- Once the app is uploaded, run ${chalk.bold('eas metadata:push')} to sync the store configuration.
- Once the app is uploaded, run ${chalk.bold('eas metadata:push')} to sync the store config.
- ${learnMore('https://docs.expo.dev/eas-metadata/introduction/')}`);
} catch (error: any) {
handleMetadataError(error);
Expand Down
128 changes: 128 additions & 0 deletions packages/eas-cli/src/metadata/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import path from 'path';

import { getStaticConfigFilePath, loadConfigAsync } from '../config';
import { MetadataValidationError } from '../errors';

describe(getStaticConfigFilePath, () => {
const projectDir = '/app';

it(`returns same file for store.config.json`, () => {
const metadataPath = 'store.config.json';
const expectedFile = path.join(projectDir, metadataPath);

expect(getStaticConfigFilePath({ projectDir, metadataPath })).toBe(expectedFile);
});

it(`returns store.config.json for store.config.js`, () => {
const metadataPath = 'store.config.js';
const expectedFile = path.join(projectDir, 'store.config.json');

expect(getStaticConfigFilePath({ projectDir, metadataPath })).toBe(expectedFile);
});

it(`returns store.staging.json file for store.staging.js`, () => {
const metadataPath = 'store.staging.js';
const expectedFile = path.join(projectDir, 'store.staging.json');

expect(getStaticConfigFilePath({ projectDir, metadataPath })).toBe(expectedFile);
});

// This shouldn't be used IRL, but it tests if this function is working properly
it(`returns custom-name.json file for custom-name.js`, () => {
const metadataPath = 'custom-name.js';
const expectedFile = path.join(projectDir, 'custom-name.json');

expect(getStaticConfigFilePath({ projectDir, metadataPath })).toBe(expectedFile);
});
});

describe(loadConfigAsync, () => {
const projectDir = path.resolve(__dirname, 'fixtures');

it(`throws when file doesn't exist`, async () => {
await expect(
loadConfigAsync({ projectDir, metadataPath: 'doesnt-exist.json' })
).rejects.toThrow('file not found');
});

it(`throws when validation errors are found without skipping validation`, async () => {
await expect(
loadConfigAsync({ projectDir, metadataPath: 'invalid.config.json' })
).rejects.toThrow('errors found');
});

it(`returns config from "store.config.json"`, async () => {
await expect(
loadConfigAsync({ projectDir, metadataPath: 'store.config.json' })
).resolves.toHaveProperty('apple.copyright', 'ACME');
});

it(`returns config from "store.config.js"`, async () => {
const year = new Date().getFullYear();
await expect(
loadConfigAsync({ projectDir, metadataPath: 'store.config.js' })
).resolves.toHaveProperty('apple.copyright', `${year} ACME`);
});

it(`returns config from "store.function.js"`, async () => {
const year = new Date().getFullYear();
await expect(
loadConfigAsync({ projectDir, metadataPath: 'store.function.js' })
).resolves.toHaveProperty('apple.copyright', `${year} ACME`);
});

it(`returns config from "store.async.js"`, async () => {
const year = new Date().getFullYear();
await expect(
loadConfigAsync({ projectDir, metadataPath: 'store.async.js' })
).resolves.toHaveProperty('apple.copyright', `${year} ACME`);
});

it(`returns invalid config from "invalid.config.json`, async () => {
await expect(
loadConfigAsync({ projectDir, metadataPath: 'invalid.config.json', skipValidation: true })
).resolves.toMatchObject({ configVersion: -1 });
});

it(`returns invalid config from "invalid.config.js`, async () => {
await expect(
loadConfigAsync({ projectDir, metadataPath: 'invalid.config.js', skipValidation: true })
).resolves.toMatchObject({ configVersion: -1 });
});

it(`returns invalid config from "invalid.function.js`, async () => {
await expect(
loadConfigAsync({ projectDir, metadataPath: 'invalid.function.js', skipValidation: true })
).resolves.toMatchObject({ configVersion: -1 });
});

it(`returns invalid config from "invalid.async.js`, async () => {
await expect(
loadConfigAsync({ projectDir, metadataPath: 'invalid.async.js', skipValidation: true })
).resolves.toMatchObject({ configVersion: -1 });
});

it(`throws invalid config type from "invalid-type.config.json"`, async () => {
await expect(
loadConfigAsync({ projectDir, metadataPath: 'invalid-type.config.json' })
).rejects.toThrow(MetadataValidationError);
});

it(`throws invalid config type from "invalid-type.config.js"`, async () => {
await expect(
loadConfigAsync({ projectDir, metadataPath: 'invalid-type.config.js' })
).rejects.toThrow(MetadataValidationError);
});

it(`throws invalid config type from "invalid-type.function.js"`, async () => {
await expect(
loadConfigAsync({ projectDir, metadataPath: 'invalid-type.function.js' })
).rejects.toThrow(MetadataValidationError);
});

it(`throws invalid config type from "invalid-type.async.js"`, async () => {
await expect(
loadConfigAsync({ projectDir, metadataPath: 'invalid-type.async.js' })
).rejects.toThrow(MetadataValidationError);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = async function () {
return await Promise.resolve(null);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = 1337;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[
{
"configVersion": -1
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = function () {
return true;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = async function () {
return await Promise.resolve({ configVersion: -1 });
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
configVersion: -1,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"configVersion": -1
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = function () {
return { configVersion: -1 };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = async function () {
const config = require('./store.config.json');
config.apple.copyright = `${new Date().getFullYear()} ACME`;
return await Promise.resolve(config);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const config = require('./store.config.json');

config.apple.copyright = `${new Date().getFullYear()} ACME`;

module.exports = config;
22 changes: 22 additions & 0 deletions packages/eas-cli/src/metadata/__tests__/fixtures/store.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"configVersion": 0,
"apple": {
"copyright": "ACME",
"info": {
"en-US": {
"title": "App title",
"subtitle": "Subtitle for your app",
"description": "A longer description of what your app does",
"keywords": [
"keyword"
],
"releaseNotes": "Bug fixes and improved stability",
"promoText": "Short tagline for your app",
"marketingUrl": "https://example.com/en/marketing",
"supportUrl": "https://example.com/en/support",
"privacyPolicyUrl": "https://example.com/en/privacy",
"privacyChoicesUrl": "https://example.com/en/choices"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = function () {
const config = require('./store.config.json');
config.apple.copyright = `${new Date().getFullYear()} ACME`;
return config;
};
65 changes: 65 additions & 0 deletions packages/eas-cli/src/metadata/config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import Ajv from 'ajv';
import assert from 'assert';
import fs from 'fs-extra';
import path from 'path';

import { AppleConfigReader } from './apple/config/reader';
import { AppleConfigWriter } from './apple/config/writer';
import { AppleMetadata } from './apple/types';
import { MetadataValidationError } from './errors';

export interface MetadataConfig {
/** The store configuration version */
Expand All @@ -12,6 +15,68 @@ export interface MetadataConfig {
apple?: AppleMetadata;
}

/**
* Resolve the dynamic config from the user.
* It supports methods, async methods, or objects (json).
*/
async function resolveDynamicConfigAsync(configFile: string): Promise<unknown> {
const userConfigOrFunction = await import(configFile).then(file => file.default ?? file);

return typeof userConfigOrFunction === 'function'
? await userConfigOrFunction()
: userConfigOrFunction;
}

/**
* Get the static configuration file path, based on the metadata context.
* This uses any custom name provided, but swaps out the extension for `.json`.
*/
export function getStaticConfigFilePath({
projectDir,
metadataPath,
}: {
projectDir: string;
metadataPath: string;
}): string {
const configFile = path.join(projectDir, metadataPath);
const configExtension = path.extname(configFile);

return path.join(projectDir, `${path.basename(configFile, configExtension)}.json`);
}

/**
* Load the store configuration from a metadata context.
* This can load `.json` and `.js` config files, using `require`.
* It throws MetadataValidationErrors when the file doesn't exist, or contains errors.
* The user is prompted to try anyway when errors are found.
*/
export async function loadConfigAsync({
projectDir,
metadataPath,
skipValidation = false,
}: {
projectDir: string;
metadataPath: string;
skipValidation?: boolean;
}): Promise<MetadataConfig> {
const configFile = path.join(projectDir, metadataPath);
if (!(await fs.pathExists(configFile))) {
throw new MetadataValidationError(`Metadata store config file not found: "${configFile}"`);
}

const configData = await resolveDynamicConfigAsync(configFile);

if (!skipValidation) {
const { valid, errors: validationErrors } = validateConfig(configData);

if (!valid) {
throw new MetadataValidationError(`Metadata store config errors found`, validationErrors);
}
}

return configData as MetadataConfig;
}

/**
* Run the JSON Schema validation to normalize defaults and flag early config errors.
* This includes validating the known store limitations for every configurable property.
Expand Down
13 changes: 7 additions & 6 deletions packages/eas-cli/src/metadata/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Log from '../log';
import { confirmAsync } from '../prompts';
import { AppleData } from './apple/data';
import { createAppleTasks } from './apple/tasks';
import { createAppleWriter } from './config';
import { createAppleWriter, getStaticConfigFilePath } from './config';
import { MetadataContext, ensureMetadataAppStoreAuthenticatedAsync } from './context';
import { MetadataDownloadError, MetadataValidationError } from './errors';
import { subscribeTelemetry } from './utils/telemetry';
Expand All @@ -16,15 +16,16 @@ import { subscribeTelemetry } from './utils/telemetry';
* Note, only App Store is supported at this time.
*/
export async function downloadMetadataAsync(metadataCtx: MetadataContext): Promise<string> {
const filePath = path.resolve(metadataCtx.projectDir, metadataCtx.metadataPath);
const filePath = getStaticConfigFilePath(metadataCtx);
const fileExists = await fs.pathExists(filePath);

if (fileExists) {
const filePathRelative = path.relative(metadataCtx.projectDir, filePath);
const overwrite = await confirmAsync({
message: `Do you want to overwrite the existing store configuration "${metadataCtx.metadataPath}"?`,
message: `Do you want to overwrite the existing "${filePathRelative}"?`,
});
if (!overwrite) {
throw new MetadataValidationError(`Store configuration already exists at "${filePath}"`);
throw new MetadataValidationError(`Store config already exists at "${filePath}"`);
}
}

Expand All @@ -35,7 +36,7 @@ export async function downloadMetadataAsync(metadataCtx: MetadataContext): Promi
);

Log.addNewLineIfNone();
Log.log('Downloading App Store configuration...');
Log.log('Downloading App Store config...');

const errors: Error[] = [];
const config = createAppleWriter();
Expand All @@ -59,7 +60,7 @@ export async function downloadMetadataAsync(metadataCtx: MetadataContext): Promi
}

try {
await fs.writeJson(filePath, config.toSchema(), { spaces: 2 });
await fs.writeJSON(filePath, config.toSchema(), { spaces: 2 });
} finally {
unsubscribeTelemetry();
}
Expand Down
Loading

0 comments on commit faf1e90

Please sign in to comment.