@platformatic/foundation 3.68.0 → 4.0.0-new-config.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.
package/lib/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import Deepmerge from '@fastify/deepmerge'
2
2
  import { bgGreen, black, bold, green, isColorSupported } from 'colorette'
3
- import { resolve } from 'node:path'
3
+ import { basename, resolve } from 'node:path'
4
4
  import { parseArgs as nodeParseArgs } from 'node:util'
5
5
  import { pino } from 'pino'
6
6
  import pinoPretty from 'pino-pretty'
@@ -8,6 +8,7 @@ import { findConfigurationFileRecursive, loadConfigurationModule, saveConfigurat
8
8
  import { hasJavascriptFiles } from './file-system.js'
9
9
  import { setPinoTimestamp } from './logger.js'
10
10
  import { detectApplicationType, getPlatformaticVersion } from './module.js'
11
+ import { findDecidingFile, isConfigurationFileName } from './v4/index.js'
11
12
 
12
13
  /* c8 ignore next 4 - else branches */
13
14
  let verbose = false
@@ -212,6 +213,32 @@ export function applicationToEnvVariable (application) {
212
213
  return `PLT_APPLICATION_${application.toUpperCase().replaceAll(/[^A-Z0-9_]/g, '_')}_PATH`
213
214
  }
214
215
 
216
+ /*
217
+ The same routing the runtime does, in the one other place a configuration is found: by an
218
+ explicit name, or by the walk from the directory. A legacy file found by the walk means "this
219
+ project is not v4", and the v3 lookups below are the ones that should answer.
220
+ */
221
+ async function findV4ConfigurationFile (root, configurationFile) {
222
+ if (typeof configurationFile === 'string') {
223
+ const named = resolve(root, configurationFile)
224
+
225
+ // The extension decides for a name given outright: v4 configuration is code, v3 is a document.
226
+ return isConfigurationFileName(basename(named)) || /\.(js|mjs|ts|mts)$/.test(named) ? named : null
227
+ }
228
+
229
+ try {
230
+ const deciding = await findDecidingFile(root, { throwOnMissing: false })
231
+
232
+ return deciding?.path ?? null
233
+ } catch (error) {
234
+ if (error.code === 'PLT_LEGACY_CONFIGURATION_FILE') {
235
+ return null
236
+ }
237
+
238
+ throw error
239
+ }
240
+ }
241
+
215
242
  export async function findRuntimeConfigurationFile (
216
243
  logger,
217
244
  root,
@@ -221,6 +248,17 @@ export async function findRuntimeConfigurationFile (
221
248
  verifyPackages = true,
222
249
  executableName = ''
223
250
  ) {
251
+ /*
252
+ v4 first. A v4 project has no v3 configuration file by construction, so every lookup below
253
+ fails and the fallback then auto-detects the directory and writes a watt.json into it -- the
254
+ command silently builds something other than the project it was pointed at.
255
+ */
256
+ const v4ConfigurationFile = await findV4ConfigurationFile(root, configurationFile)
257
+
258
+ if (v4ConfigurationFile) {
259
+ return v4ConfigurationFile
260
+ }
261
+
224
262
  let configFile = await findConfigurationFileRecursive(root, configurationFile, '@platformatic/runtime')
225
263
 
226
264
  // If a runtime was not found, search for application file that we wrap in a runtime
package/lib/schema.js CHANGED
@@ -401,7 +401,7 @@ export const server = {
401
401
  type: 'string',
402
402
  enum: ['shared', 'perWorkerIncrement'],
403
403
  description:
404
- 'Configures how entrypoint server worker ports are assigned. When set to shared, all workers listen on the same port. When set to perWorkerIncrement, each worker will use its own port, starting from port (worker 0).'
404
+ 'Configures how the port is assigned when the application runs multiple workers. When set to shared (the default), all workers listen on the same port (which requires SO_REUSEPORT support). When set to perWorkerIncrement, each worker listens on its own port, starting from port (worker 0) and incrementing by one for each additional worker.'
405
405
  },
406
406
  backlog: {
407
407
  type: 'integer',
@@ -512,6 +512,8 @@ export const fastifyServer = {
512
512
  type: 'string'
513
513
  },
514
514
  port: server.properties.port,
515
+ portAssignment: server.properties.portAssignment,
516
+ backlog: server.properties.backlog,
515
517
  pluginTimeout: {
516
518
  type: 'integer'
517
519
  },
@@ -639,15 +641,7 @@ export const fastifyServer = {
639
641
  },
640
642
  http2: server.properties.http2,
641
643
  https: server.properties.https,
642
- cors,
643
- errorHandler: {
644
- description:
645
- 'Path to a file or name of a package whose default export is a Fastify error handler. It is installed on the root instance before any route is registered, so it also covers the routes registered by the capability itself, such as the auto generated CRUD routes of @platformatic/db. Plugins can still override it for their own encapsulation context.',
646
- anyOf: [
647
- { type: 'string', resolveModule: true },
648
- { type: 'string', resolvePath: true }
649
- ]
650
- }
644
+ cors
651
645
  },
652
646
  additionalProperties: false
653
647
  }
@@ -895,7 +889,11 @@ export const application = {
895
889
  resolvePath: true
896
890
  },
897
891
  config: {
898
- type: 'string'
892
+ // v4 entries carry an inline ApplicationDefinition here — the object a capability factory
893
+ // returns — where v3 carried a path to a configuration file. The union is transitional and
894
+ // narrows to the object alone when the v3 reader leaves foundation; it is listed for the
895
+ // schema audit rather than left to be rediscovered.
896
+ anyOf: [{ type: 'string' }, { type: 'object' }]
899
897
  },
900
898
  url: {
901
899
  type: 'string'
@@ -904,12 +902,6 @@ export const application = {
904
902
  type: 'string',
905
903
  default: 'main'
906
904
  },
907
- useHttp: {
908
- type: 'boolean'
909
- },
910
- websocket: {
911
- type: 'boolean'
912
- },
913
905
  reuseTcpPorts: {
914
906
  type: 'boolean',
915
907
  default: true
@@ -1071,9 +1063,6 @@ export const runtimeProperties = {
1071
1063
  },
1072
1064
  preload,
1073
1065
  extensions,
1074
- entrypoint: {
1075
- type: 'string'
1076
- },
1077
1066
  basePath: {
1078
1067
  type: 'string'
1079
1068
  },
@@ -1119,7 +1108,6 @@ export const runtimeProperties = {
1119
1108
  default: 0
1120
1109
  },
1121
1110
  logger,
1122
- server,
1123
1111
  reuseTcpPorts: {
1124
1112
  type: 'boolean',
1125
1113
  default: true
@@ -1631,6 +1619,7 @@ export const runtimeProperties = {
1631
1619
  export const runtimeUnwrappablePropertiesList = [
1632
1620
  '$schema',
1633
1621
  'entrypoint',
1622
+ 'server',
1634
1623
  'applications',
1635
1624
  'application',
1636
1625
  'autoload',
@@ -1647,8 +1636,6 @@ export const applicationsUnwrappablePropertiesList = [
1647
1636
  'url',
1648
1637
  'gitBranch',
1649
1638
  'dependencies',
1650
- 'useHttp',
1651
- 'websocket',
1652
1639
  'management'
1653
1640
  ]
1654
1641
 
@@ -0,0 +1,206 @@
1
+ import { types } from 'node:util'
2
+ import { InvalidConfigValueError } from './errors.js'
3
+
4
+ export function formatPointer (segments) {
5
+ if (segments.length === 0) {
6
+ return '/'
7
+ }
8
+
9
+ return segments.map(segment => `/${String(segment).replace(/~/g, '~0').replace(/\//g, '~1')}`).join('')
10
+ }
11
+
12
+ export function describeValue (value) {
13
+ if (value === null) {
14
+ return 'null'
15
+ }
16
+
17
+ if (Array.isArray(value)) {
18
+ return 'array'
19
+ }
20
+
21
+ if (typeof value === 'object') {
22
+ const name = value.constructor?.name
23
+
24
+ return name && name !== 'Object' ? `${name} instance` : 'object'
25
+ }
26
+
27
+ return typeof value
28
+ }
29
+
30
+ export function isPlainObject (value) {
31
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
32
+ return false
33
+ }
34
+
35
+ const prototype = Object.getPrototypeOf(value)
36
+
37
+ return prototype === Object.prototype || prototype === null
38
+ }
39
+
40
+ // The two positions where a function survives the walk. The test is structural, not semantic,
41
+ // which is why it can run before classification: whatever the file turns out to be, these are the
42
+ // only paths a deferred definition can occupy.
43
+ export function isDeferredSlot (segments) {
44
+ if (segments.length === 2) {
45
+ return segments[0] === 'application' && segments[1] === 'config'
46
+ }
47
+
48
+ if (segments.length === 3) {
49
+ return segments[0] === 'applications' && typeof segments[1] === 'number' && segments[2] === 'config'
50
+ }
51
+
52
+ return false
53
+ }
54
+
55
+ /*
56
+ Canonicalization builds a plain-data snapshot rather than inspecting the evaluated object,
57
+ because inspecting is a time-of-check/time-of-use gap: a getter or a Proxy can return one shape
58
+ to the check and another to the clone, so the validated structure and the transported structure
59
+ need not be the same object graph. After this walk nothing else holds a reference to the
60
+ original, which is what makes "nothing downstream ever touches it" literally true.
61
+
62
+ structuredClone is not JSON.stringify — it preserves own properties whose value is undefined —
63
+ so omitting them is something this pass does rather than something the boundary does for it.
64
+ */
65
+ export function canonicalize (value, { deferred = false } = {}) {
66
+ // deferred: true records a function at the two carve-out paths and leaves the slot pending;
67
+ // 'reject' refuses one there with the object-source message; false gives it the ordinary
68
+ // functions-cannot-be-transported error, which is correct for a per-app file, where a property
69
+ // named config is a capability option rather than a slot.
70
+
71
+ const slots = []
72
+ const ancestors = new Set()
73
+
74
+ function walk (current, segments) {
75
+ const pointer = () => formatPointer(segments)
76
+
77
+ if (current === null) {
78
+ return current
79
+ }
80
+
81
+ switch (typeof current) {
82
+ case 'string':
83
+ case 'boolean':
84
+ return current
85
+ case 'number':
86
+ if (!Number.isFinite(current)) {
87
+ throw new InvalidConfigValueError(pointer(), `${current} is not a finite number`)
88
+ }
89
+
90
+ return current
91
+ case 'bigint':
92
+ throw new InvalidConfigValueError(pointer(), 'bigint values cannot be transported')
93
+ case 'symbol':
94
+ throw new InvalidConfigValueError(pointer(), 'symbol values cannot be transported')
95
+ case 'function':
96
+ if (isDeferredSlot(segments)) {
97
+ if (deferred === true) {
98
+ slots.push({ pointer: pointer(), path: segments.slice(), value: current })
99
+ return undefined
100
+ }
101
+
102
+ if (deferred === 'reject') {
103
+ // An embedder is already writing JavaScript and can call the function itself. What
104
+ // rejecting it avoids is a second, weaker evaluation contract: a callback run
105
+ // main-side would receive a resolved ctx.env while process.env around it stayed the
106
+ // caller's, and would skip the mutation diff, the deadline and the cache isolation.
107
+ throw new InvalidConfigValueError(
108
+ pointer(),
109
+ 'a function-valued config is not allowed in a programmatic configuration object; call it and pass the result'
110
+ )
111
+ }
112
+ }
113
+
114
+ throw new InvalidConfigValueError(
115
+ pointer(),
116
+ 'functions cannot be transported; use a file path loaded by the capability instead'
117
+ )
118
+ case 'undefined':
119
+ // Reached only at the root or inside an array: object properties are filtered by the
120
+ // caller below, which is where "omitted" is implemented.
121
+ throw new InvalidConfigValueError(pointer(), 'undefined is not a configuration value')
122
+ }
123
+
124
+ if (types.isProxy(current)) {
125
+ throw new InvalidConfigValueError(pointer(), 'Proxies cannot be transported')
126
+ }
127
+
128
+ if (ancestors.has(current)) {
129
+ throw new InvalidConfigValueError(pointer(), 'circular references cannot be transported')
130
+ }
131
+
132
+ if (Array.isArray(current)) {
133
+ ancestors.add(current)
134
+
135
+ const snapshot = current.map((entry, index) => walk(entry, [...segments, index]))
136
+
137
+ ancestors.delete(current)
138
+ return snapshot
139
+ }
140
+
141
+ if (!isPlainObject(current)) {
142
+ throw new InvalidConfigValueError(pointer(), `${describeValue(current)} values cannot be transported`)
143
+ }
144
+
145
+ ancestors.add(current)
146
+
147
+ const snapshot = {}
148
+ const descriptors = Object.getOwnPropertyDescriptors(current)
149
+
150
+ for (const key of Object.keys(descriptors)) {
151
+ const descriptor = descriptors[key]
152
+
153
+ if (!descriptor.enumerable) {
154
+ continue
155
+ }
156
+
157
+ const childSegments = [...segments, key]
158
+
159
+ // A property that computes on read cannot be transported, and permitting it would make the
160
+ // snapshot unreproducible. Rejected wherever it appears, before it is ever read.
161
+ if (typeof descriptor.get === 'function' || typeof descriptor.set === 'function') {
162
+ throw new InvalidConfigValueError(formatPointer(childSegments), 'accessor properties cannot be transported')
163
+ }
164
+
165
+ if (descriptor.value === undefined) {
166
+ // JSON.stringify semantics: the schema's defaults and required rules speak, rather than an
167
+ // error or a silent undefined crossing the boundary.
168
+ continue
169
+ }
170
+
171
+ const child = walk(descriptor.value, childSegments)
172
+
173
+ if (child === undefined) {
174
+ // A recorded deferred slot. The key stays absent from the snapshot until step 5 splices
175
+ // the resolved value back in.
176
+ continue
177
+ }
178
+
179
+ snapshot[key] = child
180
+ }
181
+
182
+ ancestors.delete(current)
183
+ return snapshot
184
+ }
185
+
186
+ const config = walk(value, [])
187
+
188
+ return { config, deferred: slots }
189
+ }
190
+
191
+ // Step 5 splices each resolved definition back into the slot its function occupied.
192
+ export function spliceDeferredSlot (config, path, value) {
193
+ let current = config
194
+
195
+ for (let i = 0; i < path.length - 1; i++) {
196
+ current = current[path[i]]
197
+
198
+ if (current === undefined || current === null) {
199
+ throw new InvalidConfigValueError(formatPointer(path), 'the deferred slot no longer exists in the snapshot')
200
+ }
201
+ }
202
+
203
+ current[path[path.length - 1]] = value
204
+
205
+ return config
206
+ }
@@ -0,0 +1,149 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join, parse } from 'node:path'
4
+ import semver from 'semver'
5
+ import { CapabilityNotResolvableError } from './errors.js'
6
+
7
+ function readPackageJson (path) {
8
+ try {
9
+ return JSON.parse(readFileSync(path, 'utf-8'))
10
+ } catch {
11
+ return null
12
+ }
13
+ }
14
+
15
+ // A package with an exports map need not expose ./package.json, so fall back to resolving the
16
+ // entry point and walking up to the manifest that names it.
17
+ function findPackageJson (require, module) {
18
+ try {
19
+ return require.resolve(`${module}/package.json`)
20
+ } catch {
21
+ // Fall through to the entry-point walk.
22
+ }
23
+
24
+ let directory = dirname(require.resolve(module))
25
+ const filesystemRoot = parse(directory).root
26
+
27
+ while (true) {
28
+ const candidate = join(directory, 'package.json')
29
+
30
+ if (readPackageJson(candidate)?.name === module) {
31
+ return candidate
32
+ }
33
+
34
+ if (directory === filesystemRoot) {
35
+ throw new Error(`Cannot locate the package.json of ${module}`)
36
+ }
37
+
38
+ directory = dirname(directory)
39
+ }
40
+ }
41
+
42
+ /*
43
+ The canonical capability resolution order: application-scoped first, with the runtime-bundled
44
+ copy as the fallback. It inverts v3, whose worker tried a bare import resolved from
45
+ @platformatic/basic — that is, from the runtime's own position — and only fell back to an
46
+ application-scoped require when that threw.
47
+
48
+ The order is not merely different, it is what makes the version-stamp check implementable at all:
49
+ the stamp compares the factory's copy against the copy the worker will run, so a check resolving
50
+ application-first against a worker resolving lexically would compare a copy nobody executes —
51
+ reporting skew where there is none, and missing it where there is. The same order is applied by
52
+ the worker's implementation import, this check, and the main process's schema import, so the
53
+ three cannot disagree.
54
+ */
55
+ export function resolveCapabilityPackage (module, applicationRoot, { runtimeScope } = {}) {
56
+ const attempts = [
57
+ { scope: 'application', require: createRequire(join(applicationRoot, 'noop.js')) },
58
+ // Resolved from the caller's position for the same reason the schema import is: foundation
59
+ // depends on no capability, so its own position is not where a bundled copy lives.
60
+ { scope: 'runtime', require: createRequire(runtimeScope ?? import.meta.filename) }
61
+ ]
62
+
63
+ for (const { scope, require } of attempts) {
64
+ try {
65
+ const manifestPath = findPackageJson(require, module)
66
+ const manifest = readPackageJson(manifestPath)
67
+
68
+ if (manifest) {
69
+ return { scope, path: dirname(manifestPath), version: manifest.version }
70
+ }
71
+ } catch {
72
+ // Try the next scope. The error raised when both fail names the module and the root, which
73
+ // is more useful than either resolution failure on its own.
74
+ }
75
+ }
76
+
77
+ throw new CapabilityNotResolvableError(module, applicationRoot)
78
+ }
79
+
80
+ /*
81
+ Major mismatch is a boot error; minor mismatch is a warning, since mid-upgrade drift is
82
+ legitimate; patch differences are ignored.
83
+
84
+ A prerelease component on either side demands exact identity: 4.0.0-alpha.1, 4.0.0-rc.2 and 4.0.0
85
+ agree on major, minor and patch while differing in schema and factory shape, so the relaxed
86
+ policy would pair incompatible halves precisely during the alpha and RC period, when they move
87
+ fastest and users are explicitly expected to be on them.
88
+ */
89
+ export function compareCapabilityVersions (stamped, resolved) {
90
+ if (!stamped || !resolved) {
91
+ // A hand-written { module } object carries no stamp and skips the check.
92
+ return { level: 'ok', reason: 'unstamped' }
93
+ }
94
+
95
+ const left = semver.parse(stamped)
96
+ const right = semver.parse(resolved)
97
+
98
+ if (!left || !right) {
99
+ return { level: 'ok', reason: 'unparseable' }
100
+ }
101
+
102
+ if (left.prerelease.length > 0 || right.prerelease.length > 0) {
103
+ return stamped === resolved
104
+ ? { level: 'ok', reason: 'prerelease-identical' }
105
+ : { level: 'error', reason: 'prerelease-mismatch' }
106
+ }
107
+
108
+ if (left.major !== right.major) {
109
+ return { level: 'error', reason: 'major-mismatch' }
110
+ }
111
+
112
+ if (left.minor !== right.minor) {
113
+ return { level: 'warning', reason: 'minor-mismatch' }
114
+ }
115
+
116
+ return { level: 'ok', reason: 'compatible' }
117
+ }
118
+
119
+ /*
120
+ The stamp closes the root/app skew hole: a root-inline factory resolves from the root's copy of
121
+ the capability while the worker implementation may resolve a different one — with pnpm's strict
122
+ layout those can be different versions, letting a 4.1-only option pass the editor and the factory
123
+ only to be rejected by the 4.0 schema at boot, or silently misapplied where the schemas differ
124
+ more subtly. Hoisted layouts, where factory and worker share one copy, never false-positive.
125
+ */
126
+ export function checkCapabilityVersionSkew ({ id, module, stamped, applicationRoot, runtimeScope }) {
127
+ if (!stamped) {
128
+ return null
129
+ }
130
+
131
+ const resolved = resolveCapabilityPackage(module, applicationRoot, { runtimeScope })
132
+ const { level, reason } = compareCapabilityVersions(stamped, resolved.version)
133
+
134
+ if (level === 'ok') {
135
+ return null
136
+ }
137
+
138
+ return {
139
+ level,
140
+ reason,
141
+ id,
142
+ module,
143
+ stamped,
144
+ resolved: resolved.version,
145
+ resolvedPath: resolved.path,
146
+ scope: resolved.scope,
147
+ message: `application '${id}' was configured by a ${module} factory stamped ${stamped}, but the copy the worker will load is ${resolved.version} at ${resolved.path}.`
148
+ }
149
+ }
@@ -0,0 +1,46 @@
1
+ import { describeValue, isPlainObject } from './canonicalize.js'
2
+ import { InvalidConfigurationExportError } from './errors.js'
3
+
4
+ export const applicationDefinitionKey = 'module'
5
+ export const rootConfigurationKeys = ['application', 'applications', 'autoload']
6
+
7
+ // Keys that only a root configuration has. They do not decide classification — rule 2 is
8
+ // unconditional — but they make the error actionable when a root config grew a module property.
9
+ export const rootOnlyKeys = ['autoload', 'workers', 'managementApi', 'applications', 'application']
10
+
11
+ /*
12
+ Four unconditional rules, read off the canonical snapshot and never off the raw export. Rule 1 —
13
+ the function call — has already happened by the time this runs; what is left is total over
14
+ objects, and everything that is not an object is refused ahead of them.
15
+
16
+ null is the one worth spelling out: typeof null === 'object', so it would reach rule 2 as a
17
+ property read on nothing, and the difference between a TypeError from one implementation and an
18
+ AJV error from another is exactly the divergence these rules exist to prevent.
19
+ */
20
+ export function classifyConfiguration (snapshot, file) {
21
+ if (!isPlainObject(snapshot)) {
22
+ throw new InvalidConfigurationExportError(file, describeValue(snapshot))
23
+ }
24
+
25
+ // Rule 2 is unconditional and carries no key-collision check: capabilities legitimately use
26
+ // option names that are also root keys, so any collision list would misclassify valid factory
27
+ // results. It is safe in the other direction because a v4 root config has no module key.
28
+ if (applicationDefinitionKey in snapshot) {
29
+ return 'application'
30
+ }
31
+
32
+ // Rules 3 and 4 agree on the answer; they are separate only because rule 4 is the one that says
33
+ // an empty config file is a statement rather than an absence. Classification answers "what kind
34
+ // of file is this", not "is it usable" — {} classifies here and is then rejected by validation.
35
+ return 'root'
36
+ }
37
+
38
+ export function findRootOnlyKeys (snapshot) {
39
+ return rootOnlyKeys.filter(key => key in snapshot)
40
+ }
41
+
42
+ // Auto-wrapping happens here, on the snapshot, rather than at the point of a shape read: the whole
43
+ // point of the ordering is that nothing reads the raw export's shape.
44
+ export function autoWrapApplicationDefinition (definition) {
45
+ return { application: { config: definition } }
46
+ }
@@ -0,0 +1,52 @@
1
+ import { isAbsolute, resolve } from 'node:path'
2
+
3
+ export const configurationCommands = ['dev', 'build', 'start', 'exec']
4
+
5
+ // production is the common-case shortcut: true under start and --production, and under build,
6
+ // because a build produces production artifacts.
7
+ export function isProductionCommand (command) {
8
+ return command === 'start' || command === 'build'
9
+ }
10
+
11
+ // mode defaults to development under dev and production under build/start. exec is every non-boot
12
+ // evaluation, so it takes its answer from the production flag it was given rather than inventing a
13
+ // third default.
14
+ export function defaultMode (command, production) {
15
+ return (production ?? isProductionCommand(command)) ? 'production' : 'development'
16
+ }
17
+
18
+ /*
19
+ One context object is handed to every callback in a file, so it is frozen rather than merely
20
+ typed Readonly: a config that wrote to ctx.env would change what later deferred entries observe,
21
+ make the result depend on evaluation order, and do it without tripping the process.env mutation
22
+ warning, which watches a different object.
23
+ */
24
+ export function createConfigurationContext ({ command, mode, production, env, root, onWatchFile } = {}) {
25
+ const resolvedProduction = production ?? isProductionCommand(command)
26
+ const resolvedMode = mode ?? defaultMode(command, resolvedProduction)
27
+ const snapshot = Object.freeze({ ...env })
28
+
29
+ // addWatchFile is the one member that is a function rather than data, and it does not make the
30
+ // context mutable: it reports a path outward and returns nothing, so two callbacks in one file
31
+ // cannot observe each other through it. Outside a watching command it is a no-op, which keeps a
32
+ // config that calls it from behaving differently under start.
33
+ function addWatchFile (path) {
34
+ if (typeof path !== 'string' || path.length === 0 || !onWatchFile) {
35
+ return
36
+ }
37
+
38
+ // Relative paths resolve against ctx.root — the config file's own directory, the only stable
39
+ // referent, since a helper that calls this may live anywhere and process.cwd() is wherever the
40
+ // command was typed.
41
+ onWatchFile(isAbsolute(path) ? path : resolve(root, path))
42
+ }
43
+
44
+ return Object.freeze({
45
+ command,
46
+ mode: resolvedMode,
47
+ production: resolvedProduction,
48
+ env: snapshot,
49
+ root,
50
+ addWatchFile
51
+ })
52
+ }