Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ logs
test-results.xml
.idea/
.hlx
.env
7 changes: 7 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 6 additions & 3 deletions src/content/clone.cmd.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)` : ''}.`);
Expand Down
16 changes: 16 additions & 0 deletions src/content/content-git.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>}
*/
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
Expand Down
15 changes: 13 additions & 2 deletions src/content/content-shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
};
}

/**
Expand Down
78 changes: 71 additions & 7 deletions src/content/da-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
}

Expand All @@ -44,7 +108,7 @@ export class DaClient {
* @returns {Promise<Array<{path, name, ext?, lastModified}>>}
*/
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;

Expand Down Expand Up @@ -125,7 +189,7 @@ export class DaClient {
* @returns {Promise<Response|null>}
*/
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');
Expand All @@ -149,7 +213,7 @@ export class DaClient {
* @returns {Promise<object>} 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 },
Expand All @@ -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,
Expand All @@ -191,7 +255,7 @@ export class DaClient {
* @returns {Promise<number|null>}
*/
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');
Expand Down
89 changes: 71 additions & 18 deletions src/content/da-auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────

Expand All @@ -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 ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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`);
Expand All @@ -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;
}

Expand All @@ -202,21 +254,22 @@ 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}`;
}

/**
* Returns a valid da.live access token. Triggers browser login if needed.
*
* 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
Expand All @@ -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)) {
Expand Down
Loading
Loading