wattpm 2.19.0 → 2.20.0-alpha.2

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,5 +1,5 @@
1
1
  import { bold } from 'colorette'
2
- import { buildCommand } from './lib/commands/build.js'
2
+ import { buildCommand, installCommand } from './lib/commands/build.js'
3
3
  import { devCommand, reloadCommand, restartCommand, startCommand, stopCommand } from './lib/commands/execution.js'
4
4
  import { importCommand, resolveCommand } from './lib/commands/external.js'
5
5
  import { helpCommand } from './lib/commands/help.js'
@@ -11,6 +11,8 @@ import { version } from './lib/schema.js'
11
11
  import { createLogger, overrideFatal, parseArgs, setVerbose } from './lib/utils.js'
12
12
 
13
13
  export async function main () {
14
+ globalThis.platformatic = { executable: 'watt' }
15
+
14
16
  const logger = createLogger('info')
15
17
 
16
18
  overrideFatal(logger)
@@ -92,6 +94,9 @@ export async function main () {
92
94
  case 'resolve':
93
95
  command = resolveCommand
94
96
  break
97
+ case 'install':
98
+ command = installCommand
99
+ break
95
100
  case 'help':
96
101
  command = helpCommand
97
102
  break
@@ -106,3 +111,5 @@ export async function main () {
106
111
  }
107
112
 
108
113
  export * from './lib/schema.js'
114
+
115
+ export { resolveServices } from './lib/commands/external.js'
@@ -1,7 +1,88 @@
1
1
  import { ensureLoggableError } from '@platformatic/utils'
2
2
  import { bold } from 'colorette'
3
+ import { parse } from 'dotenv'
4
+ import { execa } from 'execa'
5
+ import { existsSync } from 'node:fs'
6
+ import { readFile } from 'node:fs/promises'
3
7
  import { resolve } from 'node:path'
4
- import { buildRuntime, findConfigurationFile, overrideFatal, parseArgs } from '../utils.js'
8
+ import { buildRuntime, findConfigurationFile, loadConfigurationFile, overrideFatal, parseArgs } from '../utils.js'
9
+
10
+ // This function will not perform the command if the .npmrc file contains the 'dry-run' flag - This is useful in tests
11
+ async function executeCommand (root, ...args) {
12
+ const npmrc = resolve(root, '.npmrc')
13
+ if (existsSync(npmrc)) {
14
+ try {
15
+ const env = parse(await readFile(npmrc, 'utf8'))
16
+
17
+ if (env['dry-run'] === 'true') {
18
+ return
19
+ }
20
+ /* c8 ignore next 5 */
21
+ } catch (error) {
22
+ // No-op
23
+ }
24
+ }
25
+
26
+ /* c8 ignore next */
27
+ return execa(...args)
28
+ }
29
+
30
+ export async function installDependencies (logger, root, services, production, packageManager) {
31
+ if (typeof services === 'string') {
32
+ const config = await loadConfigurationFile(logger, services)
33
+ services = config.services
34
+ }
35
+
36
+ /* c8 ignore next 8 */
37
+ if (!packageManager) {
38
+ if (existsSync(resolve(root, 'pnpm-lock.yaml'))) {
39
+ packageManager = 'pnpm'
40
+ } else {
41
+ packageManager = 'npm'
42
+ }
43
+ }
44
+
45
+ const args = ['install']
46
+
47
+ if (production) {
48
+ switch (packageManager) {
49
+ case 'pnpm':
50
+ args.push('--prod')
51
+ break
52
+ case 'npm':
53
+ args.push('--omit=dev')
54
+ break
55
+ }
56
+ }
57
+
58
+ // Install dependencies of the application
59
+ try {
60
+ logger.info(
61
+ `Installing ${production ? 'production ' : ''}dependencies for the application using ${packageManager} ...`
62
+ )
63
+
64
+ await executeCommand(root, packageManager, args, { cwd: root, stdio: 'inherit' })
65
+ /* c8 ignore next 3 */
66
+ } catch (error) {
67
+ logger.fatal({ error: ensureLoggableError(error) }, 'Unable to install dependencies of the application.')
68
+ }
69
+
70
+ for (const service of services) {
71
+ try {
72
+ logger.info(
73
+ `Installing ${production ? 'production ' : ''}dependencies for the service ${bold(service.id)} using ${packageManager} ...`
74
+ )
75
+
76
+ await executeCommand(root, packageManager, args, { cwd: resolve(root, service.path), stdio: 'inherit' })
77
+ /* c8 ignore next 6 */
78
+ } catch (error) {
79
+ logger.fatal(
80
+ { error: ensureLoggableError(error) },
81
+ `Unable to install dependencies of the service ${bold(service.id)}.`
82
+ )
83
+ }
84
+ }
85
+ }
5
86
 
6
87
  export async function buildCommand (logger, args) {
7
88
  const { positionals } = parseArgs(args, {}, false)
@@ -40,6 +121,34 @@ export async function buildCommand (logger, args) {
40
121
  await runtime.close(false, true)
41
122
  }
42
123
 
124
+ export async function installCommand (logger, args) {
125
+ const {
126
+ values: { production, 'package-manager': packageManager },
127
+ positionals
128
+ } = parseArgs(
129
+ args,
130
+ {
131
+ production: {
132
+ type: 'boolean',
133
+ short: 'p',
134
+ default: false
135
+ },
136
+ 'package-manager': {
137
+ type: 'string',
138
+ short: 'P'
139
+ }
140
+ },
141
+ false
142
+ )
143
+
144
+ /* c8 ignore next */
145
+ const root = resolve(process.cwd(), positionals[0] ?? '')
146
+ const configurationFile = await findConfigurationFile(logger, root)
147
+
148
+ await installDependencies(logger, root, configurationFile, production, packageManager)
149
+ logger.done('All services have been resolved.')
150
+ }
151
+
43
152
  export const help = {
44
153
  build: {
45
154
  usage: 'build [root]',
@@ -50,5 +159,25 @@ export const help = {
50
159
  description: 'The directory containing the application (the default is the current directory)'
51
160
  }
52
161
  ]
162
+ },
163
+ install: {
164
+ usage: 'install [root]',
165
+ description: 'Install all dependencies of an application and its services',
166
+ args: [
167
+ {
168
+ name: 'root',
169
+ description: 'The directory containing the application (the default is the current directory)'
170
+ }
171
+ ],
172
+ options: [
173
+ {
174
+ usage: '-p --production',
175
+ description: 'Only install production dependencies'
176
+ },
177
+ {
178
+ usage: '-P, --package-manager <executable>',
179
+ description: 'Use an alternative package manager (the default is to autodetect it)'
180
+ }
181
+ ]
53
182
  }
54
183
  }
@@ -1,56 +1,26 @@
1
1
  import { configCandidates } from '@platformatic/basic'
2
- import { Store } from '@platformatic/config'
3
- import { platformaticRuntime } from '@platformatic/runtime'
4
2
  import { ensureLoggableError } from '@platformatic/utils'
5
3
  import { bold } from 'colorette'
4
+ import { parse } from 'dotenv'
6
5
  import { execa } from 'execa'
7
- import { existsSync, } from 'node:fs'
8
- import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
9
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
6
+ import { existsSync } from 'node:fs'
7
+ import { readFile, writeFile } from 'node:fs/promises'
8
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
10
9
  import { defaultServiceJson } from '../defaults.js'
11
10
  import { version } from '../schema.js'
12
- import { findConfigurationFile, overrideFatal, parseArgs } from '../utils.js'
11
+ import {
12
+ findConfigurationFile,
13
+ loadConfigurationFile,
14
+ loadRawConfigurationFile,
15
+ overrideFatal,
16
+ parseArgs,
17
+ saveConfigurationFile,
18
+ serviceToEnvVariable
19
+ } from '../utils.js'
20
+ import { installDependencies } from './build.js'
13
21
 
14
22
  const originCandidates = ['origin', 'upstream']
15
23
 
16
- export async function checkEmptyDirectory (logger, path, relativePath) {
17
- if (existsSync(path)) {
18
- const statObject = await stat(path)
19
-
20
- if (!statObject.isDirectory()) {
21
- logger.fatal(`Path ${bold(relativePath)} exists but it is not a directory.`)
22
- }
23
-
24
- const entries = await readdir(path)
25
-
26
- if (entries.filter(e => !e.startsWith('.')).length) {
27
- logger.fatal(`Directory ${bold(relativePath)} is not empty.`)
28
- }
29
- }
30
- }
31
-
32
- async function parseConfiguration (logger, configurationFile) {
33
- const store = new Store({
34
- cwd: process.cwd(),
35
- logger
36
- })
37
- store.add(platformaticRuntime)
38
-
39
- const { configManager } = await store.loadConfig({
40
- config: configurationFile,
41
- overrides: {
42
- /* c8 ignore next 3 */
43
- onMissingEnv (key) {
44
- return ''
45
- }
46
- }
47
- })
48
-
49
- await configManager.parse()
50
-
51
- return configManager.current
52
- }
53
-
54
24
  async function parseLocalFolder (path) {
55
25
  // Read the package.json, if any
56
26
  const packageJsonPath = resolve(path, 'package.json')
@@ -103,54 +73,36 @@ async function findExistingConfiguration (root, path) {
103
73
  }
104
74
  }
105
75
 
106
- async function addService (configurationFile, id, path, url) {
107
- const config = JSON.parse(await readFile(configurationFile, 'utf-8'))
108
- const root = dirname(configurationFile)
76
+ export async function appendEnvVariable (envFile, key, value) {
77
+ let contents = ''
109
78
 
110
- let autoloadPath = config.autoload?.path
79
+ if (existsSync(envFile)) {
80
+ contents = await readFile(envFile, 'utf-8')
111
81
 
112
- if (autoloadPath) {
113
- autoloadPath = join(root, autoloadPath)
114
- if (path.startsWith(autoloadPath)) {
115
- return
82
+ if (contents.length && !contents.endsWith('\n')) {
83
+ contents += '\n'
116
84
  }
117
85
  }
118
86
 
119
- /* c8 ignore next */
120
- config.web ??= []
121
- config.web.push({ id, path, url })
87
+ contents += `${key}=${value}\n`
122
88
 
123
- await writeFile(configurationFile, JSON.stringify(config, null, 2), 'utf-8')
89
+ return writeFile(envFile, contents, 'utf-8')
124
90
  }
125
91
 
126
92
  async function fixConfiguration (logger, root) {
127
93
  const configurationFile = await findConfigurationFile(logger, root)
128
- const config = JSON.parse(await readFile(configurationFile, 'utf-8'))
129
-
130
- // Load all services in the autoload and the one manually specified
131
- const services = []
132
- const autoLoadPath = config.autoload?.path
133
- if (autoLoadPath) {
134
- for (const path of await readdir(resolve(root, autoLoadPath))) {
135
- services.push(join(autoLoadPath, path))
136
- }
137
- }
138
-
139
- /* c8 ignore next */
140
- for (const service of config.services ?? []) {
141
- services.push(service.path)
142
- }
94
+ const config = await loadConfigurationFile(logger, configurationFile)
143
95
 
144
96
  // For each service, if there is no watt.json, create one and fix package dependencies
145
- for (const service of services) {
146
- const wattConfiguration = await findExistingConfiguration(root, service)
97
+ for (const { path } of config.services) {
98
+ const wattConfiguration = await findExistingConfiguration(root, path)
147
99
 
148
100
  /* c8 ignore next 3 */
149
101
  if (wattConfiguration) {
150
102
  continue
151
103
  }
152
104
 
153
- const { id, packageJson, stackable } = await parseLocalFolder(resolve(root, service))
105
+ const { id, packageJson, stackable } = await parseLocalFolder(resolve(root, path))
154
106
 
155
107
  packageJson.dependencies ??= {}
156
108
  packageJson.dependencies[stackable] = `^${version}`
@@ -161,28 +113,102 @@ async function fixConfiguration (logger, root) {
161
113
  }
162
114
 
163
115
  logger.info(`Detected stackable ${bold(stackable)} for service ${bold(id)}, adding to the service dependencies.`)
164
- await writeFile(resolve(root, service, 'package.json'), JSON.stringify(packageJson, null, 2), 'utf-8')
165
- await writeFile(resolve(root, service, 'watt.json'), JSON.stringify(wattJson, null, 2), 'utf-8')
116
+
117
+ await saveConfigurationFile(logger, resolve(path, 'package.json'), packageJson)
118
+ await saveConfigurationFile(logger, resolve(path, 'watt.json'), wattJson)
166
119
  }
167
120
  }
168
121
 
169
- async function importLocal (logger, root, configurationFile, path) {
170
- const { id, url, packageJson, stackable } = await parseLocalFolder(path)
122
+ async function importService (logger, configurationFile, id, path, url) {
123
+ const config = await loadConfigurationFile(logger, configurationFile)
124
+ const rawConfig = await loadRawConfigurationFile(logger, configurationFile)
125
+ const root = dirname(configurationFile)
126
+ const envFile = resolve(root, '.env')
127
+ const envSampleFile = resolve(root, '.env.sample')
128
+ const envVariable = serviceToEnvVariable(id)
129
+
130
+ let useEnv = true
131
+
132
+ // If there is a locale path
133
+ if (path) {
134
+ let autoloadPath = config.autoload?.path
135
+
136
+ // If we already autoload this path, there is nothing to do
137
+ if (autoloadPath) {
138
+ autoloadPath = resolve(root, autoloadPath)
139
+ if (path.startsWith(autoloadPath)) {
140
+ logger.warn('The path is already autoloaded as a service.')
141
+ return
142
+ }
143
+ }
171
144
 
172
- // Modify the configuration
173
- const config = JSON.parse(await readFile(configurationFile, 'utf-8'))
174
- let isAutoloaded = false
175
- const autoLoadPath = config.autoload?.path
176
- if (autoLoadPath) {
177
- const relativePath = relative(root, path)
145
+ // If the path is within the application repository
146
+ if (path.startsWith(root)) {
147
+ // If the path is already defined as a service, there is nothing to do
148
+ if (config.services.some(s => s.path === path)) {
149
+ logger.warn('The path is already defined as a service.')
150
+ return
151
+ }
152
+
153
+ // Do not use env variables
154
+ useEnv = false
155
+ }
178
156
 
179
- isAutoloaded = relativePath.startsWith(`${autoLoadPath}${sep}`)
157
+ if (!url) {
158
+ logger.warn(`The service ${bold(id)} does not define a Git repository.`)
159
+ }
180
160
  }
181
161
 
182
- if (!isAutoloaded) {
183
- await addService(configurationFile, id, path, url)
162
+ // Make sure the service is not already defined
163
+ if (config.serviceMap.has(id)) {
164
+ logger.fatal(`There is already a service ${bold(id)} defined, please choose a different service ID.`)
184
165
  }
185
166
 
167
+ /* c8 ignore next */
168
+ rawConfig.web ??= []
169
+
170
+ if (useEnv) {
171
+ rawConfig.web.push({ id, path: `{${envVariable}}`, url })
172
+
173
+ // Make sure the environment variable is not already defined
174
+ if (existsSync(envFile)) {
175
+ const env = parse(await readFile(envFile, 'utf-8'))
176
+
177
+ if (env[envVariable]) {
178
+ logger.fatal(
179
+ `There is already an environment variable ${bold(envVariable)} defined, please choose a different service ID.`
180
+ )
181
+ }
182
+ }
183
+
184
+ // Copy the .env file to .env.sample if it does not exist
185
+ if (!existsSync(envSampleFile)) {
186
+ await writeFile(envSampleFile, '', 'utf-8')
187
+ }
188
+
189
+ await appendEnvVariable(envFile, envVariable, path ?? '')
190
+ await appendEnvVariable(envSampleFile, envVariable, '')
191
+ } else {
192
+ rawConfig.web.push({ id, path: relative(root, path) })
193
+ }
194
+
195
+ await saveConfigurationFile(logger, configurationFile, rawConfig)
196
+ }
197
+
198
+ async function importURL (logger, _, configurationFile, rawUrl, id, http) {
199
+ let url = rawUrl
200
+ if (rawUrl.match(/^[a-z0-9-]+\/[a-z0-9-]+$/i)) {
201
+ url = http ? `https://github.com/${rawUrl}.git` : `git@github.com:${rawUrl}.git`
202
+ }
203
+
204
+ await importService(logger, configurationFile, id ?? basename(rawUrl, '.git'), null, url)
205
+ }
206
+
207
+ async function importLocal (logger, root, configurationFile, path, overridenId) {
208
+ const { id, url, packageJson, stackable } = await parseLocalFolder(path)
209
+
210
+ await importService(logger, configurationFile, overridenId ?? id, path, url)
211
+
186
212
  // Check if there is any configuration file we recognize. If so, don't do anything
187
213
  const wattConfiguration = await findExistingConfiguration(root, path)
188
214
  if (wattConfiguration) {
@@ -204,34 +230,126 @@ async function importLocal (logger, root, configurationFile, path) {
204
230
  }
205
231
 
206
232
  logger.info(`Detected stackable ${bold(stackable)} for service ${bold(id)}, adding to the service dependencies.`)
207
- await writeFile(resolve(path, 'package.json'), JSON.stringify(packageJson, null, 2), 'utf-8')
208
- await writeFile(resolve(path, 'watt.json'), JSON.stringify(wattJson, null, 2), 'utf-8')
233
+
234
+ await saveConfigurationFile(logger, resolve(path, 'package.json'), packageJson)
235
+ await saveConfigurationFile(logger, resolve(path, 'watt.json'), wattJson)
209
236
  }
210
237
 
211
- async function importURL (logger, root, configurationFile, values, rawUrl) {
212
- let url = rawUrl
213
- if (rawUrl.match(/^[a-z0-9-]+\/[a-z0-9-]+$/i)) {
214
- url = values.http ? `https://github.com/${rawUrl}.git` : `git@github.com:${rawUrl}.git`
238
+ export async function resolveServices (
239
+ logger,
240
+ root,
241
+ configurationFile,
242
+ username,
243
+ password,
244
+ skipDependencies,
245
+ packageManager
246
+ ) {
247
+ const config = await loadConfigurationFile(logger, configurationFile)
248
+
249
+ /* c8 ignore next 8 */
250
+ if (!packageManager) {
251
+ if (existsSync(resolve(root, 'pnpm-lock.yaml'))) {
252
+ packageManager = 'pnpm'
253
+ } else {
254
+ packageManager = 'npm'
255
+ }
215
256
  }
216
257
 
217
- const service = values.id ?? basename(rawUrl, '.git')
218
- const path = values.path ?? `web/${service}`
258
+ // The services which might be to be resolved are the one that have a URL and either
259
+ // no path defined (which means no environment variable set) or a non-existing path (which means not resolved yet)
260
+ const resolvableServices = config.services.filter(service => {
261
+ if (!service.url) {
262
+ return false
263
+ }
264
+
265
+ if (service.path && existsSync(service.path)) {
266
+ logger.warn(`Skipping service ${bold(service.id)} as the path already exists.`)
267
+ return false
268
+ }
269
+
270
+ return true
271
+ })
272
+
273
+ // Iterate the services a first time to verify the environment files configuration and which services must be resolved
274
+ const toResolve = []
275
+
276
+ // Simply use service.path here
277
+ for (const service of resolvableServices) {
278
+ if (!service.path) {
279
+ service.path = resolve(root, `${config.resolvedServicesBasePath}/${service.id}`)
280
+ }
281
+
282
+ const directory = resolve(root, service.path)
283
+
284
+ // If the directory already exists, it's either external or already resolved, nothing to do in both cases
285
+ if (!existsSync(directory)) {
286
+ if (!directory.startsWith(root)) {
287
+ logger.warn(
288
+ `Skipping service ${bold(service.id)} as the non existent directory ${bold(service.path)} is outside the project directory.`
289
+ )
290
+ } else {
291
+ // This repository must be resolved
292
+ toResolve.push(service)
293
+ }
294
+ } else {
295
+ logger.warn(
296
+ `Skipping service ${bold(service.id)} as the generated path ${bold(join(config.resolvedServicesBasePath, service.id))} already exists.`
297
+ )
298
+ }
299
+ }
219
300
 
220
- await addService(configurationFile, service, path, url)
301
+ // Resolve the services
302
+ for (const service of toResolve) {
303
+ const childLogger = logger.child({ name: service.id })
304
+ overrideFatal(childLogger)
305
+
306
+ try {
307
+ const absolutePath = service.path
308
+ const relativePath = relative(root, absolutePath)
309
+
310
+ // Clone and install dependencies
311
+ childLogger.info(`Resolving service ${bold(service.id)} ...`)
312
+
313
+ let url = service.url
314
+ if (url.startsWith('http') && username && password) {
315
+ const parsed = new URL(url)
316
+ parsed.username ||= username
317
+ parsed.password ||= password
318
+ url = parsed.toString()
319
+ }
320
+
321
+ if (username) {
322
+ childLogger.info(`Cloning ${bold(service.url)} as user ${bold(username)} into ${bold(relativePath)} ...`)
323
+ } else {
324
+ childLogger.info(`Cloning ${bold(service.url)} into ${bold(relativePath)} ...`)
325
+ }
326
+
327
+ await execa('git', ['clone', url, absolutePath])
328
+ } catch (error) {
329
+ childLogger.fatal(
330
+ { error: ensureLoggableError(error) },
331
+ `Unable to clone repository of the service ${bold(service.id)}`
332
+ )
333
+ }
334
+ }
335
+
336
+ // Install dependencies
337
+ if (!skipDependencies) {
338
+ await installDependencies(logger, root, toResolve, false, packageManager)
339
+ }
221
340
  }
222
341
 
223
342
  export async function importCommand (logger, args) {
224
- const { values, positionals } = parseArgs(
343
+ const {
344
+ values: { id, http },
345
+ positionals
346
+ } = parseArgs(
225
347
  args,
226
348
  {
227
349
  id: {
228
350
  type: 'string',
229
351
  short: 'i'
230
352
  },
231
- path: {
232
- type: 'string',
233
- short: 'p'
234
- },
235
353
  http: {
236
354
  type: 'boolean',
237
355
  short: 'h'
@@ -271,15 +389,15 @@ export async function importCommand (logger, args) {
271
389
  })
272
390
 
273
391
  if (local) {
274
- return importLocal(logger, root, configurationFile, local)
392
+ return importLocal(logger, root, configurationFile, local, id)
275
393
  }
276
394
 
277
- return importURL(logger, root, configurationFile, values, rawUrl)
395
+ return importURL(logger, root, configurationFile, rawUrl, id, http)
278
396
  }
279
397
 
280
398
  export async function resolveCommand (logger, args) {
281
399
  const {
282
- values: { username, password, 'skip-dependencies': skipDependencies },
400
+ values: { username, password, 'skip-dependencies': skipDependencies, 'package-manager': packageManager },
283
401
  positionals
284
402
  } = parseArgs(
285
403
  args,
@@ -298,6 +416,10 @@ export async function resolveCommand (logger, args) {
298
416
  type: 'boolean',
299
417
  short: 's',
300
418
  default: false
419
+ },
420
+ 'package-manager': {
421
+ type: 'string',
422
+ short: 'p'
301
423
  }
302
424
  },
303
425
  false
@@ -305,56 +427,9 @@ export async function resolveCommand (logger, args) {
305
427
 
306
428
  /* c8 ignore next */
307
429
  const root = resolve(process.cwd(), positionals[0] ?? '')
308
-
309
430
  const configurationFile = await findConfigurationFile(logger, root)
310
- const config = await parseConfiguration(logger, configurationFile)
311
-
312
- for (const service of config.services) {
313
- let operation
314
- const childLogger = logger.child({ name: service.id })
315
- overrideFatal(childLogger)
316
-
317
- try {
318
- if (!service.url) {
319
- continue
320
- }
321
-
322
- childLogger.info(`Resolving service ${bold(service.id)} ...`)
323
-
324
- const relativePath = relative(root, service.path)
325
-
326
- // Check that the target directory is empty, otherwise cloning will likely fail
327
- await checkEmptyDirectory(childLogger, service.path, relativePath)
328
-
329
- operation = 'clone repository'
330
-
331
- let url = service.url
332
-
333
- if (url.startsWith('http') && username && password) {
334
- const parsed = new URL(url)
335
- parsed.username ||= username
336
- parsed.password ||= password
337
- url = parsed.toString()
338
- }
339
-
340
- if (username) {
341
- childLogger.info(`Cloning ${bold(service.url)} as user ${bold(username)} into ${bold(relativePath)} ...`)
342
- } else {
343
- childLogger.info(`Cloning ${bold(service.url)} into ${bold(relativePath)} ...`)
344
- }
345
-
346
- await execa('git', ['clone', url, service.path])
347
-
348
- if (!skipDependencies) {
349
- operation = 'installing dependencies'
350
- childLogger.info('Installing dependencies ...')
351
- await execa('npm', ['i'], { cwd: service.path })
352
- }
353
- } catch (error) {
354
- childLogger.fatal({ error: ensureLoggableError(error) }, `Unable to ${operation} of service ${bold(service.id)}`)
355
- }
356
- }
357
431
 
432
+ await resolveServices(logger, root, configurationFile, username, password, skipDependencies, packageManager)
358
433
  logger.done('All services have been resolved.')
359
434
  }
360
435
 
@@ -377,10 +452,6 @@ export const help = {
377
452
  usage: '-i, --id <value>',
378
453
  description: 'The id of the service (the default is the basename of the URL)'
379
454
  },
380
- {
381
- usage: '-p, --path <value>',
382
- description: 'The path where to import the service (the default is the service id)'
383
- },
384
455
  {
385
456
  usage: '-h, --http',
386
457
  description: 'Use HTTP URL when expanding GitHub repositories'
@@ -408,6 +479,10 @@ export const help = {
408
479
  {
409
480
  usage: '-s, --skip-dependencies',
410
481
  description: 'Do not install services dependencies'
482
+ },
483
+ {
484
+ usage: 'P, --package-manager <executable>',
485
+ description: 'Use an alternative package manager (the default is to autodetect it)'
411
486
  }
412
487
  ],
413
488
  footer: `
@@ -1,13 +1,13 @@
1
1
  import { ConfigManager } from '@platformatic/config'
2
2
  import { ensureLoggableError } from '@platformatic/utils'
3
3
  import { bold } from 'colorette'
4
- import { existsSync, } from 'node:fs'
4
+ import { existsSync } from 'node:fs'
5
5
  import { mkdir, stat, writeFile } from 'node:fs/promises'
6
6
  import { basename, resolve } from 'node:path'
7
7
  import { defaultConfiguration, defaultPackageJson } from '../defaults.js'
8
8
  import { gitignore } from '../gitignore.js'
9
9
  import { schema, version } from '../schema.js'
10
- import { parseArgs, verbose } from '../utils.js'
10
+ import { parseArgs, saveConfigurationFile, verbose } from '../utils.js'
11
11
 
12
12
  export async function initCommand (logger, args) {
13
13
  const {
@@ -57,7 +57,7 @@ export async function initCommand (logger, args) {
57
57
 
58
58
  // Create the web folder, will implicitly create the root
59
59
  try {
60
- await mkdir(web, { recursive: true, })
60
+ await mkdir(web, { recursive: true })
61
61
  /* c8 ignore next 6 */
62
62
  } catch (error) {
63
63
  logger.fatal(
@@ -76,11 +76,11 @@ export async function initCommand (logger, args) {
76
76
 
77
77
  await configManager.parse()
78
78
 
79
- await writeFile(
80
- configurationFile,
81
- JSON.stringify({ $schema: schema.$id, ...configManager.current, entrypoint: positionals[1] ?? undefined }, null, 2),
82
- 'utf-8'
83
- )
79
+ await saveConfigurationFile(logger, configurationFile, {
80
+ $schema: schema.$id,
81
+ ...configManager.current,
82
+ entrypoint: positionals[1] ?? undefined
83
+ })
84
84
 
85
85
  const packageJson = {
86
86
  name: basename(root),
@@ -89,13 +89,13 @@ export async function initCommand (logger, args) {
89
89
  }
90
90
 
91
91
  if (packageManager === 'npm') {
92
- packageJson.workspaces = ['web/*']
92
+ packageJson.workspaces = ['web/*', 'external/*']
93
93
  } else if (packageManager === 'pnpm') {
94
- await writeFile(resolve(root, 'pnpm-workspace.yaml'), "packages:\n - 'web/*'", 'utf-8')
94
+ await saveConfigurationFile(logger, resolve(root, 'pnpm-workspace.yaml'), { packages: ['web/*', 'external/*'] })
95
95
  }
96
96
 
97
97
  // Write the package.json file
98
- await writeFile(resolve(root, 'package.json'), JSON.stringify(packageJson, null, 2), 'utf-8')
98
+ await saveConfigurationFile(logger, resolve(root, 'package.json'), packageJson)
99
99
 
100
100
  // Write the .gitignore file
101
101
  await writeFile(resolve(root, '.gitignore'), gitignore, 'utf-8')
@@ -116,6 +116,12 @@ export const help = {
116
116
  name: 'entrypoint',
117
117
  description: 'The name of the entrypoint service'
118
118
  }
119
+ ],
120
+ options: [
121
+ {
122
+ usage: 'p, --package-manager <executable>',
123
+ description: 'Use an alternative package manager'
124
+ }
119
125
  ]
120
126
  }
121
127
  }
package/lib/gitignore.js CHANGED
@@ -129,4 +129,7 @@ dist
129
129
  .yarn/build-state.yml
130
130
  .yarn/install-state.gz
131
131
  .pnp.*
132
+
133
+ # Watt resolved folder
134
+ external/
132
135
  `
package/lib/utils.js CHANGED
@@ -1,9 +1,16 @@
1
- import { ConfigManager, loadConfig as pltConfigLoadConfig, Store } from '@platformatic/config'
1
+ import {
2
+ ConfigManager,
3
+ getParser,
4
+ getStringifier,
5
+ loadConfig as pltConfigLoadConfig,
6
+ Store
7
+ } from '@platformatic/config'
2
8
  import { platformaticRuntime, buildRuntime as pltBuildRuntime } from '@platformatic/runtime'
3
9
  import { bgGreen, black, bold } from 'colorette'
10
+ import { readFile, writeFile } from 'node:fs/promises'
4
11
  import { dirname, resolve } from 'node:path'
5
12
  import { parseArgs as nodeParseArgs } from 'node:util'
6
- import pino from 'pino'
13
+ import { pino } from 'pino'
7
14
  import pinoPretty from 'pino-pretty'
8
15
 
9
16
  export let verbose = false
@@ -70,7 +77,12 @@ export function parseArgs (args, options, stopAtFirstPositional = true) {
70
77
  tokens: true
71
78
  })
72
79
 
73
- return { values, positionals, unparsed, tokens }
80
+ return {
81
+ values,
82
+ positionals,
83
+ unparsed,
84
+ tokens
85
+ }
74
86
  }
75
87
 
76
88
  export function getMatchingRuntimeArgs (logger, positional) {
@@ -85,12 +97,16 @@ export function getMatchingRuntimeArgs (logger, positional) {
85
97
  return args
86
98
  }
87
99
 
100
+ export function serviceToEnvVariable (service) {
101
+ return `PLT_SERVICE_${service.toUpperCase().replaceAll(/[^A-Z0-9_]/g, '_')}_PATH`
102
+ }
103
+
88
104
  export async function findConfigurationFile (logger, root) {
89
105
  let current = root
90
106
  let configurationFile
91
107
  while (configurationFile === undefined) {
92
108
  // Find a wattpm.json or watt.json file
93
- configurationFile = await ConfigManager.findConfigFile(current, ['watt.json', 'wattpm.json', 'platformatic.json'])
109
+ configurationFile = await ConfigManager.findConfigFile(current, true)
94
110
  if (!configurationFile) {
95
111
  const newCurrent = dirname(current)
96
112
 
@@ -104,7 +120,9 @@ export async function findConfigurationFile (logger, root) {
104
120
 
105
121
  if (typeof configurationFile !== 'string') {
106
122
  logger.fatal(
107
- `Cannot find a ${bold('watt.json')}, a ${bold('wattpm.json')} or a ${bold('platformatic.json')} file in ${bold(root)}.`
123
+ `Cannot find a supported Watt configuration file (like ${bold(
124
+ 'watt.json'
125
+ )}, a ${bold('wattpm.json')} or a ${bold('platformatic.json')}) in ${bold(root)}.`
108
126
  )
109
127
  }
110
128
 
@@ -112,11 +130,44 @@ export async function findConfigurationFile (logger, root) {
112
130
  return resolved
113
131
  }
114
132
 
133
+ export async function loadConfigurationFile (logger, configurationFile) {
134
+ const store = new Store({
135
+ cwd: process.cwd(),
136
+ logger
137
+ })
138
+ store.add(platformaticRuntime)
139
+
140
+ const { configManager } = await store.loadConfig({
141
+ config: configurationFile,
142
+ overrides: {
143
+ /* c8 ignore next 3 */
144
+ onMissingEnv (key) {
145
+ return ''
146
+ }
147
+ }
148
+ })
149
+
150
+ await configManager.parse(true, [])
151
+ return configManager.current
152
+ }
153
+
154
+ export async function loadRawConfigurationFile (_, configurationFile) {
155
+ const parseConfig = getParser(configurationFile)
156
+
157
+ return parseConfig(await readFile(configurationFile, 'utf-8'))
158
+ }
159
+
160
+ export function saveConfigurationFile (logger, configurationFile, config) {
161
+ const stringifyConfig = getStringifier(configurationFile)
162
+
163
+ return writeFile(configurationFile, stringifyConfig(config), 'utf-8')
164
+ }
165
+
115
166
  export async function buildRuntime (logger, configurationFile) {
116
167
  const store = new Store()
117
168
  store.add(platformaticRuntime)
118
169
 
119
- const config = await pltConfigLoadConfig({}, ['-c', configurationFile], store, {}, true)
170
+ const config = await pltConfigLoadConfig({}, ['-c', configurationFile], store, {}, true, logger)
120
171
  config.configManager.args = config.args
121
172
 
122
173
  const runtimeConfig = config.configManager
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wattpm",
3
- "version": "2.19.0",
3
+ "version": "2.20.0-alpha.2",
4
4
  "description": "The Node.js Application Server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -27,6 +27,7 @@
27
27
  "dependencies": {
28
28
  "colorette": "^2.0.20",
29
29
  "commist": "^3.2.0",
30
+ "dotenv": "^16.4.5",
30
31
  "execa": "^9.4.0",
31
32
  "help-me": "^5.0.0",
32
33
  "minimist": "^1.2.8",
@@ -34,11 +35,11 @@
34
35
  "pino-pretty": "^13.0.0",
35
36
  "split2": "^4.2.0",
36
37
  "table": "^6.8.2",
37
- "@platformatic/config": "2.19.0",
38
- "@platformatic/basic": "2.19.0",
39
- "@platformatic/control": "2.19.0",
40
- "@platformatic/runtime": "2.19.0",
41
- "@platformatic/utils": "2.19.0"
38
+ "@platformatic/basic": "2.20.0-alpha.2",
39
+ "@platformatic/control": "2.20.0-alpha.2",
40
+ "@platformatic/config": "2.20.0-alpha.2",
41
+ "@platformatic/utils": "2.20.0-alpha.2",
42
+ "@platformatic/runtime": "2.20.0-alpha.2"
42
43
  },
43
44
  "devDependencies": {
44
45
  "borp": "^0.19.0",
@@ -48,7 +49,7 @@
48
49
  "neostandard": "^0.11.1",
49
50
  "typescript": "^5.5.4",
50
51
  "undici": "^7.0.0",
51
- "@platformatic/node": "2.19.0"
52
+ "@platformatic/node": "2.20.0-alpha.2"
52
53
  },
53
54
  "scripts": {
54
55
  "test": "npm run lint && borp --concurrency=1 --timeout=300000",
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/wattpm/2.19.0.json",
2
+ "$id": "https://schemas.platformatic.dev/wattpm/2.20.0-alpha.2.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "type": "object",
5
5
  "properties": {
@@ -183,6 +183,7 @@
183
183
  },
184
184
  "path": {
185
185
  "type": "string",
186
+ "allowEmptyPaths": true,
186
187
  "resolvePath": true
187
188
  },
188
189
  "config": {
@@ -335,6 +336,7 @@
335
336
  },
336
337
  "path": {
337
338
  "type": "string",
339
+ "allowEmptyPaths": true,
338
340
  "resolvePath": true
339
341
  },
340
342
  "config": {
@@ -1107,6 +1109,10 @@
1107
1109
  }
1108
1110
  ],
1109
1111
  "default": 300000
1112
+ },
1113
+ "resolvedServicesBasePath": {
1114
+ "type": "string",
1115
+ "default": "external"
1110
1116
  }
1111
1117
  },
1112
1118
  "anyOf": [