From 6b95dc5929a23375af8499e9623970271afaf0ae Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Sun, 19 Apr 2026 15:55:01 -0600 Subject: [PATCH] feat(content): add --preview and --publish to push via admin.hlx.page - Bulk preview (POST /preview/.../*) and bulk publish (POST /live/.../*) - --publish waits for preview jobs before publish jobs; same DA/IMS token - Strip .html suffix for admin bulk paths (extensionless web URLs) - Git branch from project root for ref; branch query for slashes/uppercase - Tests for hlx-admin helpers and push integration Made-with: Cursor --- src/content/hlx-admin.js | 240 ++++++++++++++++++++++++++ src/content/push.cmd.js | 78 +++++++++ src/content/push.js | 13 ++ test/content/content-commands.test.js | 16 +- test/content/hlx-admin.test.js | 53 ++++++ test/content/push.cmd.test.js | 67 +++++++ 6 files changed, 462 insertions(+), 5 deletions(-) create mode 100644 src/content/hlx-admin.js create mode 100644 test/content/hlx-admin.test.js diff --git a/src/content/hlx-admin.js b/src/content/hlx-admin.js new file mode 100644 index 000000000..1469219b1 --- /dev/null +++ b/src/content/hlx-admin.js @@ -0,0 +1,240 @@ +/* + * 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. + */ + +import { getFetch } from '../fetch-utils.js'; + +const HLX_ADMIN = 'https://admin.hlx.page'; + +/** Polling interval for admin.hlx.page job status (ms). */ +const JOB_POLL_MS = 2000; + +/** Max time to wait for a bulk job (ms). */ +const JOB_TIMEOUT_MS = 45 * 60 * 1000; + +/** + * When the branch name contains slashes or uppercase letters, the Admin API expects a + * `branch` query parameter; the path segment uses a placeholder ref (see AEM Admin API docs). + * @param {string} ref + * @returns {{ pathRef: string, search: string }} + */ +export function branchPathAndQuery(ref) { + const needsBranchParam = ref.includes('/') || ref !== ref.toLowerCase(); + const pathRef = needsBranchParam ? 'main' : ref; + const search = needsBranchParam ? `?branch=${encodeURIComponent(ref)}` : ''; + return { pathRef, search }; +} + +/** + * Admin bulk preview/publish use extensionless web paths for HTML (see AEM routing). + * Trailing `.html` (any case) is removed (e.g. `/a/page.html` → `/a/page`). + * Other paths (e.g. `.json`, images) are unchanged. + * @param {string} daPath path as on da.live + * @returns {string} + */ +export function toAdminBulkPath(daPath) { + return daPath.replace(/\.html$/i, ''); +} + +function bulkUrl(kind, org, site, ref) { + const { pathRef, search } = branchPathAndQuery(ref); + const enc = encodeURIComponent; + return `${HLX_ADMIN}/${kind}/${enc(org)}/${enc(site)}/${enc(pathRef)}/*${search}`; +} + +/** + * @param {object} log logger with .info + * @param {*} fetchImpl + * @param {string} token Bearer token + * @param {'preview'|'live'} kind + * @param {string} org + * @param {string} site + * @param {string} ref git branch / site ref + * @param {{ paths: string[], delete?: boolean, forceUpdate?: boolean }} body + */ +export async function startBulkJob(log, fetchImpl, token, kind, org, site, ref, body) { + const url = bulkUrl(kind, org, site, ref); + const res = await fetchImpl(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let data; + try { + data = text ? JSON.parse(text) : {}; + } catch { + throw new Error(`admin API returned non-JSON (${res.status}): ${text.slice(0, 200)}`); + } + if (!res.ok) { + const msg = data.message || data.error || text || res.statusText; + throw new Error(`Bulk ${kind} failed (${res.status}): ${msg}`); + } + const selfLink = data.links?.self; + if (!selfLink) { + throw new Error('Bulk job response missing links.self'); + } + const jobUrl = String(selfLink).replace(/^<|>$/g, ''); + log.info(` Job: ${jobUrl}`); + return { jobUrl, raw: data }; +} + +/** + * @param {object} log + * @param {*} fetchImpl + * @param {string} token + * @param {string} jobUrl + */ +export async function waitForJob(log, fetchImpl, token, jobUrl) { + const cleanUrl = String(jobUrl).replace(/^<|>$/g, ''); + const started = Date.now(); + + /* eslint-disable no-await-in-loop, no-constant-condition */ + while (true) { + if (Date.now() - started > JOB_TIMEOUT_MS) { + throw new Error(`Timed out waiting for admin job: ${cleanUrl}`); + } + + const res = await fetchImpl(cleanUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + const text = await res.text(); + if (!res.ok) { + throw new Error(`Job status HTTP ${res.status}: ${text.slice(0, 200)}`); + } + let data; + try { + data = text ? JSON.parse(text) : {}; + } catch { + throw new Error(`Job status not JSON (${res.status}): ${text.slice(0, 200)}`); + } + + const job = data.job || data; + const { + state, + name = '', + progress: prog = {}, + } = job; + const { + processed = 0, + total = 0, + failed = 0, + } = prog; + + if (state === 'created' || state === 'running') { + if (total > 0) { + log.info(` Job ${name} [${state}]: ${processed}/${total} (${failed} failed)`); + } else { + log.info(` Job ${name} [${state}]…`); + } + await new Promise((resolve) => { + setTimeout(resolve, JOB_POLL_MS); + }); + } else if (failed > 0 || state === 'failed' || state === 'error') { + throw new Error( + `Job ended with failures (state=${state}, failed=${failed}). See ${cleanUrl}`, + ); + } else { + log.info(` Job finished [${state || 'done'}]`); + return job; + } + } +} + +/** + * Runs preview and/or live bulk jobs for the given path sets. + * @param {object} opts + * @param {object} opts.log + * @param {string} opts.token + * @param {string} opts.org + * @param {string} opts.site + * @param {string} opts.ref + * @param {boolean} opts.preview + * @param {boolean} opts.publish + * @param {string[]} opts.upsertPaths paths to preview/publish (add/update) + * @param {string[]} opts.deletePaths paths to remove from preview / unpublish + */ +export async function runBulkPreviewAndPublish(opts) { + const { + log, + token, + org, + site, + ref, + preview, + publish, + upsertPaths, + deletePaths, + } = opts; + + const fetchImpl = getFetch(false); + const wantPreview = preview || publish; + const wantPublish = publish; + + if (!wantPreview) { + return; + } + + log.info('Starting bulk preview on admin.hlx.page…'); + + if (upsertPaths.length > 0) { + log.info(` Preview ${upsertPaths.length} path(s) (update)…`); + // eslint-disable-next-line no-await-in-loop + const { jobUrl } = await startBulkJob(log, fetchImpl, token, 'preview', org, site, ref, { + paths: upsertPaths, + delete: false, + }); + // eslint-disable-next-line no-await-in-loop + await waitForJob(log, fetchImpl, token, jobUrl); + } + + if (deletePaths.length > 0) { + log.info(` Preview ${deletePaths.length} path(s) (delete)…`); + // eslint-disable-next-line no-await-in-loop + const { jobUrl } = await startBulkJob(log, fetchImpl, token, 'preview', org, site, ref, { + paths: deletePaths, + delete: true, + }); + // eslint-disable-next-line no-await-in-loop + await waitForJob(log, fetchImpl, token, jobUrl); + } + + if (!wantPublish) { + return; + } + + log.info('Starting bulk publish on admin.hlx.page…'); + + if (upsertPaths.length > 0) { + log.info(` Publish ${upsertPaths.length} path(s) (update)…`); + // eslint-disable-next-line no-await-in-loop + const { jobUrl } = await startBulkJob(log, fetchImpl, token, 'live', org, site, ref, { + paths: upsertPaths, + delete: false, + }); + // eslint-disable-next-line no-await-in-loop + await waitForJob(log, fetchImpl, token, jobUrl); + } + + if (deletePaths.length > 0) { + log.info(` Publish ${deletePaths.length} path(s) (delete)…`); + // eslint-disable-next-line no-await-in-loop + const { jobUrl } = await startBulkJob(log, fetchImpl, token, 'live', org, site, ref, { + paths: deletePaths, + delete: true, + }); + // eslint-disable-next-line no-await-in-loop + await waitForJob(log, fetchImpl, token, jobUrl); + } +} diff --git a/src/content/push.cmd.js b/src/content/push.cmd.js index 1df7aafc5..f1b663f6f 100644 --- a/src/content/push.cmd.js +++ b/src/content/push.cmd.js @@ -14,8 +14,10 @@ import path from 'path'; import fse from 'fs-extra'; import git from 'isomorphic-git'; import processQueue from '@adobe/helix-shared-process-queue'; +import GitUtils from '../git-utils.js'; import { DaClient, getContentType } from './da-api.js'; import { getValidToken } from './da-auth.js'; +import { runBulkPreviewAndPublish, toAdminBulkPath } from './hlx-admin.js'; import { CONTENT_DIR, CONFIG_FILE, @@ -36,6 +38,8 @@ export default class PushCommand { this._force = false; this._dryRun = false; this._pushPath = null; + this._preview = false; + this._publish = false; } withDirectory(dir) { @@ -63,6 +67,28 @@ export default class PushCommand { return this; } + withPreview(preview) { + this._preview = !!preview; + return this; + } + + withPublish(publish) { + this._publish = !!publish; + return this; + } + + /** + * Resolves the git branch / site ref for admin.hlx.page bulk jobs (parent repo). + * @returns {Promise} + */ + async _resolveSiteRef() { + try { + return await GitUtils.getBranch(this._dir, GitUtils.DEFAULT_BRANCH); + } catch { + return GitUtils.DEFAULT_BRANCH; + } + } + /** * Checks for conflicts between local changes and remote modifications. * @param {DaClient} client @@ -258,6 +284,21 @@ export default class PushCommand { log.info(` - ${p}`); } } + const upsertDry = [...added, ...modified].map(toAdminBulkPath); + const deleteDry = [...deleted].map(toAdminBulkPath); + if (this._preview || this._publish) { + const ref = await this._resolveSiteRef(); + log.info( + `\nDry run — would run admin.hlx.page bulk jobs (ref: ${ref})` + + `${this._publish ? ' [preview then publish]' : ' [preview only]'}.`, + ); + if (upsertDry.length) { + log.info(` Preview/publish upsert paths (${upsertDry.length}): ${upsertDry.join(', ')}`); + } + if (deleteDry.length) { + log.info(` Preview/publish delete paths (${deleteDry.length}): ${deleteDry.join(', ')}`); + } + } return; } @@ -287,5 +328,42 @@ export default class PushCommand { } log.info(`\nDone. ${pushed} file(s) pushed${pushErrors > 0 ? `, ${pushErrors} error(s)` : ''}.`); + + const wantAdmin = this._preview || this._publish; + if (!wantAdmin) { + return; + } + + if (!allPutsOk || !allDeletesOk) { + log.warn('Skipping admin preview/publish: not all da.live operations succeeded.'); + return; + } + + const upsertPaths = [...successfullyPushed].map(toAdminBulkPath); + const deletePaths = [...successfullyDeleted].map(toAdminBulkPath); + if (upsertPaths.length === 0 && deletePaths.length === 0) { + return; + } + + const ref = await this._resolveSiteRef(); + const runPreview = this._preview || this._publish; + const runPublish = this._publish; + + try { + await runBulkPreviewAndPublish({ + log, + token, + org, + site: repo, + ref, + preview: runPreview, + publish: runPublish, + upsertPaths, + deletePaths, + }); + } catch (err) { + log.warn(`admin.hlx.page: ${err.message}`); + process.exitCode = 1; + } } } diff --git a/src/content/push.js b/src/content/push.js index a11b16407..1ac956195 100644 --- a/src/content/push.js +++ b/src/content/push.js @@ -40,6 +40,17 @@ export default function push() { type: 'boolean', default: false, }) + .option('preview', { + describe: 'After a successful push, start a bulk preview job on admin.hlx.page for changed paths', + type: 'boolean', + default: false, + }) + .option('publish', { + describe: + 'After a successful push, bulk preview then bulk publish on admin.hlx.page (publish waits for preview)', + type: 'boolean', + default: false, + }) .help(); }, handler: async (argv) => { @@ -52,6 +63,8 @@ export default function push() { .withPath(argv.path) .withForce(argv.force) .withDryRun(argv.dryRun) + .withPreview(argv.preview) + .withPublish(argv.publish) .run(); }, }; diff --git a/test/content/content-commands.test.js b/test/content/content-commands.test.js index 1e7171c5d..18450a9c8 100644 --- a/test/content/content-commands.test.js +++ b/test/content/content-commands.test.js @@ -219,7 +219,7 @@ describe('push()', () => { assert.ok(cmd.description && cmd.description.length > 0); }); - it('has a builder that registers --token, --path, --force, --dry-run options', () => { + it('has a builder that registers --token, --path, --force, --dry-run, --preview, --publish options', () => { const cmd = push(); const registered = {}; const chainable = { @@ -234,6 +234,8 @@ describe('push()', () => { assert.ok('force' in registered); assert.ok('dry-run' in registered); assert.ok('path' in registered); + assert.ok('preview' in registered); + assert.ok('publish' in registered); }); it('executor setter works', () => { @@ -249,16 +251,20 @@ describe('push()', () => { withPath: () => ({ withForce: () => ({ withDryRun: () => ({ - run: async () => { - called = true; - }, + withPreview: () => ({ + withPublish: () => ({ + run: async () => { + called = true; + }, + }), + }), }), }), }), }), }; await cmd.handler({ - token: 't', path: null, force: false, dryRun: false, + token: 't', path: null, force: false, dryRun: false, preview: false, publish: false, }); assert.strictEqual(called, true); }); diff --git a/test/content/hlx-admin.test.js b/test/content/hlx-admin.test.js new file mode 100644 index 000000000..6ca580136 --- /dev/null +++ b/test/content/hlx-admin.test.js @@ -0,0 +1,53 @@ +/* + * 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 { branchPathAndQuery, toAdminBulkPath } from '../../src/content/hlx-admin.js'; + +describe('hlx-admin', () => { + describe('branchPathAndQuery()', () => { + it('uses ref in the path for simple branch names', () => { + const { pathRef, search } = branchPathAndQuery('main'); + assert.strictEqual(pathRef, 'main'); + assert.strictEqual(search, ''); + }); + + it('uses branch query for slashes', () => { + const { pathRef, search } = branchPathAndQuery('release/v2'); + assert.strictEqual(pathRef, 'main'); + assert.strictEqual(search, '?branch=release%2Fv2'); + }); + + it('uses branch query for uppercase', () => { + const { pathRef, search } = branchPathAndQuery('Feature'); + assert.strictEqual(pathRef, 'main'); + assert.strictEqual(search, '?branch=Feature'); + }); + }); + + describe('toAdminBulkPath()', () => { + it('strips .html suffix (case-insensitive)', () => { + assert.strictEqual(toAdminBulkPath('/my/path.html'), '/my/path'); + assert.strictEqual(toAdminBulkPath('/my/path.HTML'), '/my/path'); + }); + + it('strips root index.html to /index', () => { + assert.strictEqual(toAdminBulkPath('/index.html'), '/index'); + }); + + it('leaves non-html paths unchanged', () => { + assert.strictEqual(toAdminBulkPath('/metadata.json'), '/metadata.json'); + assert.strictEqual(toAdminBulkPath('/assets/x.jpg'), '/assets/x.jpg'); + }); + }); +}); diff --git a/test/content/push.cmd.test.js b/test/content/push.cmd.test.js index 0e1448f0f..eb350ceef 100644 --- a/test/content/push.cmd.test.js +++ b/test/content/push.cmd.test.js @@ -72,6 +72,18 @@ describe('PushCommand', () => { cmd.withPath(undefined); assert.strictEqual(cmd._pushPath, null); // eslint-disable-line no-underscore-dangle }); + + it('withPreview sets _preview', async () => { + const cmd = await makePushCommand(testRoot, createDaClientClass()); + cmd.withPreview(true); + assert.strictEqual(cmd._preview, true); // eslint-disable-line no-underscore-dangle + }); + + it('withPublish sets _publish', async () => { + const cmd = await makePushCommand(testRoot, createDaClientClass()); + cmd.withPublish(true); + assert.strictEqual(cmd._publish, true); // eslint-disable-line no-underscore-dangle + }); }); describe('run()', () => { @@ -275,5 +287,60 @@ describe('PushCommand', () => { || allMsgs.includes('Would delete'), ); }); + + it('dry-run with --preview logs bulk job intent', async () => { + const contentDir = await setupContentDir(testRoot); + await fse.writeFile(path.join(contentDir, 'index.html'), 'changed'); + await stageAllAndCommit(contentDir, 'edit index'); + + const log = makeLogger(); + const mod = await esmock('../../src/content/push.cmd.js', { + '../../src/content/da-auth.js': { getValidToken: async () => 'token' }, + '../../src/content/da-api.js': { + DaClient: createDaClientClass(), + getContentType: () => 'text/html', + }, + '../../src/content/hlx-admin.js': { runBulkPreviewAndPublish: async () => assert.fail('should not call') }, + }); + const Cmd = mod.default; + const cmd = new Cmd(log).withDirectory(testRoot).withDryRun(true).withPreview(true); + await cmd.run(); + + const allMsgs = log.logs.map((l) => l.msg).join('\n'); + assert.ok(allMsgs.includes('admin.hlx.page bulk jobs')); + }); + + it('invokes runBulkPreviewAndPublish after successful push when --preview', async () => { + const contentDir = await setupContentDir(testRoot); + await fse.writeFile(path.join(contentDir, 'index.html'), 'changed'); + await stageAllAndCommit(contentDir, 'edit index'); + + let bulkOpts; + const log = makeLogger(); + const mod = await esmock('../../src/content/push.cmd.js', { + '../../src/content/da-auth.js': { getValidToken: async () => 'token' }, + '../../src/content/da-api.js': { + DaClient: createDaClientClass({ + onPut: () => {}, + }), + getContentType: () => 'text/html', + }, + '../../src/content/hlx-admin.js': { + runBulkPreviewAndPublish: async (opts) => { + bulkOpts = opts; + }, + }, + }); + const Cmd = mod.default; + const cmd = new Cmd(log).withDirectory(testRoot).withPreview(true); + await cmd.run(); + + assert.ok(bulkOpts); + assert.strictEqual(bulkOpts.org, 'myorg'); + assert.strictEqual(bulkOpts.site, 'myrepo'); + assert.strictEqual(bulkOpts.preview, true); + assert.strictEqual(bulkOpts.publish, false); + assert.ok(bulkOpts.upsertPaths.includes('/index')); + }); }); });