diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 4493aca36a83d7235e473ff9477d86734eb2ed97..c356cb9dd8ed407e6ba944dd7f02291bca65cc28 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -10,6 +10,8 @@ For a detailed view of what's changed, refer to the {url-repo}/commits[commit hi * set `collectorWorktree` property on origin to directory of worktree when origin has local worktree * support short form value for `clean`, `run`, and `scan` config (#25) +* add `env` key to run entry to set (or unset) environment variables for the run command (#24) +* add support for referencing context variables in the value of an `env` entry (#24) == 1.0.0-alpha.7 (2024-07-24) diff --git a/docs/modules/ROOT/pages/configuration-keys.adoc b/docs/modules/ROOT/pages/configuration-keys.adoc index 277da044d288d435975ee3b36bd7ebc74faf2deb..66bde62109f7e758a06ad4770e0b9e4b7e66fd0f 100644 --- a/docs/modules/ROOT/pages/configuration-keys.adoc +++ b/docs/modules/ROOT/pages/configuration-keys.adoc @@ -193,6 +193,61 @@ If the value is `log`, the failure is logged at the error level. The level can be tuned by appending it as a property on the log keyword (e.g., `log.warn`). If the value is `throw` (the default), the error bubbles, which causes the executio of Antora to immediately stop, just like any other fatal error. +The `env` key can be used to set (or unset) additional environment variables for the run command. +The value of this key must be an array. +Each entry consists of a `name` and `value` key. +If there's only a single entry, the array can be flattened to a map that matches the schema of a single entry. +These environment variables are overlaid onto the environment variables from the Antora process (i.e., the parent process) and are scoped to the command. + +[NOTE] +==== +It's also possible to express the environment variables in the form of a map. + +[,yaml] +---- +env: + refname: ${{origin.refname}} +---- + +However, versions of Antora before 3.2.0 automatically transform keys (and thus environment variable names) to the camelCase form (e.g., `THE_NAME` becomes `theName`). +Only lowercase environment variables without any separators are kept as is. +Starting with Antora 3.2.0, the key is always preserved as entered, so you can use this form fully. +==== + +The value can include references to context variables in the Antora runtime (e.g., `origin`). +The context variable reference must be enclosed in `${{` and `}}`, referred to as a context variable expression (e.g., `${{origin.refname}}`). +Currently, the supported context variables are `origin` (the content source origin on which Collector is running), `playbook` (the compiled Antora playbook), and `env` (the map of environment variables). +The dot notation can be used to access nested properties on the context variable (e.g., `origin.refname`). +This notation provides safe navigation, which means if any property resolves to null, the whole value resolves to null. + +If the value is null, it unsets the environment variable. +If the value is non-null, the value is coerced to a string. + +You can convert the value of the context variable to JSON or YAML by appending `as json` or `as yaml`, respectively, to the context variable reference (e.g., `${{origin as json}}`. + +.antora.yml +[,yaml] +---- +name: colorado +title: Colorado +version: '5.6.0' +ext: + collector: + run: + command: node generate-files.js + env: + - name: ANTORA_COLLECTOR + value: '1' + - name: ANTORA_SITE_TITLE + value: ${{playbook.site.title}} + - name: ANTORA_COLLECTOR_ORIGIN + value: ${{origin as json}} + scan: + dir: build/generated +---- + +The value may contain zero or more context variable expressions. + [#scan-key] == scan key @@ -329,6 +384,15 @@ Otherwise, the path is resolved relative to the worktree. |Not set |Path relative to the content root +|`run.env` +|Additional environment variables to be added (or removed) to the environment variables inherited from the Antora process. +These environment variables are scoped to the run command. +The value of the `env` key is an array of entries. +Each entry has a `name` and `value` key. +The `name` key specifies the name of the environment variable and the `value` key the value to assign to it. +The value may contain references to the `origin`, `playbook`, and `env` context variables and their properties using the context variable reference syntax (e.g., `${{origin.refname}}`. +If the value is null, the existing environment variable is unset. + |`run.local` |Prevents system from resolving global command on the user's PATH. In this case, the command must be located at the location relative to where the command is run. diff --git a/packages/collector-extension/lib/index.js b/packages/collector-extension/lib/index.js index 604d36d16b641491cb2d17ca92ebcd31091ef5c3..d57ed62000f13faa71457e46f33a1f055d74c16d 100644 --- a/packages/collector-extension/lib/index.js +++ b/packages/collector-extension/lib/index.js @@ -19,6 +19,8 @@ const yaml = require('js-yaml') const GLOB_OPTS = { ignore: ['.git'], objectMode: true, onlyFiles: false, unique: false } const PACKAGE_NAME = require('../package.json').name +const CONTEXT_VARIABLE_REF_RX = /\$\{\{ *([a-z0-9.]+)(?: as (json|yaml))? *\}\}/gi + module.exports.register = function ({ config: { keepWorktrees = false } }) { this.once('contentAggregated', async ({ playbook, contentAggregate }) => { let logger @@ -62,10 +64,25 @@ module.exports.register = function ({ config: { keepWorktrees = false } }) { }, [])), run: (Array.isArray(runConfig) ? runConfig : [runConfig]).reduce((accum, run) => { if (typeof run === 'string' ? (run = { command: run }) : typeof run.command === 'string') { - if (run.command) { - const dir = typeof run.dir === 'string' ? expandPath(run.dir, expandPathContext) : worktreeDir - accum.push({ ...run, dir }) + if (!run.command) return accum + const dir = typeof run.dir === 'string' ? expandPath(run.dir, expandPathContext) : worktreeDir + let env + let envConfig = run.env + if (typeof envConfig === 'object') { + if (!Array.isArray(envConfig)) { + envConfig = + 'name' in envConfig && 'value' in envConfig && Object.keys(envConfig).length === 2 + ? [envConfig] + : Object.entries(envConfig).map(([name, value]) => ({ name, value })) + } + env = Object.assign({}, process.env) + const evaluateWithVars = evaluate.bind(null, { env, origin, playbook }) + for (const envEntry of envConfig) { + if (!(typeof envEntry === 'object' && envEntry.name)) continue + env[envEntry.name] = evaluateWithVars(envEntry.value) + } } + accum.push({ ...run, dir, env }) } return accum }, []), @@ -90,9 +107,10 @@ module.exports.register = function ({ config: { keepWorktrees = false } }) { } for (const { clean: cleans, run: runs, scan: scans } of collectors) { for (const clean of cleans) await fsp.rm(clean.dir, { recursive: true, force: true }) - for (const { dir: cwd, command, local, onFailure = 'throw' } of runs) { + for (const { dir: cwd, command, local, env, onFailure = 'throw' } of runs) { let cmd = command const opts = { cwd, output: true, quiet } + if (env) opts.env = env if (local) { opts.local = true } else if (cmd.startsWith('./')) { @@ -303,3 +321,20 @@ function removeUntrackedFiles (repo) { function mv (from, to) { return fsp.cp(from, to).then(() => fsp.rm(from)) } + +function evaluate (vars, expression) { + if (typeof expression !== 'string') return expression == null ? undefined : String(expression) + if (!~expression.indexOf('${{')) return expression + return expression.replace(CONTEXT_VARIABLE_REF_RX, (_, propertyRef, format) => { + const value = dereferenceProperty(propertyRef, vars) + if (format && (format = format.toLowerCase()) === 'json') return JSON.stringify(value) + if (format === 'yaml') return yaml.dump(value, { noArrayIndent: true }) + return String(value ?? '') + }) +} + +function dereferenceProperty (propertyRef, vars) { + const [propertyRoot, ...propertyPath] = propertyRef.split('.') + if (!(propertyRoot in vars)) return + return propertyPath.length ? propertyPath.reduce((v, p) => v && v[p], vars[propertyRoot]) : vars[propertyRoot] +} diff --git a/packages/collector-extension/test/collector-extension-test.js b/packages/collector-extension/test/collector-extension-test.js index 4dd57dd72c8055030cf38b994f0311f482f22d51..eef4d9993b42daaa4cb66da12d631c0d6be04039 100644 --- a/packages/collector-extension/test/collector-extension-test.js +++ b/packages/collector-extension/test/collector-extension-test.js @@ -1040,6 +1040,135 @@ describe('collector extension', () => { }) }) + it('should set specified environment variables for run operation', async () => { + const collectorConfig = { + run: [ + { command: 'node .gen-component-desc.js' }, + { + command: 'node .gen-using-env.js', + env: [ + { + name: 'ANTORA_COLLECTOR_ORIGIN', + value: '$' + '{{ origin as json }}', + }, + { + name: 'ANTORA_COLLECTOR_WORKTREE', + value: '$' + '{{ origin.collectorWorktree }}', + }, + { + name: 'ANTORA_COLLECTOR_SITE_TITLE', + value: '$' + '{{ playbook.site.title }}', + }, + ], + }, + ], + scan: { dir: 'build' }, + } + await runScenario({ + repoName: 'test-at-root', + collectorConfig, + before: (contentAggregate, playbook) => { + playbook.site = { title: 'My Site' } + expect(contentAggregate).to.have.lengthOf(1) + }, + after: (contentAggregate) => { + expect(contentAggregate).to.have.lengthOf(1) + const bucket = contentAggregate[0] + const expectedWorktree = getCollectorWorktree(bucket.origins[0]) + const expectedContents = `reftype:: branch\nrefname:: main\nworktree:: ${expectedWorktree}\ntitle:: My Site` + expect(bucket.files).to.have.lengthOf(1) + expect(bucket.files[0].path).to.equal('modules/ROOT/pages/index.adoc') + expect(bucket.files[0].contents.toString()).to.include(expectedContents) + }, + }) + }) + + it('should handle non-string environment variable values', async () => { + const collectorConfig = { + run: [ + { command: 'node .gen-component-desc.js' }, + { + command: 'node .gen-using-env.js', + env: [ + { name: 'USER', value: null }, + { name: 'ENV_VALUE_AS_STRING', value: 99.9 }, + ], + }, + ], + scan: { dir: 'build' }, + } + await runScenario({ + repoName: 'test-at-root', + collectorConfig, + before: (contentAggregate, playbook) => { + playbook.site = { title: 'My Site' } + expect(contentAggregate).to.have.lengthOf(1) + }, + after: (contentAggregate) => { + const expectedContents = '{"ENV_VALUE_AS_STRING":"99.9"}' + expect(contentAggregate).to.have.lengthOf(1) + const bucket = contentAggregate[0] + expect(bucket.files).to.have.lengthOf(1) + expect(bucket.files[0].path).to.equal('modules/ROOT/pages/index.adoc') + expect(bucket.files[0].contents.toString()).to.include(expectedContents) + }, + }) + }) + + it('should allow value of env key on run to be expressed as an object', async () => { + const collectorConfig = { + run: [ + { command: 'node .gen-component-desc.js' }, + { + command: 'node .gen-using-env.js', + env: { name: 'ANTORA_COLLECTOR_ORIGIN', value: '$' + '{{origin as json}}' }, + }, + ], + scan: { dir: 'build' }, + } + await runScenario({ + repoName: 'test-at-root', + collectorConfig, + before: (contentAggregate, playbook) => { + expect(contentAggregate).to.have.lengthOf(1) + }, + after: (contentAggregate) => { + expect(contentAggregate).to.have.lengthOf(1) + const bucket = contentAggregate[0] + expect(bucket.files).to.have.lengthOf(1) + expect(bucket.files[0].path).to.equal('modules/ROOT/pages/index.adoc') + expect(bucket.files[0].contents.toString()).to.include('\nrefname:: main\n') + }, + }) + }) + + it('should allow value of env key on run to be a map', async () => { + const collectorConfig = { + run: [ + { command: 'node .gen-component-desc.js' }, + { + command: 'node .gen-using-env.js', + env: { refname: '$' + '{{origin.refname}}' }, + }, + ], + scan: { dir: 'build' }, + } + await runScenario({ + repoName: 'test-at-root', + collectorConfig, + before: (contentAggregate, playbook) => { + expect(contentAggregate).to.have.lengthOf(1) + }, + after: (contentAggregate) => { + expect(contentAggregate).to.have.lengthOf(1) + const bucket = contentAggregate[0] + expect(bucket.files).to.have.lengthOf(1) + expect(bucket.files[0].path).to.equal('modules/ROOT/pages/index.adoc') + expect(bucket.files[0].contents.toString()).to.equal('= Refname\n\nmain') + }, + }) + }) + it('should create dedicated cache folder for collector under Antora cache dir', async () => { await fsp.mkdir(CACHE_DIR, { recursive: true }) await fsp.writeFile(getCollectorCacheDir(), Buffer.alloc(0)) diff --git a/packages/collector-extension/test/fixtures/test-at-root/.gen-using-env.js b/packages/collector-extension/test/fixtures/test-at-root/.gen-using-env.js new file mode 100644 index 0000000000000000000000000000000000000000..4b3ab7ecd1d038d81659bfa59c74e76ad4aa620e --- /dev/null +++ b/packages/collector-extension/test/fixtures/test-at-root/.gen-using-env.js @@ -0,0 +1,25 @@ +'use strict' + +const fsp = require('node:fs/promises') + +;(async () => { + await fsp.mkdir('build/modules/ROOT/pages', { recursive: true }) + let contents + if ('ENV_VALUE_AS_STRING' in process.env) { + const envData = JSON.stringify({ ENV_VALUE_AS_STRING: process.env.ENV_VALUE_AS_STRING, USER: process.env.USER }) + contents = `= Environment Variables\n\n ${envData}` + } else if ('refname' in process.env) { + contents = `= Refname\n\n${process.env.refname}` + } else { + const origin = JSON.parse(process.env.ANTORA_COLLECTOR_ORIGIN || '{}') + contents = [ + '= Origin Info', + '', + 'reftype:: ' + origin.reftype, + 'refname:: ' + origin.refname, + 'worktree:: ' + process.env.ANTORA_COLLECTOR_WORKTREE, + 'title:: ' + process.env.ANTORA_COLLECTOR_SITE_TITLE, + ].join('\n') + } + await fsp.writeFile('build/modules/ROOT/pages/index.adoc', contents, 'utf8') +})()