import { path, Plugin, Shape, FileSystem, NowConfig, resolveAiuxSdkBuild } from '@servicenow/sdk-build-core'
import { JsonFileShape } from '../json-plugin'
import { generateId } from '../static-content-plugin'
import {
createAttachmentRecords,
SOURCE_ARTIFACT_RELATIONSHIPS,
createArtifactRecord,
createArtifactM2mRecord,
serializeWithArtifact,
} from '../source-artifact-utils'
import type { AiuxRecordMetadata } from './aiux-types'
import { collectSourceArtifactFiles } from './aiux-source-files'
const AIUX_ENTRY_POINT = 'aiux.json'
/**
* Filters metadata directory entries to only actionable per-record JSON files. Files whose name
* starts with an underscore (e.g. `_project.json`) are treated as reserved for other purposes, and skipped.
*/
export function isJsonFileForProcessingToXml(name: string): boolean {
return name.endsWith('.json') && !name.startsWith('_')
}
// Per-record metadata JSON files are written by aiux-sdk's writeMetadataJson during the
// prebuild step (run via now.prebuild.mjs in the aiux layer, or default-ui-build.ts when
// no custom prebuild is configured). File naming convention: `
_.json`.
// Per-record files keep individual payloads small and let JSON parse failures be isolated
// to a single record rather than the whole build. The output directory is owned by aiux-sdk
// and resolved at build time via getAiuxMetadataOutputDir() so both layers stay in sync.
const SKIP_SERIALIZED_KEYS = new Set(['sys_id', 'sys_scope', 'sys_update_name'])
// A dedicated sys_module created solely as the m2m anchor for the AIUX source artifact.
// It is distinct from the package.json and bom.json sys_module records the build
// generates implicitly, so attaching source here never affects those.
const AIUX_SOURCE_MODULE_ID = 'aiux_source'
const AIUX_SOURCE_MODULE_FILE = 'aiux-source.js'
const noOpToShape = {
toShape(record: Parameters[0]) {
return { success: true as const, value: Shape.noOp(record) }
},
}
export const AiuxPlugin = Plugin.create({
name: 'AiuxPlugin',
files: [
{
entryPoint: true,
matcher: (currentPath: string) => path.basename(currentPath) === AIUX_ENTRY_POINT,
},
],
records: {
sys_aix_widget: noOpToShape,
sys_aix_widget_instance: noOpToShape,
sys_aix_entity_widget_mapping: noOpToShape,
sys_aix_page: noOpToShape,
sys_aix_page_route_map: noOpToShape,
sys_aix_experience: noOpToShape,
sys_module: {
relationships: SOURCE_ARTIFACT_RELATIONSHIPS,
async toFile(record, { database, descendants, config, transform }) {
const m2ms = descendants.query('sn_glider_source_artifact_m2m')
if (m2ms.length === 0) {
// Not the AIUX source-artifact anchor module (e.g. the package.json
// or bom.json modules); defer to the default serializer.
return { success: false }
}
const metadataProps = [...record.keys()].filter((k) => !SKIP_SERIALIZED_KEYS.has(k))
return {
success: true,
value: await serializeWithArtifact(record, metadataProps, {
database,
descendants,
config,
transform,
}),
}
},
},
sys_aix_experience_properties: noOpToShape,
sys_aix_experience_page_rel: noOpToShape,
sys_aix_app_shell: noOpToShape,
sys_aix_menu: noOpToShape,
sys_aix_menu_item: noOpToShape,
sys_aix_menu_item_category: noOpToShape,
sys_aix_layout: noOpToShape,
sys_aix_theme: noOpToShape,
sys_aix_color_swatch: noOpToShape,
sys_aix_m2m_experience_theme: noOpToShape,
sys_aix_dashboard: noOpToShape,
sys_aix_dashboard_item: noOpToShape,
sys_aix_dashboard_personalization_item: noOpToShape,
sys_aix_m2m_experience_dashboard: noOpToShape,
sys_aix_container: noOpToShape,
sys_aix_dependency: noOpToShape,
sys_aix_dependency_bundle: noOpToShape,
sys_aix_m2m_widget_dependency: noOpToShape,
sys_aix_m2m_widget_dependency_bundle: noOpToShape,
sys_aix_m2m_bundle_dependency: noOpToShape,
},
shapes: [
{
shape: JsonFileShape,
async toRecord(shape, context) {
if (path.basename(shape.getPath()) !== AIUX_ENTRY_POINT) {
return { success: false }
}
const { factory, project, logger, diagnostics, fs, config, packageJson } = context
const projectRoot = project.getRootDir()
// Use a plain object as the record source instead of the SourceFileShape.
// This prevents records from being tied to aiux.json's AST — the transform
// command would otherwise call remove() on JSON AST nodes and crash because
// the entire JSON content is a unary chain to the SourceFile root.
const fileSource = { path: shape.getPath(), content: shape.getContent() }
const { getAiuxMetadataOutputDir } = await resolveAiuxSdkBuild(projectRoot)
const metadataDir = getAiuxMetadataOutputDir({ fs, projectRoot })
if (!FileSystem.existsSync(fs, metadataDir)) {
diagnostics.warn(
shape.getJson(),
`Detected ${AIUX_ENTRY_POINT} but no AIUX prebuild output found at ${metadataDir}.\n` +
`Configure a prebuild step (now.prebuild.mjs or scripts.prebuild) that runs @servicenow/aiux's build pipeline.`
)
return { success: false }
}
const metadataFiles = fs
.readdirSync(metadataDir)
.filter((name: string) => isJsonFileForProcessingToXml(name))
const allMetadata: AiuxRecordMetadata[] = []
for (const name of metadataFiles) {
const filePath = path.join(metadataDir, name)
try {
const content = fs.readFileSync(filePath, { encoding: 'utf-8' }).toString()
allMetadata.push(JSON.parse(content) as AiuxRecordMetadata)
} catch (e) {
// Isolate a single malformed file rather than failing the whole build
diagnostics.error(
shape.getJson(),
`Failed to parse AIUX metadata file ${filePath}: ${e instanceof Error ? e.message : String(e)}`
)
}
}
const records = []
for (const metadata of allMetadata) {
const { table, fields, explicitSysId, nowIdKey, demo_install, fluentPlugin } = metadata
// fluentPlugin is not yet implemented, but there is a future planned use case
// so this is here to provide backwards compat behavior early. Records that omit
// the fluentPlugin field (or set it to 'Record') will be processed as before;
// any other value is something we can't handle yet, so the record is skipped.
// This will probably build out into a handful of switch statements over time as
// more fluentPlugin values become supported.
if (fluentPlugin && fluentPlugin !== 'Record') {
continue
}
const sysId = explicitSysId || generateId(config.scope, nowIdKey)
const properties: Record = { sys_id: sysId }
for (const [key, field] of Object.entries(fields)) {
if ('isRef' in field) {
const guid = field.nowIdKey ?? field.explicitSysId
if (guid) {
properties[key] = await factory.createReference({
source: fileSource,
table: field.table,
guid,
})
}
continue
}
properties[key] =
'isCdata' in field
? Shape.from(fileSource, field.value).asString().withContentType('cdata')
: field.value
}
const record = await factory.createRecord({
source: fileSource,
table,
installCategory: demo_install ? 'unload.demo' : 'update',
explicitId: nowIdKey,
properties,
})
records.push(record)
}
// --- App source artifact (zip) ---
// The source zip is attached to a dedicated sys_module that exists solely as
// the m2m anchor for the artifact, separate from the package.json and bom.json
// sys_module records the build generates implicitly.
//
// Built only when packageSourceCodeOnInstance is explicitly true in
// now.config.json. When the flag is missing or false (the default), the
// artifact is skipped.
if (config.packageSourceCodeOnInstance !== true) {
logger.debug('packageSourceCodeOnInstance is not true; skipping source artifact zip.')
} else {
try {
const artifact = await collectSourceArtifactFiles(fs, {
metadataDir,
projectDir: path.dirname(shape.getPath()),
useNowPackIgnore: true,
logger,
})
if (artifact) {
const { files: attachmentFiles, totalSize, skippedFiles } = artifact
for (const warning of skippedFiles) {
diagnostics.warn(shape.getJson(), warning)
}
if (attachmentFiles.size > 0) {
const sourceModulePath = NowConfig.moduleResolutionPath(
config,
packageJson,
false,
AIUX_SOURCE_MODULE_FILE
)
const sourceModuleRecord = await factory.createRecord({
source: fileSource,
table: 'sys_module',
// explicitId registers the record in keys.ts; the deterministic
// sys_id below is what the factory uses as the guidOverride, so
// the id stays stable across clean rebuilds.
explicitId: AIUX_SOURCE_MODULE_ID,
properties: {
sys_id: generateId(config.scope, 'sys_module', AIUX_SOURCE_MODULE_ID),
path: sourceModulePath,
sys_name: sourceModulePath,
external_source: false,
content: Shape.from(
fileSource,
'/* AIUX source artifact anchor module. Do not import. */\n'
)
.asString()
.withContentType('cdata'),
},
})
const artifactName = `aiux-source ${config.scopeId}`
const artifactRecord = await createArtifactRecord(
artifactName,
sourceModuleRecord,
factory
)
const attachmentRecords = await createAttachmentRecords(
factory,
artifactRecord,
attachmentFiles,
totalSize
)
const m2mRecord = await createArtifactM2mRecord(
artifactRecord,
sourceModuleRecord,
factory
)
records.push(sourceModuleRecord, artifactRecord, ...attachmentRecords, m2mRecord)
const totalSizeMB = (totalSize / (1024 * 1024)).toFixed(2)
logger.info(
`Bundled AIUX project source code into a zip ` +
`(${attachmentFiles.size} files, ${totalSizeMB} MB). `
)
}
}
} catch (e) {
diagnostics.error(
shape.getJson(),
`Failed to build AIUX source artifact: ${e instanceof Error ? e.message : String(e)}`
)
}
}
logger.debug(`AiuxPlugin: produced ${records.length} records from ${projectRoot}`)
const first = records[0]
if (!first) {
return { success: false }
}
const rest = records.slice(1)
return { success: true, value: first.with(...rest) }
},
},
],
})