Skip to main content

Groovy Console

The AEM Groovy Console provides a web-based interface for running Groovy scripts inside an AEM instance. Scripts can manipulate JCR content, call OSGi services, or execute arbitrary code using the AEM, Sling, and JCR APIs - all without deploying a code package.

It is the go-to tool for one-off maintenance tasks, bulk content operations, data migrations, and quick debugging on author and publish instances.

Access the console at /groovyconsole (or the legacy path /apps/groovyconsole.html).

:::note Community tool -- not an Adobe product The Groovy Console is an open-source, community-maintained tool, not part of AEM and not supported by Adobe Customer Care. On AEM as a Cloud Service it must be added as a project dependency and is intended for author / RDE / local SDK use; do not ship it to production publish tiers. On Adobe Managed Services (AMS) Adobe blocks its installation on production publish for the same reason. Treat every script as code: review it, test it locally, and keep it out of your production deployment pipeline unless it is deliberately scoped and access-controlled. :::

Installation

The actively maintained fork by Orbinson supports AEM 6.5.10+, AEM as a Cloud Service (2022.11+), Sling 12+, and Java 8/11/17/21.

Add the dependency to your root pom.xml:

pom.xml
<dependency>
<groupId>be.orbinson.aem</groupId>
<artifactId>aem-groovy-console-all</artifactId>
<version>19.0.8</version>
<type>zip</type>
</dependency>

Embed it in your all package via the filevault-package-maven-plugin:

all/pom.xml -- embeddeds section
<embedded>
<groupId>be.orbinson.aem</groupId>
<artifactId>aem-groovy-console-all</artifactId>
<target>/apps/vendor-packages/content/install</target>
</embedded>

Alternatively, download the .zip package from the GitHub releases page and install it manually via Package Manager.

Legacy CID15 version

Installation steps for the older org.cid15 version

Add the dependency to your root pom.xml:

pom.xml
<dependency>
<groupId>org.cid15.aem.groovy.console</groupId>
<artifactId>aem-groovy-console-all</artifactId>
<version>18.0.2</version>
<type>zip</type>
</dependency>

In your all module, add the dependency and embed:

all/pom.xml
<plugin>
<groupId>org.apache.jackrabbit</groupId>
<artifactId>filevault-package-maven-plugin</artifactId>
<extensions>true</extensions>
<configuration>
<showImportPackageReport>false</showImportPackageReport>
<group>some.group</group>
<packageType>container</packageType>
<skipSubPackageValidation>true</skipSubPackageValidation>
<embeddeds>
<embedded>
<groupId>org.cid15.aem.groovy.console</groupId>
<artifactId>aem-groovy-console-all</artifactId>
<type>zip</type>
<target>/apps/groovy-vendor-packages/container/install</target>
</embedded>
</embeddeds>
</configuration>
</plugin>
all/pom.xml -- dependency
<dependency>
<groupId>org.cid15.aem.groovy.console</groupId>
<artifactId>aem-groovy-console-all</artifactId>
<type>zip</type>
</dependency>

Add the root path in all/src/main/content/META-INF/vault/filter.xml:

<filter root="/apps/groovy-vendor-packages"/>

OSGi configuration

Restrict console access to trusted admin groups only:

ui.config/src/main/content/jcr_root/apps/<your-app>/osgiconfig/config.author/be.orbinson.aem.groovy.console.configuration.impl.DefaultConfigurationService.cfg.json
{
"allowedGroups": [
"administrators"
],
"allowedScheduledJobsGroups": [
"administrators"
],
"auditDisabled": false,
"emailEnabled": false,
"threadTimeout": 0
}
PropertyDescriptionDefault
allowedGroupsGroups authorized to execute scripts. Only admin user has access by default[]
allowedScheduledJobsGroupsGroups authorized to schedule jobs[]
auditDisabledDisable auditing of script execution historyfalse
displayAllAuditRecordsShow audit records from all users (not just current)false
emailEnabledSend email notifications on script completionfalse
emailRecipientsEmail addresses to notify[]
threadTimeoutSeconds before a script is interrupted (0 = no timeout)0
distributedExecutionEnabledReplicate and execute script on all default replication agentsfalse
danger

Never leave the Groovy Console accessible on publish instances in production. It provides full JCR access and can execute arbitrary code. Adobe Managed Services currently blocks Groovy Console installation on production publish environments for this reason.

Available Bindings

Every script automatically has access to these pre-bound variables - no imports or setup required:

VariableTypeDescription
sessionjavax.jcr.SessionThe current JCR session
resourceResolverResourceResolverSling resource resolver for the current user
pageManagerPageManagerAEM page management API
queryBuilderQueryBuilderAEM QueryBuilder API
bundleContextBundleContextOSGi bundle context for accessing services
logLoggerSLF4J logger (output appears in error.log)
outPrintWriterWrites output to the console result panel
slingSlingScriptHelperAccess to Sling services via sling.getService(...)

Getting other services

You can retrieve any OSGi service via the bundleContext or sling bindings:

import com.day.cq.replication.Replicator

// Option 1: via sling helper
def replicator = sling.getService(Replicator)

// Option 2: via bundleContext
def ref = bundleContext.getServiceReference(Replicator.class.name)
def replicator2 = bundleContext.getService(ref)

Safety Best Practices

Before running any script - especially on shared or production environments - follow these guidelines:

Always use DRY_RUN

Guard all mutations behind a DRY_RUN flag. Run the script first with DRY_RUN = true to verify what would change, then flip to false to apply.

final boolean DRY_RUN = true

// ... find nodes ...

if (!DRY_RUN) {
node.setProperty('myProp', 'newValue')
mutated++
}

// Save only when not dry-running
if (!DRY_RUN) {
session.save()
}

Batch your saves

Calling session.save() after every single node change is extremely slow and can cause OutOfMemoryError on large datasets. Save in batches:

final int SAVE_THRESHOLD = 1000
int changeCount = 0

// inside your loop:
if (!DRY_RUN) {
node.setProperty('updated', true)
changeCount++
if (changeCount % SAVE_THRESHOLD == 0) {
session.save()
out.println("Saved batch at ${changeCount} changes")
}
}

// final save for remaining changes
if (!DRY_RUN && changeCount % SAVE_THRESHOLD != 0) {
session.save()
}

Additional tips

  • Test on a local instance first. Never run an untested script on a shared environment.
  • Use session.refresh(false) to discard pending changes if something goes wrong mid-execution.
  • Add throttling (sleep(100)) in tight loops to avoid overloading the repository on busy instances.
  • Log progress with out.println() so you can monitor long-running scripts in real time.
  • Set a thread timeout in the OSGi configuration to prevent runaway scripts from locking the instance.

Script Examples

Batched SQL2 query (baseline template)

A reusable template that queries nodes with SQL2, iterates in batches, and supports optional mutations guarded by DRY_RUN. Use this as your starting point for almost any bulk operation.

import javax.jcr.Node
import javax.jcr.Session
import javax.jcr.query.Query
import javax.jcr.query.QueryManager
import javax.jcr.query.QueryResult
import javax.jcr.query.RowIterator

// ---------------- Configuration ----------------
final String BASE_PATH = '/content' // Change to your subtree
final String NODE_TYPE = 'cq:Page' // e.g., 'nt:unstructured', 'dam:Asset'
final int BATCH_SIZE = 500
final long THROTTLE_MILLIS = 0L // e.g., 100-250ms to be gentle
final boolean DRY_RUN = true

// ---------------- Helper ----------------
static String safeTitle(Session s, String pagePath) {
try {
String contentPath = pagePath + '/jcr:content'
if (s.nodeExists(contentPath)) {
Node content = s.getNode(contentPath)
return content.hasProperty('jcr:title') ? content.getProperty('jcr:title').string : ''
}
} catch (Throwable ignored) {}
return ''
}

// -------------------- Main --------------------
Session s = session
QueryManager qm = s.workspace.queryManager

String baseSql = """
SELECT p.[jcr:path]
FROM [${NODE_TYPE}] AS p
WHERE ISDESCENDANTNODE(p, '${BASE_PATH}')
ORDER BY p.[jcr:path]
""".stripIndent().trim()

int offset = 0
int scanned = 0
int mutated = 0

while (true) {
Query q = qm.createQuery(baseSql, Query.JCR_SQL2)
q.setLimit(BATCH_SIZE)
q.setOffset(offset)
QueryResult result = q.execute()
RowIterator rows = result.rows
int returned = 0

while (rows.hasNext()) {
def row = rows.nextRow()
returned++
scanned++
String path = row.getPath('p')
Node node = s.getNode(path)

String title = safeTitle(s, path)
out.println("Found: ${path}${title ? " | title='${title}'" : ''}")

if (!DRY_RUN) {
node.setProperty('demo:lastScanned', java.time.Instant.now().toString())
mutated++
}
}

out.println("Scanned: ${scanned} | Batch: ${returned} | Offset: ${offset}")

if (returned < BATCH_SIZE) break
offset += returned
if (THROTTLE_MILLIS > 0) sleep THROTTLE_MILLIS
}

if (!DRY_RUN) s.save()

out.println('----------------------------------------')
out.println("Base path: ${BASE_PATH}")
out.println("Node type: ${NODE_TYPE}")
out.println("Total scanned: ${scanned}")
out.println("Total mutated: ${mutated}")
return scanned

Multi-selector SQL2 join

Find pages that have a specific child node (e.g. pages with a rep:cugPolicy child that has rep:principalNames defined):

String baseSql = """
SELECT p.[jcr:path]
FROM [cq:Page] AS p
INNER JOIN [nt:base] AS c ON ISCHILDNODE(c, p)
WHERE ISDESCENDANTNODE(p, '${BASE_PATH}')
AND NAME(c) = 'rep:cugPolicy'
AND c.[rep:principalNames] IS NOT NULL
ORDER BY p.[jcr:path]
""".stripIndent().trim()

When using more than one selector (p, c), always qualify column names with the selector alias (e.g. p.[jcr:path]).

Bulk update a property on all pages

Find all pages using a specific template and update a property on their jcr:content node:

final String BASE_PATH = '/content/my-site'
final String TEMPLATE = '/conf/my-site/settings/wcm/templates/article-page'
final boolean DRY_RUN = true

int count = 0

getPage(BASE_PATH).recurse { page ->
if (page.properties['cq:template'] == TEMPLATE) {
def content = page.node // jcr:content node
out.println("${page.path} | current hideInNav = ${content.get('hideInNav')}")

if (!DRY_RUN) {
content.set('hideInNav', true)
count++
}
}
}

if (!DRY_RUN) session.save()
out.println("Updated ${count} pages (DRY_RUN=${DRY_RUN})")

Find and replace text in properties

Search for a string across all jcr:content nodes and replace it:

final String BASE_PATH = '/content/my-site'
final String PROPERTY = 'jcr:title'
final String SEARCH = 'Old Brand Name'
final String REPLACE = 'New Brand Name'
final boolean DRY_RUN = true

int count = 0

getPage(BASE_PATH).recurse { page ->
def content = page.node
if (content.get(PROPERTY)?.contains(SEARCH)) {
String oldValue = content.get(PROPERTY)
String newValue = oldValue.replace(SEARCH, REPLACE)
out.println("${page.path}: '${oldValue}' -> '${newValue}'")

if (!DRY_RUN) {
content.set(PROPERTY, newValue)
count++
}
}
}

if (!DRY_RUN) session.save()
out.println("Replaced in ${count} pages (DRY_RUN=${DRY_RUN})")

Find pages by template

List all pages using a given template, grouped by path:

final String BASE_PATH = '/content'
final String TEMPLATE = '/conf/my-site/settings/wcm/templates/homepage'

int count = 0

getPage(BASE_PATH).recurse { page ->
if (page.properties['cq:template'] == TEMPLATE) {
out.println("${page.path} | title: ${page.properties['jcr:title'] ?: 'n/a'}")
count++
}
}

out.println("----------------------------------------")
out.println("Found ${count} pages with template: ${TEMPLATE}")
return count

Find orphaned components

Find component nodes whose sling:resourceType no longer exists in the repository (broken components):

final String BASE_PATH = '/content/my-site'
int orphanCount = 0

getPage(BASE_PATH).recurse { page ->
page.node?.recurse { node ->
String resourceType = node.get('sling:resourceType')
if (resourceType && !resourceResolver.getResource('/apps/' + resourceType)
&& !resourceResolver.getResource('/libs/' + resourceType)) {
out.println("ORPHAN: ${node.path} | resourceType: ${resourceType}")
orphanCount++
}
}
}

out.println("----------------------------------------")
out.println("Found ${orphanCount} orphaned components")
return orphanCount

Activate / deactivate pages

Replicate pages programmatically:

import com.day.cq.replication.ReplicationActionType
import com.day.cq.replication.Replicator

final String BASE_PATH = '/content/my-site/en/news'
final boolean DRY_RUN = true

def replicator = sling.getService(Replicator)
int count = 0

getPage(BASE_PATH).recurse { page ->
// Example: deactivate all pages that are hidden in nav
if (page.properties['hideInNav'] == 'true') {
out.println("Deactivating: ${page.path}")

if (!DRY_RUN) {
replicator.replicate(session, ReplicationActionType.DEACTIVATE, page.path)
count++
}
}
}

out.println("Deactivated ${count} pages (DRY_RUN=${DRY_RUN})")

DAM: find assets by MIME type

import javax.jcr.query.Query

final String BASE_PATH = '/content/dam/my-site'
final String MIME_TYPE = 'application/pdf'

String sql = """
SELECT a.[jcr:path]
FROM [dam:Asset] AS a
INNER JOIN [nt:resource] AS r ON ISDESCENDANTNODE(r, a)
WHERE ISDESCENDANTNODE(a, '${BASE_PATH}')
AND r.[jcr:mimeType] = '${MIME_TYPE}'
ORDER BY a.[jcr:path]
"""

def qm = session.workspace.queryManager
def result = qm.createQuery(sql, Query.JCR_SQL2).execute()
int count = 0

result.rows.each { row ->
out.println(row.getPath('a'))
count++
}

out.println("----------------------------------------")
out.println("Found ${count} assets of type ${MIME_TYPE}")
return count

DAM: update asset metadata

final String BASE_PATH = '/content/dam/my-site'
final String PROPERTY = 'dc:rights'
final String NEW_VALUE = '© 2025 My Company. All rights reserved.'
final boolean DRY_RUN = true

int count = 0

def damRoot = resourceResolver.getResource(BASE_PATH)

damRoot.listChildren().each { child ->
def metadataRes = child.getChild('jcr:content/metadata')
if (metadataRes != null) {
def metadata = metadataRes.adaptTo(javax.jcr.Node)
out.println("${child.path} | current ${PROPERTY}: ${metadata.hasProperty(PROPERTY) ? metadata.getProperty(PROPERTY).string : 'not set'}")

if (!DRY_RUN) {
metadata.setProperty(PROPERTY, NEW_VALUE)
count++
}
}
}

if (!DRY_RUN) session.save()
out.println("Updated ${count} assets (DRY_RUN=${DRY_RUN})")

Delete nodes matching a pattern

Remove all nodes of a specific type or name under a given path:

final String BASE_PATH = '/content/my-site'
final String NODE_NAME_TO_DELETE = 'cq:LiveSyncConfig'
final boolean DRY_RUN = true

int count = 0
def nodesToDelete = []

// Collect first, then delete (avoid ConcurrentModificationException)
session.getNode(BASE_PATH).recurse { node ->
if (node.name == NODE_NAME_TO_DELETE) {
nodesToDelete.add(node.path)
}
}

nodesToDelete.each { path ->
out.println("Deleting: ${path}")
if (!DRY_RUN) {
session.getNode(path).remove()
count++
}
}

if (!DRY_RUN) session.save()
out.println("Deleted ${count} nodes (DRY_RUN=${DRY_RUN})")

Create content pages programmatically

import com.day.cq.wcm.api.Page

final String PARENT_PATH = '/content/my-site/en'
final String TEMPLATE = '/conf/my-site/settings/wcm/templates/content-page'
final boolean DRY_RUN = true

def pages = [
[name: 'about-us', title: 'About Us'],
[name: 'contact', title: 'Contact'],
[name: 'privacy', title: 'Privacy Policy'],
]

int count = 0

pages.each { p ->
String fullPath = "${PARENT_PATH}/${p.name}"
if (session.nodeExists(fullPath)) {
out.println("SKIP (exists): ${fullPath}")
} else {
out.println("CREATE: ${fullPath} | title: ${p.title}")
if (!DRY_RUN) {
pageManager.create(PARENT_PATH, p.name, TEMPLATE, p.title)
count++
}
}
}

if (!DRY_RUN) session.save()
out.println("Created ${count} pages (DRY_RUN=${DRY_RUN})")

Query with QueryBuilder

Use the AEM QueryBuilder API instead of raw SQL2:

import com.day.cq.search.PredicateGroup

def params = [
'path' : '/content/my-site',
'type' : 'cq:Page',
'property' : 'jcr:content/cq:template',
'property.value' : '/conf/my-site/settings/wcm/templates/article-page',
'orderby' : 'path',
'p.limit' : '-1' // all results (use with caution)
]

def query = queryBuilder.createQuery(PredicateGroup.create(params), session)
def result = query.result

out.println("Found ${result.totalMatches} pages")

result.hits.each { hit ->
out.println("${hit.path} | title: ${hit.properties['jcr:content/jcr:title'] ?: 'n/a'}")
}

return result.totalMatches

List OSGi configurations

import org.osgi.service.cm.ConfigurationAdmin

def configAdmin = sling.getService(ConfigurationAdmin)
def configs = configAdmin.listConfigurations(null)

configs?.sort { it.pid }.each { config ->
out.println(config.pid)
}

out.println("----------------------------------------")
out.println("Total configurations: ${configs?.size() ?: 0}")

Audit users, groups, and memberships

List the members of a group, or find every group a user belongs to, via the JCR User Management API:

import org.apache.jackrabbit.api.security.user.Authorizable
import org.apache.jackrabbit.api.security.user.Group
import org.apache.jackrabbit.api.security.user.UserManager
import org.apache.jackrabbit.api.security.user.User

final String GROUP_ID = 'content-authors'

def userManager = resourceResolver.adaptTo(UserManager)
def group = userManager.getAuthorizable(GROUP_ID) as Group

if (group == null) {
out.println("Group not found: ${GROUP_ID}")
return
}

int count = 0
group.getMembers().each { Authorizable member ->
out.println("${member.getID()}${member instanceof Group ? ' (group)' : ''} -> ${member.path}")
count++
}

out.println("----------------------------------------")
out.println("${GROUP_ID} has ${count} direct members")
return count

Start a workflow on a payload

Trigger an AEM workflow model programmatically (e.g. to re-run an approval or publish workflow):

import com.adobe.granite.workflow.WorkflowSession
import com.adobe.granite.workflow.exec.WorkflowData
import com.adobe.granite.workflow.model.WorkflowModel

final String MODEL_ID = '/var/workflow/models/request_for_activation'
final String PAYLOAD = '/content/my-site/en/news'
final boolean DRY_RUN = true

def wfSession = resourceResolver.adaptTo(WorkflowSession)
WorkflowModel model = wfSession.getModel(MODEL_ID)
WorkflowData data = wfSession.newWorkflowData('JCR_PATH', PAYLOAD)

out.println("Model: ${MODEL_ID}")
out.println("Payload: ${PAYLOAD}")

if (!DRY_RUN) {
def workflow = wfSession.startWorkflow(model, data)
out.println("Started workflow instance: ${workflow.id}")
} else {
out.println("DRY_RUN - workflow not started")
}

Export query results to CSV

Stream query results into a CSV table. The Groovy Console renders a table binding as a downloadable grid in the Data tab; you can also build the CSV string yourself and print it:

import javax.jcr.query.Query

final String BASE_PATH = '/content/my-site'

String sql = """
SELECT p.[jcr:path], p.[jcr:content/jcr:title] AS title, p.[jcr:content/cq:template] AS template
FROM [cq:Page] AS p
WHERE ISDESCENDANTNODE(p, '${BASE_PATH}')
ORDER BY p.[jcr:path]
"""

def qm = session.workspace.queryManager
def rows = qm.createQuery(sql, Query.JCR_SQL2).execute().rows

def csv = new StringBuilder("path,title,template\n")
int count = 0

rows.each { row ->
// Quote fields and escape embedded quotes to keep the CSV well-formed
def cells = ['p.jcr:path', 'title', 'template'].collect { col ->
def v = row.getValue(col)?.string ?: ''
'"' + v.replace('"', '""') + '"'
}
csv.append(cells.join(',')).append('\n')
count++
}

out.println(csv.toString())
out.println("----------------------------------------")
out.println("Exported ${count} rows")
return count

:::tip The table binding The Groovy Console exposes a table binding (a list of maps) that renders as a sortable, exportable grid in the Data tab of the result panel - often nicer than printing CSV to the output. Populate it with table = rows.collect { [path: it.getPath('p'), title: ...] }. :::

Count nodes by type

A quick repository health check - how many nodes of each primary type live under a path:

final String BASE_PATH = '/content/my-site'
def counts = [:].withDefault { 0 }

session.getNode(BASE_PATH).recurse { node ->
counts[node.primaryNodeType.name]++
}

counts.sort { -it.value }.each { type, n ->
out.println("${n.toString().padLeft(8)} ${type}")
}
return counts.values().sum()

Maintenance recipes

Ready-to-adapt scripts for the audits and clean-ups teams run most. All mutating recipes are guarded with DRY_RUN -- run once to report, then flip to false.

Scans page content for properties that look like internal paths and reports any that no longer resolve (common after a content move or a botched rollout):

final String BASE_PATH = '/content/my-site'
int broken = 0

getPage(BASE_PATH).recurse { page ->
page.node?.recurse { node ->
// node.properties is a JCR PropertyIterator of javax.jcr.Property objects
node.properties.each { prop ->
// Only check single-value string properties that look like a content/dam path
if (prop.multiple || prop.type != javax.jcr.PropertyType.STRING) return
String value = prop.string
if (value.startsWith('/content/')) {
// Strip selectors/extensions and anchors before resolving
String path = value.replaceAll(/[?#].*$/, '').replaceAll(/\.[^\/.]+$/, '')
if (!session.nodeExists(path) && !session.nodeExists(value)) {
out.println("BROKEN: ${node.path}@${prop.name} -> ${value}")
broken++
}
}
}
}
}

out.println('----------------------------------------')
out.println("Broken internal references: ${broken}")
return broken

Find orphaned (unreferenced) DAM assets

Lists assets under a folder that nothing references. Reference lookups are expensive, so scope the BASE_PATH tightly and run off-hours:

import com.day.cq.dam.api.Asset

final String BASE_PATH = '/content/dam/my-site'
int orphans = 0

resourceResolver.getResource(BASE_PATH).listChildren().each { child ->
def asset = child.adaptTo(Asset.class)
if (asset == null) return

// There is no QueryBuilder "any property" predicate, so use a full-text phrase
// search for the asset path anywhere under /content as a reference heuristic.
def params = [
'path' : '/content',
'fulltext': '"' + child.path + '"',
'p.limit' : '1'
]
def result = queryBuilder.createQuery(
com.day.cq.search.PredicateGroup.create(params), session).result

if (result.totalMatches == 0) {
out.println("POSSIBLE ORPHAN: ${child.path}")
orphans++
}
}

out.println('----------------------------------------')
out.println("Possible orphans (no full-text reference found): ${orphans}")
return orphans

:::warning "Unreferenced" is not "safe to delete" This is a full-text heuristic: it can miss references (external systems, hardcoded URLs, rich-text HTML, paths split across properties) and is only as good as the search index. For an authoritative answer, enumerate the known reference properties or use com.day.cq.wcm.api.reference.ReferenceSearch. Always review the list (and back up) before deleting. :::

Permission report for a path

Dumps the access control entries (ACEs) on a path -- useful for answering "who can edit this?":

import javax.jcr.security.AccessControlManager
import javax.jcr.security.AccessControlList
import javax.jcr.security.AccessControlPolicy

final String PATH = '/content/my-site'

AccessControlManager acm = session.accessControlManager
AccessControlPolicy[] policies = acm.getPolicies(PATH)

if (policies.length == 0) {
out.println("No ACLs set directly on ${PATH} (permissions are inherited)")
}

policies.each { policy ->
if (policy instanceof AccessControlList) {
policy.accessControlEntries.each { ace ->
String privileges = ace.privileges.collect { it.name }.join(', ')
out.println("${ace.principal.name}: ${privileges}")
}
}
}
return policies.length

Purge old page versions

Removes page versions older than a cutoff while keeping the most recent ones, reclaiming repository space. Version operations are irreversible -- keep DRY_RUN = true until you are sure:

import javax.jcr.version.VersionManager
import javax.jcr.version.VersionHistory
import javax.jcr.version.Version

final String BASE_PATH = '/content/my-site'
final int KEEP_LAST = 5
final boolean DRY_RUN = true

VersionManager vm = session.workspace.versionManager
int removed = 0

getPage(BASE_PATH).recurse { page ->
String contentPath = page.path + '/jcr:content'
if (!session.nodeExists(contentPath)) return
def node = session.getNode(contentPath)
if (!node.isNodeType('mix:versionable')) return

VersionHistory history = vm.getVersionHistory(contentPath)
def versions = []
def it = history.allVersions
while (it.hasNext()) {
Version v = it.nextVersion()
if (v.name != 'jcr:rootVersion') versions << v
}
// Oldest first; drop everything except the last KEEP_LAST
versions.sort { it.created.timeInMillis }
def toRemove = versions.size() > KEEP_LAST ? versions[0..<(versions.size() - KEEP_LAST)] : []

toRemove.each { Version v ->
out.println("${DRY_RUN ? 'WOULD REMOVE' : 'REMOVING'}: ${contentPath} version ${v.name}")
if (!DRY_RUN) {
history.removeVersion(v.name)
removed++
}
}
}

out.println('----------------------------------------')
out.println("Versions removed: ${removed} (DRY_RUN=${DRY_RUN})")
return removed

Replication queue report

Reports the status and queue size of every replication agent on this instance (AEM 6.5 / AMS -- on AEMaaCS distribution is Adobe-managed and has no agents):

import com.day.cq.replication.AgentManager
import com.day.cq.replication.Agent

def agentManager = sling.getService(AgentManager)

agentManager.agents.values().each { Agent agent ->
if (!agent.isValid() || !agent.isEnabled()) {
out.println("${agent.id}: disabled/invalid")
return
}
def queue = agent.queue
int blocked = queue.isPaused() ? 1 : 0
out.println("${agent.id}: ${queue.entries().size()} queued${queue.isPaused() ? ' (PAUSED)' : ''}")
}
return agentManager.agents.size()

Scheduling Scripts

The Groovy Console includes a built-in scheduler for running scripts on a cron schedule or asynchronously. Scripts run as Sling Jobs and are audited in the same way as interactive executions.

To schedule a script:

  1. Save your script in the console (it will be stored under /conf/groovyconsole/scripts)
  2. Open the Scheduler tab in the console UI
  3. Select the saved script and set either an immediate (async) execution or a Cron expression

Remote / batch execution via cURL

Saved scripts can also be triggered via a POST request, which is useful for CI/CD pipelines or automated maintenance windows:

# Local SDK / dev only -- production instances should authenticate with a dedicated service
# account and rotated credentials pulled from CI secrets, not admin:admin.

# Execute a single script
curl -d "scriptPath=/conf/groovyconsole/scripts/samples/JcrSearch.groovy" \
-X POST -u admin:admin \
http://localhost:4502/bin/groovyconsole/post.json

# Execute multiple scripts sequentially
curl -d "scriptPaths=/conf/groovyconsole/scripts/cleanup.groovy&scriptPaths=/conf/groovyconsole/scripts/reindex.groovy" \
-X POST -u admin:admin \
http://localhost:4502/bin/groovyconsole/post.json
warning

Protect the /bin/groovyconsole/* path with Dispatcher rules and authentication. Anyone who can reach this endpoint can execute arbitrary code on your AEM instance.

Extensions

The Groovy Console can be extended with custom bindings, metaclasses, and compilation customizers via OSGi services:

Extension InterfacePurpose
BindingExtensionProviderAdd custom variables (e.g. project-specific helpers) to every script execution
CompilationCustomizerExtensionProviderRestrict or enhance language features, provide AST transformations
ScriptMetaClassExtensionProviderAdd methods to the script class at runtime (e.g. node.doSomething())
StarImportExtensionProviderProvide additional star imports for the compiler
NotificationServiceCustom notifications after script execution (e.g. Slack, Teams)

Example: adding a custom binding that provides a projectHelper object to all scripts:

core/src/main/java/com/example/core/groovy/ProjectBindingProvider.java
import be.orbinson.aem.groovy.console.api.BindingExtensionProvider;
import be.orbinson.aem.groovy.console.api.BindingVariable;
import org.osgi.service.component.annotations.Component;

@Component(service = BindingExtensionProvider.class, immediate = true)
public class ProjectBindingProvider implements BindingExtensionProvider {

@Override
public Map<String, BindingVariable> getBindingVariables() {
Map<String, BindingVariable> variables = new LinkedHashMap<>();
variables.put("projectName", new BindingVariable("my-project", String.class));
return variables;
}
}

Dispatcher Configuration

If you need the Groovy Console accessible through the Dispatcher (e.g. on a secured publish instance for debugging):

dispatcher.any -- filter section
# Allow Groovy Console page
/001 { /type "allow" /url "/groovyconsole" }
/002 { /type "allow" /url "/apps/groovyconsole.html" }

# Allow servlets
/003 { /type "allow" /path "/bin/groovyconsole/*" }

Troubleshooting

SymptomLikely causeFix
Script is killed after N secondsthreadTimeout in the OSGi config is setIncrease or set threadTimeout=0 (no limit) for legitimately long jobs
OutOfMemoryError / instance grinds to a haltOne giant session.save() after millions of changes, or loading all results into memorySave in batches (see Batch your saves) and iterate query results instead of collecting them
AccessDeniedException / PathNotFoundExceptionThe console runs as the current user, who may lack write access to the pathRun as a user in allowedGroups with the right ACLs; never grant blanket admin to author groups
Changes "didn't take"You forgot session.save() / resourceResolver.commit(), or DRY_RUN was still trueConfirm the commit runs and the flag is flipped
ConcurrentModificationException while deletingRemoving nodes while iterating the same treeCollect paths first, then delete in a second loop (see Delete nodes matching a pattern)
Need to abandon mid-run changesPending transient changes are in the sessionsession.refresh(false) discards uncommitted changes
Script not visible in SchedulerIt was never savedSave the script first; scheduled scripts live under /conf/groovyconsole/scripts

When a bulk script goes wrong, the fastest recovery is usually a content-package restore of the affected subtree - take a Package Manager backup before running anything destructive on a shared environment.

See also