@platformatic/foundation 3.68.0 → 4.0.0-new-config.1

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.
@@ -0,0 +1,233 @@
1
+ import Ajv from 'ajv'
2
+ import { pathToFileURL } from 'node:url'
3
+ import { canonicalize } from './canonicalize.js'
4
+ import { autoWrapApplicationDefinition, classifyConfiguration } from './classify.js'
5
+ import { createConfigurationContext } from './context.js'
6
+ import {
7
+ ApplicationShorthandConflictError,
8
+ DeferredSlotInApplicationDefinitionError,
9
+ InvalidRootConfigurationError,
10
+ NestedFunctionExportError,
11
+ RootConfigurationInApplicationEntryError
12
+ } from './errors.js'
13
+ import { topologyVariableName } from './identifiers.js'
14
+ import { expandAutoload, filterEnabledApplications, normalizeApplications, recordResolveCandidates } from './topology.js'
15
+
16
+ /*
17
+ The evaluation pipeline, shared by the eval worker and by --debug-config's in-process mode. It is
18
+ one implementation because the printed configuration has to equal a real boot's; a second one
19
+ written for the diagnostic would be a second contract.
20
+ */
21
+
22
+ function validateOrchestration (config, { schema, path }) {
23
+ if (!schema) {
24
+ return
25
+ }
26
+
27
+ // A shape check that injects no defaults: the useDefaults pass runs main-side on the returned
28
+ // snapshot, which is what keeps step 4's projection carrying authored values rather than
29
+ // schema-supplied ones. Coercion is disabled in v4 — on the genuine unions that survive the
30
+ // audit it is a documented hazard rather than a convenience.
31
+ const ajv = new Ajv({ useDefaults: false, coerceTypes: false, allErrors: true, strict: false })
32
+ const validate = ajv.compile(schema)
33
+
34
+ if (!validate(config)) {
35
+ const messages = validate.errors.map(error => `${error.instancePath || '/'} ${error.message}`).join('; ')
36
+
37
+ throw new InvalidRootConfigurationError(path, messages)
38
+ }
39
+ }
40
+
41
+ // Recording the container rather than the index is what makes step 5 survive step 4: expansion can
42
+ // append entries and the enabled filter removes them, so a slot addressed by position would be
43
+ // spliced into the wrong entry — or into one this boot excludes.
44
+ function resolveSlotContainers (config, deferred) {
45
+ return deferred.map(slot => {
46
+ let container = config
47
+
48
+ for (const segment of slot.path.slice(0, -1)) {
49
+ container = container[segment]
50
+ }
51
+
52
+ return { ...slot, container, key: slot.path[slot.path.length - 1] }
53
+ })
54
+ }
55
+
56
+ /*
57
+ The root eval worker cannot have the topology keys stripped from its environment the way a
58
+ per-app worker does: the ids that generate those names are declared by the very file being
59
+ evaluated, and by autoload expansion that completes only after it returns. It gets a post-unwrap
60
+ check instead — a value visible here is necessarily inherited from the surrounding environment
61
+ rather than injected by this runtime.
62
+
63
+ A warning rather than an error, because presence is not use: a nested runtime legitimately passes
64
+ such variables through, and only a config file that actually reads one bakes a stale value.
65
+ */
66
+ export function checkInheritedTopologyKeys (applications, env) {
67
+ const warnings = []
68
+
69
+ for (const entry of applications) {
70
+ if (typeof entry.id !== 'string') {
71
+ continue
72
+ }
73
+
74
+ const key = topologyVariableName(entry.id)
75
+
76
+ if (key in env) {
77
+ warnings.push({
78
+ type: 'inherited-topology-key',
79
+ key,
80
+ applicationId: entry.id,
81
+ message: `${key} was inherited from the surrounding environment; it collides with application '${entry.id}' and is not the value this runtime injects.`
82
+ })
83
+ }
84
+ }
85
+
86
+ return warnings
87
+ }
88
+
89
+ /*
90
+ Everything after the unwrap happens on the canonical snapshot, in this order, because the
91
+ original object is reachable exactly once. Reading module, applications, autoload or a config
92
+ slot off the raw export would re-open the time-of-check/time-of-use gap canonicalization exists
93
+ to close: a getter can return one array to the expansion and another to the walk.
94
+ */
95
+ export async function runRootPipeline (exported, { path, directory, schema, production, env, context, deferred: mode = true }) {
96
+ // Step 1.
97
+ const { config: snapshot, deferred } = canonicalize(exported, { deferred: mode })
98
+
99
+ // Step 2.
100
+ const classification = classifyConfiguration(snapshot, path)
101
+
102
+ if (classification === 'application') {
103
+ if (deferred.length > 0) {
104
+ // A per-app file has no config slots, so the only thing that path can hold there is a
105
+ // capability option that happens to be named config; calling it would be the loader
106
+ // inventing a callback the author never declared.
107
+ throw new DeferredSlotInApplicationDefinitionError(path, deferred[0].pointer)
108
+ }
109
+
110
+ const config = autoWrapApplicationDefinition(snapshot)
111
+
112
+ normalizeApplications(config, { directory })
113
+ return { config, classification, resolveCandidates: [], warnings: [] }
114
+ }
115
+
116
+ const slots = resolveSlotContainers(snapshot, deferred)
117
+
118
+ normalizeApplications(snapshot, {
119
+ directory,
120
+ onConflict () {
121
+ throw new ApplicationShorthandConflictError(path, snapshot.autoload ? 'autoload' : 'applications')
122
+ }
123
+ })
124
+
125
+ // Step 3. Orchestration keys only: a pending config slot has nothing to validate yet, and
126
+ // capability configuration is validated later, main-side, against each capability's own schema.
127
+ validateOrchestration(snapshot, { schema, path })
128
+
129
+ // Step 4. The recording sits between expansion and the filter because that is the only moment
130
+ // both lists exist: after expansion, so autoloaded entries are in it, and before the filter,
131
+ // which is what lets resolve fetch an application this boot excludes.
132
+ snapshot.applications = await expandAutoload(snapshot, { root: directory })
133
+
134
+ const resolveCandidates = recordResolveCandidates(snapshot.applications)
135
+
136
+ snapshot.applications = filterEnabledApplications(snapshot.applications, context.mode)
137
+
138
+ const warnings = checkInheritedTopologyKeys(snapshot.applications, env)
139
+
140
+ // Step 5. Steps 3-5 are in this order for one reason: a disabled entry's config callback must
141
+ // never run. An entry excluded from this boot may name a capability the production image does
142
+ // not ship, and invoking it to find out would fail a boot that excludes it.
143
+ const surviving = new Set(snapshot.applications)
144
+
145
+ for (const slot of slots) {
146
+ if (!surviving.has(slot.container)) {
147
+ continue
148
+ }
149
+
150
+ const resolved = await slot.value(context)
151
+
152
+ // No carve-out on the way back: a deferred config may not itself return a function.
153
+ slot.container[slot.key] = canonicalize(resolved).config
154
+ }
155
+
156
+ return { config: snapshot, classification, resolveCandidates, warnings }
157
+ }
158
+
159
+ export function runApplicationPipeline (exported, { path, applicationId, directory }) {
160
+ const { config } = canonicalize(exported)
161
+ const classification = classifyConfiguration(config, path)
162
+
163
+ if (classification === 'root') {
164
+ throw new RootConfigurationInApplicationEntryError(path, applicationId ?? directory)
165
+ }
166
+
167
+ return { config, classification, resolveCandidates: [], warnings: [] }
168
+ }
169
+
170
+ // Unwrapping is only the function call: if the export is a function it is called with the context
171
+ // and awaited, and a result that is itself a function is an error naming the file. Nothing is
172
+ // classified, auto-wrapped or read for its shape yet.
173
+ export async function importAndUnwrap (path, context) {
174
+ const module = await import(pathToFileURL(path).toString())
175
+ const exported = module.default
176
+
177
+ if (typeof exported !== 'function') {
178
+ return exported
179
+ }
180
+
181
+ const resolved = await exported(context)
182
+
183
+ if (typeof resolved === 'function') {
184
+ throw new NestedFunctionExportError(path)
185
+ }
186
+
187
+ return resolved
188
+ }
189
+
190
+ export function diffEnvironment (before, after) {
191
+ const mutated = []
192
+
193
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
194
+ if (before[key] !== after[key]) {
195
+ mutated.push(key)
196
+ }
197
+ }
198
+
199
+ return mutated.sort()
200
+ }
201
+
202
+ /*
203
+ One evaluation, from import to snapshot. The caller supplies the environment: in a worker it is
204
+ process.env, which the parent constructed explicitly; in-process it is a view installed and
205
+ restored around the call, so the "does not propagate" statement stays true in debug mode too.
206
+ */
207
+ export async function evaluateConfiguration ({
208
+ path,
209
+ directory,
210
+ role = 'root',
211
+ applicationId,
212
+ command,
213
+ mode,
214
+ production,
215
+ schema,
216
+ env,
217
+ onWatchFile
218
+ }) {
219
+ const before = { ...env }
220
+
221
+ const context = createConfigurationContext({ command, mode, production, env, root: directory, onWatchFile })
222
+ const exported = await importAndUnwrap(path, context)
223
+
224
+ const result =
225
+ role === 'application'
226
+ ? runApplicationPipeline(exported, { path, applicationId, directory })
227
+ : await runRootPipeline(exported, { path, directory, schema, production, env, context })
228
+
229
+ // Mutations still work within the evaluation — it is one thread, one env — they just never
230
+ // silently cross into the runtime. The diff reports keys only: it cannot attribute a write to a
231
+ // module or a line, and the diagnostics must not claim otherwise.
232
+ return { ...result, mutatedEnvKeys: diffEnvironment(before, env) }
233
+ }
@@ -0,0 +1,184 @@
1
+ import { basename, dirname, isAbsolute, join, parse, resolve } from 'node:path'
2
+ import { isFileAccessible } from '../file-system.js'
3
+ import { ConfigurationFileNotFoundError, LegacyConfigurationFileError } from './errors.js'
4
+ import {
5
+ configurationFileNames,
6
+ findAnyConfigurationFile,
7
+ hasConfigurationFile,
8
+ inspectDirectory,
9
+ isConfigurationFileName,
10
+ isLegacyConfigurationFileName
11
+ } from './filenames.js'
12
+
13
+ export function ancestorDirectories (directory) {
14
+ const directories = []
15
+ const filesystemRoot = parse(directory).root
16
+ let current = directory
17
+
18
+ while (true) {
19
+ directories.push(current)
20
+
21
+ if (current === filesystemRoot) {
22
+ break
23
+ }
24
+
25
+ const parent = dirname(current)
26
+
27
+ if (parent === current) {
28
+ break
29
+ }
30
+
31
+ current = parent
32
+ }
33
+
34
+ return directories
35
+ }
36
+
37
+ // A directory holding a package.json is where a Node project begins, so a configuration above it
38
+ // belongs to something else. This is the whole of the trust story for the config search.
39
+ export async function findPackageBoundary (directory) {
40
+ for (const candidate of ancestorDirectories(directory)) {
41
+ if (await isFileAccessible('package.json', candidate)) {
42
+ return candidate
43
+ }
44
+ }
45
+
46
+ return null
47
+ }
48
+
49
+ // Step 1 of "run what is here": find the nearest watt.config.* from `directory` upward, stopping
50
+ // at — and including — the nearest ancestor containing a package.json, and searching `directory`
51
+ // alone when there is no such ancestor. By filename alone; nothing is executed.
52
+ export async function findDecidingFile (directory, { throwOnMissing = false } = {}) {
53
+ const packageBoundary = await findPackageBoundary(directory)
54
+ const stopDirectory = packageBoundary ?? directory
55
+
56
+ for (const candidate of ancestorDirectories(directory)) {
57
+ const found = await inspectDirectory(candidate)
58
+
59
+ if (found) {
60
+ return { path: found, directory: candidate, stopDirectory }
61
+ }
62
+
63
+ if (candidate === stopDirectory) {
64
+ break
65
+ }
66
+ }
67
+
68
+ if (throwOnMissing) {
69
+ throw new ConfigurationFileNotFoundError(directory, stopDirectory)
70
+ }
71
+
72
+ return null
73
+ }
74
+
75
+ // --config names the configuration and takes cwd out of the decision. It accepts any of the four
76
+ // v4 names and nothing else: pointing it at a v3 file is the migrate hint, not a parse attempt.
77
+ export async function resolveNamedConfigurationFile (path, cwd = process.cwd()) {
78
+ const resolved = isAbsolute(path) ? path : resolve(cwd, path)
79
+ const name = basename(resolved)
80
+
81
+ if (isLegacyConfigurationFileName(name)) {
82
+ throw new LegacyConfigurationFileError(resolved)
83
+ }
84
+
85
+ if (!isConfigurationFileName(name)) {
86
+ throw new ConfigurationFileNotFoundError(resolved, configurationFileNames.join(', '))
87
+ }
88
+
89
+ if (!(await isFileAccessible(resolved))) {
90
+ throw new ConfigurationFileNotFoundError(resolved, dirname(resolved))
91
+ }
92
+
93
+ return { path: resolved, directory: dirname(resolved), stopDirectory: dirname(resolved) }
94
+ }
95
+
96
+ // The ancestor scan is the one thing that looks above the search stop point. It executes nothing,
97
+ // and it never decides which configuration boots — but it is not diagnostics-only either: it
98
+ // selects the env root, and the env root decides how far up .env layering reaches.
99
+ export async function scanAncestorConfigurations (directory) {
100
+ const found = []
101
+
102
+ for (const candidate of ancestorDirectories(directory)) {
103
+ if (await hasConfigurationFile(candidate)) {
104
+ found.push(candidate)
105
+ }
106
+ }
107
+
108
+ return found
109
+ }
110
+
111
+ // A config file's chain runs from its own directory up to and including the directory of the
112
+ // outermost watt.config.* above it — or its own directory alone when there is none. The
113
+ // own-directory floor is what makes every chain terminate.
114
+ export async function findEnvRoot (directory) {
115
+ const found = await scanAncestorConfigurations(directory)
116
+
117
+ return found.length > 0 ? found[found.length - 1] : directory
118
+ }
119
+
120
+ // The watched horizon is the scan's reach, not its current answer: watching only as far as the
121
+ // present env root cannot see a configuration appearing above it, which is exactly the event that
122
+ // moves the root outward. These paths mostly do not exist — they are watched for creation.
123
+ export function listAncestorCandidatePaths (directory) {
124
+ const paths = []
125
+
126
+ for (const candidate of ancestorDirectories(directory)) {
127
+ for (const name of configurationFileNames) {
128
+ paths.push(join(candidate, name))
129
+ }
130
+ }
131
+
132
+ return paths
133
+ }
134
+
135
+ // The standalone warning fires when the deciding file classified as an application definition and
136
+ // a watt.config.* exists in some ancestor directory. Because it is a filename check it cannot know
137
+ // whether that ancestor is a root config, so the caller names the file rather than asserting.
138
+ export async function findAncestorConfiguration (directory) {
139
+ const parent = dirname(directory)
140
+
141
+ if (parent === directory) {
142
+ return null
143
+ }
144
+
145
+ const found = await scanAncestorConfigurations(parent)
146
+
147
+ return found.length > 0 ? found[0] : null
148
+ }
149
+
150
+ // The same walk, over both candidate sets, for the zero-config warning. It stops at the first
151
+ // ancestor that has anything, since that is the file the user is asked about.
152
+ export async function findAncestorConfigurationOfAnyKind (directory) {
153
+ const parent = dirname(directory)
154
+
155
+ if (parent === directory) {
156
+ return null
157
+ }
158
+
159
+ for (const candidate of ancestorDirectories(parent)) {
160
+ const found = await findAnyConfigurationFile(candidate)
161
+
162
+ if (found) {
163
+ return found
164
+ }
165
+ }
166
+
167
+ return null
168
+ }
169
+
170
+ // Per-app discovery consults a directory the same way the walk does, with one exception: a
171
+ // candidate that is the deciding file itself is skipped, whatever the entry's shape, so an entry
172
+ // with a defaulted path does not discover the very file that produced it. Entries that carry an
173
+ // inline config use the same call — for them a returned path is the configured-twice error, not a
174
+ // file to evaluate — which is why the legacy and ambiguity checks belong here rather than at the
175
+ // two call sites.
176
+ export async function findApplicationConfigurationFile (directory, decidingFile) {
177
+ const found = await inspectDirectory(directory)
178
+
179
+ if (!found || found === decidingFile) {
180
+ return null
181
+ }
182
+
183
+ return found
184
+ }
@@ -0,0 +1,82 @@
1
+ import { ApplicationStartsNothingError } from './errors.js'
2
+
3
+ /*
4
+ A declaration is a constant in the common case and a callable for the capability whose behaviour
5
+ its own configuration selects — the shape a per-package constant cannot express.
6
+
7
+ The callable receives the configuration as authored and validated, never as transformed: this is
8
+ read main-side, after validation and before any worker, while the capability transform runs
9
+ worker-side and later. So it sees whatever shapes the schema admits, and any capability whose
10
+ schema accepts a shorthand has to read every spelling rather than the one transform produces.
11
+ */
12
+ export function evaluateServesWithoutPort (declaration, config) {
13
+ const resolved = typeof declaration === 'function' ? declaration(config) : declaration
14
+
15
+ // Absent means 'worker', not false. A third-party capability that does not declare this is one
16
+ // the loader knows nothing about, and the two wrong answers are opposite: false rejects at load
17
+ // a capability that would have served the mesh perfectly well, and true prints a mesh URL that
18
+ // answers nothing. Deferring to the started worker does neither.
19
+ return resolved ?? 'worker'
20
+ }
21
+
22
+ export function servingEnvironment (production) {
23
+ return production ? 'production' : 'development'
24
+ }
25
+
26
+ /*
27
+ An application will serve if any of three things holds: its capability can serve without a
28
+ listener in the mode this boot will use, its server.port is defined, or it declares a custom
29
+ command for that mode.
30
+
31
+ The third is not a technicality. Every framework capability checks its command before the port,
32
+ so a framework application with a custom command and no server.port is valid and starts — its
33
+ command binds whatever it binds and the runtime observes the address. A predicate that looked
34
+ only at the capability would reject that configuration at load.
35
+
36
+ Which command counts is decided by the mode, not by either command existing: the development and
37
+ production start paths read their own key and neither falls back to the other, so an application
38
+ declaring only a development command and booted with start has no command and no port.
39
+ */
40
+ export function willApplicationServe ({ declaration, config, production }) {
41
+ const resolved = evaluateServesWithoutPort(declaration, config)
42
+
43
+ // Worker-classified rows are exempt in both modes, because nothing main-side can prove they
44
+ // start nothing — which is what worker classification means. Reading "framework capability under
45
+ // dev" as covering Vite SSR would reject exactly the configuration the matrix exists to admit.
46
+ if (resolved === 'worker') {
47
+ return { serves: true, reason: 'worker-classified' }
48
+ }
49
+
50
+ const environment = servingEnvironment(production)
51
+
52
+ if (resolved?.[environment]) {
53
+ return { serves: true, reason: 'serves-without-port' }
54
+ }
55
+
56
+ if (config?.server?.port !== undefined) {
57
+ return { serves: true, reason: 'port' }
58
+ }
59
+
60
+ if (config?.application?.commands?.[environment]) {
61
+ return { serves: true, reason: 'command' }
62
+ }
63
+
64
+ return { serves: false, reason: 'no-port-no-command' }
65
+ }
66
+
67
+ // All three inputs are configuration, so the predicate is decidable before boot. An application
68
+ // satisfying none of them fails the load naming it and its capability — rather than booting a
69
+ // runtime with one application silently missing.
70
+ export function assertApplicationServes ({ id, module, declaration, config, production }) {
71
+ const outcome = willApplicationServe({ declaration, config, production })
72
+
73
+ if (!outcome.serves) {
74
+ // The environment appears twice: once as the mode that refuses, and once as the key under
75
+ // application.commands the reader would have to add.
76
+ const environment = servingEnvironment(production)
77
+
78
+ throw new ApplicationStartsNothingError(id, module, environment, environment)
79
+ }
80
+
81
+ return outcome
82
+ }
@@ -0,0 +1,137 @@
1
+ import { readdir, readFile } from 'node:fs/promises'
2
+ import { isAbsolute, join, resolve } from 'node:path'
3
+ import { deriveApplicationId } from './identifiers.js'
4
+
5
+ // The shorthand exists so a single app with runtime options never needs a one-element array.
6
+ // Declaring it alongside applications or autoload is an error: either combination would smuggle a
7
+ // multi-app runtime out of the single-app form.
8
+ export function normalizeApplications (config, { directory, onConflict }) {
9
+ if (config.application !== undefined) {
10
+ if (config.applications !== undefined || config.autoload !== undefined) {
11
+ onConflict?.()
12
+ }
13
+
14
+ // The shorthand entry — and only it — defaults its path to the config file's own directory.
15
+ // Defaulting every element of applications instead would give an explicit entry a path before
16
+ // expansion could supply the autoloaded one, and the explicit-wins merge would then keep the
17
+ // root directory for an application that lives under web/.
18
+ config.application.path ??= directory
19
+ config.applications = [config.application]
20
+ delete config.application
21
+ }
22
+
23
+ config.applications ??= []
24
+
25
+ return config
26
+ }
27
+
28
+ // One derivation used at every position means one reader for its middle rung as well: autoload
29
+ // expansion and the main-side driver both ask this, so they cannot drift.
30
+ export async function readPackageName (directory) {
31
+ try {
32
+ const contents = await readFile(join(directory, 'package.json'), 'utf-8')
33
+
34
+ return JSON.parse(contents)?.name
35
+ } catch {
36
+ // On purpose: an application directory need not have a package.json, and the derivation falls
37
+ // through to the directory name when it does not.
38
+ return undefined
39
+ }
40
+ }
41
+
42
+ /*
43
+ Expansion is the only place autoload runs — the runtime transform consumes the already-expanded
44
+ list. Orchestration drives filesystem access, which is why it is validated before it is acted on.
45
+
46
+ The id follows the same derivation as everywhere else, where v3 used the directory name alone. A
47
+ default that varied by boot style would move the mesh hostname, the injected variable name, the
48
+ metrics label, wattpm inject's argument and the dependencies spelling all at once.
49
+ */
50
+ export async function expandAutoload (config, { root }) {
51
+ if (!config.autoload) {
52
+ return config.applications
53
+ }
54
+
55
+ const { exclude = [], mappings = {} } = config.autoload
56
+ const path = isAbsolute(config.autoload.path) ? config.autoload.path : resolve(root, config.autoload.path)
57
+ const entries = await readdir(path, { withFileTypes: true })
58
+ const applications = config.applications
59
+
60
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
61
+ if (!entry.isDirectory() || exclude.includes(entry.name)) {
62
+ continue
63
+ }
64
+
65
+ const mapping = mappings[entry.name] ?? {}
66
+ const directory = join(path, entry.name)
67
+ const { id } = deriveApplicationId({
68
+ id: mapping.id,
69
+ packageName: await readPackageName(directory),
70
+ directory
71
+ })
72
+
73
+ const expanded = { id, path: directory, ...mapping }
74
+ const existing = applications.findIndex(application => application.id === id)
75
+
76
+ if (existing !== -1) {
77
+ // Shallow explicit-wins merge, v3 semantics. Assigning in place rather than reordering keeps
78
+ // every explicit entry's position stable.
79
+ applications[existing] = { ...expanded, ...applications[existing] }
80
+ } else {
81
+ applications.push(expanded)
82
+ }
83
+ }
84
+
85
+ return applications
86
+ }
87
+
88
+ /*
89
+ The projection resolve is owed, captured between expansion and the enabled filter — the only
90
+ moment both lists exist. A remote entry excluded in the current mode is fetched all the same.
91
+
92
+ It is a projection rather than the unfiltered entries because a disabled entry's deferred config
93
+ slot is never called: an unfiltered list would put entries with an unfilled slot into the main
94
+ process, one forgotten drop away from the runtime. A projection carrying no capability
95
+ configuration cannot be booted by accident, whatever downstream code does with it.
96
+ */
97
+ export function recordResolveCandidates (applications) {
98
+ return applications
99
+ .filter(entry => typeof entry.url === 'string' && entry.url.length > 0)
100
+ .map(({ id, url, path, gitBranch }) => ({ id, url, path, gitBranch }))
101
+ }
102
+
103
+ /*
104
+ The object form is keyed by mode, not by a separate binary environment. production and
105
+ development remain the default mode names under start/build and dev, so every v3 configuration
106
+ keeps its meaning — and enabled: { staging: false } now does what it looks like under
107
+ --mode staging, where v3 silently ignored the key because it only ever compared against those
108
+ two names.
109
+ */
110
+ export function isApplicationEnabled (entry, mode) {
111
+ const { enabled } = entry
112
+
113
+ if (typeof enabled === 'undefined') {
114
+ return true
115
+ }
116
+
117
+ if (typeof enabled === 'string') {
118
+ return enabled !== 'false'
119
+ }
120
+
121
+ if (typeof enabled === 'object' && enabled !== null) {
122
+ return enabled[mode] ?? true
123
+ }
124
+
125
+ return enabled
126
+ }
127
+
128
+ /*
129
+ enabled is orchestration, so its value is always lexically present in the root config or in
130
+ autoload.mappings, and the root context already carries the mode — so disabled entries are
131
+ dropped immediately after expansion, before any per-app worker is spawned, before the detector
132
+ runs, and before capability validation. A decommissioned app whose capability is absent from the
133
+ production image must not be able to fail a boot that excludes it.
134
+ */
135
+ export function filterEnabledApplications (applications, mode) {
136
+ return applications.filter(entry => isApplicationEnabled(entry, mode))
137
+ }