From 473e72f1750d2de58e0728689fe816ff3fbc5893 Mon Sep 17 00:00:00 2001 From: alexweininger Date: Fri, 10 Sep 2021 16:02:47 -0700 Subject: [PATCH 1/3] Add support for swa-cli.config.json file --- cypress/fixtures/static/swa-cli.config.json | 19 ++++ package.json | 2 + schema/swa-cli.config.schema.json | 96 +++++++++++++++++++++ src/cli/index.ts | 34 ++++++-- src/core/utils/cli-config.spec.ts | 87 +++++++++++++++++++ src/core/utils/cli-config.ts | 53 ++++++++++++ src/swa.d.ts | 12 ++- 7 files changed, 292 insertions(+), 11 deletions(-) create mode 100644 cypress/fixtures/static/swa-cli.config.json create mode 100644 schema/swa-cli.config.schema.json create mode 100644 src/core/utils/cli-config.spec.ts create mode 100644 src/core/utils/cli-config.ts diff --git a/cypress/fixtures/static/swa-cli.config.json b/cypress/fixtures/static/swa-cli.config.json new file mode 100644 index 00000000..eca6f891 --- /dev/null +++ b/cypress/fixtures/static/swa-cli.config.json @@ -0,0 +1,19 @@ +{ + "$schema": "../../../schema/swa-cli.config.schema.json", + "configurations": { + "app": { + "context": "./cypress/fixtures/static", + "apiLocation": "./cypress/fixtures/api", + "port": 1234, + "devserverTimeout": 10000, + "verbose": "silly" + }, + "app2": { + "context": "./cypress/fixtures/static", + "apiLocation": "./cypress/fixtures/api", + "port": 4321, + "devserverTimeout": 10000, + "verbose": "silly" + } + } +} diff --git a/package.json b/package.json index 1eae0703..2a429189 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,12 @@ "description": "Azure Static Web Apps CLI", "scripts": { "start": "node ./dist/cli/bin.js start ./cypress/fixtures/static --api=./cypress/fixtures/api --port 1234 --devserver-timeout 10000 --verbose silly", + "start:config": "node ./dist/cli/bin.js --config ./cypress/fixtures/static/swa-cli.config.json start app", "prestart": "npm run build", "pretest": "npm run build", "test": "jest --detectOpenHandles --silent --verbose", "e2e": "start-server-and-test start http://0.0.0.0:1234 cy:run", + "e2e:config": "start-server-and-test start:config http://0.0.0.0:1234 cy:run", "cy:run": "cypress run", "cy:open": "cypress open", "build": "tsc", diff --git a/schema/swa-cli.config.schema.json b/schema/swa-cli.config.schema.json new file mode 100644 index 00000000..52c1c3a8 --- /dev/null +++ b/schema/swa-cli.config.schema.json @@ -0,0 +1,96 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "configurations": { + "additionalProperties": { + "allOf": [ + { + "properties": { + "apiLocation": { + "description": "API folder or Azure Functions emulator address", + "type": "string" + }, + "apiPort": { + "description": "API backend port", + "type": "number" + }, + "apiPrefix": { + "enum": ["api"], + "type": "string" + }, + "app": { + "description": "Location of the build output directory relative to the appLocation", + "type": "string" + }, + "build": { + "type": "boolean" + }, + "customUrlScheme": { + "type": "string" + }, + "devserverTimeout": { + "description": "Time to wait(in ms) for the dev server to start", + "type": "number" + }, + "host": { + "description": "CLI host address", + "type": "string" + }, + "overridableErrorCode": { + "items": { + "type": "number" + }, + "type": "array" + }, + "port": { + "description": "set the cli port", + "type": "number" + }, + "run": { + "description": "Run a command at startup", + "type": "string" + }, + "ssl": { + "description": "Serve the app and API over HTTPS", + "type": "boolean" + }, + "sslCert": { + "description": "SSL certificate (.crt) to use for serving HTTPS", + "type": "string" + }, + "sslKey": { + "description": "SSL key (.key) to use for serving HTTPS", + "type": "string" + }, + "swaConfigFilename": { + "enum": ["staticwebapp.config.json"], + "type": "string" + }, + "swaConfigFilenameLegacy": { + "enum": ["routes.json"], + "type": "string" + }, + "swaConfigLocation": { + "type": "string" + }, + "verbose": { + "type": "string" + } + }, + "type": "object" + }, + { + "properties": { + "context": { + "type": "string" + } + }, + "type": "object" + } + ] + }, + "type": "object" + } + }, + "type": "object" +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 4fef7fab..953027a3 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,12 +1,15 @@ import program, { Option } from "commander"; import path from "path"; import { DEFAULT_CONFIG } from "../config"; -import { parsePort } from "../core"; +import { logger, parsePort } from "../core"; import { parseDevserverTimeout } from "../core"; import { start } from "./commands/start"; import updateNotifier from "update-notifier"; +import { getFileOptions, swaCliConfigFilename } from "../core/utils/cli-config"; const pkg = require("../../package.json"); +export const defaultStartContext = `.${path.sep}`; + export async function run(argv?: string[]) { // Once a day, check for updates updateNotifier({ pkg }).notify(); @@ -18,8 +21,10 @@ export async function run(argv?: string[]) { // SWA config .option("--verbose [prefix]", "enable verbose output. Values are: silly,info,log,silent", DEFAULT_CONFIG.verbose) + .addHelpText("after", "\nDocumentation:\n https://aka.ms/swa/cli-local-development\n") - .addHelpText("after", "\nDocumentation:\n https://aka.ms/swa/cli-local-development\n"); + .option("--config ", "Path to swa-cli.config.json file to use.", path.relative(process.cwd(), swaCliConfigFilename)) + .option("--print-config", "Print all resolved options.", false); program .command("start [context]") @@ -60,20 +65,31 @@ export async function run(argv?: string[]) { .option("--func-args ", "pass additional arguments to the func start command") .action(async (context: string = `.${path.sep}`, options: SWACLIConfig) => { - options = { - ...options, - verbose: cli.opts().verbose, - }; + const verbose = cli.opts().verbose; // make sure the start command gets the right verbosity level - process.env.SWA_CLI_DEBUG = options.verbose; - if (options.verbose?.includes("silly")) { + process.env.SWA_CLI_DEBUG = verbose; + if (verbose?.includes("silly")) { // when silly level is set, // propagate debugging level to other tools using the DEBUG environment variable process.env.DEBUG = "*"; } - await start(context, options); + const fileOptions = await getFileOptions(context, cli.opts().config); + + options = { + ...options, + ...fileOptions, + verbose, + }; + + if (cli.opts().printConfig) { + logger.log("", "swa"); + logger.log("Options: ", "swa"); + logger.log({ ...DEFAULT_CONFIG, ...options }, "swa"); + } + + await start(fileOptions.context ?? context, options); }) .addHelpText( diff --git a/src/core/utils/cli-config.spec.ts b/src/core/utils/cli-config.spec.ts new file mode 100644 index 00000000..70a53b0e --- /dev/null +++ b/src/core/utils/cli-config.spec.ts @@ -0,0 +1,87 @@ +import mockFs from "mock-fs"; +import { defaultStartContext } from "../../cli"; + +import { getFileOptions } from "./cli-config"; + +const mockConfig1 = { + $schema: "../../../schema/swa-cli.config.schema.json", + configurations: { + app: { + context: "./cypress/fixtures/static", + apiLocation: "./cypress/fixtures/api", + port: 1111, + devServerTimeout: 10000, + verbose: "silly", + }, + app2: { + context: "./cypress/fixtures/static", + apiLocation: "./cypress/fixtures/api", + port: 2222, + devServerTimeout: 10000, + verbose: "silly", + }, + }, +}; + +const mockConfig2 = { + $schema: "../../../schema/swa-cli.config.schema.json", + configurations: { + app: { + context: "./cypress/fixtures/static", + apiLocation: "./cypress/fixtures/api", + port: 3333, + devServerTimeout: 10000, + verbose: "silly", + }, + }, +}; + +describe("getFileOptions()", () => { + afterEach(() => { + mockFs.restore(); + }); + + const mockConfig = (config: any = mockConfig1) => { + mockFs({ + "swa-cli.config.json": JSON.stringify(config), + }); + }; + + mockFs({ + "swa-cli.config.json": ``, + }); + + it("Should return empty object if not found", async () => { + mockConfig(); + expect(await getFileOptions("app", "")).toStrictEqual({}); + }); + + it("Should return empty object if config name is not found", async () => { + mockConfig(); + expect(await getFileOptions("configName", "swa-cli.config.json")).toStrictEqual({}); + }); + + it("Should return proper config options", async () => { + mockConfig(); + expect(await getFileOptions("app", "swa-cli.config.json")).toStrictEqual(mockConfig1.configurations.app); + }); + + it("Should only return a default config if there is only one config", async () => { + mockConfig(); + expect(await getFileOptions(defaultStartContext, "swa-cli.config.json")).toStrictEqual({}); + }); + + it("Should return a default config", async () => { + mockConfig(mockConfig2); + expect(await getFileOptions(defaultStartContext, "swa-cli.config.json")).toStrictEqual(mockConfig2.configurations.app); + }); + + it("Should return empty object if config file is not found", async () => { + expect(await getFileOptions(defaultStartContext, "swa-cli.config.json")).toStrictEqual({}); + }); + + it("Should return proper config without path specified", async () => { + mockConfig(mockConfig1); + expect(await getFileOptions("app", "swa-cli.config.json")).toStrictEqual(mockConfig1.configurations.app); + }); +}); diff --git a/src/core/utils/cli-config.ts b/src/core/utils/cli-config.ts new file mode 100644 index 00000000..859a2478 --- /dev/null +++ b/src/core/utils/cli-config.ts @@ -0,0 +1,53 @@ +import * as path from "path"; +import fs, { promises as fsPromises } from "fs"; +import { logger } from "./logger"; +import { defaultStartContext } from "../../cli"; +const { readFile } = fsPromises; + +export const swaCliConfigFilename = "swa-cli.config.json"; + +export async function getFileOptions(context: string, configFilePath: string): Promise { + if (!fs.existsSync(configFilePath)) { + return {}; + } + + const cliConfig = await tryParseSwaCliConfig(configFilePath); + if (cliConfig.configurations === undefined) { + logger.error(`${swaCliConfigFilename} is missing the "configurations" property. No options will be loaded.`); + return {}; + } + + const hasOnlyOneConfig = Object.entries(cliConfig.configurations).length === 1; + if (hasOnlyOneConfig && context === defaultStartContext) { + const [configName, config] = Object.entries(cliConfig.configurations)[0]; + printConfigMsg(configName, configFilePath); + return config; + } + + const config = cliConfig.configurations?.[context]; + if (config) { + printConfigMsg(context, configFilePath); + return config; + } + + return {}; +} + +async function tryParseSwaCliConfig(file: string) { + try { + return JSON.parse((await readFile(file)).toString("utf-8")) as SWACLIConfigFile; + } catch (e) { + logger.error(`Error parsing swa-cli.config.json file at ${file}`); + if (e instanceof Error) { + logger.error(e); + } + return {}; + } +} + +function printConfigMsg(name: string, file: string) { + logger.log(`Using configuration "${name}" from file:`, "swa"); + logger.log(`\t${path.resolve(process.cwd(), file)}`, "swa"); + logger.log("", "swa"); + logger.log(`Options passed in via CLI will be overridden by options in file.`, "swa"); +} diff --git a/src/swa.d.ts b/src/swa.d.ts index 23f9e8ee..1a113976 100644 --- a/src/swa.d.ts +++ b/src/swa.d.ts @@ -57,7 +57,7 @@ declare type GithubActionWorkflow = { files?: string[]; }; -declare type SWACLIConfig = GithubActionWorkflow & { +declare type SWACLIOptions = { port?: number; host?: string; apiPort?: number; @@ -68,7 +68,7 @@ declare type SWACLIConfig = GithubActionWorkflow & { swaConfigFilename?: "staticwebapp.config.json"; swaConfigFilenameLegacy?: "routes.json"; app?: string; - api?: string; + apiLocation?: string; build?: boolean; verbose?: string; run?: string; @@ -79,6 +79,8 @@ declare type SWACLIConfig = GithubActionWorkflow & { funcArgs?: string; }; +declare type SWACLIConfig = SWACLIOptions & GithubActionWorkflow; + declare type ResponseOptions = { [key: string]: any; }; @@ -135,3 +137,9 @@ declare type SWAConfigFile = { }; declare type DebugFilterLevel = "silly" | "silent" | "log" | "info" | "error"; + +declare type SWACLIConfigFile = { + configurations?: { + [name: string]: SWACLIOptions & { context?: string }; + }; +}; From 103f0600e64b3133ab13e34ce892780dd0e3ff3d Mon Sep 17 00:00:00 2001 From: alexweininger Date: Fri, 10 Sep 2021 16:24:45 -0700 Subject: [PATCH 2/3] Document swa-cli.config.json in readme --- readme.md | 93 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 15 deletions(-) diff --git a/readme.md b/readme.md index 104e285f..99d92089 100644 --- a/readme.md +++ b/readme.md @@ -165,21 +165,84 @@ swa start http://localhost:3000 --swa-config-location ./my-app-source If you need to override the default values, provide the following options: -| Options | Description | Default | Example | -| -------------------------------- | ------------------------------------------------------- | --------- | ---------------------------------------------------- | -| `--app-location` | set location for the static app source code | `./` | `--app-location="./my-project"` | -| `--app, --app-artifact-location` | set app artifact (dist) folder or dev server | `./` | `--app="./my-dist"` or `--app=http://localhost:4200` | -| `--api, --api-artifact-location` | set the API folder or dev server | | `--api="./api"` or `--api=http://localhost:8083` | -| `--swa-config-location` | set the directory of the staticwebapp.config.json file. | | `--swa-config-location=./my-project-folder` | -| `--api-port` | set the API server port | `7071` | `--api-port=8082` | -| `--host` | set the emulator host address | `0.0.0.0` | `--host=192.168.68.80` | -| `--port` | set the emulator port value | `4280` | `--port=8080` | -| `--ssl` | serving the app and API over HTTPS (default: false) | `false` | `--ssl` or `--ssl=true` | -| `--ssl-cert` | SSL certificate to use for serving HTTPS | | `--ssl-cert="/home/user/ssl/example.crt"` | -| `--ssl-key` | SSL key to use for serving HTTPS | | `--ssl-key="/home/user/ssl/example.key"` | -| `--run` | Run a command at startup | | `--run="cd app & npm start"` | -| `--devserver-timeout` | The time to wait(in ms) for the dev server to start | 30000 | `--devserver-timeout=60000` | -| `--func-args` | Additional arguments to pass to `func start` | | `--func-args="--javascript"` | +| Options | Description | Default | Example | +| -------------------------------- | ------------------------------------------------------- | ----------------------- | ---------------------------------------------------- | +| `--app-location` | set location for the static app source code | `./` | `--app-location="./my-project"` | +| `--app, --app-artifact-location` | set app artifact (dist) folder or dev server | `./` | `--app="./my-dist"` or `--app=http://localhost:4200` | +| `--api, --api-artifact-location` | set the API folder or dev server | | `--api="./api"` or `--api=http://localhost:8083` | +| `--swa-config-location` | set the directory of the staticwebapp.config.json file. | | `--swa-config-location=./my-project-folder` | +| `--api-port` | set the API server port | `7071` | `--api-port=8082` | +| `--host` | set the emulator host address | `0.0.0.0` | `--host=192.168.68.80` | +| `--port` | set the emulator port value | `4280` | `--port=8080` | +| `--ssl` | serving the app and API over HTTPS (default: false) | `false` | `--ssl` or `--ssl=true` | +| `--ssl-cert` | SSL certificate to use for serving HTTPS | | `--ssl-cert="/home/user/ssl/example.crt"` | +| `--ssl-key` | SSL key to use for serving HTTPS | | `--ssl-key="/home/user/ssl/example.key"` | +| `--run` | Run a command at startup | | `--run="cd app & npm start"` | +| `--devserver-timeout` | The time to wait(in ms) for the dev server to start | 30000 | `--devserver-timeout=60000` | +| `--func-args` | Additional arguments to pass to `func start` | | `--func-args="--javascript"` | +| `--config` | Path to swa-cli.config.json file to use. | `./swa-cli.config.json` | `--config ./config/swa-cli.config.json` | +| `--print-config` | Print all resolved options. Useful for debugging. | | `--print-config` or `--print-config=true` | + +## swa-cli.config.json file + +The CLI can also load options from a `swa-cli.config.json` file. + +```json +{ + "configurations": { + "app": { + "context": "http://localhost:3000", + "apiLocation": "api", + "run": "npm run start", + "swaConfigLocation": "./my-app-source" + } + } +} +``` + +If only a single configuration is present in the `swa-cli.config.json` file, running `swa start` will use it by default. If options are loaded from a config file, no options passed in via command line will be respected. For example `swa start app --ssl=true`. The `--ssl=true` option will not be picked up by the CLI. + +### Example + +We can simplify these commands by putting the options into a config file. + +```bash +# static configuration +swa start ./my-dist --swa-config-location ./my-app-source + +# devserver configuration +swa start http://localhost:3000 --swa-config-location ./my-app-source +``` + +```json +{ + "configurations": { + "static": { + "context": "./my-dist", + "swaConfigLocation": "./my-app-source" + }, + "devserver": { + "context": "http://localhost:3000", + "swaConfigLocation": "./my-app-source" + } + } +} +``` + +These configurations can be run with `swa start static` and `swa start devserver`. + +### Validation + +You can validate your `swa-cli.config.json` with a JSON Schema like so: + +```json +{ + "$schema": "https://raw.githubusercontent.com/Azure/static-web-apps-cli/main/schema/swa-cli.config.schema.json", + "configurations": { + ... + } +} +``` ## Local authentication & authorization emulation From 28d0107a1be7e2eeabcafc352597a30ccc3787c4 Mon Sep 17 00:00:00 2001 From: Alex Weininger Date: Fri, 17 Sep 2021 17:35:20 -0700 Subject: [PATCH 3/3] PR feedback --- schema/swa-cli.config.schema.json | 6 +++++- src/core/utils/cli-config.ts | 8 ++++---- src/core/utils/logger.ts | 9 ++++++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/schema/swa-cli.config.schema.json b/schema/swa-cli.config.schema.json index 52c1c3a8..c20c17e0 100644 --- a/schema/swa-cli.config.schema.json +++ b/schema/swa-cli.config.schema.json @@ -18,10 +18,14 @@ "enum": ["api"], "type": "string" }, - "app": { + "appArtifactLocation": { "description": "Location of the build output directory relative to the appLocation", "type": "string" }, + "appLocation": { + "description": "Location for the static app source code", + "type": "string" + }, "build": { "type": "boolean" }, diff --git a/src/core/utils/cli-config.ts b/src/core/utils/cli-config.ts index 859a2478..68365e74 100644 --- a/src/core/utils/cli-config.ts +++ b/src/core/utils/cli-config.ts @@ -12,8 +12,8 @@ export async function getFileOptions(context: string, configFilePath: string): P } const cliConfig = await tryParseSwaCliConfig(configFilePath); - if (cliConfig.configurations === undefined) { - logger.error(`${swaCliConfigFilename} is missing the "configurations" property. No options will be loaded.`); + if (!cliConfig.configurations) { + logger.warn(`${swaCliConfigFilename} is missing the "configurations" property. No options will be loaded.`); return {}; } @@ -21,13 +21,13 @@ export async function getFileOptions(context: string, configFilePath: string): P if (hasOnlyOneConfig && context === defaultStartContext) { const [configName, config] = Object.entries(cliConfig.configurations)[0]; printConfigMsg(configName, configFilePath); - return config; + return { context: `.${path.sep}`, ...config }; } const config = cliConfig.configurations?.[context]; if (config) { printConfigMsg(context, configFilePath); - return config; + return { context: `.${path.sep}`, ...config }; } return {}; diff --git a/src/core/utils/logger.ts b/src/core/utils/logger.ts index 01b412ce..0d15957f 100644 --- a/src/core/utils/logger.ts +++ b/src/core/utils/logger.ts @@ -42,7 +42,14 @@ export const logger = { log(data: string | object, prefix: string | null = null) { this.silly(data, prefix, "log", chalk.reset); }, - + /** + * Print information data. + * @param data Either a string or an object to be printed. + * @param prefix (optional) A prefix to prepend to the printed message. + */ + warn(data: string | object, prefix: string | null = null) { + this.silly(data, prefix, "log", chalk.yellow); + }, /** * Print error data and optionally exit the CLI instance. * @param data Either a string or an object to be printed.