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
240 changes: 240 additions & 0 deletions src/content/hlx-admin.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
78 changes: 78 additions & 0 deletions src/content/push.cmd.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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<string>}
*/
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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}
}
}
13 changes: 13 additions & 0 deletions src/content/push.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -52,6 +63,8 @@ export default function push() {
.withPath(argv.path)
.withForce(argv.force)
.withDryRun(argv.dryRun)
.withPreview(argv.preview)
.withPublish(argv.publish)
.run();
},
};
Expand Down
Loading
Loading