From 0807e630c4cbf8f5f18b7d5b5c2ee440ec55e971 Mon Sep 17 00:00:00 2001 From: Burkhard Pauli Date: Wed, 23 Sep 2026 18:10:01 +0200 Subject: [PATCH 1/3] feat(content): resolve the DA admin host from the environment Load the project .env in src/cli.js so that AEM_* variables reach the commands, and resolve the da.live admin host from AEM_DA_ADMIN with https://admin.da.live as the default. Variables already present in the real environment keep precedence over the .env file, and .env is now git ignored. --- .gitignore | 1 + src/cli.js | 7 ++++ src/content/da-api.js | 34 ++++++++++++---- test/cli.test.js | 33 +++++++++++++++ test/content/da-api.test.js | 81 ++++++++++++++++++++++++++++++++++++- 5 files changed, 148 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 176bc6ae4..19a0c8059 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ logs test-results.xml .idea/ .hlx +.env diff --git a/src/cli.js b/src/cli.js index 24dd6a77f..d935da91e 100755 --- a/src/cli.js +++ b/src/cli.js @@ -13,9 +13,16 @@ import yargs from 'yargs'; import camelcase from 'camelcase'; import path from 'path'; import chalk from 'chalk-template'; +import dotenv from 'dotenv'; import { resetContext } from './fetch-utils.js'; import pkgJson from './package.cjs'; +// Load the project's .env before any command is built, so that AEM_* variables are +// available to yargs' .env('AEM_') parsing and to the commands themselves. This does not +// override variables already present in the real environment (the shell wins), and it also +// covers programmatic use of this module, where index.js is not the entry point. +dotenv.config({ quiet: true }); + const MIN_MSG = 'You need at least one command.'; function envAwareStrict(args, aliases) { diff --git a/src/content/da-api.js b/src/content/da-api.js index 22d843df5..a00ff8678 100644 --- a/src/content/da-api.js +++ b/src/content/da-api.js @@ -14,7 +14,22 @@ import processQueue from '@adobe/helix-shared-process-queue'; import { getFetch } from '../fetch-utils.js'; import { CONTENT_IO_CONCURRENCY } from './content-shared.js'; -const DA_ADMIN = 'https://admin.da.live'; +/** Default DA admin host. */ +export const DEFAULT_DA_ADMIN = 'https://admin.da.live'; + +/** + * Resolves the DA admin host to use. + * + * Order: explicit value, then the `AEM_DA_ADMIN` environment variable, then the default. + * Trailing slashes are removed so the host can be concatenated with API paths. + * + * @param {string} [daAdmin] explicit admin host, overriding the environment + * @returns {string} admin host without a trailing slash + */ +export function resolveDaAdmin(daAdmin) { + const value = (daAdmin ?? process.env.AEM_DA_ADMIN ?? '').trim(); + return (value || DEFAULT_DA_ADMIN).replace(/\/+$/, ''); +} /** Response header used to page past the per-request list limit (e.g. 1000 items). */ const LIST_CONTINUATION_HEADER = 'da-continuation-token'; @@ -27,8 +42,13 @@ export function getContentType(ext) { } export class DaClient { - constructor(token) { + /** + * @param {string} token IMS bearer token + * @param {string} [daAdmin] admin host, defaults to {@link resolveDaAdmin} + */ + constructor(token, daAdmin) { this.token = token; + this.daAdmin = resolveDaAdmin(daAdmin); this.fetch = getFetch(false); } @@ -44,7 +64,7 @@ export class DaClient { * @returns {Promise>} */ async list(org, site, daPath) { - const url = `${DA_ADMIN}/list/${org}/${site}${daPath}`; + const url = `${this.daAdmin}/list/${org}/${site}${daPath}`; const aggregated = []; let continuation = null; @@ -125,7 +145,7 @@ export class DaClient { * @returns {Promise} */ async getSource(org, site, daPath) { - const url = `${DA_ADMIN}/source/${org}/${site}${daPath}`; + const url = `${this.daAdmin}/source/${org}/${site}${daPath}`; const res = await this.fetch(url, { headers: this.authHeader }); if (res.status === 401) { throw new Error('Unauthorized: invalid or missing token'); @@ -149,7 +169,7 @@ export class DaClient { * @returns {Promise} API response body */ async putSource(org, site, daPath, buffer, contentType) { - const url = `${DA_ADMIN}/source/${org}/${site}${daPath}`; + const url = `${this.daAdmin}/source/${org}/${site}${daPath}`; const res = await this.fetch(url, { method: 'PUT', headers: { ...this.authHeader, 'Content-Type': contentType }, @@ -169,7 +189,7 @@ export class DaClient { * Throws on transport or server errors so callers don't silently treat them as success. */ async deleteSource(org, site, daPath) { - const url = `${DA_ADMIN}/source/${org}/${site}${daPath}`; + const url = `${this.daAdmin}/source/${org}/${site}${daPath}`; const res = await this.fetch(url, { method: 'DELETE', headers: this.authHeader, @@ -191,7 +211,7 @@ export class DaClient { * @returns {Promise} */ async getRemoteLastModified(org, site, daPath) { - const url = `${DA_ADMIN}/source/${org}/${site}${daPath}`; + const url = `${this.daAdmin}/source/${org}/${site}${daPath}`; const res = await this.fetch(url, { method: 'HEAD', headers: this.authHeader }); if (res.status === 401) { throw new Error('Unauthorized: invalid or missing token'); diff --git a/test/cli.test.js b/test/cli.test.js index 7d691bc18..e7b5a7239 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -27,6 +27,21 @@ function runCLI(...args) { return shell.exec(cmd); } +/** + * Imports src/cli.js in a child process running in `cwd` and reports the AEM_DA_ADMIN + * variable as seen after the module loaded the project's .env file. + */ +function readDaAdminAfterCliLoad(cwd, env = {}) { + const cliPath = path.resolve(__rootdir, 'src', 'cli.js').split(path.sep).join('/'); + const script = `import('file://${cliPath}').then(() => {` + + ' process.stdout.write(String(process.env.AEM_DA_ADMIN)); });'; + return shell.exec(`node -e ${JSON.stringify(script)}`, { + cwd, + silent: true, + env: { ...process.env, ...env }, + }); +} + describe('hlx command line', () => { let cwd; let deleted; @@ -85,6 +100,24 @@ describe('hlx command line', () => { await fse.remove(testRoot); }).timeout(4000); + it('loads AEM_ variables from the project .env', async () => { + const testRoot = await createTestRoot(); + await fse.writeFile(path.resolve(testRoot, '.env'), 'AEM_DA_ADMIN=https://env-file.example.com\n', 'utf-8'); + const cmd = readDaAdminAfterCliLoad(testRoot); + assert.equal(cmd.code, 0); + assert.equal(cmd.stdout.trim(), 'https://env-file.example.com'); + await fse.remove(testRoot); + }).timeout(10000); + + it('lets the real environment win over the .env file', async () => { + const testRoot = await createTestRoot(); + await fse.writeFile(path.resolve(testRoot, '.env'), 'AEM_DA_ADMIN=https://env-file.example.com\n', 'utf-8'); + const cmd = readDaAdminAfterCliLoad(testRoot, { AEM_DA_ADMIN: 'https://shell.example.com' }); + assert.equal(cmd.code, 0); + assert.equal(cmd.stdout.trim(), 'https://shell.example.com'); + await fse.remove(testRoot); + }).timeout(10000); + it('un-supported node version should give warning', async () => { const testVersions = [ '1.0.0', 0, diff --git a/test/content/da-api.test.js b/test/content/da-api.test.js index 33a902a14..7d5162405 100644 --- a/test/content/da-api.test.js +++ b/test/content/da-api.test.js @@ -12,7 +12,12 @@ /* eslint-env mocha */ import assert from 'assert'; -import { DaClient, getContentType } from '../../src/content/da-api.js'; +import { + DaClient, + DEFAULT_DA_ADMIN, + getContentType, + resolveDaAdmin, +} from '../../src/content/da-api.js'; function mockResponse(status, body, ok = status >= 200 && status < 300, responseHeaders = {}) { const lower = Object.fromEntries( @@ -88,13 +93,87 @@ describe('getContentType', () => { }); }); +describe('resolveDaAdmin', () => { + let saved; + + beforeEach(() => { + saved = process.env.AEM_DA_ADMIN; + delete process.env.AEM_DA_ADMIN; + }); + + afterEach(() => { + if (saved === undefined) { + delete process.env.AEM_DA_ADMIN; + } else { + process.env.AEM_DA_ADMIN = saved; + } + }); + + it('defaults to the public admin host', () => { + assert.strictEqual(resolveDaAdmin(), DEFAULT_DA_ADMIN); + assert.strictEqual(DEFAULT_DA_ADMIN, 'https://admin.da.live'); + }); + + it('uses AEM_DA_ADMIN when set', () => { + process.env.AEM_DA_ADMIN = 'https://admin.example.com'; + assert.strictEqual(resolveDaAdmin(), 'https://admin.example.com'); + }); + + it('ignores an empty AEM_DA_ADMIN', () => { + process.env.AEM_DA_ADMIN = ' '; + assert.strictEqual(resolveDaAdmin(), DEFAULT_DA_ADMIN); + }); + + it('removes trailing slashes', () => { + process.env.AEM_DA_ADMIN = 'https://admin.example.com//'; + assert.strictEqual(resolveDaAdmin(), 'https://admin.example.com'); + }); + + it('prefers an explicit host over the environment', () => { + process.env.AEM_DA_ADMIN = 'https://admin.example.com'; + assert.strictEqual(resolveDaAdmin('https://other.example.com'), 'https://other.example.com'); + }); + + it('gives the client the resolved host', async () => { + process.env.AEM_DA_ADMIN = 'https://admin.example.com'; + const client = new DaClient('test-token'); + assert.strictEqual(client.daAdmin, 'https://admin.example.com'); + + let calledUrl; + client.fetch = async (url) => { + calledUrl = url; + return mockResponse(200, []); + }; + await client.list('myorg', 'myrepo', '/some/path'); + assert.strictEqual(calledUrl, 'https://admin.example.com/list/myorg/myrepo/some/path'); + + await client.getSource('myorg', 'myrepo', '/some/path.html'); + assert.strictEqual(calledUrl, 'https://admin.example.com/source/myorg/myrepo/some/path.html'); + }); +}); + describe('DaClient', () => { let client; + let savedAdmin; beforeEach(() => { + savedAdmin = process.env.AEM_DA_ADMIN; + delete process.env.AEM_DA_ADMIN; client = new DaClient('test-token'); }); + afterEach(() => { + if (savedAdmin === undefined) { + delete process.env.AEM_DA_ADMIN; + } else { + process.env.AEM_DA_ADMIN = savedAdmin; + } + }); + + it('uses the default admin host when AEM_DA_ADMIN is unset', () => { + assert.strictEqual(client.daAdmin, 'https://admin.da.live'); + }); + describe('authHeader', () => { it('returns Authorization Bearer header', () => { assert.deepStrictEqual(client.authHeader, { Authorization: 'Bearer test-token' }); From 34303e916db915de2057a226036ac6031ec19871 Mon Sep 17 00:00:00 2001 From: Burkhard Pauli Date: Wed, 23 Sep 2026 21:40:19 +0200 Subject: [PATCH 2/3] feat(content): resolve the IMS config and the token file per environment Replace the hardcoded IMS origin, client id and scope in da-auth.js with values resolved from AEM_DA_IMS_ORIGIN, AEM_DA_IMS_CLIENT_ID and AEM_DA_IMS_SCOPE, each defaulting to today's prod value when unset, and key the cached token file by an environment label derived from the resolved DA admin host. The default host keeps the unchanged .hlx/.da-token.json name, so no token migrates; every other host gets its own .hlx/.da-token-