@launch77/cli 1.4.3 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/infrastructure/npm-package.d.ts +42 -0
  3. package/dist/infrastructure/npm-package.d.ts.map +1 -0
  4. package/dist/infrastructure/npm-package.js +46 -0
  5. package/dist/infrastructure/npm-package.js.map +1 -0
  6. package/dist/infrastructure/package-resolver.d.ts +107 -0
  7. package/dist/infrastructure/package-resolver.d.ts.map +1 -0
  8. package/dist/infrastructure/package-resolver.js +143 -0
  9. package/dist/infrastructure/package-resolver.js.map +1 -0
  10. package/dist/infrastructure/package-resolver.test.d.ts +2 -0
  11. package/dist/infrastructure/package-resolver.test.d.ts.map +1 -0
  12. package/dist/infrastructure/package-resolver.test.js +251 -0
  13. package/dist/infrastructure/package-resolver.test.js.map +1 -0
  14. package/dist/modules/app/commands/create-app.d.ts.map +1 -1
  15. package/dist/modules/app/commands/create-app.js +6 -2
  16. package/dist/modules/app/commands/create-app.js.map +1 -1
  17. package/dist/modules/app/lib/app-template-resolver.d.ts +14 -0
  18. package/dist/modules/app/lib/app-template-resolver.d.ts.map +1 -0
  19. package/dist/modules/app/lib/app-template-resolver.js +36 -0
  20. package/dist/modules/app/lib/app-template-resolver.js.map +1 -0
  21. package/dist/modules/app/services/app-svc.d.ts +2 -1
  22. package/dist/modules/app/services/app-svc.d.ts.map +1 -1
  23. package/dist/modules/app/services/app-svc.js +46 -11
  24. package/dist/modules/app/services/app-svc.js.map +1 -1
  25. package/dist/modules/plugin/lib/plugin-resolver.d.ts +10 -72
  26. package/dist/modules/plugin/lib/plugin-resolver.d.ts.map +1 -1
  27. package/dist/modules/plugin/lib/plugin-resolver.js +12 -115
  28. package/dist/modules/plugin/lib/plugin-resolver.js.map +1 -1
  29. package/dist/modules/plugin/lib/plugin-resolver.test.js +43 -39
  30. package/dist/modules/plugin/lib/plugin-resolver.test.js.map +1 -1
  31. package/dist/modules/plugin/services/plugin-svc.d.ts +2 -0
  32. package/dist/modules/plugin/services/plugin-svc.d.ts.map +1 -1
  33. package/dist/modules/plugin/services/plugin-svc.js +14 -17
  34. package/dist/modules/plugin/services/plugin-svc.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/infrastructure/npm-package.ts +73 -0
  37. package/src/infrastructure/package-resolver.test.ts +313 -0
  38. package/src/infrastructure/package-resolver.ts +194 -0
  39. package/src/modules/app/commands/create-app.ts +6 -2
  40. package/src/modules/app/lib/app-template-resolver.ts +40 -0
  41. package/src/modules/app/services/app-svc.ts +49 -12
  42. package/src/modules/plugin/lib/plugin-resolver.test.ts +46 -39
  43. package/src/modules/plugin/lib/plugin-resolver.ts +12 -142
  44. package/src/modules/plugin/services/plugin-svc.ts +15 -15
@@ -1,99 +1,106 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
- import * as path from 'path'
3
1
  import * as os from 'os'
2
+ import * as path from 'path'
3
+
4
4
  import fs from 'fs-extra'
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
5
6
 
6
- import { validatePluginInput, toNpmPackageName, resolvePluginLocation } from './plugin-resolver.js'
7
+ import { PluginResolver } from './plugin-resolver.js'
7
8
 
8
9
  describe('Plugin Resolver', () => {
9
- describe('validatePluginInput', () => {
10
+ let resolver: PluginResolver
11
+
12
+ beforeEach(() => {
13
+ resolver = new PluginResolver()
14
+ })
15
+
16
+ describe('validateInput', () => {
10
17
  it('should accept valid unscoped plugin names', () => {
11
- expect(validatePluginInput('release')).toEqual({ isValid: true })
12
- expect(validatePluginInput('my-plugin')).toEqual({ isValid: true })
13
- expect(validatePluginInput('analytics-v2')).toEqual({ isValid: true })
18
+ expect(resolver.validateInput('release')).toEqual({ isValid: true })
19
+ expect(resolver.validateInput('my-plugin')).toEqual({ isValid: true })
20
+ expect(resolver.validateInput('analytics-v2')).toEqual({ isValid: true })
14
21
  })
15
22
 
16
23
  it('should accept valid scoped npm packages', () => {
17
- expect(validatePluginInput('@ibm/plugin-name')).toEqual({ isValid: true })
18
- expect(validatePluginInput('@launch77-shared/plugin-release')).toEqual({ isValid: true })
19
- expect(validatePluginInput('@org/analytics')).toEqual({ isValid: true })
24
+ expect(resolver.validateInput('@ibm/plugin-name')).toEqual({ isValid: true })
25
+ expect(resolver.validateInput('@launch77-shared/plugin-release')).toEqual({ isValid: true })
26
+ expect(resolver.validateInput('@org/analytics')).toEqual({ isValid: true })
20
27
  })
21
28
 
22
29
  it('should reject empty names', () => {
23
- const result = validatePluginInput('')
30
+ const result = resolver.validateInput('')
24
31
  expect(result.isValid).toBe(false)
25
32
  expect(result.error).toBeDefined()
26
33
  })
27
34
 
28
35
  it('should reject whitespace-only names', () => {
29
- const result = validatePluginInput(' ')
36
+ const result = resolver.validateInput(' ')
30
37
  expect(result.isValid).toBe(false)
31
38
  expect(result.error).toBeDefined()
32
39
  })
33
40
 
34
41
  it('should reject invalid scoped packages', () => {
35
- const result1 = validatePluginInput('@invalid')
42
+ const result1 = resolver.validateInput('@invalid')
36
43
  expect(result1.isValid).toBe(false)
37
44
  expect(result1.error).toBeDefined()
38
45
 
39
- const result2 = validatePluginInput('@/package')
46
+ const result2 = resolver.validateInput('@/package')
40
47
  expect(result2.isValid).toBe(false)
41
48
  expect(result2.error).toBeDefined()
42
49
 
43
- const result3 = validatePluginInput('@org/')
50
+ const result3 = resolver.validateInput('@org/')
44
51
  expect(result3.isValid).toBe(false)
45
52
  expect(result3.error).toBeDefined()
46
53
  })
47
54
 
48
55
  it('should reject names with uppercase letters', () => {
49
- const result = validatePluginInput('MyPlugin')
56
+ const result = resolver.validateInput('MyPlugin')
50
57
  expect(result.isValid).toBe(false)
51
58
  expect(result.error).toContain('lowercase')
52
59
  })
53
60
 
54
61
  it('should reject names starting with numbers', () => {
55
- const result = validatePluginInput('123plugin')
62
+ const result = resolver.validateInput('123plugin')
56
63
  expect(result.isValid).toBe(false)
57
64
  expect(result.error).toBeDefined()
58
65
  })
59
66
 
60
67
  it('should reject names with special characters', () => {
61
- const result1 = validatePluginInput('plugin_name')
68
+ const result1 = resolver.validateInput('plugin_name')
62
69
  expect(result1.isValid).toBe(false)
63
70
 
64
- const result2 = validatePluginInput('plugin.name')
71
+ const result2 = resolver.validateInput('plugin.name')
65
72
  expect(result2.isValid).toBe(false)
66
73
 
67
- const result3 = validatePluginInput('plugin name')
74
+ const result3 = resolver.validateInput('plugin name')
68
75
  expect(result3.isValid).toBe(false)
69
76
  })
70
77
 
71
78
  it('should trim whitespace before validation', () => {
72
- expect(validatePluginInput(' release ')).toEqual({ isValid: true })
73
- expect(validatePluginInput(' @ibm/analytics ')).toEqual({ isValid: true })
79
+ expect(resolver.validateInput(' release ')).toEqual({ isValid: true })
80
+ expect(resolver.validateInput(' @ibm/analytics ')).toEqual({ isValid: true })
74
81
  })
75
82
  })
76
83
 
77
84
  describe('toNpmPackageName', () => {
78
85
  it('should prefix unscoped names with @launch77-shared/plugin-', () => {
79
- expect(toNpmPackageName('release')).toBe('@launch77-shared/plugin-release')
80
- expect(toNpmPackageName('my-plugin')).toBe('@launch77-shared/plugin-my-plugin')
81
- expect(toNpmPackageName('analytics-v2')).toBe('@launch77-shared/plugin-analytics-v2')
86
+ expect(resolver.toNpmPackageName('release')).toBe('@launch77-shared/plugin-release')
87
+ expect(resolver.toNpmPackageName('my-plugin')).toBe('@launch77-shared/plugin-my-plugin')
88
+ expect(resolver.toNpmPackageName('analytics-v2')).toBe('@launch77-shared/plugin-analytics-v2')
82
89
  })
83
90
 
84
91
  it('should return scoped packages as-is', () => {
85
- expect(toNpmPackageName('@ibm/analytics')).toBe('@ibm/analytics')
86
- expect(toNpmPackageName('@launch77-shared/plugin-release')).toBe('@launch77-shared/plugin-release')
87
- expect(toNpmPackageName('@org/package')).toBe('@org/package')
92
+ expect(resolver.toNpmPackageName('@ibm/analytics')).toBe('@ibm/analytics')
93
+ expect(resolver.toNpmPackageName('@launch77-shared/plugin-release')).toBe('@launch77-shared/plugin-release')
94
+ expect(resolver.toNpmPackageName('@org/package')).toBe('@org/package')
88
95
  })
89
96
 
90
97
  it('should trim whitespace', () => {
91
- expect(toNpmPackageName(' release ')).toBe('@launch77-shared/plugin-release')
92
- expect(toNpmPackageName(' @ibm/analytics ')).toBe('@ibm/analytics')
98
+ expect(resolver.toNpmPackageName(' release ')).toBe('@launch77-shared/plugin-release')
99
+ expect(resolver.toNpmPackageName(' @ibm/analytics ')).toBe('@ibm/analytics')
93
100
  })
94
101
  })
95
102
 
96
- describe('resolvePluginLocation', () => {
103
+ describe('resolveLocation', () => {
97
104
  let tempDir: string
98
105
 
99
106
  beforeEach(async () => {
@@ -115,7 +122,7 @@ describe('Plugin Resolver', () => {
115
122
  await fs.writeFile(path.join(pluginPath, 'plugin.json'), JSON.stringify({ name: 'my-plugin', version: '1.0.0' }))
116
123
  await fs.writeFile(path.join(pluginPath, 'dist/generator.js'), 'console.log("test")')
117
124
 
118
- const result = await resolvePluginLocation('my-plugin', tempDir)
125
+ const result = await resolver.resolveLocation('my-plugin', tempDir)
119
126
 
120
127
  expect(result).toEqual({
121
128
  source: 'local',
@@ -125,7 +132,7 @@ describe('Plugin Resolver', () => {
125
132
  })
126
133
 
127
134
  it('should resolve to npm if local plugin does not exist', async () => {
128
- const result = await resolvePluginLocation('release', tempDir)
135
+ const result = await resolver.resolveLocation('release', tempDir)
129
136
 
130
137
  expect(result).toEqual({
131
138
  source: 'npm',
@@ -141,7 +148,7 @@ describe('Plugin Resolver', () => {
141
148
  await fs.ensureDir(path.join(pluginPath, 'dist'))
142
149
  await fs.writeFile(path.join(pluginPath, 'dist/generator.js'), 'console.log("test")')
143
150
 
144
- const result = await resolvePluginLocation('incomplete', tempDir)
151
+ const result = await resolver.resolveLocation('incomplete', tempDir)
145
152
 
146
153
  expect(result).toEqual({
147
154
  source: 'npm',
@@ -156,7 +163,7 @@ describe('Plugin Resolver', () => {
156
163
  await fs.ensureDir(pluginPath)
157
164
  await fs.writeFile(path.join(pluginPath, 'plugin.json'), JSON.stringify({ name: 'incomplete', version: '1.0.0' }))
158
165
 
159
- const result = await resolvePluginLocation('incomplete', tempDir)
166
+ const result = await resolver.resolveLocation('incomplete', tempDir)
160
167
 
161
168
  expect(result).toEqual({
162
169
  source: 'npm',
@@ -170,7 +177,7 @@ describe('Plugin Resolver', () => {
170
177
  const pluginPath = path.join(tempDir, 'plugins', '@ibm')
171
178
  await fs.ensureDir(pluginPath)
172
179
 
173
- const result = await resolvePluginLocation('@ibm/analytics', tempDir)
180
+ const result = await resolver.resolveLocation('@ibm/analytics', tempDir)
174
181
 
175
182
  expect(result).toEqual({
176
183
  source: 'npm',
@@ -180,7 +187,7 @@ describe('Plugin Resolver', () => {
180
187
  })
181
188
 
182
189
  it('should resolve scoped @launch77-shared packages to npm', async () => {
183
- const result = await resolvePluginLocation('@launch77-shared/plugin-release', tempDir)
190
+ const result = await resolver.resolveLocation('@launch77-shared/plugin-release', tempDir)
184
191
 
185
192
  expect(result).toEqual({
186
193
  source: 'npm',
@@ -193,7 +200,7 @@ describe('Plugin Resolver', () => {
193
200
  // Remove plugins directory
194
201
  await fs.remove(path.join(tempDir, 'plugins'))
195
202
 
196
- const result = await resolvePluginLocation('release', tempDir)
203
+ const result = await resolver.resolveLocation('release', tempDir)
197
204
 
198
205
  expect(result).toEqual({
199
206
  source: 'npm',
@@ -203,7 +210,7 @@ describe('Plugin Resolver', () => {
203
210
  })
204
211
 
205
212
  it('should trim whitespace from plugin names', async () => {
206
- const result = await resolvePluginLocation(' release ', tempDir)
213
+ const result = await resolver.resolveLocation(' release ', tempDir)
207
214
 
208
215
  expect(result).toEqual({
209
216
  source: 'npm',
@@ -1,160 +1,30 @@
1
1
  import * as path from 'path'
2
2
 
3
3
  import fs from 'fs-extra'
4
- import { parsePluginName, isValidNpmPackageName } from '@launch77/plugin-runtime'
5
4
 
6
- import type { ValidationResult } from '@launch77/plugin-runtime'
5
+ import { PackageResolver } from '../../../infrastructure/package-resolver.js'
7
6
 
8
7
  /**
9
- * Plugin resolution result
10
- */
11
- export interface PluginResolution {
12
- /** The source of the plugin */
13
- source: 'local' | 'npm'
14
- /** The resolved name/package to use */
15
- resolvedName: string
16
- /** The local path if source is 'local' */
17
- localPath?: string
18
- /** The npm package name if source is 'npm' */
19
- npmPackage?: string
20
- }
21
-
22
- /**
23
- * Validate plugin input name
24
- *
25
- * Accepts:
26
- * - Unscoped names (e.g., "release", "my-plugin")
27
- * - Scoped npm packages (e.g., "@ibm/plugin-name")
8
+ * Plugin resolver implementation
28
9
  *
29
- * Rejects:
30
- * - Invalid formats
31
- * - Empty strings
32
- * - Names with invalid characters
33
- *
34
- * @param name - The plugin name to validate
35
- * @returns ValidationResult with isValid and optional error message
36
- *
37
- * @example
38
- * validatePluginInput('release') // { isValid: true }
39
- * validatePluginInput('@ibm/analytics') // { isValid: true }
40
- * validatePluginInput('@invalid') // { isValid: false, error: '...' }
10
+ * Resolves plugins from:
11
+ * - Local workspace plugins/ directory
12
+ * - npm packages with @launch77-shared/plugin- prefix
41
13
  */
42
- export function validatePluginInput(name: string): ValidationResult {
43
- if (!name || name.trim().length === 0) {
44
- return {
45
- isValid: false,
46
- error: 'Plugin name cannot be empty',
47
- }
14
+ export class PluginResolver extends PackageResolver {
15
+ protected getFolderName(): string {
16
+ return 'plugins'
48
17
  }
49
18
 
50
- const trimmedName = name.trim()
51
-
52
- // Parse the name to determine type and validate
53
- const parsed = parsePluginName(trimmedName)
54
-
55
- if (!parsed.isValid) {
56
- return {
57
- isValid: false,
58
- error: parsed.error,
59
- }
19
+ protected getPackagePrefix(): string {
20
+ return '@launch77-shared/plugin-'
60
21
  }
61
22
 
62
- // If it's scoped, it must be a valid npm package name
63
- if (parsed.type === 'scoped') {
64
- return isValidNpmPackageName(trimmedName)
65
- }
66
-
67
- // Unscoped names are valid for both local and npm
68
- return { isValid: true }
69
- }
70
-
71
- /**
72
- * Convert an unscoped plugin name to an npm package name
73
- *
74
- * Rules:
75
- * - Unscoped names: prefix with @launch77-shared/plugin-
76
- * - Scoped names: use as-is
77
- *
78
- * @param name - The plugin name (must be validated first)
79
- * @returns The npm package name
80
- *
81
- * @example
82
- * toNpmPackageName('release') // '@launch77-shared/plugin-release'
83
- * toNpmPackageName('@ibm/analytics') // '@ibm/analytics'
84
- */
85
- export function toNpmPackageName(name: string): string {
86
- const trimmedName = name.trim()
87
-
88
- // If already scoped, use as-is
89
- if (trimmedName.startsWith('@')) {
90
- return trimmedName
91
- }
92
-
93
- // Otherwise, convert to @launch77-shared/plugin-<name>
94
- return `@launch77-shared/plugin-${trimmedName}`
95
- }
96
-
97
- /**
98
- * Resolve plugin location from name
99
- *
100
- * Resolution order:
101
- * 1. Check local workspace plugins directory
102
- * 2. Resolve to npm package name
103
- *
104
- * @param name - The plugin name to resolve
105
- * @param workspaceRoot - The workspace root directory
106
- * @returns PluginResolution with source and resolved location
107
- *
108
- * @example
109
- * // Local plugin found
110
- * await resolvePluginLocation('my-plugin', '/workspace')
111
- * // { source: 'local', resolvedName: 'my-plugin', localPath: '/workspace/plugins/my-plugin' }
112
- *
113
- * // Not found locally, resolve to npm
114
- * await resolvePluginLocation('release', '/workspace')
115
- * // { source: 'npm', resolvedName: 'release', npmPackage: '@launch77-shared/plugin-release' }
116
- *
117
- * // Scoped package always resolves to npm
118
- * await resolvePluginLocation('@ibm/analytics', '/workspace')
119
- * // { source: 'npm', resolvedName: '@ibm/analytics', npmPackage: '@ibm/analytics' }
120
- */
121
- export async function resolvePluginLocation(name: string, workspaceRoot: string): Promise<PluginResolution> {
122
- const trimmedName = name.trim()
123
- const parsed = parsePluginName(trimmedName)
124
-
125
- // If scoped, always use npm (local plugins are never scoped)
126
- if (parsed.type === 'scoped') {
127
- return {
128
- source: 'npm',
129
- resolvedName: trimmedName,
130
- npmPackage: trimmedName,
131
- }
132
- }
133
-
134
- // Check local workspace plugins directory
135
- const localPath = path.join(workspaceRoot, 'plugins', trimmedName)
136
- const localExists = await fs.pathExists(localPath)
137
-
138
- if (localExists) {
23
+ protected async verify(localPath: string): Promise<boolean> {
139
24
  // Verify it's a valid plugin (has plugin.json and dist/generator.js)
140
25
  const hasPluginJson = await fs.pathExists(path.join(localPath, 'plugin.json'))
141
26
  const hasGenerator = await fs.pathExists(path.join(localPath, 'dist/generator.js'))
142
27
 
143
- if (hasPluginJson && hasGenerator) {
144
- return {
145
- source: 'local',
146
- resolvedName: trimmedName,
147
- localPath,
148
- }
149
- }
150
- }
151
-
152
- // Not found locally, resolve to npm package
153
- const npmPackage = toNpmPackageName(trimmedName)
154
-
155
- return {
156
- source: 'npm',
157
- resolvedName: trimmedName,
158
- npmPackage,
28
+ return hasPluginJson && hasGenerator
159
29
  }
160
30
  }
@@ -5,8 +5,9 @@ import chalk from 'chalk'
5
5
  import { execa } from 'execa'
6
6
  import { readPluginMetadata } from '@launch77/plugin-runtime'
7
7
 
8
+ import { downloadNpmPackage } from '../../../infrastructure/npm-package.js'
8
9
  import { PluginInstallationError, InvalidPluginContextError, createInvalidContextError, MissingPluginTargetsError, createInvalidTargetError, NpmInstallationError, PluginResolutionError } from '../errors/plugin-errors.js'
9
- import { validatePluginInput, resolvePluginLocation } from '../lib/plugin-resolver.js'
10
+ import { PluginResolver } from '../lib/plugin-resolver.js'
10
11
 
11
12
  import type { Launch77Context, Launch77LocationType, Launch77PackageManifest, InstalledPluginMetadata, PluginMetadata } from '@launch77/plugin-runtime'
12
13
  import type { InstallPluginRequest, InstallPluginResult } from '../types/plugin-types.js'
@@ -30,6 +31,12 @@ function locationTypeToTarget(locationType: Launch77LocationType): string | null
30
31
  }
31
32
 
32
33
  export class PluginService {
34
+ private pluginResolver: PluginResolver
35
+
36
+ constructor() {
37
+ this.pluginResolver = new PluginResolver()
38
+ }
39
+
33
40
  /**
34
41
  * Validate that we're in a valid package directory and return the target type
35
42
  */
@@ -47,14 +54,14 @@ export class PluginService {
47
54
  logger(chalk.blue(`\nšŸ” Resolving plugin "${pluginName}"...`))
48
55
  logger(` ā”œā”€ Validating plugin name...`)
49
56
 
50
- const validation = validatePluginInput(pluginName)
57
+ const validation = this.pluginResolver.validateInput(pluginName)
51
58
  if (!validation.isValid) {
52
59
  throw new PluginResolutionError(pluginName, validation.error || 'Invalid plugin name')
53
60
  }
54
61
  logger(` │ └─ ${chalk.green('āœ“')} Valid plugin name`)
55
62
 
56
63
  logger(` ā”œā”€ Checking local workspace: ${chalk.dim(`plugins/${pluginName}`)}`)
57
- const resolution = await resolvePluginLocation(pluginName, workspaceRoot)
64
+ const resolution = await this.pluginResolver.resolveLocation(pluginName, workspaceRoot)
58
65
 
59
66
  let pluginPath: string
60
67
 
@@ -154,19 +161,12 @@ export class PluginService {
154
161
  private async downloadNpmPlugin(npmPackage: string, workspaceRoot: string, logger: (message: string) => void): Promise<string> {
155
162
  logger(` └─ Installing from npm: ${chalk.cyan(npmPackage)}...`)
156
163
 
157
- try {
158
- // Install the npm package to the workspace
159
- await execa('npm', ['install', npmPackage, '--save-dev'], {
160
- cwd: workspaceRoot,
161
- stdio: 'pipe', // Capture output for clean logging
162
- })
164
+ const result = await downloadNpmPackage({
165
+ packageName: npmPackage,
166
+ workspaceRoot,
167
+ })
163
168
 
164
- // Return path to installed plugin in node_modules
165
- const pluginPath = path.join(workspaceRoot, 'node_modules', npmPackage)
166
- return pluginPath
167
- } catch (error) {
168
- throw new NpmInstallationError(npmPackage, error instanceof Error ? error : undefined)
169
- }
169
+ return result.packagePath
170
170
  }
171
171
 
172
172
  private getPackagePath(context: Launch77Context): string {