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) } }, }, ], })