@platformatic/generators 4.0.0-new-config.2 → 4.0.0-new-config.3

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/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export { BaseGenerator } from './lib/base-generator.js'
2
- export { ImportGenerator } from './lib/import-generator.js'
2
+ export { ImportGenerator, importedConfiguration } from './lib/import-generator.js'
3
3
  export * from './lib/utils.js'
@@ -1,20 +1,34 @@
1
1
  import { generateDashedName } from '@platformatic/foundation'
2
+ import {
3
+ capabilityFactories,
4
+ evaluateConfigurationFile,
5
+ configurationFileNameFor,
6
+ listDirectoryEntries,
7
+ raw,
8
+ selectConfigurationFileNames,
9
+ selectLegacyConfigurationFileNames,
10
+ serializeConfiguration
11
+ } from '@platformatic/foundation/lib/v4/index.js'
12
+ import { builders, generateCode, parseModule } from 'magicast'
2
13
  import { readFile } from 'node:fs/promises'
3
- import { join } from 'node:path'
4
- import { generateGitignore } from './create-gitignore.js'
5
- import { MissingEnvVariable, ModuleNeeded, PrepareError } from './errors.js'
6
- import { FileGenerator } from './file-generator.js'
14
+ import { dirname, join } from 'node:path'
7
15
  import {
8
16
  convertApplicationNameToPrefix,
17
+ equivalentSource,
18
+ findAnyConfigurationFile,
19
+ readEnvironmentReferences,
20
+ readEnvFile,
9
21
  envStringToObject,
10
22
  extractEnvVariablesFromText,
11
- flattenObject,
12
23
  getApplicationTemplateFromSchemaUrl,
13
24
  getLatestNpmVersion,
14
25
  getPackageConfigurationObject,
15
26
  PLT_ROOT,
16
27
  stripVersion
17
28
  } from './utils.js'
29
+ import { generateGitignore } from './create-gitignore.js'
30
+ import { MissingEnvVariable, ModuleNeeded, PrepareError } from './errors.js'
31
+ import { FileGenerator } from './file-generator.js'
18
32
 
19
33
  /* c8 ignore start */
20
34
  const fakeLogger = {
@@ -183,23 +197,7 @@ class BaseGenerator extends FileGenerator {
183
197
  if (this.config.isUpdating) {
184
198
  // only the packages options may have changed, let's update those
185
199
  await this.generateConfigFile()
186
- const generatedConfigFile = JSON.parse(this.getFileObject(this.runtimeConfig, '').contents)
187
- const fileFromDisk = await this.loadFile({ file: this.runtimeConfig, path: '' })
188
- const currentConfigFile = JSON.parse(fileFromDisk.contents)
189
- if (currentConfigFile.plugins) {
190
- if (generatedConfigFile.plugins && generatedConfigFile.plugins.packages) {
191
- currentConfigFile.plugins.packages = generatedConfigFile.plugins.packages
192
- } else {
193
- // remove packages because new configuration does not have them
194
- currentConfigFile.plugins.packages = []
195
- }
196
- }
197
- this.reset()
198
- this.addFile({
199
- path: '',
200
- file: this.runtimeConfig,
201
- contents: JSON.stringify(currentConfigFile, null, 2)
202
- })
200
+ await this.#updatePackagesInPlace()
203
201
  } else {
204
202
  await this.getFastifyVersion()
205
203
  await this.getPlatformaticVersion()
@@ -241,9 +239,14 @@ class BaseGenerator extends FileGenerator {
241
239
 
242
240
  checkEnvVariablesInConfigFile () {
243
241
  const excludedEnvs = [PLT_ROOT]
244
- const configFileName = this.runtimeConfig
245
- const fileObject = this.getFileObject(configFileName)
246
- const envVars = extractEnvVariablesFromText(fileObject.contents)
242
+ const configFileName = this.configurationFileName()
243
+ /*
244
+ Read from the configuration the generator built rather than from the file it wrote: the file
245
+ resolves each placeholder into an expression, so the text no longer carries the `{VAR}` this
246
+ is looking for. What is being checked is whether the generator declared every variable it
247
+ used, which is a question about the object.
248
+ */
249
+ const envVars = extractEnvVariablesFromText(JSON.stringify(this.generatedConfig ?? {}))
247
250
  const envKeys = Object.keys(this.config.env)
248
251
  if (envVars.length > 0) {
249
252
  for (const ev of envVars) {
@@ -282,7 +285,7 @@ class BaseGenerator extends FileGenerator {
282
285
  }
283
286
 
284
287
  async generateConfigFile () {
285
- const configFileName = this.runtimeConfig
288
+ const configFileName = this.configurationFileName()
286
289
  const contents = await this._getConfigFileContents()
287
290
  // handle packages
288
291
  if (this.packages.length > 0) {
@@ -308,15 +311,203 @@ class BaseGenerator extends FileGenerator {
308
311
  })
309
312
  }
310
313
 
314
+ /*
315
+ Kept so that what reads this configuration back reads the object rather than re-parsing the
316
+ file: the file is a module now, and its values are expressions rather than the literals the
317
+ generator put in.
318
+ */
319
+ this.generatedConfig = contents
320
+
311
321
  this.addFile({
312
322
  path: '',
313
323
  file: configFileName,
314
- contents: JSON.stringify(contents, null, 2)
324
+ contents: this.serializeConfigFile(contents)
315
325
  })
316
326
 
317
327
  return contents
318
328
  }
319
329
 
330
+ /*
331
+ The v4 per-app form. A capability with a factory is spelled by calling it; one without keeps the
332
+ stamped plain-object form, which is what the `$schema` marker exists for.
333
+
334
+ The placeholders the generator writes are resolved here rather than left as text. v3 substituted
335
+ `{PLT_API_PORT}` before anything read it, and v4 has no interpolation -- so the scaffolded value
336
+ becomes the expression it stood for, with the default the generator was going to write into
337
+ `.env` anyway.
338
+ */
339
+ /*
340
+ An update touches one thing -- which packages the application loads -- and has to leave
341
+ everything else exactly as the user left it.
342
+
343
+ For a v4 configuration that means editing the module rather than rewriting it: its values are
344
+ expressions, so reading it back and re-emitting would bake `Number(process.env.PORT || 3042)`
345
+ into whatever the port happens to be on this machine, and would drop every comment with it.
346
+ */
347
+ async #updatePackagesInPlace () {
348
+ const generated = this.generatedConfig ?? {}
349
+ const packages = generated.plugins?.packages ?? []
350
+ const existing = await this.#findExistingConfiguration()
351
+
352
+ if (!existing) {
353
+ /*
354
+ Nothing on disk to update, which is what an "update" of a directory that does not exist yet
355
+ looks like -- adding an application to a project is one. The generated files stand as they
356
+ are; resetting here would throw away everything this pass just produced.
357
+ */
358
+ return
359
+ }
360
+
361
+ /*
362
+ Only once there is something to rewrite. An update replaces the configuration and nothing
363
+ else, so the staged files go -- but a pass that found no configuration is adding an
364
+ application rather than updating one, and its files are the whole output.
365
+ */
366
+ this.reset()
367
+
368
+ if (existing.file.endsWith('.json')) {
369
+ // A v3 project being updated: it is data, and rewriting it loses nothing it carries.
370
+ const current = JSON.parse(existing.contents)
371
+
372
+ if (current.plugins) {
373
+ current.plugins.packages = packages
374
+ }
375
+
376
+ this.addFile({ path: '', file: existing.file, contents: JSON.stringify(current, null, 2) })
377
+ return
378
+ }
379
+
380
+ const module = parseModule(existing.contents)
381
+ const target = module.exports.default
382
+ // The factory form is a call whose first argument is the configuration; the plain object form
383
+ // is the configuration itself.
384
+ const configuration = target?.$type === 'function-call' ? target.$args[0] : target
385
+
386
+ if (configuration) {
387
+ configuration.plugins ??= {}
388
+ /*
389
+ Resolved the same way the file itself spells them -- the generator's model still holds
390
+ `{PLT_X}`, and writing that into a module would put the placeholder's own text where the
391
+ expression belongs. magicast has its own way of saying "this is source".
392
+ */
393
+ configuration.plugins.packages = this.resolveScaffoldedPlaceholders(packages, builders.raw)
394
+ }
395
+
396
+ const updated = generateCode(module).code
397
+
398
+ /*
399
+ An update that changes nothing about the configuration leaves the file alone. Plugin option
400
+ *values* live in `.env`, so changing one moves nothing here -- and rewriting the file anyway
401
+ would reprint the block, producing a diff that says something changed when nothing did.
402
+ */
403
+ if (equivalentSource(updated, existing.contents)) {
404
+ this.addFile({ path: '', file: existing.file, contents: existing.contents })
405
+ return
406
+ }
407
+
408
+ this.addFile({ path: '', file: existing.file, contents: updated })
409
+ }
410
+
411
+ async #findExistingConfiguration () {
412
+ const entries = await listDirectoryEntries(this.targetDirectory)
413
+ const file = selectConfigurationFileNames(entries)[0] ?? selectLegacyConfigurationFileNames(entries)[0]
414
+
415
+ if (!file) {
416
+ return null
417
+ }
418
+
419
+ const loaded = await this.loadFile({ file, path: '' })
420
+
421
+ return { contents: loaded.contents, file }
422
+ }
423
+
424
+ /*
425
+ The suffix follows the rule the format sets: `.js` in a "type": "commonjs" package is CommonJS,
426
+ where `export default` is a syntax error. The generator wrote that package.json a moment ago in
427
+ this same pass, so it reads its own answer rather than the filesystem's.
428
+ */
429
+ configurationFileName () {
430
+ let module = false
431
+
432
+ try {
433
+ module = JSON.parse(this.getFileObject('package.json', '')?.contents ?? '{}').type === 'module'
434
+ } catch {
435
+ // Not generated, or not JSON yet. The unambiguous suffix is the safe answer.
436
+ }
437
+
438
+ return configurationFileNameFor({ module, typescript: this.config.typescript })
439
+ }
440
+
441
+ serializeConfigFile (config) {
442
+ const { $schema, module: declared, ...rest } = config
443
+ const module = declared ?? this.module
444
+ const factory = capabilityFactories[module]
445
+ const resolved = this.resolveScaffoldedPlaceholders(rest)
446
+
447
+ if (!factory) {
448
+ return `export default ${serializeConfiguration({ $schema, module, ...resolved })}\n`
449
+ }
450
+
451
+ return `import { ${factory} } from '${module}'\n\nexport default ${factory}(${serializeConfiguration(resolved)})\n`
452
+ }
453
+
454
+ resolveScaffoldedPlaceholders (value, expression = raw) {
455
+ if (typeof value === 'string') {
456
+ const whole = value.match(/^\{([A-Za-z0-9_]+)\}$/)
457
+
458
+ if (!whole) {
459
+ return value
460
+ }
461
+
462
+ const name = whole[1]
463
+ const fallback = this.config.env?.[name]
464
+
465
+ if (fallback === undefined) {
466
+ return expression(`process.env.${name}`)
467
+ }
468
+
469
+ /*
470
+ A numeric default is written into the expression, and `||` rather than `??`: an env file
471
+ carrying the ordinary empty assignment `PORT=` supplies `''`, which is present, so `??`
472
+ would not fall back and `Number('')` is an ephemeral port where the reader of that line
473
+ expects 3042.
474
+
475
+ Everything else is a bare reference. Its value lives in `.env` -- the wizard's answers among
476
+ them -- and copying it into the configuration would put the same fact in two places, where
477
+ editing one leaves the other saying something else.
478
+ */
479
+ /*
480
+ A boolean default becomes a comparison, because v4 validates without coercion: a bare
481
+ reference hands the schema the string 'true', which is neither of the things a boolean
482
+ position accepts. The comparison reproduces the scaffolded default when the variable is
483
+ absent and lets its opposite flip it, which is what the value was there to express.
484
+ */
485
+ if (fallback === 'true' || fallback === true) {
486
+ return expression(`process.env.${name} !== 'false'`)
487
+ }
488
+
489
+ if (fallback === 'false' || fallback === false) {
490
+ return expression(`process.env.${name} === 'true'`)
491
+ }
492
+
493
+ return /^[0-9]+$/.test(String(fallback))
494
+ ? expression(`Number(process.env.${name} || ${fallback})`)
495
+ : expression(`process.env.${name}`)
496
+ }
497
+
498
+ if (value === null || typeof value !== 'object') {
499
+ return value
500
+ }
501
+
502
+ if (Array.isArray(value)) {
503
+ return value.map(entry => this.resolveScaffoldedPlaceholders(entry, expression))
504
+ }
505
+
506
+ return Object.fromEntries(
507
+ Object.entries(value).map(([key, entry]) => [key, this.resolveScaffoldedPlaceholders(entry, expression)])
508
+ )
509
+ }
510
+
320
511
  /**
321
512
  * Reads the content of package.json and returns it as an object
322
513
  * @returns Object
@@ -421,36 +612,77 @@ class BaseGenerator extends FileGenerator {
421
612
  this.packages.push(pkg)
422
613
  }
423
614
 
615
+ /*
616
+ Reads a configuration file whichever dialect it is in. A v3 file is data and parses; a v4 one is
617
+ a module whose values are expressions, so the only way to know what it says is to evaluate it --
618
+ which is what the loader does, in a worker with an explicit environment.
619
+ */
620
+ async readConfigurationFile (path, role = 'root', envRoot = null) {
621
+ if (path.endsWith('.json')) {
622
+ return JSON.parse(await readFile(path, 'utf-8'))
623
+ }
624
+
625
+ /*
626
+ The project's own environment, because the file's values are expressions that read it: a port
627
+ written as `Number(process.env.PORT || 3042)` evaluates to NaN without it, and the generator
628
+ would be reading a configuration the project never has.
629
+ */
630
+ const env = { ...process.env, ...(await readEnvFile(envRoot ?? dirname(path))) }
631
+
632
+ // The role decides how the file is classified, and a root read as an application -- or the
633
+ // reverse -- is refused rather than silently accepted.
634
+ const { config } = await evaluateConfigurationFile({ path, env, command: 'start', production: false, role })
635
+
636
+ return config
637
+ }
638
+
424
639
  async loadFromDir (applicationName, runtimeRootPath) {
425
- const runtimePkgConfigFileData = JSON.parse(await readFile(join(runtimeRootPath, this.runtimeConfig), 'utf-8'))
640
+ const runtimePkgConfigFileData = await this.readConfigurationFile(
641
+ join(runtimeRootPath, await findAnyConfigurationFile(runtimeRootPath)),
642
+ 'root',
643
+ runtimeRootPath
644
+ )
426
645
  const applicationsPath = runtimePkgConfigFileData.autoload?.path ?? DEFAULT_SERVICES_PATH
427
- const applicationPkgJsonFileData = JSON.parse(
428
- await readFile(join(runtimeRootPath, applicationsPath, applicationName, 'platformatic.json'), 'utf-8')
646
+ const applicationRoot = join(runtimeRootPath, applicationsPath, applicationName)
647
+ // The environment is the runtime's, not the application's: the .env sits at the root beside
648
+ // the configuration that autoloads it.
649
+ const applicationPkgJsonFileData = await this.readConfigurationFile(
650
+ join(applicationRoot, await findAnyConfigurationFile(applicationRoot)),
651
+ 'application',
652
+ runtimeRootPath
429
653
  )
430
654
  const runtimeEnv = envStringToObject(await readFile(join(runtimeRootPath, '.env'), 'utf-8'))
431
655
  const applicationNamePrefix = convertApplicationNameToPrefix(applicationName)
656
+ /*
657
+ Which options read the environment, taken from the source rather than from the loaded value.
658
+ A v3 configuration carried `"{PLT_X}"` in its data and survived being read as JSON; a v4 one
659
+ says `process.env.PLT_X` in its code, and reading it here gives whatever that produced in
660
+ *this* process -- `undefined`, since these are the scaffolded application's variables and not
661
+ ours. The name is in the file, so that is where it is read from.
662
+ */
663
+ const applicationConfigurationFile = join(applicationRoot, await findAnyConfigurationFile(applicationRoot))
664
+ const environmentReferences = readEnvironmentReferences(await readFile(applicationConfigurationFile, 'utf-8'))
432
665
  const plugins = []
433
666
  if (applicationPkgJsonFileData.plugins && applicationPkgJsonFileData.plugins.packages) {
434
- for (const pkg of applicationPkgJsonFileData.plugins.packages) {
435
- const flattened = flattenObject(pkg)
667
+ for (const [index, pkg] of applicationPkgJsonFileData.plugins.packages.entries()) {
436
668
  const output = {
437
- name: flattened.name,
669
+ name: pkg.name,
438
670
  options: []
439
671
  }
440
- if (pkg.options) {
441
- Object.entries(flattened)
442
- .filter(([key, value]) => key.indexOf('options.') === 0 && flattened[key].startsWith('{PLT_'))
443
- .forEach(([key, value]) => {
444
- const runtimeEnvVarKey = value.replace(/[{}]/g, '')
445
- const applicationEnvVarKey = runtimeEnvVarKey.replace(`PLT_${applicationNamePrefix}_`, '')
446
- const option = {
447
- name: applicationEnvVarKey,
448
- path: key.replace('options.', ''),
449
- type: 'string',
450
- value: runtimeEnv[runtimeEnvVarKey]
451
- }
452
- output.options.push(option)
453
- })
672
+
673
+ const prefix = `plugins.packages.${index}.options.`
674
+
675
+ for (const [path, runtimeEnvVarKey] of environmentReferences) {
676
+ if (!path.startsWith(prefix)) {
677
+ continue
678
+ }
679
+
680
+ output.options.push({
681
+ name: runtimeEnvVarKey.replace(`PLT_${applicationNamePrefix}_`, ''),
682
+ path: path.slice(prefix.length),
683
+ type: 'string',
684
+ value: runtimeEnv[runtimeEnvVarKey]
685
+ })
454
686
  }
455
687
 
456
688
  plugins.push(output)
@@ -459,7 +691,13 @@ class BaseGenerator extends FileGenerator {
459
691
 
460
692
  return {
461
693
  name: applicationName,
462
- template: getApplicationTemplateFromSchemaUrl(applicationPkgJsonFileData.$schema),
694
+ /*
695
+ A v4 configuration says which capability it is outright -- the factory sets `module` -- and
696
+ a v3 one says it through the `$schema` URL, which is what that reader is for.
697
+ */
698
+ template:
699
+ applicationPkgJsonFileData.module ??
700
+ getApplicationTemplateFromSchemaUrl(applicationPkgJsonFileData.$schema),
463
701
  fields: [],
464
702
  plugins
465
703
  }
@@ -1,9 +1,20 @@
1
1
  import { findConfigurationFileRecursive, safeRemove } from '@platformatic/foundation'
2
+ import { capabilityFactories } from '@platformatic/foundation/lib/v4/index.js'
2
3
  import { spawnSync } from 'node:child_process'
3
4
  import { readFile, readdir, stat } from 'node:fs/promises'
4
- import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
5
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
5
6
  import { BaseGenerator } from './base-generator.js'
6
7
 
8
+ export function importedConfiguration (pkg) {
9
+ const factory = capabilityFactories[pkg]
10
+
11
+ if (factory) {
12
+ return `import { ${factory} } from '${pkg}'\n\nexport default ${factory}({})\n`
13
+ }
14
+
15
+ return `export default {\n module: '${pkg}'\n}\n`
16
+ }
17
+
7
18
  export class ImportGenerator extends BaseGenerator {
8
19
  constructor (options = {}) {
9
20
  const { applicationName, module, version, parent: runtime, ...opts } = options
@@ -124,7 +135,7 @@ export class ImportGenerator extends BaseGenerator {
124
135
  }
125
136
 
126
137
  async #generateConfigFile (originalPath, updatedPath) {
127
- // Determine if there is a watt.json file in the application path - If it's missing, insert one
138
+ // Determine if there is a config file in the application path - If it's missing, insert one
128
139
  // For import it means we don't update the file, for copy it means it was already copied in #copy.
129
140
  const existingConfig = await findConfigurationFileRecursive(originalPath)
130
141
 
@@ -132,21 +143,18 @@ export class ImportGenerator extends BaseGenerator {
132
143
  return
133
144
  }
134
145
 
135
- const { module: pkg, version } = this.config
136
-
137
- if (pkg.startsWith('@platformatic/')) {
138
- this.addFile({
139
- path: '',
140
- file: join(updatedPath, this.runtimeConfig),
141
- contents: JSON.stringify({ $schema: `https://schemas.platformatic.dev/${pkg}/${version}.json` }, null, 2)
142
- })
143
- } else {
144
- this.addFile({
145
- path: '',
146
- file: join(updatedPath, this.runtimeConfig),
147
- contents: JSON.stringify({ module: pkg }, null, 2)
148
- })
149
- }
146
+ const { module: pkg } = this.config
147
+
148
+ /*
149
+ The v4 per-app form rather than a JSON stub. This is the path taken for a capability that
150
+ ships no generator of its own, so a stub here would leave the wizard writing the old dialect
151
+ for exactly the applications least likely to be exercised by anything else.
152
+ */
153
+ this.addFile({
154
+ path: '',
155
+ file: join(updatedPath, 'watt.config.mjs'),
156
+ contents: importedConfiguration(pkg)
157
+ })
150
158
  }
151
159
 
152
160
  async #updatePackageJson (originalPath, updatedPath, pkg, version) {
@@ -170,43 +178,37 @@ export class ImportGenerator extends BaseGenerator {
170
178
  }
171
179
 
172
180
  async #updateRuntime (runtime) {
173
- const configObject = runtime.getRuntimeConfigFileObject()
174
- /* c8 ignore next - else */
175
- const config = JSON.parse(configObject?.contents ?? '{}')
176
- const envObject = runtime.getRuntimeEnvFileObject()
177
- /* c8 ignore next - else */
178
- let env = envObject?.contents ?? ''
179
-
180
- // Find which key is being used for the manual applications
181
- let key
182
- for (const candidate of new Set([runtime.applicationsFolder, 'applications', 'services', 'web'])) {
183
- if (Array.isArray(config[candidate])) {
184
- key = candidate
185
- break
186
- }
187
- }
188
-
189
- /* c8 ignore next - else */
190
- key ??= runtime.applicationsFolder ?? 'applications'
191
- const applications = config[key] ?? []
181
+ /*
182
+ The configuration the runtime generator built, not a parse of the file it wrote: that file is
183
+ a module now, and its values are expressions rather than the data this is about to add an
184
+ entry to. It is written back through the same generator, which knows how to spell it.
185
+
186
+ One spelling: the list is `applications`, because the loader refuses the v3 aliases by name.
187
+ And a literal path rather than v3's `{PLT_APPLICATION_<ID>_PATH}` placeholder plus its `.env`
188
+ line -- the indirection bought nothing and left the entry pointing at the root itself in
189
+ every clone missing the gitignored file the value lived in.
190
+ */
191
+ const config = runtime.generatedConfig ?? {}
192
+ const applications = Array.isArray(config.applications) ? config.applications : []
192
193
 
193
194
  if (!applications.some(application => application.id === this.config.applicationName)) {
195
+ const base = this.runtime?.targetDirectory ?? this.targetDirectory
196
+ const absolute = isAbsolute(this.config.applicationPath)
197
+ ? this.config.applicationPath
198
+ : resolve(base, this.config.applicationPath)
199
+ const relativePath = relative(base, absolute)
200
+
194
201
  applications.push({
195
202
  id: this.config.applicationName,
196
- path: `{${this.config.applicationPathEnvName}}`,
203
+ // A cross-drive path has no relative spelling; `relative` answers with the absolute one.
204
+ path: (isAbsolute(relativePath) ? absolute : relativePath).split(sep).join('/'),
197
205
  url: this.config.gitUrl
198
206
  })
199
207
  }
200
208
 
201
- config[key] = applications
202
-
203
- if (env.length > 0) {
204
- env += '\n'
205
- }
206
- env += `${this.config.applicationPathEnvName}=${this.config.applicationPath}`
209
+ config.applications = applications
207
210
 
208
211
  runtime.updateRuntimeConfig(config)
209
- runtime.updateRuntimeEnv(env)
210
212
  }
211
213
 
212
214
  async #copy (root) {
package/lib/utils.js CHANGED
@@ -1,3 +1,10 @@
1
+ import {
2
+ listDirectoryEntries,
3
+ selectConfigurationFileNames,
4
+ selectLegacyConfigurationFileNames
5
+ } from '@platformatic/foundation/lib/v4/index.js'
6
+ import { builders, generateCode, parseModule } from 'magicast'
7
+ import { readFile } from 'node:fs/promises'
1
8
  import { EOL } from 'node:os'
2
9
  import { join } from 'node:path'
3
10
  import { setTimeout } from 'timers/promises'
@@ -180,3 +187,178 @@ export function getApplicationTemplateFromSchemaUrl (schemaUrl) {
180
187
  }
181
188
  return `@platformatic/${splitted[splitted.length - 2]}`
182
189
  }
190
+
191
+ /*
192
+ The configuration file in a directory, whichever dialect it is in. A generator reading an existing
193
+ project meets both: one it scaffolded under v4, and one that predates the switch.
194
+ */
195
+ export async function findAnyConfigurationFile (directory) {
196
+ const entries = await listDirectoryEntries(directory)
197
+
198
+ return selectConfigurationFileNames(entries)[0] ?? selectLegacyConfigurationFileNames(entries)[0] ?? null
199
+ }
200
+
201
+ // The environment a project supplies to its own configuration. Absent is the same as empty here:
202
+ // a project without one simply has nothing to layer.
203
+ export async function readEnvFile (directory) {
204
+ try {
205
+ return envStringToObject(await readFile(join(directory, '.env'), 'utf-8'))
206
+ } catch {
207
+ return {}
208
+ }
209
+ }
210
+
211
+ /*
212
+ Whether two spellings of a module say the same thing. Used to decide that an edit produced no
213
+ change: the printer reflows what it touches, so comparing the text byte for byte would report a
214
+ change on every update whether or not one happened.
215
+ */
216
+ export function equivalentSource (left, right) {
217
+ /*
218
+ Whitespace removed rather than collapsed, because the printer's difference is exactly one space:
219
+ `packages: [{` against `packages: [\n {`. Two spellings that differ only inside a string
220
+ literal would compare equal here, and the consequence of that is leaving a file alone whose only
221
+ change was spaces inside a string -- which is the harmless direction to be wrong in.
222
+ */
223
+ return left.replace(/\s/g, '') === right.replace(/\s/g, '')
224
+ }
225
+
226
+ /*
227
+ A string to be printed as source rather than as a string literal. Exported so that a caller
228
+ outside this package can hand one to `resolveScaffoldedPlaceholders` without taking on the AST
229
+ library itself.
230
+ */
231
+ export const rawSource = builders.raw
232
+
233
+ /*
234
+ Add applications to an existing root configuration by editing the file the user has, rather than
235
+ by writing a new one from the configuration the loader returned.
236
+
237
+ The difference is everything the source says and the loaded configuration does not:
238
+ `process.env.PLT_SERVER_LOGGER_LEVEL` comes back as `'info'`, a factory call comes back as the
239
+ object it built, and comments come back not at all. Re-emitting replaces each of those with the
240
+ value it happened to have on the machine doing the writing.
241
+
242
+ Returns `null` when the file's shape cannot be edited in place -- a configuration built by a call,
243
+ or held in a variable, or an application list that is not a literal array. There is nothing to
244
+ edit there, and the caller is expected to say so rather than to write something else.
245
+ */
246
+ export function appendApplications (source, entries, resolveEntry = entry => entry) {
247
+ const module = parseModule(source)
248
+ const target = module.exports.default
249
+ // The plain object form exports the configuration; the factory form passes it as the first argument.
250
+ const configuration = target?.$type === 'function-call' ? target.$args[0] : target
251
+
252
+ if (configuration?.$type !== 'object') {
253
+ return null
254
+ }
255
+
256
+ // One spelling: the loader refuses `services` and `web` by name, so a file this editor sees
257
+ // lists its applications under `applications` or not at all.
258
+ const key = 'applications'
259
+ const listed = configuration[key] ?? []
260
+
261
+ if (!Array.isArray(listed)) {
262
+ return null
263
+ }
264
+
265
+ const present = new Set(Array.from(listed, entry => entry.id))
266
+ const added = entries.filter(entry => entry.id && !present.has(entry.id))
267
+
268
+ if (added.length === 0) {
269
+ return source
270
+ }
271
+
272
+ configuration[key] = [...listed, ...added.map(entry => resolveEntry(entry))]
273
+
274
+ return generateCode(module).code
275
+ }
276
+
277
+ // The applications a configuration lists. One spelling: the v3 aliases are refused by the loader.
278
+ export function listedApplications (config) {
279
+ return config?.applications ?? []
280
+ }
281
+
282
+ /*
283
+ The environment variable an expression reads, if it reads one.
284
+
285
+ `process.env.NAME` is the bare form; `process.env.NAME ?? ''` and `process.env.NAME || 3042` are
286
+ what a writer emits when the position needs a fallback. All three name the same variable, and the
287
+ fallback is not part of the name.
288
+ */
289
+ function environmentReference (node) {
290
+ if (!node) {
291
+ return null
292
+ }
293
+
294
+ if (node.type === 'LogicalExpression') {
295
+ return environmentReference(node.left)
296
+ }
297
+
298
+ if (node.type !== 'MemberExpression' || node.property?.type !== 'Identifier') {
299
+ return null
300
+ }
301
+
302
+ const { object } = node
303
+
304
+ if (object?.type !== 'MemberExpression' || object.object?.name !== 'process' || object.property?.name !== 'env') {
305
+ return null
306
+ }
307
+
308
+ return node.property.name
309
+ }
310
+
311
+ function collectEnvironmentReferences (node, path, found) {
312
+ if (!node) {
313
+ return
314
+ }
315
+
316
+ if (node.type === 'ObjectExpression') {
317
+ for (const property of node.properties) {
318
+ const key = property.key?.name ?? property.key?.value
319
+
320
+ if (key !== undefined) {
321
+ collectEnvironmentReferences(property.value, [...path, key], found)
322
+ }
323
+ }
324
+
325
+ return
326
+ }
327
+
328
+ if (node.type === 'ArrayExpression') {
329
+ node.elements.forEach((element, index) => collectEnvironmentReferences(element, [...path, index], found))
330
+ return
331
+ }
332
+
333
+ const name = environmentReference(node)
334
+
335
+ if (name) {
336
+ found.set(path.join('.'), name)
337
+ }
338
+ }
339
+
340
+ /*
341
+ Which values in a configuration come from the environment, by their path in it.
342
+
343
+ A v3 configuration said this in its data: `"port": "{PORT}"` survives being read as JSON. A v4
344
+ configuration says it in its code, and reading the file gives you the value the expression
345
+ produced -- `undefined`, for a variable that is not set in the process doing the reading. So a
346
+ tool that needs to know *which* variable a setting reads has to look at the source.
347
+ */
348
+ export function readEnvironmentReferences (source) {
349
+ const { $ast: ast } = parseModule(source)
350
+ const found = new Map()
351
+
352
+ const declaration = ast.body.find(node => node.type === 'ExportDefaultDeclaration')?.declaration
353
+
354
+ if (!declaration) {
355
+ return found
356
+ }
357
+
358
+ // The plain-object form exports the configuration; a factory or `defineConfig` passes it along.
359
+ const configuration = declaration.type === 'CallExpression' ? declaration.arguments[0] : declaration
360
+
361
+ collectEnvironmentReferences(configuration, [], found)
362
+
363
+ return found
364
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/generators",
3
- "version": "4.0.0-new-config.2",
3
+ "version": "4.0.0-new-config.3",
4
4
  "description": "Main classes and utils for generators.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -21,9 +21,10 @@
21
21
  "change-case-all": "^2.1.0",
22
22
  "execa": "^9.6.0",
23
23
  "fastify": "^5.0.0",
24
+ "magicast": "^0.5.4",
24
25
  "pino": "^9.9.0",
25
- "undici": "^7.27.2",
26
- "@platformatic/foundation": "4.0.0-new-config.2"
26
+ "undici": "^8.5.0",
27
+ "@platformatic/foundation": "4.0.0-new-config.3"
27
28
  },
28
29
  "devDependencies": {
29
30
  "@types/inquirer": "^9.0.7",