configuration-management 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +199 -0
  2. package/actions/configurations/commerce/index.js +55 -0
  3. package/actions/configurations/export-config/index.js +259 -0
  4. package/actions/configurations/ext.config.yaml +151 -0
  5. package/actions/configurations/import-config/index.js +544 -0
  6. package/actions/configurations/registration/index.js +37 -0
  7. package/actions/configurations/sync-store-mappings-from-commerce/index.js +199 -0
  8. package/actions/configurations/system-config-list/index.js +127 -0
  9. package/actions/configurations/system-config-save/index.js +160 -0
  10. package/actions/configurations/system-config-schema/index.js +327 -0
  11. package/actions/utils.js +73 -0
  12. package/package.json +74 -0
  13. package/scripts/setup-app-config.js +114 -0
  14. package/src/abdb-config.js +241 -0
  15. package/src/abdb-helper.js +476 -0
  16. package/src/index.js +20 -0
  17. package/src/oauth1a.js +135 -0
  18. package/src/system-config-crypto.js +113 -0
  19. package/src/system-config-shared.js +89 -0
  20. package/web/src/components/App.js +47 -0
  21. package/web/src/components/AppSectionNav.js +49 -0
  22. package/web/src/components/ExtensionRegistration.js +33 -0
  23. package/web/src/components/MainPage.js +46 -0
  24. package/web/src/components/SystemConfig.js +1464 -0
  25. package/web/src/components/SystemConfigSchemaEditor.js +459 -0
  26. package/web/src/hooks/useConfirm.js +355 -0
  27. package/web/src/hooks/useSystemConfig.js +238 -0
  28. package/web/src/hooks/useSystemConfigSchema.js +102 -0
  29. package/web/src/index.js +41 -0
  30. package/web/src/schema/systemConfigSchema.js +82 -0
  31. package/web/src/settings.js +57 -0
  32. package/web/src/styles/index.css +326 -0
  33. package/web/src/theme.js +104 -0
  34. package/web/src/utils/storeMappingsFromCommerceRest.js +73 -0
  35. package/web/src/utils.js +52 -0
@@ -0,0 +1,327 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ */
7
+
8
+ const { Core } = require('@adobe/aio-sdk')
9
+ const { errorResponse, checkMissingRequestInputs } = require('../../utils')
10
+ const { getClient } = require('configuration-management/abdb')
11
+
12
+ const COLLECTION = 'system_config_schema'
13
+ const DOC_ID = 'v1'
14
+ const FIELD_TYPES = new Set(['text', 'textarea', 'password', 'number', 'select', 'boolean'])
15
+ const SCOPES = ['default', 'websites', 'stores']
16
+ const ID_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/
17
+
18
+ function emptySchema () {
19
+ return { sections: [] }
20
+ }
21
+
22
+ function isString (v) {
23
+ return typeof v === 'string'
24
+ }
25
+
26
+ function validateField (field, ctx) {
27
+ if (!field || typeof field !== 'object') throw new Error(`${ctx}: field must be an object`)
28
+ if (!isString(field.id) || !ID_RE.test(field.id)) {
29
+ throw new Error(`${ctx}: invalid field id "${field.id}"`)
30
+ }
31
+ if (!isString(field.label) || !field.label.trim()) {
32
+ throw new Error(`${ctx}.${field.id}: label is required`)
33
+ }
34
+ if (!FIELD_TYPES.has(field.type)) {
35
+ throw new Error(`${ctx}.${field.id}: unknown field type "${field.type}"`)
36
+ }
37
+ if (!Array.isArray(field.showIn) || field.showIn.length === 0) {
38
+ throw new Error(`${ctx}.${field.id}: showIn must be a non-empty array`)
39
+ }
40
+ for (const s of field.showIn) {
41
+ if (!SCOPES.includes(s)) {
42
+ throw new Error(`${ctx}.${field.id}: invalid scope "${s}" in showIn`)
43
+ }
44
+ }
45
+ if (field.type === 'select') {
46
+ if (!Array.isArray(field.options) || field.options.length === 0) {
47
+ throw new Error(`${ctx}.${field.id}: select field requires options[]`)
48
+ }
49
+ for (const opt of field.options) {
50
+ if (!opt || !isString(opt.value) || !isString(opt.label)) {
51
+ throw new Error(`${ctx}.${field.id}: each option needs string value & label`)
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ function validateSchema (schema) {
58
+ if (!schema || typeof schema !== 'object') throw new Error('schema must be an object')
59
+ if (!Array.isArray(schema.sections)) throw new Error('schema.sections must be an array')
60
+ const seenSection = new Set()
61
+ for (const section of schema.sections) {
62
+ if (!isString(section.id) || !ID_RE.test(section.id)) {
63
+ throw new Error(`section id "${section.id}" is invalid`)
64
+ }
65
+ if (seenSection.has(section.id)) throw new Error(`duplicate section id "${section.id}"`)
66
+ seenSection.add(section.id)
67
+ if (!isString(section.label) || !section.label.trim()) {
68
+ throw new Error(`section "${section.id}": label required`)
69
+ }
70
+ if (!Array.isArray(section.groups)) throw new Error(`section "${section.id}": groups must be array`)
71
+ const seenGroup = new Set()
72
+ for (const group of section.groups) {
73
+ if (!isString(group.id) || !ID_RE.test(group.id)) {
74
+ throw new Error(`section ${section.id}: group id "${group.id}" is invalid`)
75
+ }
76
+ if (seenGroup.has(group.id)) {
77
+ throw new Error(`section ${section.id}: duplicate group id "${group.id}"`)
78
+ }
79
+ seenGroup.add(group.id)
80
+ if (!isString(group.label) || !group.label.trim()) {
81
+ throw new Error(`${section.id}.${group.id}: label required`)
82
+ }
83
+ if (!Array.isArray(group.fields)) {
84
+ throw new Error(`${section.id}.${group.id}: fields must be array`)
85
+ }
86
+ const seenField = new Set()
87
+ for (const field of group.fields) {
88
+ validateField(field, `${section.id}.${group.id}`)
89
+ if (seenField.has(field.id)) {
90
+ throw new Error(`${section.id}.${group.id}: duplicate field id "${field.id}"`)
91
+ }
92
+ seenField.add(field.id)
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Ensure the system_config_schema collection exists. ABDB will throw if
100
+ * createCollection is called for an existing collection; the helper swallows
101
+ * the duplicate error like ensureImportCollectionsExist does.
102
+ */
103
+ async function ensureCollection (client) {
104
+ try {
105
+ await client.createCollection(COLLECTION)
106
+ } catch (err) {
107
+ const msg = (err && err.message) ? String(err.message) : String(err)
108
+ if (!/exist|already|duplicate/i.test(msg)) throw err
109
+ }
110
+ }
111
+
112
+ /**
113
+ * ABDB's `findOne` throws "Document not found" on a miss in this driver
114
+ * version. `find().limit(1).toArray()` always returns an array, so use that
115
+ * to mean "fetch one or null". Belt-and-braces try/catch in case `find`
116
+ * itself starts throwing in a future version.
117
+ */
118
+ async function tryFindOne (collection, query) {
119
+ try {
120
+ const arr = await collection.find(query).limit(1).toArray()
121
+ return arr && arr.length ? arr[0] : null
122
+ } catch (err) {
123
+ const msg = (err && err.message) ? String(err.message) : String(err)
124
+ if (/not found/i.test(msg)) return null
125
+ throw err
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Flatten a schema to the set of fully-qualified field paths it defines.
131
+ */
132
+ function pathsInSchema (schema) {
133
+ const out = new Set()
134
+ if (!schema || !Array.isArray(schema.sections)) return out
135
+ for (const section of schema.sections) {
136
+ if (!section || !Array.isArray(section.groups)) continue
137
+ for (const group of section.groups) {
138
+ if (!group || !Array.isArray(group.fields)) continue
139
+ for (const field of group.fields) {
140
+ if (field && field.id) {
141
+ out.add(`${section.id}/${group.id}/${field.id}`)
142
+ }
143
+ }
144
+ }
145
+ }
146
+ return out
147
+ }
148
+
149
+ /**
150
+ * Delete every system_config_data document whose `path` is in the given set,
151
+ * across all scopes. Uses deleteMany when available, falls back to per-doc
152
+ * deletes if the driver doesn't support $in or deleteMany.
153
+ */
154
+ async function cascadeDeleteData (client, removedPaths, logger) {
155
+ if (!removedPaths || removedPaths.size === 0) {
156
+ return { deletedCount: 0, deletedPaths: [] }
157
+ }
158
+ const dataCollection = await client.collection('system_config_data')
159
+ const paths = [...removedPaths]
160
+ let deletedCount = 0
161
+ try {
162
+ const res = await dataCollection.deleteMany({ path: { $in: paths } })
163
+ deletedCount = (res && (res.deletedCount ?? res.deleted ?? 0)) || 0
164
+ } catch (e) {
165
+ logger.warn(`deleteMany unsupported (${e.message}); falling back to find+deleteOne`)
166
+ for (const path of paths) {
167
+ try {
168
+ const docs = await dataCollection.find({ path }).toArray()
169
+ for (const doc of docs) {
170
+ await dataCollection.deleteOne({ _id: doc._id })
171
+ deletedCount++
172
+ }
173
+ } catch (innerErr) {
174
+ logger.warn(`Failed to delete docs for path ${path}: ${innerErr.message}`)
175
+ }
176
+ }
177
+ }
178
+ logger.info(`Cascade-deleted ${deletedCount} document(s) for paths: ${paths.join(', ')}`)
179
+ return { deletedCount, deletedPaths: paths }
180
+ }
181
+
182
+ async function main (params) {
183
+ const logger = Core.Logger('system-config-schema', { level: params.LOG_LEVEL || 'info' })
184
+
185
+ const errorMessage = checkMissingRequestInputs(params, ['operation'], [])
186
+ if (errorMessage) return errorResponse(400, errorMessage, logger)
187
+
188
+ let dbHandle
189
+ try {
190
+ dbHandle = await getClient(params)
191
+ } catch (e) {
192
+ logger.error(`ABDB connect failed: ${e.message}`)
193
+ return errorResponse(500, `ABDB connect failed: ${e.message}`, logger)
194
+ }
195
+ const { client, close } = dbHandle
196
+
197
+ try {
198
+ const op = params.operation
199
+ await ensureCollection(client)
200
+ const collection = await client.collection(COLLECTION)
201
+
202
+ if (op === 'get') {
203
+ const doc = await tryFindOne(collection, { _id: DOC_ID })
204
+ const schema = (doc && doc.schema) || emptySchema()
205
+ return {
206
+ statusCode: 200,
207
+ body: { message: 'Schema fetched', schema }
208
+ }
209
+ }
210
+
211
+ if (op === 'save') {
212
+ if (!params.schema) {
213
+ return errorResponse(400, 'schema is required', logger)
214
+ }
215
+ try {
216
+ validateSchema(params.schema)
217
+ } catch (e) {
218
+ return errorResponse(400, `Invalid schema: ${e.message}`, logger)
219
+ }
220
+ const now = new Date().toISOString()
221
+ const existing = await tryFindOne(collection, { _id: DOC_ID })
222
+
223
+ // Detect removed paths against the previously stored schema so we can
224
+ // cascade-delete their values from system_config_data after the save.
225
+ const prevPaths = pathsInSchema(existing && existing.schema)
226
+ const nextPaths = pathsInSchema(params.schema)
227
+ const removedPaths = new Set(
228
+ [...prevPaths].filter((p) => !nextPaths.has(p))
229
+ )
230
+
231
+ // Caller must explicitly opt into the cascade by passing
232
+ // `confirmCascade: true` (or the equivalent string). Without it we
233
+ // refuse to save when removals are detected so the UI can confirm
234
+ // with the user first.
235
+ const cascadeConfirmed =
236
+ params.confirmCascade === true || params.confirmCascade === 'true'
237
+ if (removedPaths.size > 0 && !cascadeConfirmed) {
238
+ return {
239
+ statusCode: 409,
240
+ body: {
241
+ error: 'Schema removes paths — confirmation required',
242
+ removedPaths: [...removedPaths]
243
+ }
244
+ }
245
+ }
246
+
247
+ let writeResult
248
+ if (existing) {
249
+ writeResult = await collection.updateOne(
250
+ { _id: DOC_ID },
251
+ { $set: { schema: params.schema, updatedAt: now } }
252
+ )
253
+ } else {
254
+ writeResult = await collection.insertOne({
255
+ _id: DOC_ID,
256
+ schema: params.schema,
257
+ createdAt: now,
258
+ updatedAt: now
259
+ })
260
+ }
261
+ logger.info(
262
+ `Schema saved (existing=${!!existing}, removed=${removedPaths.size}, result=${JSON.stringify(writeResult)})`
263
+ )
264
+
265
+ // Round-trip verify so silent no-op writes surface immediately.
266
+ const verify = await tryFindOne(collection, { _id: DOC_ID })
267
+ if (!verify || !verify.schema) {
268
+ return errorResponse(
269
+ 500,
270
+ 'Schema write completed but document is missing — check ABDB region/permissions',
271
+ logger
272
+ )
273
+ }
274
+
275
+ // Cascade delete only AFTER the schema upsert verifies — we don't
276
+ // want to lose data if the schema write fails.
277
+ let cascade = { deletedCount: 0, deletedPaths: [] }
278
+ if (removedPaths.size > 0) {
279
+ cascade = await cascadeDeleteData(client, removedPaths, logger)
280
+ }
281
+
282
+ return {
283
+ statusCode: 200,
284
+ body: {
285
+ message: 'Schema saved',
286
+ schema: verify.schema,
287
+ removedPaths: [...removedPaths],
288
+ deletedCount: cascade.deletedCount
289
+ }
290
+ }
291
+ }
292
+
293
+ if (op === 'reset') {
294
+ // Reset wipes the schema doc; cascade-delete every stored value so the
295
+ // store doesn't keep orphan rows pointing at nothing.
296
+ const existing = await tryFindOne(collection, { _id: DOC_ID })
297
+ const prevPaths = pathsInSchema(existing && existing.schema)
298
+ await collection.deleteOne({ _id: DOC_ID })
299
+ let cascade = { deletedCount: 0, deletedPaths: [] }
300
+ if (prevPaths.size > 0) {
301
+ cascade = await cascadeDeleteData(client, prevPaths, logger)
302
+ }
303
+ return {
304
+ statusCode: 200,
305
+ body: {
306
+ message: 'Schema reset',
307
+ schema: emptySchema(),
308
+ removedPaths: [...prevPaths],
309
+ deletedCount: cascade.deletedCount
310
+ }
311
+ }
312
+ }
313
+
314
+ return errorResponse(400, `Unknown operation "${op}". Expected get|save|reset`, logger)
315
+ } catch (error) {
316
+ logger.error(error)
317
+ return errorResponse(500, error.message || 'Schema action failed', logger)
318
+ } finally {
319
+ try {
320
+ await close()
321
+ } catch (_) {
322
+ // ignore
323
+ }
324
+ }
325
+ }
326
+
327
+ exports.main = main
@@ -0,0 +1,73 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ */
7
+
8
+ require('dotenv').config()
9
+ const { Core } = require('@adobe/aio-sdk')
10
+
11
+ function getMissingKeys (obj, required) {
12
+ return required.filter((r) => {
13
+ const splits = r.split('.')
14
+ const last = splits[splits.length - 1]
15
+ const traverse = splits.slice(0, -1).reduce((tObj, split) => (tObj[split] || {}), obj)
16
+ return traverse[last] === undefined || traverse[last] === ''
17
+ })
18
+ }
19
+
20
+ /**
21
+ * Validate that required params (and optionally headers) are present on the
22
+ * OpenWhisk action input. Returns null when complete, or an error string the
23
+ * caller can hand straight to `errorResponse`.
24
+ */
25
+ function checkMissingRequestInputs (params, requiredParams = [], requiredHeaders = []) {
26
+ let errorMessage = null
27
+ const safeParams = params ?? {}
28
+ requiredHeaders = requiredHeaders.map((h) => h.toLowerCase())
29
+
30
+ const missingHeaders = getMissingKeys(safeParams.__ow_headers || {}, requiredHeaders)
31
+ if (missingHeaders.length > 0) {
32
+ errorMessage = `missing header(s) '${missingHeaders}'`
33
+ }
34
+ const missingParams = getMissingKeys(safeParams, requiredParams)
35
+ if (missingParams.length > 0) {
36
+ errorMessage = errorMessage ? `${errorMessage} and ` : ''
37
+ errorMessage += `missing parameter(s) '${missingParams}'`
38
+ }
39
+ return errorMessage
40
+ }
41
+
42
+ /**
43
+ * Standard OpenWhisk-friendly error envelope.
44
+ */
45
+ function errorResponse (statusCode, message, logger) {
46
+ if (logger && typeof logger.info === 'function') {
47
+ logger.info(`${statusCode}: ${message}`)
48
+ }
49
+ return {
50
+ error: {
51
+ statusCode,
52
+ body: { error: message }
53
+ }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Lightweight logger wrapper used by action handlers that need to emit a
59
+ * log line under a different module name without holding a Core.Logger
60
+ * reference.
61
+ */
62
+ function logDetails (logName, message, type = 'info') {
63
+ const logger = Core.Logger(logName, { level: type || 'info' })
64
+ if (type === 'debug') logger.debug(message)
65
+ else if (type === 'error') logger.error(message)
66
+ else logger.info(message)
67
+ }
68
+
69
+ module.exports = {
70
+ errorResponse,
71
+ checkMissingRequestInputs,
72
+ logDetails
73
+ }
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "configuration-management",
3
+ "version": "0.1.0",
4
+ "description": "Schema-driven system configuration for Adobe Commerce App Builder sync apps. Magento-style scoped config in Adobe App Builder Database (ABDB) with encryption, Commerce REST helpers, and React Admin UI.",
5
+ "license": "Apache-2.0",
6
+ "author": "Adobe Inc.",
7
+ "keywords": [
8
+ "adobe-io",
9
+ "aio",
10
+ "app-builder",
11
+ "adobe-commerce",
12
+ "magento",
13
+ "configuration-management",
14
+ "abdb",
15
+ "react-spectrum"
16
+ ],
17
+ "main": "./src/index.js",
18
+ "bin": {
19
+ "configuration-management-setup": "./scripts/setup-app-config.js"
20
+ },
21
+ "scripts": {
22
+ "postinstall": "node ./scripts/setup-app-config.js"
23
+ },
24
+ "exports": {
25
+ ".": "./src/index.js",
26
+ "./abdb": "./src/abdb-helper.js",
27
+ "./config": "./src/abdb-config.js",
28
+ "./crypto": "./src/system-config-crypto.js",
29
+ "./shared": "./src/system-config-shared.js",
30
+ "./oauth1a": "./src/oauth1a.js",
31
+ "./web": "./web/src/index.js",
32
+ "./web/styles.css": "./web/src/styles/index.css",
33
+ "./actions/utils": "./actions/utils.js",
34
+ "./actions/ext.config.yaml": "./actions/configurations/ext.config.yaml"
35
+ },
36
+ "files": [
37
+ "src",
38
+ "web",
39
+ "actions",
40
+ "scripts",
41
+ "README.md"
42
+ ],
43
+ "engines": {
44
+ "node": ">=18"
45
+ },
46
+ "peerDependencies": {
47
+ "@adobe/aio-lib-core-auth": "^1.1.0",
48
+ "@adobe/aio-lib-db": "^1.0.1",
49
+ "@adobe/aio-lib-ims": "^8.0.0",
50
+ "@adobe/aio-sdk": "^6.0.0",
51
+ "@adobe/exc-app": "^1.1.3",
52
+ "@adobe/react-spectrum": "^3.30.0",
53
+ "@adobe/uix-guest": "^0.8.3",
54
+ "@spectrum-icons/workflow": "^4.2.4",
55
+ "react": "^18.2.0",
56
+ "react-dom": "^18.2.0",
57
+ "react-error-boundary": "^3.1.4",
58
+ "react-router-dom": "^6.8.1",
59
+ "dotenv": "^16.4.5"
60
+ },
61
+ "dependencies": {
62
+ "got": "^11.8.5",
63
+ "oauth-1.0a": "^2.2.6"
64
+ },
65
+ "repository": {
66
+ "type": "git",
67
+ "url": "https://github.com/adobe/config-management-poc.git",
68
+ "directory": "packages/configuration-management"
69
+ },
70
+ "bugs": {
71
+ "url": "https://github.com/adobe/config-management-poc/issues"
72
+ },
73
+ "homepage": "https://github.com/adobe/config-management-poc/tree/main/packages/configuration-management#readme"
74
+ }
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ Copyright 2025 Adobe. All rights reserved.
4
+ Licensed under the Apache License, Version 2.0
5
+ */
6
+
7
+ const fs = require('fs')
8
+ const path = require('path')
9
+
10
+ const EXTENSION_POINT = 'commerce/backend-ui/1'
11
+ const INCLUDE_REL = 'node_modules/configuration-management/actions/configurations/ext.config.yaml'
12
+ const MARKER = '# configuration-management (auto-linked on npm install)'
13
+
14
+ function findProjectRoot (startDir) {
15
+ let dir = startDir
16
+ while (dir && dir !== path.dirname(dir)) {
17
+ if (fs.existsSync(path.join(dir, 'app.config.yaml'))) {
18
+ return dir
19
+ }
20
+ dir = path.dirname(dir)
21
+ }
22
+ return null
23
+ }
24
+
25
+ function resolveProjectRoot () {
26
+ const initCwd = process.env.INIT_CWD
27
+ if (initCwd) {
28
+ const fromInit = findProjectRoot(initCwd)
29
+ if (fromInit) return fromInit
30
+ }
31
+ return findProjectRoot(process.cwd())
32
+ }
33
+
34
+ function alreadyLinked (content) {
35
+ return content.includes('configuration-management/actions/configurations/ext.config.yaml')
36
+ }
37
+
38
+ function buildExtensionBlock () {
39
+ return [
40
+ MARKER,
41
+ 'extensions:',
42
+ ` ${EXTENSION_POINT}:`,
43
+ ` $include: ${INCLUDE_REL}`
44
+ ].join('\n')
45
+ }
46
+
47
+ function patchAppConfig (content) {
48
+ if (alreadyLinked(content)) {
49
+ return { content, changed: false, reason: 'already-linked' }
50
+ }
51
+
52
+ if (/^extensions:\s*$/m.test(content) || /^extensions:\n/m.test(content)) {
53
+ const injection = [
54
+ ` ${EXTENSION_POINT}:`,
55
+ ` $include: ${INCLUDE_REL}`
56
+ ].join('\n')
57
+ const next = content.replace(
58
+ /^extensions:\s*\n/m,
59
+ `extensions:\n${injection}\n`
60
+ )
61
+ if (next !== content) {
62
+ return { content: next, changed: true, reason: 'merged-into-extensions' }
63
+ }
64
+ }
65
+
66
+ const trimmed = content.replace(/\s+$/, '')
67
+ const separator = trimmed.length > 0 ? '\n\n' : ''
68
+ return {
69
+ content: `${trimmed}${separator}${buildExtensionBlock()}\n`,
70
+ changed: true,
71
+ reason: 'appended'
72
+ }
73
+ }
74
+
75
+ function main () {
76
+ if (process.env.CONFIGURATION_MANAGEMENT_SKIP_SETUP === '1') {
77
+ return
78
+ }
79
+
80
+ const projectRoot = resolveProjectRoot()
81
+ if (!projectRoot) {
82
+ console.log(
83
+ '[configuration-management] No app.config.yaml found — skip auto-link. ' +
84
+ 'Run `npx configuration-management-setup` after creating your App Builder app.'
85
+ )
86
+ return
87
+ }
88
+
89
+ const appConfigPath = path.join(projectRoot, 'app.config.yaml')
90
+ const original = fs.readFileSync(appConfigPath, 'utf8')
91
+ const { content, changed, reason } = patchAppConfig(original)
92
+
93
+ if (!changed) {
94
+ console.log(`[configuration-management] app.config.yaml already links actions (${reason}).`)
95
+ return
96
+ }
97
+
98
+ fs.writeFileSync(appConfigPath, content, 'utf8')
99
+ console.log(
100
+ `[configuration-management] Updated app.config.yaml (${reason}):\n` +
101
+ ` $include: ${INCLUDE_REL}`
102
+ )
103
+ }
104
+
105
+ if (require.main === module) {
106
+ try {
107
+ main()
108
+ } catch (err) {
109
+ console.error('[configuration-management] setup failed:', err.message)
110
+ process.exitCode = 1
111
+ }
112
+ }
113
+
114
+ module.exports = { patchAppConfig, INCLUDE_REL, EXTENSION_POINT }