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/clone.cmd.js b/src/content/clone.cmd.js index 06eaf129a..f08cbade2 100644 --- a/src/content/clone.cmd.js +++ b/src/content/clone.cmd.js @@ -17,7 +17,7 @@ import git from 'isomorphic-git'; import processQueue from '@adobe/helix-shared-process-queue'; import GitUtils from '../git-utils.js'; import { prompt } from '../cli-util.js'; -import { DaClient } from './da-api.js'; +import { DaClient, resolveDaAdmin } from './da-api.js'; import { getValidToken } from './da-auth.js'; import { CONTENT_DIR, @@ -144,7 +144,8 @@ export default class CloneCommand { const token = await getValidToken(log, this._token, this._dir); // 4. Fetch file list (no local content dir required yet) - const client = new DaClient(token); + const daAdmin = resolveDaAdmin(); + const client = new DaClient(token, daAdmin); log.info('Fetching file list...'); const showDiscoveryProgress = process.stdout.isTTY; const files = await client.listAll(org, site, this._rootPath, showDiscoveryProgress @@ -222,11 +223,13 @@ export default class CloneCommand { const headOid = await git.resolveRef({ fs, dir: contentDir, ref: 'HEAD' }); await writeSyncedRef(fs, contentDir, headOid); - // 8. Write config (not tracked by git) + // 8. Write config (not tracked by git). `daAdmin` records the backend this content + // came from, so push can tell a same-backend sync from a cross-backend copy. await fse.writeJson(path.join(contentDir, CONFIG_FILE), { org, site, rootPath: this._rootPath, + daAdmin, }, { spaces: 2 }); log.info(`\nDone. ${downloaded.length} file(s) downloaded${errors > 0 ? `, ${errors} error(s)` : ''}.`); diff --git a/src/content/content-git.js b/src/content/content-git.js index 630c625ad..7d329ff95 100644 --- a/src/content/content-git.js +++ b/src/content/content-git.js @@ -124,6 +124,22 @@ export async function diffCommitTrees(fs, dir, baseOid, headOid) { return { added, modified, deleted }; } +/** Files clone writes for local bookkeeping; they never belong on da.live. */ +const LOCAL_ONLY_FILES = new Set(['.gitignore']); + +/** + * All content files at a commit, as da.live paths (`/file`). Local bookkeeping + * files are left out. Used when the whole tree is copied instead of diffed. + * @param {import('isomorphic-git').FsClient} fs + * @param {string} dir + * @param {string} oid + * @returns {Promise} + */ +export async function listCommitFiles(fs, dir, oid) { + const files = await git.listFiles({ fs, dir, ref: oid }); + return files.filter((f) => !LOCAL_ONLY_FILES.has(f)).map((f) => `/${f}`); +} + /** * Number of commits reachable from `tipOid` before hitting `ancestorOid` (exclusive). * @param {import('isomorphic-git').FsClient} fs diff --git a/src/content/content-shared.js b/src/content/content-shared.js index ba7ed22f4..b9838779c 100644 --- a/src/content/content-shared.js +++ b/src/content/content-shared.js @@ -29,8 +29,14 @@ export const CONTENT_IO_CONCURRENCY = 10; * Reads and normalizes the content config from a content directory. * Accepts both current keys (org/site) and legacy keys (owner/repo) written * by earlier versions of clone. org/site take priority when both are present. + * + * `daAdmin` records the admin host the content was cloned from. Configs written + * before that key existed simply omit it, and callers then treat the backend as + * unknown rather than as a mismatch. + * * @param {string} contentDir - absolute path to the content/ directory - * @returns {Promise<{org: string, site: string, rootPath: string|undefined}>} + * @returns {Promise<{org: string, site: string, rootPath: string|undefined, + * daAdmin: string|undefined}>} * @throws if the config file is missing or org/site cannot be resolved */ export async function readContentConfig(contentDir) { @@ -44,7 +50,12 @@ export async function readContentConfig(contentDir) { if (!org || !site) { throw new Error(`Invalid config: org and site are required in ${configPath}.`); } - return { org, site, rootPath: raw.rootPath }; + const daAdmin = typeof raw.daAdmin === 'string' && raw.daAdmin.trim() + ? raw.daAdmin.trim() + : undefined; + return { + org, site, rootPath: raw.rootPath, daAdmin, + }; } /** diff --git a/src/content/da-api.js b/src/content/da-api.js index 22d843df5..bff28fe1c 100644 --- a/src/content/da-api.js +++ b/src/content/da-api.js @@ -14,7 +14,66 @@ 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(/\/+$/, ''); +} + +/** Label used for the default admin host. */ +export const DEFAULT_DA_ENV_LABEL = 'prod'; + +/** + * Compares two already resolved admin hosts. The comparison is on the origin only, + * so a trailing slash or a different case still counts as the same backend. + * + * @param {string} a first admin host + * @param {string} b second admin host + * @returns {boolean} true when both point at the same backend + */ +export function isSameDaAdmin(a, b) { + const origin = (value) => { + const normalized = String(value ?? '').trim().replace(/\/+$/, ''); + try { + return new URL(normalized).origin.toLowerCase(); + } catch { + return normalized.toLowerCase(); + } + }; + return origin(a) === origin(b); +} + +/** + * Derives a short environment label from the resolved DA admin host, so that per-host + * state (the cached IMS token, for example) never clobbers another host's state. + * + * The default host keeps the {@link DEFAULT_DA_ENV_LABEL} label. A host whose first + * name ends in `-admin` contributes the part before it, so `foo-admin.example.com` + * becomes `foo`. Anything else falls back to the sanitized host name. + * + * @param {string} [daAdmin] explicit admin host, overriding the environment + * @returns {string} label safe to use in a file name + */ +export function resolveDaEnvLabel(daAdmin) { + const sanitize = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + const { hostname } = new URL(resolveDaAdmin(daAdmin)); + if (hostname.toLowerCase() === new URL(DEFAULT_DA_ADMIN).hostname) { + return DEFAULT_DA_ENV_LABEL; + } + const prefix = hostname.toLowerCase().split('.')[0].match(/^(.+)-admin$/); + return sanitize(prefix ? prefix[1] : hostname) || DEFAULT_DA_ENV_LABEL; +} /** Response header used to page past the per-request list limit (e.g. 1000 items). */ const LIST_CONTINUATION_HEADER = 'da-continuation-token'; @@ -27,8 +86,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 +108,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 +189,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 +213,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 +233,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 +255,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/src/content/da-auth.js b/src/content/da-auth.js index 83b304431..d074f6133 100644 --- a/src/content/da-auth.js +++ b/src/content/da-auth.js @@ -14,18 +14,70 @@ import path from 'path'; import fse from 'fs-extra'; import open from 'open'; import { ensureGitIgnored } from './content-git.js'; +import { resolveDaEnvLabel, DEFAULT_DA_ENV_LABEL } from './da-api.js'; -const IMS_ORIGIN = 'https://ims-na1.adobelogin.com'; +/** Default IMS origin. */ +export const DEFAULT_DA_IMS_ORIGIN = 'https://ims-na1.adobelogin.com'; /** Shared with da-live's own IMS client (see da-live/scripts/scripts.js). */ -export const DA_IMS_CLIENT_ID = 'darkalley'; -export const DA_IMS_SCOPE = 'ab.manage,AdobeID,gnav,openid,org.read,read_organizations,session,aem.frontend.all,additional_info.ownerOrg,additional_info.projectedProductContext,account_cluster.read'; -const CLIENT_ID = DA_IMS_CLIENT_ID; -const SCOPE = DA_IMS_SCOPE; +export const DEFAULT_DA_IMS_CLIENT_ID = 'darkalley'; +/** Default IMS scope. */ +export const DEFAULT_DA_IMS_SCOPE = 'ab.manage,AdobeID,gnav,openid,org.read,read_organizations,session,aem.frontend.all,additional_info.ownerOrg,additional_info.projectedProductContext,account_cluster.read'; const CALLBACK_PORT = 9898; const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/callback`; -/** Token file stored in the project's .hlx folder, alongside the site token. */ -export const DA_TOKEN_FILE = path.join('.hlx', '.da-token.json'); +/** + * Reads an environment variable, falling back to the given default when it is unset or empty. + * @param {string} name variable name + * @param {string} fallback default value + * @returns {string} + */ +function fromEnv(name, fallback) { + return (process.env[name] ?? '').trim() || fallback; +} + +/** + * Resolves the IMS origin to use: the `AEM_DA_IMS_ORIGIN` environment variable, + * then {@link DEFAULT_DA_IMS_ORIGIN}. + * @returns {string} IMS origin without a trailing slash + */ +export function resolveDaImsOrigin() { + return fromEnv('AEM_DA_IMS_ORIGIN', DEFAULT_DA_IMS_ORIGIN).replace(/\/+$/, ''); +} + +/** + * Resolves the IMS client id to use: the `AEM_DA_IMS_CLIENT_ID` environment variable, + * then {@link DEFAULT_DA_IMS_CLIENT_ID}. + * @returns {string} + */ +export function resolveDaImsClientId() { + return fromEnv('AEM_DA_IMS_CLIENT_ID', DEFAULT_DA_IMS_CLIENT_ID); +} + +/** + * Resolves the IMS scope to use: the `AEM_DA_IMS_SCOPE` environment variable, + * then {@link DEFAULT_DA_IMS_SCOPE}. + * @returns {string} + */ +export function resolveDaImsScope() { + return fromEnv('AEM_DA_IMS_SCOPE', DEFAULT_DA_IMS_SCOPE); +} + +/** Git ignore entry covering the token file of every environment. */ +export const DA_TOKEN_IGNORE_ENTRY = path.join('.hlx', '.da-token*.json'); + +/** + * Token file stored in the project's .hlx folder, alongside the site token, one per + * environment: the default environment keeps the plain `.hlx/.da-token.json` name, and + * every other environment gets its label appended, so their tokens never clobber each other. + * + * @param {string} [daAdmin] explicit admin host, overriding the environment + * @returns {string} token file path relative to the project directory + */ +export function resolveDaTokenFile(daAdmin) { + const label = resolveDaEnvLabel(daAdmin); + const name = label === DEFAULT_DA_ENV_LABEL ? '.da-token.json' : `.da-token-${label}.json`; + return path.join('.hlx', name); +} // ─── Token storage ─────────────────────────────────────────────────────────── @@ -47,11 +99,11 @@ async function loadStoredToken(tokenFile) { * @param {object} tokenData */ async function saveDaTokenToFile(projectDir, tokenData) { - const tokenFile = path.join(projectDir, DA_TOKEN_FILE); + const tokenFile = path.join(projectDir, resolveDaTokenFile()); await fse.ensureDir(path.dirname(tokenFile)); await fse.writeJson(tokenFile, tokenData, { spaces: 2 }); - await ensureGitIgnored(projectDir, DA_TOKEN_FILE); + await ensureGitIgnored(projectDir, DA_TOKEN_IGNORE_ENTRY); } // ─── Token validity ────────────────────────────────────────────────────────── @@ -161,11 +213,11 @@ function waitForToken(finalRedirectUrl) { async function login(log, projectDir) { const params = new URLSearchParams({ response_type: 'token', - client_id: CLIENT_ID, - scope: SCOPE, + client_id: resolveDaImsClientId(), + scope: resolveDaImsScope(), redirect_uri: REDIRECT_URI, }); - const authUrl = `${IMS_ORIGIN}/ims/authorize/v2?${params}`; + const authUrl = `${resolveDaImsOrigin()}/ims/authorize/v2?${params}`; log.info('Opening browser for da.live login...'); log.info(`If the browser does not open automatically, visit:\n ${authUrl}\n`); @@ -180,7 +232,7 @@ async function login(log, projectDir) { expires_at: expiresIn ? Date.now() + (expiresIn * 1000) : null, }); - log.info(`Login successful. Token saved to ${path.join(projectDir, DA_TOKEN_FILE)}`); + log.info(`Login successful. Token saved to ${path.join(projectDir, resolveDaTokenFile())}`); return token; } @@ -202,13 +254,13 @@ async function login(log, projectDir) { export function startDaLoginRedirect(finalRedirectUrl) { const params = new URLSearchParams({ response_type: 'token', - client_id: CLIENT_ID, - scope: SCOPE, + client_id: resolveDaImsClientId(), + scope: resolveDaImsScope(), redirect_uri: REDIRECT_URI, }); // fire-and-forget: the callback server delivers the browser to finalRedirectUrl itself waitForToken(finalRedirectUrl).catch(() => {}); - return `${IMS_ORIGIN}/ims/authorize/v2?${params}`; + return `${resolveDaImsOrigin()}/ims/authorize/v2?${params}`; } /** @@ -216,7 +268,8 @@ export function startDaLoginRedirect(finalRedirectUrl) { * * Priority: * 1. Caller-supplied token (--token flag) — used as-is, not persisted - * 2. Stored token in .hlx/.da-token.json that is still valid + * 2. Stored token in the environment's token file (see {@link resolveDaTokenFile}) + * that is still valid * 3. Full browser implicit login flow * * @param {object} log @@ -229,7 +282,7 @@ export async function getValidToken(log, override, projectDir) { return override; } - const tokenFile = path.join(projectDir, DA_TOKEN_FILE); + const tokenFile = path.join(projectDir, resolveDaTokenFile()); const stored = await loadStoredToken(tokenFile); if (stored?.access_token && !isTokenExpired(stored)) { diff --git a/src/content/push.cmd.js b/src/content/push.cmd.js index 9473e4b18..c53b6cced 100644 --- a/src/content/push.cmd.js +++ b/src/content/push.cmd.js @@ -14,7 +14,9 @@ import path from 'path'; import fse from 'fs-extra'; import git from 'isomorphic-git'; import processQueue from '@adobe/helix-shared-process-queue'; -import { DaClient, getContentType } from './da-api.js'; +import { + DaClient, getContentType, isSameDaAdmin, resolveDaAdmin, +} from './da-api.js'; import { getValidToken } from './da-auth.js'; import { CONTENT_DIR, @@ -26,6 +28,7 @@ import { writeSyncedRef, statusMatrixHasUncommitted, diffCommitTrees, + listCommitFiles, getCommitCommitterTimeMs, } from './content-git.js'; @@ -186,7 +189,7 @@ export default class PushCommand { async run() { const { log } = this; const contentDir = path.resolve(this._dir, CONTENT_DIR); - const { org, site } = await readContentConfig(contentDir); + const { org, site, daAdmin: clonedFrom } = await readContentConfig(contentDir); const matrix = await git.statusMatrix({ fs, dir: contentDir }); if (statusMatrixHasUncommitted(matrix)) { @@ -197,24 +200,52 @@ export default class PushCommand { } const headOid = await git.resolveRef({ fs, dir: contentDir, ref: 'HEAD' }); - const syncedOid = await resolveSyncedOid(fs, contentDir); - const lastSyncTime = await getCommitCommitterTimeMs(fs, contentDir, syncedOid); - const fullChanges = await diffCommitTrees(fs, contentDir, syncedOid, headOid); + // A push to a backend other than the one cloned from is a copy, not a sync: the local + // baseline describes the source backend, so it says nothing about the target. + const crossBackend = clonedFrom !== undefined + && !isSameDaAdmin(clonedFrom, resolveDaAdmin()); + if (crossBackend && !this._force) { + log.warn( + 'Push aborted: pushing to a different backend than cloned from; use --force to copy.', + ); + process.exitCode = 1; + return; + } const scope = this._pushPath ? this._pushPath.replace(/\/+$/, '') : null; const inScope = (daPath) => scope === null || daPath === scope || daPath.startsWith(`${scope}/`); - const added = fullChanges.added.filter(inScope); - const modified = fullChanges.modified.filter(inScope); - const deleted = fullChanges.deleted.filter(inScope); - const fullCount = fullChanges.added.length - + fullChanges.modified.length - + fullChanges.deleted.length; - const scopeCount = added.length + modified.length + deleted.length; - const scopeIsComplete = fullCount === scopeCount; + let added; + let modified; + let deleted; + let scopeIsComplete; + let lastSyncTime = null; + + if (crossBackend) { + // Overwrite copy: every file is uploaded, nothing is deleted on the target, and the + // sync baseline stays with the backend it belongs to. + added = (await listCommitFiles(fs, contentDir, headOid)).filter(inScope); + modified = []; + deleted = []; + scopeIsComplete = false; + } else { + const syncedOid = await resolveSyncedOid(fs, contentDir); + lastSyncTime = await getCommitCommitterTimeMs(fs, contentDir, syncedOid); + + const fullChanges = await diffCommitTrees(fs, contentDir, syncedOid, headOid); + added = fullChanges.added.filter(inScope); + modified = fullChanges.modified.filter(inScope); + deleted = fullChanges.deleted.filter(inScope); + + const fullCount = fullChanges.added.length + + fullChanges.modified.length + + fullChanges.deleted.length; + const scopeCount = added.length + modified.length + deleted.length; + scopeIsComplete = fullCount === scopeCount; + } if (added.length === 0 && modified.length === 0 && deleted.length === 0) { log.info('Nothing to push. No commits ahead of the last da.live sync.'); @@ -224,20 +255,28 @@ export default class PushCommand { const token = await getValidToken(log, this._token, this._dir); log.info(`Pushing content to da.live: ${org}/${site}`); + if (crossBackend) { + log.info( + 'Target backend differs from the one cloned from: copying all files and ' + + 'skipping the conflict check.', + ); + } log.info(`${added.length} added, ${modified.length} modified, ${deleted.length} deleted`); const client = new DaClient(token); - const shouldAbort = await this._checkConflicts( - client, - org, - site, - modified, - deleted, - lastSyncTime, - ); - if (shouldAbort) { - return; + if (!crossBackend) { + const shouldAbort = await this._checkConflicts( + client, + org, + site, + modified, + deleted, + lastSyncTime, + ); + if (shouldAbort) { + return; + } } if (this._dryRun) { @@ -277,7 +316,12 @@ export default class PushCommand { const pushErrors = putErrors + deleteErrors; const allOk = pushErrors === 0; - if (allOk && scopeIsComplete) { + if (allOk && crossBackend) { + log.info( + '\nCopied to a different backend. The local sync baseline still points at the ' + + 'backend you cloned from.', + ); + } else if (allOk && scopeIsComplete) { await writeSyncedRef(fs, contentDir, headOid); } else if (allOk) { log.info( diff --git a/src/server/HelixServer.js b/src/server/HelixServer.js index 9ba4ee8f8..c00e7a9e0 100644 --- a/src/server/HelixServer.js +++ b/src/server/HelixServer.js @@ -23,7 +23,7 @@ import LiveReload from './LiveReload.js'; import { saveSiteTokenToFile } from '../config/config-utils.js'; import { CONTENT_DIR } from '../content/content-shared.js'; import { renderContentHtml } from '../content/content-html-pipeline.js'; -import { DA_IMS_CLIENT_ID, DA_IMS_SCOPE, startDaLoginRedirect } from '../content/da-auth.js'; +import { resolveDaImsClientId, resolveDaImsScope, startDaLoginRedirect } from '../content/da-auth.js'; const LOGIN_ROUTE = '/.aem/cli/login'; const LOGIN_ACK_ROUTE = '/.aem/cli/login/ack'; @@ -555,8 +555,8 @@ export class HelixServer extends BaseServer { htmlContent = utils.injectDaContentAuthScript(htmlContent, { previewOrigin, probePath: utils.findDaPreviewProbePath(htmlContent, previewOrigin), - clientId: DA_IMS_CLIENT_ID, - scope: DA_IMS_SCOPE, + clientId: resolveDaImsClientId(), + scope: resolveDaImsScope(), }); } if (liveReload) { 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/content-git.test.js b/test/content/content-git.test.js index 3a3df33b3..49b57e34e 100644 --- a/test/content/content-git.test.js +++ b/test/content/content-git.test.js @@ -12,10 +12,13 @@ /* eslint-env mocha */ import assert from 'assert'; +import fs from 'fs'; import path from 'path'; import fse from 'fs-extra'; +import git from 'isomorphic-git'; import { createTestRoot } from '../utils.js'; -import { ensureGitIgnored } from '../../src/content/content-git.js'; +import { ensureGitIgnored, listCommitFiles } from '../../src/content/content-git.js'; +import { setupContentDir } from './content-test-utils.js'; describe('ensureGitIgnored', () => { let testRoot; @@ -50,3 +53,29 @@ describe('ensureGitIgnored', () => { assert.ok(content.includes('content')); }); }); + +describe('listCommitFiles', () => { + let testRoot; + + beforeEach(async () => { + testRoot = await createTestRoot(); + }); + + afterEach(async () => { + await fse.remove(testRoot); + }); + + it('returns every content file at a commit as a da.live path', async () => { + const contentDir = await setupContentDir(testRoot); + const headOid = await git.resolveRef({ fs, dir: contentDir, ref: 'HEAD' }); + const files = await listCommitFiles(fs, contentDir, headOid); + assert.deepStrictEqual(files.sort(), ['/blog/post.html', '/index.html']); + }); + + it('leaves out the local .gitignore bookkeeping file', async () => { + const contentDir = await setupContentDir(testRoot); + const headOid = await git.resolveRef({ fs, dir: contentDir, ref: 'HEAD' }); + const files = await listCommitFiles(fs, contentDir, headOid); + assert.ok(!files.includes('/.gitignore')); + }); +}); diff --git a/test/content/cross-backend.test.js b/test/content/cross-backend.test.js new file mode 100644 index 000000000..f4c6780e3 --- /dev/null +++ b/test/content/cross-backend.test.js @@ -0,0 +1,291 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import fse from 'fs-extra'; +import git from 'isomorphic-git'; +import esmock from 'esmock'; +import { createTestRoot, Nock } from '../utils.js'; +import { CONFIG_FILE, CONTENT_DIR } from '../../src/content/content-shared.js'; +import { DA_SYNCED_REF } from '../../src/content/content-git.js'; +import { makeLogger, setupContentDir } from './content-test-utils.js'; + +// Two arbitrary backends: the mechanism is generic, any two admin hosts behave the same way. +const HOST_A = 'https://admin-a.example.com'; +const HOST_B = 'https://admin-b.example.com'; + +const ORG = 'myorg'; +const SITE = 'mysite'; + +/** Files the fake backend A serves. */ +const REMOTE_FILES = { + '/index.html': '

index from A

', + '/blog/post.html': '

post from A

', +}; + +async function makeCloneCommand(testRoot) { + const mod = await esmock('../../src/content/clone.cmd.js', { + '../../src/content/da-auth.js': { getValidToken: async () => 'mock-token' }, + }); + const Cmd = mod.default; + const log = makeLogger(); + return { + log, + cmd: new Cmd(log) + .withDirectory(testRoot) + .withOrg(ORG) + .withSite(SITE) + .withRootPath('/'), + }; +} + +async function makePushCommand(testRoot) { + const mod = await esmock('../../src/content/push.cmd.js', { + '../../src/content/da-auth.js': { getValidToken: async () => 'mock-token' }, + }); + const Cmd = mod.default; + const log = makeLogger(); + return { log, cmd: new Cmd(log).withDirectory(testRoot) }; +} + +/** Replies to the list and source requests a full clone of {@link REMOTE_FILES} makes. */ +function nockClone(nock, host) { + nock(host) + .get(`/list/${ORG}/${SITE}/`) + .reply(200, [ + { path: `/${ORG}/${SITE}/index.html`, name: 'index.html', ext: 'html' }, + { path: `/${ORG}/${SITE}/blog`, name: 'blog' }, + ]) + .get(`/list/${ORG}/${SITE}/blog`) + .reply(200, [ + { path: `/${ORG}/${SITE}/blog/post.html`, name: 'post.html', ext: 'html' }, + ]); + for (const [daPath, body] of Object.entries(REMOTE_FILES)) { + nock(host) + .get(`/source/${ORG}/${SITE}${daPath}`) + .reply(200, body, { 'content-type': 'text/html' }); + } +} + +describe('cross-backend content copy', () => { + let testRoot; + let nock; + let savedAdmin; + + beforeEach(async () => { + testRoot = await createTestRoot(); + nock = new Nock(); + savedAdmin = process.env.AEM_DA_ADMIN; + }); + + afterEach(async () => { + if (savedAdmin === undefined) { + delete process.env.AEM_DA_ADMIN; + } else { + process.env.AEM_DA_ADMIN = savedAdmin; + } + process.exitCode = 0; + nock.done(); + await fse.remove(testRoot); + }); + + /** Clones from HOST_A and returns the content dir. */ + async function cloneFromHostA() { + process.env.AEM_DA_ADMIN = HOST_A; + nockClone(nock, HOST_A); + const { cmd } = await makeCloneCommand(testRoot); + await cmd.run(); + return path.resolve(testRoot, CONTENT_DIR); + } + + it('clone records the admin host it cloned from', async () => { + const contentDir = await cloneFromHostA(); + const config = await fse.readJson(path.join(contentDir, CONFIG_FILE)); + assert.strictEqual(config.daAdmin, HOST_A); + assert.strictEqual(config.org, ORG); + assert.strictEqual(config.site, SITE); + }); + + it('refuses a push to a different backend without --force', async () => { + await cloneFromHostA(); + + process.env.AEM_DA_ADMIN = HOST_B; + const { log, cmd } = await makePushCommand(testRoot); + await cmd.run(); + + assert.ok(log.logs.some((l) => l.msg.includes( + 'pushing to a different backend than cloned from; use --force to copy', + ))); + assert.strictEqual(process.exitCode, 1); + }); + + it('copies every file to the other backend with --force', async () => { + const contentDir = await cloneFromHostA(); + const syncedBefore = await git.resolveRef({ fs, dir: contentDir, ref: DA_SYNCED_REF }); + + const puts = []; + nock(HOST_B) + .put(`/source/${ORG}/${SITE}/index.html`) + .reply((uri, body) => { + puts.push({ uri, body }); + return [200, {}]; + }) + .put(`/source/${ORG}/${SITE}/blog/post.html`) + .reply((uri, body) => { + puts.push({ uri, body }); + return [200, {}]; + }); + + process.env.AEM_DA_ADMIN = HOST_B; + const { log, cmd } = await makePushCommand(testRoot); + await cmd.withForce(true).run(); + + assert.deepStrictEqual( + puts.map((p) => p.uri).sort(), + [`/source/${ORG}/${SITE}/blog/post.html`, `/source/${ORG}/${SITE}/index.html`], + ); + assert.ok(log.logs.some((l) => l.msg.includes('skipping the conflict check'))); + assert.ok(log.logs.some((l) => l.msg.includes('2 added, 0 modified, 0 deleted'))); + + // the baseline still describes the backend the content came from + const syncedAfter = await git.resolveRef({ fs, dir: contentDir, ref: DA_SYNCED_REF }); + assert.strictEqual(syncedAfter, syncedBefore); + const config = await fse.readJson(path.join(contentDir, CONFIG_FILE)); + assert.strictEqual(config.daAdmin, HOST_A); + }); + + it('never uploads the local .gitignore bookkeeping file', async () => { + const contentDir = await cloneFromHostA(); + assert.ok(await fse.pathExists(path.join(contentDir, '.gitignore'))); + + const puts = []; + nock(HOST_B) + .put(`/source/${ORG}/${SITE}/index.html`) + .reply((uri) => { + puts.push(uri); + return [200, {}]; + }) + .put(`/source/${ORG}/${SITE}/blog/post.html`) + .reply((uri) => { + puts.push(uri); + return [200, {}]; + }); + + process.env.AEM_DA_ADMIN = HOST_B; + const { cmd } = await makePushCommand(testRoot); + await cmd.withForce(true).run(); + + assert.ok(!puts.some((uri) => uri.endsWith('/.gitignore'))); + }); + + it('a dry run against a different backend lists the whole copy and uploads nothing', async () => { + await cloneFromHostA(); + + process.env.AEM_DA_ADMIN = HOST_B; + const { log, cmd } = await makePushCommand(testRoot); + await cmd.withForce(true).withDryRun(true).run(); + + assert.ok(log.logs.some((l) => l.msg.includes('Dry run'))); + assert.ok(log.logs.some((l) => l.msg.includes('+ /index.html'))); + assert.ok(log.logs.some((l) => l.msg.includes('+ /blog/post.html'))); + }); + + it('keeps the normal conflict check for a push to the same backend', async () => { + const contentDir = await cloneFromHostA(); + + await fse.writeFile(path.join(contentDir, 'index.html'), '

edited locally

'); + await git.add({ fs, dir: contentDir, filepath: 'index.html' }); + await git.commit({ + fs, + dir: contentDir, + message: 'edit index', + author: { name: 'aem-cli', email: 'aem-cli@adobe.com' }, + }); + + // the remote moved after the clone: the same-backend path must still detect the conflict + nock(HOST_A) + .head(`/source/${ORG}/${SITE}/index.html`) + .reply(200, '', { 'last-modified': new Date(Date.now() + 60000).toUTCString() }); + + process.env.AEM_DA_ADMIN = HOST_A; + const { log, cmd } = await makePushCommand(testRoot); + await cmd.run(); + + assert.ok(log.logs.some((l) => l.msg.includes('Conflicts detected'))); + assert.ok(log.logs.some((l) => l.msg.includes('Use --force to overwrite remote changes'))); + assert.strictEqual(process.exitCode, 1); + }); + + it('treats a config without a recorded host as the same backend', async () => { + // content cloned before the host was recorded must keep the old sync behavior + const contentDir = await setupContentDir(testRoot, ORG, SITE); + await fse.writeJson(path.join(contentDir, CONFIG_FILE), { org: ORG, site: SITE, daAdmin: ' ' }); + + await fse.writeFile(path.join(contentDir, 'index.html'), '

edited

'); + await git.add({ fs, dir: contentDir, filepath: 'index.html' }); + await git.commit({ + fs, + dir: contentDir, + message: 'edit index', + author: { name: 'aem-cli', email: 'aem-cli@adobe.com' }, + }); + + const puts = []; + nock(HOST_B) + .head(`/source/${ORG}/${SITE}/index.html`) + .reply(404) + .put(`/source/${ORG}/${SITE}/index.html`) + .reply((uri) => { + puts.push(uri); + return [200, {}]; + }); + + process.env.AEM_DA_ADMIN = HOST_B; + const { cmd } = await makePushCommand(testRoot); + await cmd.run(); + + assert.deepStrictEqual(puts, [`/source/${ORG}/${SITE}/index.html`]); + assert.notStrictEqual(process.exitCode, 1); + }); + + it('pushes only the changed file to the same backend', async () => { + const contentDir = await cloneFromHostA(); + + await fse.writeFile(path.join(contentDir, 'index.html'), '

edited locally

'); + await git.add({ fs, dir: contentDir, filepath: 'index.html' }); + await git.commit({ + fs, + dir: contentDir, + message: 'edit index', + author: { name: 'aem-cli', email: 'aem-cli@adobe.com' }, + }); + + const puts = []; + nock(HOST_A) + .head(`/source/${ORG}/${SITE}/index.html`) + .reply(404) + .put(`/source/${ORG}/${SITE}/index.html`) + .reply((uri) => { + puts.push(uri); + return [200, {}]; + }); + + process.env.AEM_DA_ADMIN = HOST_A; + const { cmd } = await makePushCommand(testRoot); + await cmd.run(); + + assert.deepStrictEqual(puts, [`/source/${ORG}/${SITE}/index.html`]); + }); +}); diff --git a/test/content/da-api.test.js b/test/content/da-api.test.js index 33a902a14..2edbbbf64 100644 --- a/test/content/da-api.test.js +++ b/test/content/da-api.test.js @@ -12,7 +12,15 @@ /* eslint-env mocha */ import assert from 'assert'; -import { DaClient, getContentType } from '../../src/content/da-api.js'; +import { + DaClient, + DEFAULT_DA_ADMIN, + DEFAULT_DA_ENV_LABEL, + getContentType, + isSameDaAdmin, + resolveDaAdmin, + resolveDaEnvLabel, +} from '../../src/content/da-api.js'; function mockResponse(status, body, ok = status >= 200 && status < 300, responseHeaders = {}) { const lower = Object.fromEntries( @@ -88,13 +96,159 @@ 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('resolveDaEnvLabel', () => { + 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('labels the default admin host as the default environment', () => { + assert.strictEqual(resolveDaEnvLabel(), DEFAULT_DA_ENV_LABEL); + assert.strictEqual(DEFAULT_DA_ENV_LABEL, 'prod'); + }); + + it('takes the label from an