@launch77/cli 1.3.0 → 1.4.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.
Files changed (154) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/cli.js +6 -3
  3. package/dist/cli.js.map +1 -1
  4. package/dist/infrastructure/template-generator.d.ts +1 -1
  5. package/dist/infrastructure/template-generator.d.ts.map +1 -1
  6. package/dist/infrastructure/template.d.ts +5 -0
  7. package/dist/infrastructure/template.d.ts.map +1 -1
  8. package/dist/infrastructure/template.js +11 -0
  9. package/dist/infrastructure/template.js.map +1 -1
  10. package/dist/modules/app/commands/create-app.js +1 -1
  11. package/dist/modules/app/commands/create-app.js.map +1 -1
  12. package/dist/modules/app/commands/delete-app.js +1 -1
  13. package/dist/modules/app/commands/delete-app.js.map +1 -1
  14. package/dist/modules/app/services/app-svc.d.ts +1 -1
  15. package/dist/modules/app/services/app-svc.d.ts.map +1 -1
  16. package/dist/modules/app/services/manifest-svc.d.ts +1 -1
  17. package/dist/modules/app/services/manifest-svc.d.ts.map +1 -1
  18. package/dist/modules/catalog/config/catalog-config.test.js +1 -1
  19. package/dist/modules/catalog/config/catalog-config.test.js.map +1 -1
  20. package/dist/modules/git/commands/git-connect.js +2 -2
  21. package/dist/modules/git/commands/git-connect.js.map +1 -1
  22. package/dist/modules/git/errors/git-errors.d.ts +3 -0
  23. package/dist/modules/git/errors/git-errors.d.ts.map +1 -1
  24. package/dist/modules/git/errors/git-errors.js +6 -0
  25. package/dist/modules/git/errors/git-errors.js.map +1 -1
  26. package/dist/modules/git/index.d.ts +3 -1
  27. package/dist/modules/git/index.d.ts.map +1 -1
  28. package/dist/modules/git/index.js +6 -1
  29. package/dist/modules/git/index.js.map +1 -1
  30. package/dist/modules/git/services/git-service.d.ts +5 -0
  31. package/dist/modules/git/services/git-service.d.ts.map +1 -1
  32. package/dist/modules/git/services/git-service.js +11 -1
  33. package/dist/modules/git/services/git-service.js.map +1 -1
  34. package/dist/modules/plugin/commands/plugin-create.d.ts +3 -0
  35. package/dist/modules/plugin/commands/plugin-create.d.ts.map +1 -0
  36. package/dist/modules/plugin/commands/plugin-create.js +59 -0
  37. package/dist/modules/plugin/commands/plugin-create.js.map +1 -0
  38. package/dist/modules/plugin/commands/plugin-install.d.ts.map +1 -1
  39. package/dist/modules/plugin/commands/plugin-install.js +9 -24
  40. package/dist/modules/plugin/commands/plugin-install.js.map +1 -1
  41. package/dist/modules/plugin/errors/plugin-errors.d.ts +24 -1
  42. package/dist/modules/plugin/errors/plugin-errors.d.ts.map +1 -1
  43. package/dist/modules/plugin/errors/plugin-errors.js +79 -6
  44. package/dist/modules/plugin/errors/plugin-errors.js.map +1 -1
  45. package/dist/modules/plugin/index.d.ts +4 -2
  46. package/dist/modules/plugin/index.d.ts.map +1 -1
  47. package/dist/modules/plugin/index.js +4 -2
  48. package/dist/modules/plugin/index.js.map +1 -1
  49. package/dist/modules/plugin/lib/plugin-registry.d.ts +6 -12
  50. package/dist/modules/plugin/lib/plugin-registry.d.ts.map +1 -1
  51. package/dist/modules/plugin/lib/plugin-registry.js +13 -30
  52. package/dist/modules/plugin/lib/plugin-registry.js.map +1 -1
  53. package/dist/modules/plugin/lib/plugin-resolver.d.ts +76 -0
  54. package/dist/modules/plugin/lib/plugin-resolver.d.ts.map +1 -0
  55. package/dist/modules/plugin/lib/plugin-resolver.js +128 -0
  56. package/dist/modules/plugin/lib/plugin-resolver.js.map +1 -0
  57. package/dist/modules/plugin/lib/plugin-resolver.test.d.ts +2 -0
  58. package/dist/modules/plugin/lib/plugin-resolver.test.d.ts.map +1 -0
  59. package/dist/modules/plugin/lib/plugin-resolver.test.js +175 -0
  60. package/dist/modules/plugin/lib/plugin-resolver.test.js.map +1 -0
  61. package/dist/modules/plugin/services/plugin-create-service.d.ts +16 -0
  62. package/dist/modules/plugin/services/plugin-create-service.d.ts.map +1 -0
  63. package/dist/modules/plugin/services/plugin-create-service.js +47 -0
  64. package/dist/modules/plugin/services/plugin-create-service.js.map +1 -0
  65. package/dist/modules/plugin/services/plugin-svc.d.ts +8 -3
  66. package/dist/modules/plugin/services/plugin-svc.d.ts.map +1 -1
  67. package/dist/modules/plugin/services/plugin-svc.js +96 -15
  68. package/dist/modules/plugin/services/plugin-svc.js.map +1 -1
  69. package/dist/modules/release/commands/release-init.d.ts +3 -0
  70. package/dist/modules/release/commands/release-init.d.ts.map +1 -0
  71. package/dist/modules/release/commands/release-init.js +92 -0
  72. package/dist/modules/release/commands/release-init.js.map +1 -0
  73. package/dist/modules/release/errors/release-errors.d.ts +7 -0
  74. package/dist/modules/release/errors/release-errors.d.ts.map +1 -0
  75. package/dist/modules/release/errors/release-errors.js +13 -0
  76. package/dist/modules/release/errors/release-errors.js.map +1 -0
  77. package/dist/modules/release/index.d.ts +4 -0
  78. package/dist/modules/release/index.d.ts.map +1 -0
  79. package/dist/modules/release/index.js +7 -0
  80. package/dist/modules/release/index.js.map +1 -0
  81. package/dist/modules/release/services/release-service.d.ts +34 -0
  82. package/dist/modules/release/services/release-service.d.ts.map +1 -0
  83. package/dist/modules/release/services/release-service.js +154 -0
  84. package/dist/modules/release/services/release-service.js.map +1 -0
  85. package/dist/templates/plugin/README.md.hbs +39 -0
  86. package/dist/templates/plugin/package.json.hbs +34 -0
  87. package/dist/templates/plugin/plugin.json.hbs +7 -0
  88. package/dist/templates/plugin/src/generator.ts.hbs +64 -0
  89. package/dist/templates/plugin/templates/src/.gitkeep +0 -0
  90. package/dist/templates/plugin/tsconfig.json +10 -0
  91. package/dist/templates/plugin/tsup.config.ts +9 -0
  92. package/dist/templates/workspace/.github/workflows/ci.yml +8 -5
  93. package/dist/templates/workspace/package.json +1 -0
  94. package/dist/templates/workspace/turbo.json +5 -0
  95. package/dist/utils/launch77-context.d.ts +1 -1
  96. package/dist/utils/launch77-context.d.ts.map +1 -1
  97. package/dist/utils/launch77-context.js +25 -2
  98. package/dist/utils/launch77-context.js.map +1 -1
  99. package/dist/utils/launch77-validation.d.ts +1 -1
  100. package/dist/utils/launch77-validation.d.ts.map +1 -1
  101. package/dist/utils/string.d.ts +13 -0
  102. package/dist/utils/string.d.ts.map +1 -0
  103. package/dist/utils/string.js +18 -0
  104. package/dist/utils/string.js.map +1 -0
  105. package/package.json +6 -9
  106. package/src/cli.ts +7 -3
  107. package/src/infrastructure/template-generator.ts +1 -1
  108. package/src/infrastructure/template.ts +14 -0
  109. package/src/modules/app/commands/create-app.ts +1 -1
  110. package/src/modules/app/commands/delete-app.ts +1 -1
  111. package/src/modules/app/services/app-svc.ts +1 -1
  112. package/src/modules/app/services/manifest-svc.ts +1 -1
  113. package/src/modules/catalog/config/catalog-config.test.ts +1 -1
  114. package/src/modules/git/commands/git-connect.ts +2 -2
  115. package/src/modules/git/errors/git-errors.ts +7 -0
  116. package/src/modules/git/index.ts +8 -1
  117. package/src/modules/git/services/git-service.ts +12 -1
  118. package/src/modules/plugin/commands/plugin-create.ts +68 -0
  119. package/src/modules/plugin/commands/plugin-install.ts +9 -26
  120. package/src/modules/plugin/errors/plugin-errors.ts +87 -6
  121. package/src/modules/plugin/index.ts +4 -2
  122. package/src/modules/plugin/lib/plugin-registry.ts +14 -37
  123. package/src/modules/plugin/lib/plugin-resolver.test.ts +215 -0
  124. package/src/modules/plugin/lib/plugin-resolver.ts +160 -0
  125. package/src/modules/plugin/services/plugin-create-service.ts +69 -0
  126. package/src/modules/plugin/services/plugin-svc.ts +108 -15
  127. package/src/modules/release/commands/release-init.ts +102 -0
  128. package/src/modules/release/errors/release-errors.ts +13 -0
  129. package/src/modules/release/index.ts +8 -0
  130. package/src/modules/release/services/release-service.ts +170 -0
  131. package/src/utils/launch77-context.ts +29 -3
  132. package/src/utils/launch77-validation.ts +1 -1
  133. package/src/utils/string.ts +17 -0
  134. package/templates/plugin/README.md.hbs +39 -0
  135. package/templates/plugin/package.json.hbs +34 -0
  136. package/templates/plugin/plugin.json.hbs +7 -0
  137. package/templates/plugin/src/generator.ts.hbs +64 -0
  138. package/templates/plugin/templates/src/.gitkeep +0 -0
  139. package/templates/plugin/tsconfig.json +10 -0
  140. package/templates/plugin/tsup.config.ts +9 -0
  141. package/templates/workspace/.github/workflows/ci.yml +8 -5
  142. package/templates/workspace/package.json +1 -0
  143. package/templates/workspace/turbo.json +5 -0
  144. package/tests/integration/cli.test.ts +25 -0
  145. package/tests/integration/setup.ts +20 -0
  146. package/vitest.config.ts +9 -0
  147. package/vitest.integration.config.ts +9 -0
  148. package/dist/modules/git/commands/git-setup-releases.d.ts +0 -3
  149. package/dist/modules/git/commands/git-setup-releases.d.ts.map +0 -1
  150. package/dist/modules/git/commands/git-setup-releases.js +0 -128
  151. package/dist/modules/git/commands/git-setup-releases.js.map +0 -1
  152. package/launch77-cli-1.2.0.tgz +0 -0
  153. package/src/modules/git/commands/git-setup-releases.ts +0 -148
  154. package/src/modules/plugin/lib/launch77-workspace.code-workspace +0 -14
@@ -0,0 +1,215 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import * as path from 'path'
3
+ import * as os from 'os'
4
+ import fs from 'fs-extra'
5
+
6
+ import { validatePluginInput, toNpmPackageName, resolvePluginLocation } from './plugin-resolver.js'
7
+
8
+ describe('Plugin Resolver', () => {
9
+ describe('validatePluginInput', () => {
10
+ 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 })
14
+ })
15
+
16
+ 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 })
20
+ })
21
+
22
+ it('should reject empty names', () => {
23
+ const result = validatePluginInput('')
24
+ expect(result.isValid).toBe(false)
25
+ expect(result.error).toBeDefined()
26
+ })
27
+
28
+ it('should reject whitespace-only names', () => {
29
+ const result = validatePluginInput(' ')
30
+ expect(result.isValid).toBe(false)
31
+ expect(result.error).toBeDefined()
32
+ })
33
+
34
+ it('should reject invalid scoped packages', () => {
35
+ const result1 = validatePluginInput('@invalid')
36
+ expect(result1.isValid).toBe(false)
37
+ expect(result1.error).toBeDefined()
38
+
39
+ const result2 = validatePluginInput('@/package')
40
+ expect(result2.isValid).toBe(false)
41
+ expect(result2.error).toBeDefined()
42
+
43
+ const result3 = validatePluginInput('@org/')
44
+ expect(result3.isValid).toBe(false)
45
+ expect(result3.error).toBeDefined()
46
+ })
47
+
48
+ it('should reject names with uppercase letters', () => {
49
+ const result = validatePluginInput('MyPlugin')
50
+ expect(result.isValid).toBe(false)
51
+ expect(result.error).toContain('lowercase')
52
+ })
53
+
54
+ it('should reject names starting with numbers', () => {
55
+ const result = validatePluginInput('123plugin')
56
+ expect(result.isValid).toBe(false)
57
+ expect(result.error).toBeDefined()
58
+ })
59
+
60
+ it('should reject names with special characters', () => {
61
+ const result1 = validatePluginInput('plugin_name')
62
+ expect(result1.isValid).toBe(false)
63
+
64
+ const result2 = validatePluginInput('plugin.name')
65
+ expect(result2.isValid).toBe(false)
66
+
67
+ const result3 = validatePluginInput('plugin name')
68
+ expect(result3.isValid).toBe(false)
69
+ })
70
+
71
+ it('should trim whitespace before validation', () => {
72
+ expect(validatePluginInput(' release ')).toEqual({ isValid: true })
73
+ expect(validatePluginInput(' @ibm/analytics ')).toEqual({ isValid: true })
74
+ })
75
+ })
76
+
77
+ describe('toNpmPackageName', () => {
78
+ 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')
82
+ })
83
+
84
+ 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')
88
+ })
89
+
90
+ it('should trim whitespace', () => {
91
+ expect(toNpmPackageName(' release ')).toBe('@launch77-shared/plugin-release')
92
+ expect(toNpmPackageName(' @ibm/analytics ')).toBe('@ibm/analytics')
93
+ })
94
+ })
95
+
96
+ describe('resolvePluginLocation', () => {
97
+ let tempDir: string
98
+
99
+ beforeEach(async () => {
100
+ // Create a temporary workspace directory
101
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'plugin-resolver-test-'))
102
+ await fs.ensureDir(path.join(tempDir, 'plugins'))
103
+ })
104
+
105
+ afterEach(async () => {
106
+ // Clean up
107
+ await fs.remove(tempDir)
108
+ })
109
+
110
+ it('should resolve local plugins first', async () => {
111
+ // Create a valid local plugin
112
+ const pluginPath = path.join(tempDir, 'plugins', 'my-plugin')
113
+ await fs.ensureDir(pluginPath)
114
+ await fs.ensureDir(path.join(pluginPath, 'dist'))
115
+ await fs.writeFile(path.join(pluginPath, 'plugin.json'), JSON.stringify({ name: 'my-plugin', version: '1.0.0' }))
116
+ await fs.writeFile(path.join(pluginPath, 'dist/generator.js'), 'console.log("test")')
117
+
118
+ const result = await resolvePluginLocation('my-plugin', tempDir)
119
+
120
+ expect(result).toEqual({
121
+ source: 'local',
122
+ resolvedName: 'my-plugin',
123
+ localPath: pluginPath,
124
+ })
125
+ })
126
+
127
+ it('should resolve to npm if local plugin does not exist', async () => {
128
+ const result = await resolvePluginLocation('release', tempDir)
129
+
130
+ expect(result).toEqual({
131
+ source: 'npm',
132
+ resolvedName: 'release',
133
+ npmPackage: '@launch77-shared/plugin-release',
134
+ })
135
+ })
136
+
137
+ it('should resolve to npm if local plugin is incomplete (missing plugin.json)', async () => {
138
+ // Create incomplete local plugin (no plugin.json)
139
+ const pluginPath = path.join(tempDir, 'plugins', 'incomplete')
140
+ await fs.ensureDir(pluginPath)
141
+ await fs.ensureDir(path.join(pluginPath, 'dist'))
142
+ await fs.writeFile(path.join(pluginPath, 'dist/generator.js'), 'console.log("test")')
143
+
144
+ const result = await resolvePluginLocation('incomplete', tempDir)
145
+
146
+ expect(result).toEqual({
147
+ source: 'npm',
148
+ resolvedName: 'incomplete',
149
+ npmPackage: '@launch77-shared/plugin-incomplete',
150
+ })
151
+ })
152
+
153
+ it('should resolve to npm if local plugin is incomplete (missing generator.js)', async () => {
154
+ // Create incomplete local plugin (no generator.js)
155
+ const pluginPath = path.join(tempDir, 'plugins', 'incomplete')
156
+ await fs.ensureDir(pluginPath)
157
+ await fs.writeFile(path.join(pluginPath, 'plugin.json'), JSON.stringify({ name: 'incomplete', version: '1.0.0' }))
158
+
159
+ const result = await resolvePluginLocation('incomplete', tempDir)
160
+
161
+ expect(result).toEqual({
162
+ source: 'npm',
163
+ resolvedName: 'incomplete',
164
+ npmPackage: '@launch77-shared/plugin-incomplete',
165
+ })
166
+ })
167
+
168
+ it('should always resolve scoped packages to npm', async () => {
169
+ // Even if a directory exists locally, scoped names go to npm
170
+ const pluginPath = path.join(tempDir, 'plugins', '@ibm')
171
+ await fs.ensureDir(pluginPath)
172
+
173
+ const result = await resolvePluginLocation('@ibm/analytics', tempDir)
174
+
175
+ expect(result).toEqual({
176
+ source: 'npm',
177
+ resolvedName: '@ibm/analytics',
178
+ npmPackage: '@ibm/analytics',
179
+ })
180
+ })
181
+
182
+ it('should resolve scoped @launch77-shared packages to npm', async () => {
183
+ const result = await resolvePluginLocation('@launch77-shared/plugin-release', tempDir)
184
+
185
+ expect(result).toEqual({
186
+ source: 'npm',
187
+ resolvedName: '@launch77-shared/plugin-release',
188
+ npmPackage: '@launch77-shared/plugin-release',
189
+ })
190
+ })
191
+
192
+ it('should handle plugins directory not existing', async () => {
193
+ // Remove plugins directory
194
+ await fs.remove(path.join(tempDir, 'plugins'))
195
+
196
+ const result = await resolvePluginLocation('release', tempDir)
197
+
198
+ expect(result).toEqual({
199
+ source: 'npm',
200
+ resolvedName: 'release',
201
+ npmPackage: '@launch77-shared/plugin-release',
202
+ })
203
+ })
204
+
205
+ it('should trim whitespace from plugin names', async () => {
206
+ const result = await resolvePluginLocation(' release ', tempDir)
207
+
208
+ expect(result).toEqual({
209
+ source: 'npm',
210
+ resolvedName: 'release',
211
+ npmPackage: '@launch77-shared/plugin-release',
212
+ })
213
+ })
214
+ })
215
+ })
@@ -0,0 +1,160 @@
1
+ import * as path from 'path'
2
+
3
+ import fs from 'fs-extra'
4
+ import { parsePluginName, isValidNpmPackageName } from '@launch77/plugin-runtime'
5
+
6
+ import type { ValidationResult } from '@launch77/plugin-runtime'
7
+
8
+ /**
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")
28
+ *
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: '...' }
41
+ */
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
+ }
48
+ }
49
+
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
+ }
60
+ }
61
+
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) {
139
+ // Verify it's a valid plugin (has plugin.json and dist/generator.js)
140
+ const hasPluginJson = await fs.pathExists(path.join(localPath, 'plugin.json'))
141
+ const hasGenerator = await fs.pathExists(path.join(localPath, 'dist/generator.js'))
142
+
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,
159
+ }
160
+ }
@@ -0,0 +1,69 @@
1
+ import * as path from 'path'
2
+
3
+ import fs from 'fs-extra'
4
+
5
+ import { validatePluginName } from '@launch77/plugin-runtime'
6
+ import { processTemplate, getPluginTemplatePath } from '../../../infrastructure/template.js'
7
+ import { toPascalCase } from '../../../utils/string.js'
8
+
9
+ import type { Launch77Context } from '@launch77/plugin-runtime'
10
+
11
+ export interface CreatePluginRequest {
12
+ pluginName: string
13
+ description?: string
14
+ }
15
+
16
+ export interface CreatePluginResult {
17
+ pluginName: string
18
+ pluginPath: string
19
+ }
20
+
21
+ export class PluginCreateService {
22
+ /**
23
+ * Create a new plugin from template
24
+ */
25
+ async createPlugin(request: CreatePluginRequest, context: Launch77Context): Promise<CreatePluginResult> {
26
+ const { pluginName, description } = request
27
+
28
+ // 1. Validate plugin name
29
+ const nameValidation = validatePluginName(pluginName)
30
+ if (!nameValidation.isValid) {
31
+ throw new Error(nameValidation.error || 'Invalid plugin name')
32
+ }
33
+
34
+ // 2. Validate workspace context
35
+ if (!context.isValid) {
36
+ throw new Error('Must be run from within a Launch77 workspace')
37
+ }
38
+
39
+ // 3. Determine plugin path
40
+ const pluginPath = path.join(context.workspaceRoot, 'plugins', pluginName)
41
+
42
+ // 4. Check if plugin already exists
43
+ if (await fs.pathExists(pluginPath)) {
44
+ throw new Error(`Plugin '${pluginName}' already exists at ${pluginPath}`)
45
+ }
46
+
47
+ // 5. Get plugin template path
48
+ const templatePath = getPluginTemplatePath('plugin')
49
+
50
+ // 6. Check if template exists
51
+ if (!(await fs.pathExists(templatePath))) {
52
+ throw new Error(`Plugin template not found at ${templatePath}`)
53
+ }
54
+
55
+ // 7. Process template with context
56
+ await processTemplate(templatePath, pluginPath, {
57
+ appName: pluginName, // Required by TemplateContext but not used for plugin templates
58
+ pluginName,
59
+ pluginNamePascal: toPascalCase(pluginName),
60
+ workspaceName: context.workspaceName,
61
+ description: description || `Launch77 plugin for ${pluginName}`,
62
+ })
63
+
64
+ return {
65
+ pluginName,
66
+ pluginPath,
67
+ }
68
+ }
69
+ }
@@ -1,34 +1,90 @@
1
1
  import * as path from 'path'
2
2
 
3
+ import chalk from 'chalk'
3
4
  import { execa } from 'execa'
5
+ import { readPluginMetadata } from '@launch77/plugin-runtime'
4
6
 
5
- import { PluginInstallationError, PluginNotFoundError, InvalidPluginContextError, createInvalidContextError } from '../errors/plugin-errors.js'
6
- import { pluginExists, getPluginPath } from '../lib/plugin-registry.js'
7
+ import { PluginInstallationError, InvalidPluginContextError, createInvalidContextError, MissingPluginTargetsError, createInvalidTargetError, NpmInstallationError, PluginResolutionError } from '../errors/plugin-errors.js'
8
+ import { validatePluginInput, resolvePluginLocation } from '../lib/plugin-resolver.js'
7
9
 
8
- import type { Launch77Context } from '../../../utils/launch77-context.js'
10
+ import type { Launch77Context, Launch77LocationType } from '@launch77/plugin-runtime'
9
11
  import type { InstallPluginRequest, InstallPluginResult } from '../types/plugin-types.js'
10
12
 
13
+ /**
14
+ * Map location type to target string
15
+ */
16
+ function locationTypeToTarget(locationType: Launch77LocationType): string | null {
17
+ switch (locationType) {
18
+ case 'workspace-app':
19
+ return 'app'
20
+ case 'workspace-library':
21
+ return 'library'
22
+ case 'workspace-plugin':
23
+ return 'plugin'
24
+ case 'workspace-app-template':
25
+ return 'app-template'
26
+ default:
27
+ return null
28
+ }
29
+ }
30
+
11
31
  export class PluginService {
12
32
  /**
13
- * Install a plugin to the current app
33
+ * Install a plugin to the current package
14
34
  */
15
- async installPlugin(request: InstallPluginRequest, context: Launch77Context): Promise<InstallPluginResult> {
35
+ async installPlugin(request: InstallPluginRequest, context: Launch77Context, logger: (message: string) => void = console.log): Promise<InstallPluginResult> {
16
36
  const { pluginName } = request
17
37
 
18
- // Validate context - must be in app directory
19
- if (context.locationType !== 'workspace-app') throw createInvalidContextError(context.locationType)
20
- if (!context.appName) throw new InvalidPluginContextError('Could not determine app name. This is a bug. Please report it.')
38
+ // Must be in a package directory (app, library, plugin, or app-template)
39
+ const currentTarget = locationTypeToTarget(context.locationType)
40
+ if (!currentTarget) throw createInvalidContextError(context.locationType)
41
+ if (!context.appName) throw new InvalidPluginContextError('Could not determine package name. This is a bug. Please report it.')
42
+
43
+ // Step 1: Validate plugin input
44
+ logger(chalk.blue(`\nšŸ” Resolving plugin "${pluginName}"...`))
45
+ logger(` ā”œā”€ Validating plugin name...`)
46
+
47
+ const validation = validatePluginInput(pluginName)
48
+ if (!validation.isValid) {
49
+ throw new PluginResolutionError(pluginName, validation.error || 'Invalid plugin name')
50
+ }
51
+ logger(` │ └─ ${chalk.green('āœ“')} Valid plugin name`)
52
+
53
+ // Step 2: Resolve plugin location
54
+ logger(` ā”œā”€ Checking local workspace: ${chalk.dim(`plugins/${pluginName}`)}`)
55
+ const resolution = await resolvePluginLocation(pluginName, context.workspaceRoot)
21
56
 
22
- // Get app directory path
23
- const appPath = path.join(context.appsDir, context.appName)
57
+ let pluginPath: string
58
+
59
+ if (resolution.source === 'local') {
60
+ logger(` │ └─ ${chalk.green('āœ“')} Found local plugin`)
61
+ pluginPath = resolution.localPath!
62
+ } else {
63
+ logger(` │ └─ ${chalk.dim('Not found locally')}`)
64
+ logger(` ā”œā”€ Resolving to npm package: ${chalk.cyan(resolution.npmPackage)}`)
65
+
66
+ // Download npm plugin
67
+ pluginPath = await this.downloadNpmPlugin(resolution.npmPackage!, context.workspaceRoot, logger)
68
+ }
69
+
70
+ logger(` └─ ${chalk.green('āœ“')} Plugin resolved\n`)
71
+
72
+ // Step 3: Read plugin metadata and validate targets
73
+ const metadata = await readPluginMetadata(pluginPath)
74
+
75
+ if (!metadata.targets || metadata.targets.length === 0) {
76
+ throw new MissingPluginTargetsError(pluginName)
77
+ }
24
78
 
25
- // Check if plugin exists
26
- if (!(await pluginExists(pluginName))) throw new PluginNotFoundError(pluginName)
79
+ if (!metadata.targets.includes(currentTarget)) {
80
+ throw createInvalidTargetError(pluginName, currentTarget, metadata.targets)
81
+ }
27
82
 
28
- const pluginPath = getPluginPath(pluginName)
83
+ // Step 4: Get package directory path
84
+ const packagePath = this.getPackagePath(context)
29
85
 
30
- // Run generator
31
- await this.runGenerator(pluginPath, appPath, context)
86
+ // Step 5: Run generator
87
+ await this.runGenerator(pluginPath, packagePath, context)
32
88
 
33
89
  return {
34
90
  pluginName,
@@ -38,6 +94,43 @@ export class PluginService {
38
94
  }
39
95
  }
40
96
 
97
+ /**
98
+ * Download and install an npm plugin package
99
+ */
100
+ private async downloadNpmPlugin(npmPackage: string, workspaceRoot: string, logger: (message: string) => void): Promise<string> {
101
+ logger(` └─ Installing from npm: ${chalk.cyan(npmPackage)}...`)
102
+
103
+ try {
104
+ // Install the npm package to the workspace
105
+ await execa('npm', ['install', npmPackage, '--save-dev'], {
106
+ cwd: workspaceRoot,
107
+ stdio: 'pipe', // Capture output for clean logging
108
+ })
109
+
110
+ // Return path to installed plugin in node_modules
111
+ const pluginPath = path.join(workspaceRoot, 'node_modules', npmPackage)
112
+ return pluginPath
113
+ } catch (error) {
114
+ throw new NpmInstallationError(npmPackage, error instanceof Error ? error : undefined)
115
+ }
116
+ }
117
+
118
+ private getPackagePath(context: Launch77Context): string {
119
+ // Determine the base directory based on location type
120
+ switch (context.locationType) {
121
+ case 'workspace-app':
122
+ return path.join(context.appsDir, context.appName!)
123
+ case 'workspace-library':
124
+ return path.join(context.workspaceRoot, 'libraries', context.appName!)
125
+ case 'workspace-plugin':
126
+ return path.join(context.workspaceRoot, 'plugins', context.appName!)
127
+ case 'workspace-app-template':
128
+ return path.join(context.workspaceRoot, 'app-templates', context.appName!)
129
+ default:
130
+ throw new InvalidPluginContextError(`Cannot install plugin from ${context.locationType}`)
131
+ }
132
+ }
133
+
41
134
  private async runGenerator(pluginPath: string, appPath: string, context: Launch77Context): Promise<void> {
42
135
  try {
43
136
  const generatorPath = path.join(pluginPath, 'dist/generator.js')