diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d6cd845463..8e90b66875 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -15,6 +15,7 @@ - Regex-based configurations now have ordered entries and use the first match found. - When starting the server through code, it is now possible to provide CLI value bindings as well in `AppRunner`. - Support for Node v12 was dropped. +- The server configuration settings can be set from the package.json or .community-solid-server.config.json/.js files. ### Data migration diff --git a/documentation/markdown/README.md b/documentation/markdown/README.md index 150942931a..38e8764c5f 100644 --- a/documentation/markdown/README.md +++ b/documentation/markdown/README.md @@ -34,6 +34,7 @@ the [changelog](https://github.com/CommunitySolidServer/CommunitySolidServer/blo * [How to use the Identity Provider](usage/identity-provider.md) * [How to automate authentication](usage/client-credentials.md) * [How to automatically seed pods on startup](usage/seeding-pods.md) +* [Using the CSS as a development server in another project](usage/dev-configuration.md) ## What the internals look like diff --git a/documentation/markdown/usage/dev-configuration.md b/documentation/markdown/usage/dev-configuration.md new file mode 100644 index 0000000000..783bdf390b --- /dev/null +++ b/documentation/markdown/usage/dev-configuration.md @@ -0,0 +1,49 @@ +# Configuring the CSS as a development server in another project + +It can be useful to use the CSS as local server to develop Solid applications against. +As an alternative to using CLI arguments, or environment variables, the CSS can be configured in the `package.json` as follows: + +```json +{ + "name": "test", + "version": "0.0.0", + "private": "true", + "config": { + "community-solid-server": { + "port": 3001, + "loggingLevel": "error" + } + }, + "scripts": { + "dev:pod": "community-solid-server" + }, + "devDependencies": { + "@solid/community-server": "^6.0.0" + } +} +``` + +These parameters will then be used when the `community-solid-server` +command is executed as an npm script (as shown in the example above). +Or whenever the `community-solid-server` command is executed in the same +folder as the `package.json`. + +Alternatively, the configuration parameters may be placed in a configuration file named +`.community-solid-server.config.json` as follows: + +```json +{ + "port": 3001, + "loggingLevel": "error" +} +``` + +The config may also be written in JavaScript with the config as the default export +such as the following `.community-solid-server.config.js`: + +```js +module.exports = { + port: 3001, + loggingLevel: "error" +}; +``` diff --git a/src/init/AppRunner.ts b/src/init/AppRunner.ts index 09e8126ca8..882f242913 100644 --- a/src/init/AppRunner.ts +++ b/src/init/AppRunner.ts @@ -1,13 +1,15 @@ /* eslint-disable unicorn/no-process-exit */ +import { existsSync } from 'fs'; import type { WriteStream } from 'tty'; import type { IComponentsManagerBuilderOptions } from 'componentsjs'; import { ComponentsManager } from 'componentsjs'; +import { readJSON } from 'fs-extra'; import yargs from 'yargs'; import { LOG_LEVELS } from '../logging/LogLevel'; import { getLoggerFor } from '../logging/LogUtil'; import { createErrorMessage, isError } from '../util/errors/ErrorUtil'; import { InternalServerError } from '../util/errors/InternalServerError'; -import { resolveModulePath, resolveAssetPath } from '../util/PathUtil'; +import { resolveModulePath, resolveAssetPath, joinFilePath } from '../util/PathUtil'; import type { App } from './App'; import type { CliExtractor } from './cli/CliExtractor'; import type { CliResolver } from './CliResolver'; @@ -135,7 +137,7 @@ export class AppRunner { */ public async createCli(argv: CliArgv = process.argv): Promise { // Parse only the core CLI arguments needed to load the configuration - const yargv = yargs(argv.slice(2)) + let yargv = yargs(argv.slice(2)) .usage('node ./bin/server.js [args]') .options(CORE_CLI_PARAMETERS) // We disable help here as it would only show the core parameters @@ -143,6 +145,12 @@ export class AppRunner { // We also read from environment variables .env(ENV_VAR_PREFIX); + const settings = await this.getPackageSettings(); + + if (typeof settings !== 'undefined') { + yargv = yargv.default(settings); + } + const params = await yargv.parse(); const loaderProperties = { @@ -165,12 +173,45 @@ export class AppRunner { } // Build the CLI components and use them to generate values for the Components.js variables - const variables = await this.cliToVariables(componentsManager, argv); + const variables = await this.cliToVariables(componentsManager, argv, settings); // Build and start the actual server application using the generated variable values return await this.createApp(componentsManager, variables); } + /** + * Retrieves settings from package.json or configuration file when + * part of an npm project. + * @returns The settings defined in the configuration file + */ + public async getPackageSettings(): Promise> { + // Only try and retrieve config file settings if there is a package.json in the + // scope of the current directory + const packageJsonPath = joinFilePath(process.cwd(), 'package.json'); + if (!existsSync(packageJsonPath)) { + return; + } + + // First see if there is a dedicated .json configuration file + const cssConfigPath = joinFilePath(process.cwd(), '.community-solid-server.config.json'); + if (existsSync(cssConfigPath)) { + return readJSON(cssConfigPath); + } + + // Next see if there is a dedicated .js file + const cssConfigPathJs = joinFilePath(process.cwd(), '.community-solid-server.config.js'); + if (existsSync(cssConfigPathJs)) { + return import(cssConfigPathJs); + } + + // Finally try and read from the config.community-solid-server + // field in the root package.json + const pkg = await readJSON(packageJsonPath); + if (typeof pkg.config?.['community-solid-server'] === 'object') { + return pkg.config['community-solid-server']; + } + } + /** * Creates the Components Manager that will be used for instantiating. */ @@ -187,11 +228,14 @@ export class AppRunner { * Handles the first Components.js instantiation. * Uses it to extract the CLI shorthand values and use those to create variable bindings. */ - private async cliToVariables(componentsManager: ComponentsManager, argv: CliArgv): - Promise { + private async cliToVariables( + componentsManager: ComponentsManager, + argv: CliArgv, + settings?: Record, + ): Promise { const cliResolver = await this.createCliResolver(componentsManager); const shorthand = await this.extractShorthand(cliResolver.cliExtractor, argv); - return await this.resolveShorthand(cliResolver.shorthandResolver, shorthand); + return await this.resolveShorthand(cliResolver.shorthandResolver, { ...settings, ...shorthand }); } /** diff --git a/test/unit/init/AppRunner.test.ts b/test/unit/init/AppRunner.test.ts index e975f97472..6714350a14 100644 --- a/test/unit/init/AppRunner.test.ts +++ b/test/unit/init/AppRunner.test.ts @@ -7,20 +7,42 @@ import type { ShorthandResolver } from '../../../src/init/variables/ShorthandRes import { joinFilePath } from '../../../src/util/PathUtil'; import { flushPromises } from '../../util/Util'; -const defaultParameters = { +let defaultParameters: Record = { port: 3000, logLevel: 'info', }; + const cliExtractor: jest.Mocked = { - handleSafe: jest.fn().mockResolvedValue(defaultParameters), + handleSafe: jest.fn((): Record => defaultParameters), } as any; -const defaultVariables = { +let defaultVariables: Record = { 'urn:solid-server:default:variable:port': 3000, 'urn:solid-server:default:variable:loggingLevel': 'info', }; + +const shorthandKeys: Record = { + port: 'urn:solid-server:default:variable:port', + logLevel: 'urn:solid-server:default:variable:loggingLevel', +}; + const shorthandResolver: jest.Mocked = { - handleSafe: jest.fn().mockResolvedValue(defaultVariables), + handleSafe: jest.fn((args: Record): Record => { + const variables: Record = {}; + + for (const key in args) { + if (key in shorthandKeys) { + variables[shorthandKeys[key]] = args[key]; + + // We ignore the default key as this is introduced by the way + // we are mocking the module + } else if (key !== 'default') { + throw new Error(`Unexpected key ${key}`); + } + } + + return variables; + }), } as any; const mockLogger = { @@ -74,11 +96,61 @@ jest.mock('componentsjs', (): any => ({ }, })); +let files: Record = {}; + +const alternateParameters = { + port: 3101, + logLevel: 'error', +}; + +const packageJSONbase = { + name: 'test', + version: '0.0.0', + private: true, +}; + +const packageJSON = { + ...packageJSONbase, + config: { + 'community-solid-server': alternateParameters, + }, +}; + +jest.mock('fs', (): Partial> => ({ + cwd: jest.fn((): string => __dirname), + existsSync: jest.fn((pth: string): boolean => typeof pth === 'string' && pth in files), +})); + +jest.mock('fs-extra', (): Partial> => ({ + readJSON: jest.fn(async(pth: string): Promise => files[pth]), + pathExists: jest.fn(async(pth: string): Promise => typeof pth === 'string' && pth in files), +})); + +jest.mock( + '/var/cwd/.community-solid-server.config.js', + (): any => alternateParameters, + { virtual: true }, +); + jest.spyOn(process, 'cwd').mockReturnValue('/var/cwd'); const write = jest.spyOn(process.stderr, 'write').mockImplementation(jest.fn()); const exit = jest.spyOn(process, 'exit').mockImplementation(jest.fn() as any); describe('AppRunner', (): void => { + beforeEach((): void => { + files = {}; + + defaultParameters = { + port: 3000, + logLevel: 'info', + }; + + defaultVariables = { + 'urn:solid-server:default:variable:port': 3000, + 'urn:solid-server:default:variable:loggingLevel': 'info', + }; + }); + afterEach((): void => { jest.clearAllMocks(); }); @@ -516,6 +588,100 @@ describe('AppRunner', (): void => { } }); + it('runs with no parameters.', async(): Promise => { + defaultParameters = {}; + defaultVariables = {}; + + await expect(new AppRunner().runCli()).resolves.toBeUndefined(); + expect(manager.instantiate).toHaveBeenNthCalledWith( + 2, 'urn:solid-server:default:App', { variables: {}}, + ); + }); + + it('runs honouring package.json configuration.', async(): Promise => { + files = { '/var/cwd/package.json': packageJSON }; + defaultParameters = {}; + defaultVariables = {}; + + await expect(new AppRunner().runCli()).resolves.toBeUndefined(); + expect(manager.instantiate).toHaveBeenNthCalledWith( + 2, 'urn:solid-server:default:App', { variables: { + 'urn:solid-server:default:variable:port': 3101, + 'urn:solid-server:default:variable:loggingLevel': 'error', + }}, + ); + }); + + it('runs honouring package.json configuration with empty config.', async(): Promise => { + files = { '/var/cwd/package.json': packageJSONbase }; + defaultParameters = {}; + defaultVariables = {}; + + await expect(new AppRunner().runCli()).resolves.toBeUndefined(); + expect(manager.instantiate).toHaveBeenNthCalledWith( + 2, 'urn:solid-server:default:App', { variables: {}}, + ); + }); + + it('runs honouring .community-solid-server.config.json if package.json is present.', async(): Promise => { + files = { + '/var/cwd/.community-solid-server.config.json': alternateParameters, + '/var/cwd/package.json': packageJSONbase, + }; + defaultParameters = {}; + defaultVariables = {}; + + await expect(new AppRunner().runCli()).resolves.toBeUndefined(); + expect(manager.instantiate).toHaveBeenNthCalledWith( + 2, 'urn:solid-server:default:App', { variables: { + 'urn:solid-server:default:variable:port': 3101, + 'urn:solid-server:default:variable:loggingLevel': 'error', + }}, + ); + }); + + it('runs honouring .community-solid-server.config.js if package.json is present.', async(): Promise => { + files = { + '/var/cwd/.community-solid-server.config.js': alternateParameters, + '/var/cwd/package.json': packageJSONbase, + }; + + defaultParameters = {}; + defaultVariables = {}; + + await expect(new AppRunner().runCli()).resolves.toBeUndefined(); + expect(manager.instantiate).toHaveBeenNthCalledWith( + 2, 'urn:solid-server:default:App', { variables: { + 'urn:solid-server:default:variable:port': 3101, + 'urn:solid-server:default:variable:loggingLevel': 'error', + }}, + ); + }); + + it('runs ignoring .community-solid-server.config.json if no package.json present.', async(): Promise => { + files = { '/var/cwd/.community-solid-server.config.json': alternateParameters }; + defaultParameters = {}; + defaultVariables = {}; + + await expect(new AppRunner().runCli()).resolves.toBeUndefined(); + expect(manager.instantiate).toHaveBeenNthCalledWith( + 2, 'urn:solid-server:default:App', { variables: {}}, + ); + }); + + it('runs ignoring .community-solid-server.config.js if no package.json present.', async(): Promise => { + files = { + '/var/cwd/.community-solid-server.config.js': `module.exports = ${JSON.stringify(alternateParameters)}`, + }; + defaultParameters = {}; + defaultVariables = {}; + + await expect(new AppRunner().runCli()).resolves.toBeUndefined(); + expect(manager.instantiate).toHaveBeenNthCalledWith( + 2, 'urn:solid-server:default:App', { variables: {}}, + ); + }); + it('throws an error if the server could not start.', async(): Promise => { app.start.mockRejectedValueOnce(new Error('Fatal'));