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
Orbinson fork (recommended)
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:
<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:
<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:
<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:
<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>
<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:
{
"allowedGroups": [
"administrators"
],
"allowedScheduledJobsGroups": [
"administrators"
],
"auditDisabled": false,
"emailEnabled": false,
"threadTimeout": 0
}
| Property | Description | Default |
|---|---|---|
allowedGroups | Groups authorized to execute scripts. Only admin user has access by default | [] |
allowedScheduledJobsGroups | Groups authorized to schedule jobs | [] |
auditDisabled | Disable auditing of script execution history | false |
displayAllAuditRecords | Show audit records from all users (not just current) | false |
emailEnabled | Send email notifications on script completion | false |
emailRecipients | Email addresses to notify | [] |
threadTimeout | Seconds before a script is interrupted (0 = no timeout) | 0 |
distributedExecutionEnabled | Replicate and execute script on all default replication agents | false |
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:
| Variable | Type | Description |
|---|---|---|
session | javax.jcr.Session | The current JCR session |
resourceResolver | ResourceResolver | Sling resource resolver for the current user |
pageManager | PageManager | AEM page management API |
queryBuilder | QueryBuilder | AEM QueryBuilder API |
bundleContext | BundleContext | OSGi bundle context for accessing services |
log | Logger | SLF4J logger (output appears in error.log) |
out | PrintWriter | Writes output to the console result panel |
sling | SlingScriptHelper | Access 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.
Find broken internal links
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:
- Save your script in the console (it will be stored under
/conf/groovyconsole/scripts) - Open the Scheduler tab in the console UI
- 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
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 Interface | Purpose |
|---|---|
BindingExtensionProvider | Add custom variables (e.g. project-specific helpers) to every script execution |
CompilationCustomizerExtensionProvider | Restrict or enhance language features, provide AST transformations |
ScriptMetaClassExtensionProvider | Add methods to the script class at runtime (e.g. node.doSomething()) |
StarImportExtensionProvider | Provide additional star imports for the compiler |
NotificationService | Custom notifications after script execution (e.g. Slack, Teams) |
Example: adding a custom binding that provides a projectHelper object to all scripts:
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):
# 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
| Symptom | Likely cause | Fix |
|---|---|---|
| Script is killed after N seconds | threadTimeout in the OSGi config is set | Increase or set threadTimeout=0 (no limit) for legitimately long jobs |
OutOfMemoryError / instance grinds to a halt | One giant session.save() after millions of changes, or loading all results into memory | Save in batches (see Batch your saves) and iterate query results instead of collecting them |
AccessDeniedException / PathNotFoundException | The console runs as the current user, who may lack write access to the path | Run 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 true | Confirm the commit runs and the flag is flipped |
ConcurrentModificationException while deleting | Removing nodes while iterating the same tree | Collect paths first, then delete in a second loop (see Delete nodes matching a pattern) |
| Need to abandon mid-run changes | Pending transient changes are in the session | session.refresh(false) discards uncommitted changes |
| Script not visible in Scheduler | It was never saved | Save 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
- Architecture
- Modify and query the JCR
- Node operations
- Replication and activation
- Sling Models
- Workflows
- OSGi Configuration
- Security basics
- Orbinson Groovy Console (GitHub)
- Orbinson Groovy Console documentation
- CID15 Groovy Console (legacy)
- Sample scripts (GitHub)
- Apache Groovy documentation
- AEM QueryBuilder Predicate Reference (Experience League)
- JCR Query (JCR-SQL2) -- Apache Jackrabbit Oak Query docs
- JCR 2.0 specification (JSR-283)