@launch77/cli 1.3.0 → 1.4.1

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 (159) hide show
  1. package/CHANGELOG.md +19 -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 +29 -3
  66. package/dist/modules/plugin/services/plugin-svc.d.ts.map +1 -1
  67. package/dist/modules/plugin/services/plugin-svc.js +192 -17
  68. package/dist/modules/plugin/services/plugin-svc.js.map +1 -1
  69. package/dist/modules/plugin/services/plugin-svc.test.d.ts +2 -0
  70. package/dist/modules/plugin/services/plugin-svc.test.d.ts.map +1 -0
  71. package/dist/modules/plugin/services/plugin-svc.test.js +362 -0
  72. package/dist/modules/plugin/services/plugin-svc.test.js.map +1 -0
  73. package/dist/modules/release/commands/release-init.d.ts +3 -0
  74. package/dist/modules/release/commands/release-init.d.ts.map +1 -0
  75. package/dist/modules/release/commands/release-init.js +92 -0
  76. package/dist/modules/release/commands/release-init.js.map +1 -0
  77. package/dist/modules/release/errors/release-errors.d.ts +7 -0
  78. package/dist/modules/release/errors/release-errors.d.ts.map +1 -0
  79. package/dist/modules/release/errors/release-errors.js +13 -0
  80. package/dist/modules/release/errors/release-errors.js.map +1 -0
  81. package/dist/modules/release/index.d.ts +4 -0
  82. package/dist/modules/release/index.d.ts.map +1 -0
  83. package/dist/modules/release/index.js +7 -0
  84. package/dist/modules/release/index.js.map +1 -0
  85. package/dist/modules/release/services/release-service.d.ts +34 -0
  86. package/dist/modules/release/services/release-service.d.ts.map +1 -0
  87. package/dist/modules/release/services/release-service.js +154 -0
  88. package/dist/modules/release/services/release-service.js.map +1 -0
  89. package/dist/templates/plugin/README.md.hbs +39 -0
  90. package/dist/templates/plugin/package.json.hbs +34 -0
  91. package/dist/templates/plugin/plugin.json.hbs +7 -0
  92. package/dist/templates/plugin/src/generator.ts.hbs +64 -0
  93. package/dist/templates/plugin/templates/src/.gitkeep +0 -0
  94. package/dist/templates/plugin/tsconfig.json +10 -0
  95. package/dist/templates/plugin/tsup.config.ts +9 -0
  96. package/dist/templates/workspace/.github/workflows/ci.yml +8 -5
  97. package/dist/templates/workspace/package.json +3 -1
  98. package/dist/templates/workspace/turbo.json +5 -0
  99. package/dist/utils/launch77-context.d.ts +1 -1
  100. package/dist/utils/launch77-context.d.ts.map +1 -1
  101. package/dist/utils/launch77-context.js +25 -2
  102. package/dist/utils/launch77-context.js.map +1 -1
  103. package/dist/utils/launch77-validation.d.ts +1 -1
  104. package/dist/utils/launch77-validation.d.ts.map +1 -1
  105. package/dist/utils/string.d.ts +13 -0
  106. package/dist/utils/string.d.ts.map +1 -0
  107. package/dist/utils/string.js +18 -0
  108. package/dist/utils/string.js.map +1 -0
  109. package/package.json +6 -9
  110. package/src/cli.ts +7 -3
  111. package/src/infrastructure/template-generator.ts +1 -1
  112. package/src/infrastructure/template.ts +14 -0
  113. package/src/modules/app/commands/create-app.ts +1 -1
  114. package/src/modules/app/commands/delete-app.ts +1 -1
  115. package/src/modules/app/services/app-svc.ts +1 -1
  116. package/src/modules/app/services/manifest-svc.ts +1 -1
  117. package/src/modules/catalog/config/catalog-config.test.ts +1 -1
  118. package/src/modules/git/commands/git-connect.ts +2 -2
  119. package/src/modules/git/errors/git-errors.ts +7 -0
  120. package/src/modules/git/index.ts +8 -1
  121. package/src/modules/git/services/git-service.ts +12 -1
  122. package/src/modules/plugin/commands/plugin-create.ts +68 -0
  123. package/src/modules/plugin/commands/plugin-install.ts +9 -26
  124. package/src/modules/plugin/errors/plugin-errors.ts +87 -6
  125. package/src/modules/plugin/index.ts +4 -2
  126. package/src/modules/plugin/lib/plugin-registry.ts +14 -37
  127. package/src/modules/plugin/lib/plugin-resolver.test.ts +215 -0
  128. package/src/modules/plugin/lib/plugin-resolver.ts +160 -0
  129. package/src/modules/plugin/services/plugin-create-service.ts +69 -0
  130. package/src/modules/plugin/services/plugin-svc.test.ts +418 -0
  131. package/src/modules/plugin/services/plugin-svc.ts +217 -17
  132. package/src/modules/release/commands/release-init.ts +102 -0
  133. package/src/modules/release/errors/release-errors.ts +13 -0
  134. package/src/modules/release/index.ts +8 -0
  135. package/src/modules/release/services/release-service.ts +170 -0
  136. package/src/utils/launch77-context.ts +29 -3
  137. package/src/utils/launch77-validation.ts +1 -1
  138. package/src/utils/string.ts +17 -0
  139. package/templates/plugin/README.md.hbs +39 -0
  140. package/templates/plugin/package.json.hbs +34 -0
  141. package/templates/plugin/plugin.json.hbs +7 -0
  142. package/templates/plugin/src/generator.ts.hbs +64 -0
  143. package/templates/plugin/templates/src/.gitkeep +0 -0
  144. package/templates/plugin/tsconfig.json +10 -0
  145. package/templates/plugin/tsup.config.ts +9 -0
  146. package/templates/workspace/.github/workflows/ci.yml +8 -5
  147. package/templates/workspace/package.json +3 -1
  148. package/templates/workspace/turbo.json +5 -0
  149. package/tests/integration/cli.test.ts +25 -0
  150. package/tests/integration/setup.ts +20 -0
  151. package/vitest.config.ts +9 -0
  152. package/vitest.integration.config.ts +9 -0
  153. package/dist/modules/git/commands/git-setup-releases.d.ts +0 -3
  154. package/dist/modules/git/commands/git-setup-releases.d.ts.map +0 -1
  155. package/dist/modules/git/commands/git-setup-releases.js +0 -128
  156. package/dist/modules/git/commands/git-setup-releases.js.map +0 -1
  157. package/launch77-cli-1.2.0.tgz +0 -0
  158. package/src/modules/git/commands/git-setup-releases.ts +0 -148
  159. package/src/modules/plugin/lib/launch77-workspace.code-workspace +0 -14
@@ -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
+ }
@@ -0,0 +1,418 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'vitest'
2
+ import * as path from 'path'
3
+ import * as os from 'os'
4
+ import fs from 'fs-extra'
5
+ import { PluginService } from './plugin-svc.js'
6
+ import type { Launch77Context } from '@launch77/plugin-runtime'
7
+
8
+ describe('PluginService', () => {
9
+ let tempDir: string
10
+ let service: PluginService
11
+
12
+ beforeEach(async () => {
13
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'plugin-svc-test-'))
14
+ service = new PluginService()
15
+ })
16
+
17
+ afterEach(async () => {
18
+ await fs.remove(tempDir)
19
+ })
20
+
21
+ describe('validateContext', () => {
22
+ // Valid contexts - test return values
23
+ test('should return "app" for workspace-app location', () => {
24
+ const context: Launch77Context = {
25
+ isValid: true,
26
+ locationType: 'workspace-app',
27
+ workspaceRoot: tempDir,
28
+ workspaceName: 'test-workspace',
29
+ workspaceVersion: '1.0.0',
30
+ appsDir: path.join(tempDir, 'apps'),
31
+ packageName: 'test-app',
32
+ appName: 'test-app',
33
+ }
34
+
35
+ const result = (service as any).validateContext(context)
36
+ expect(result).toBe('app')
37
+ })
38
+
39
+ test('should return "library" for workspace-library location', () => {
40
+ const context: Launch77Context = {
41
+ isValid: true,
42
+ locationType: 'workspace-library',
43
+ workspaceRoot: tempDir,
44
+ workspaceName: 'test-workspace',
45
+ workspaceVersion: '1.0.0',
46
+ appsDir: path.join(tempDir, 'apps'),
47
+ packageName: 'test-library',
48
+ appName: 'test-library',
49
+ }
50
+
51
+ const result = (service as any).validateContext(context)
52
+ expect(result).toBe('library')
53
+ })
54
+
55
+ test('should return "plugin" for workspace-plugin location', () => {
56
+ const context: Launch77Context = {
57
+ isValid: true,
58
+ locationType: 'workspace-plugin',
59
+ workspaceRoot: tempDir,
60
+ workspaceName: 'test-workspace',
61
+ workspaceVersion: '1.0.0',
62
+ appsDir: path.join(tempDir, 'apps'),
63
+ packageName: 'test-plugin',
64
+ appName: 'test-plugin',
65
+ }
66
+
67
+ const result = (service as any).validateContext(context)
68
+ expect(result).toBe('plugin')
69
+ })
70
+
71
+ test('should return "app-template" for workspace-app-template location', () => {
72
+ const context: Launch77Context = {
73
+ isValid: true,
74
+ locationType: 'workspace-app-template',
75
+ workspaceRoot: tempDir,
76
+ workspaceName: 'test-workspace',
77
+ workspaceVersion: '1.0.0',
78
+ appsDir: path.join(tempDir, 'apps'),
79
+ packageName: 'test-template',
80
+ appName: 'test-template',
81
+ }
82
+
83
+ const result = (service as any).validateContext(context)
84
+ expect(result).toBe('app-template')
85
+ })
86
+
87
+ // Invalid contexts - test exact error messages
88
+ test('should throw InvalidPluginContextError for workspace-root', () => {
89
+ const context: Launch77Context = {
90
+ isValid: true,
91
+ locationType: 'workspace-root',
92
+ workspaceRoot: tempDir,
93
+ workspaceName: 'test-workspace',
94
+ workspaceVersion: '1.0.0',
95
+ appsDir: path.join(tempDir, 'apps'),
96
+ packageName: 'workspace',
97
+ appName: undefined,
98
+ }
99
+
100
+ expect(() => (service as any).validateContext(context)).toThrow('plugin:install must be run from within a package directory.')
101
+ })
102
+
103
+ test('should throw InvalidPluginContextError for non-workspace', () => {
104
+ const context = {
105
+ isValid: false,
106
+ locationType: 'non-workspace' as const,
107
+ workspaceRoot: tempDir,
108
+ workspaceName: 'test-workspace',
109
+ workspaceVersion: '1.0.0',
110
+ appsDir: path.join(tempDir, 'apps'),
111
+ packageName: 'unknown',
112
+ appName: undefined,
113
+ }
114
+
115
+ expect(() => (service as any).validateContext(context)).toThrow('plugin:install must be run from within a package directory.')
116
+ })
117
+
118
+ test('should throw InvalidPluginContextError when appName is missing', () => {
119
+ const context: Launch77Context = {
120
+ isValid: true,
121
+ locationType: 'workspace-app',
122
+ workspaceRoot: tempDir,
123
+ workspaceName: 'test-workspace',
124
+ workspaceVersion: '1.0.0',
125
+ appsDir: path.join(tempDir, 'apps'),
126
+ packageName: 'test-app',
127
+ appName: undefined,
128
+ }
129
+
130
+ expect(() => (service as any).validateContext(context)).toThrow('Could not determine package name. This is a bug. Please report it.')
131
+ })
132
+
133
+ test('should throw InvalidPluginContextError when appName is empty string', () => {
134
+ const context: Launch77Context = {
135
+ isValid: true,
136
+ locationType: 'workspace-app',
137
+ workspaceRoot: tempDir,
138
+ workspaceName: 'test-workspace',
139
+ workspaceVersion: '1.0.0',
140
+ appsDir: path.join(tempDir, 'apps'),
141
+ packageName: 'test-app',
142
+ appName: '',
143
+ }
144
+
145
+ expect(() => (service as any).validateContext(context)).toThrow('Could not determine package name. This is a bug. Please report it.')
146
+ })
147
+ })
148
+
149
+ describe('validatePluginTargets', () => {
150
+ // Valid scenarios - verify metadata returned
151
+ test('should return metadata when plugin targets current package type', async () => {
152
+ const pluginDir = path.join(tempDir, 'test-plugin')
153
+ await fs.ensureDir(pluginDir)
154
+ await fs.writeJson(path.join(pluginDir, 'plugin.json'), {
155
+ name: 'test-plugin',
156
+ version: '1.0.0',
157
+ targets: ['app', 'library'],
158
+ })
159
+
160
+ const result = await (service as any).validatePluginTargets(pluginDir, 'test-plugin', 'app')
161
+ expect(result).toEqual({
162
+ name: 'test-plugin',
163
+ version: '1.0.0',
164
+ targets: ['app', 'library'],
165
+ })
166
+ })
167
+
168
+ test('should accept plugin with multiple targets including current', async () => {
169
+ const pluginDir = path.join(tempDir, 'multi-target-plugin')
170
+ await fs.ensureDir(pluginDir)
171
+ await fs.writeJson(path.join(pluginDir, 'plugin.json'), {
172
+ name: 'multi-target-plugin',
173
+ version: '2.0.0',
174
+ targets: ['app', 'library', 'plugin', 'app-template'],
175
+ })
176
+
177
+ const result = await (service as any).validatePluginTargets(pluginDir, 'multi-target-plugin', 'library')
178
+ expect(result.targets).toContain('library')
179
+ expect(result.targets).toHaveLength(4)
180
+ })
181
+
182
+ test('should handle plugin.json with optional fields (pluginDependencies, libraryDependencies)', async () => {
183
+ const pluginDir = path.join(tempDir, 'full-plugin')
184
+ await fs.ensureDir(pluginDir)
185
+ await fs.writeJson(path.join(pluginDir, 'plugin.json'), {
186
+ name: 'full-plugin',
187
+ version: '1.5.0',
188
+ targets: ['app'],
189
+ pluginDependencies: { 'other-plugin': '^1.0.0' },
190
+ libraryDependencies: { react: '^18.0.0' },
191
+ })
192
+
193
+ const result = await (service as any).validatePluginTargets(pluginDir, 'full-plugin', 'app')
194
+ expect(result.pluginDependencies).toEqual({ 'other-plugin': '^1.0.0' })
195
+ expect(result.libraryDependencies).toEqual({ react: '^18.0.0' })
196
+ })
197
+
198
+ // Error scenarios - test exact error messages
199
+ test('should throw MissingPluginTargetsError when targets is undefined', async () => {
200
+ const pluginDir = path.join(tempDir, 'no-targets-plugin')
201
+ await fs.ensureDir(pluginDir)
202
+ await fs.writeJson(path.join(pluginDir, 'plugin.json'), {
203
+ name: 'no-targets-plugin',
204
+ version: '1.0.0',
205
+ })
206
+
207
+ await expect((service as any).validatePluginTargets(pluginDir, 'no-targets-plugin', 'app')).rejects.toThrow("Plugin 'no-targets-plugin' is missing the required 'targets' field in plugin.json.")
208
+ })
209
+
210
+ test('should throw MissingPluginTargetsError when targets is empty array', async () => {
211
+ const pluginDir = path.join(tempDir, 'empty-targets-plugin')
212
+ await fs.ensureDir(pluginDir)
213
+ await fs.writeJson(path.join(pluginDir, 'plugin.json'), {
214
+ name: 'empty-targets-plugin',
215
+ version: '1.0.0',
216
+ targets: [],
217
+ })
218
+
219
+ await expect((service as any).validatePluginTargets(pluginDir, 'empty-targets-plugin', 'app')).rejects.toThrow("Plugin 'empty-targets-plugin' is missing the required 'targets' field in plugin.json.")
220
+ })
221
+
222
+ test('should throw error when targets does not include current target', async () => {
223
+ const pluginDir = path.join(tempDir, 'incompatible-plugin')
224
+ await fs.ensureDir(pluginDir)
225
+ await fs.writeJson(path.join(pluginDir, 'plugin.json'), {
226
+ name: 'incompatible-plugin',
227
+ version: '1.0.0',
228
+ targets: ['library', 'plugin'],
229
+ })
230
+
231
+ await expect((service as any).validatePluginTargets(pluginDir, 'incompatible-plugin', 'app')).rejects.toThrow("Plugin 'incompatible-plugin' cannot be installed in a 'app' package.")
232
+ })
233
+
234
+ test('should handle missing plugin.json file gracefully', async () => {
235
+ const pluginDir = path.join(tempDir, 'no-plugin-json')
236
+ await fs.ensureDir(pluginDir)
237
+
238
+ await expect((service as any).validatePluginTargets(pluginDir, 'no-plugin-json', 'app')).rejects.toThrow()
239
+ })
240
+ })
241
+
242
+ describe('checkExistingInstallation', () => {
243
+ const mockLogger = (message: string) => {
244
+ /* capture logs */
245
+ }
246
+
247
+ // Not installed - return null
248
+ test('should return null when plugin is not installed', async () => {
249
+ const packageDir = path.join(tempDir, 'app1')
250
+ await fs.ensureDir(packageDir)
251
+ await fs.writeJson(path.join(packageDir, 'package.json'), {
252
+ name: 'app1',
253
+ version: '1.0.0',
254
+ launch77: {
255
+ installedPlugins: {},
256
+ },
257
+ })
258
+
259
+ const result = await (service as any).checkExistingInstallation('test-plugin', packageDir, mockLogger)
260
+ expect(result).toBeNull()
261
+ })
262
+
263
+ test('should return null when package.json does not exist', async () => {
264
+ const packageDir = path.join(tempDir, 'nonexistent')
265
+ await fs.ensureDir(packageDir)
266
+
267
+ const result = await (service as any).checkExistingInstallation('test-plugin', packageDir, mockLogger)
268
+ expect(result).toBeNull()
269
+ })
270
+
271
+ test('should return null when launch77 field is missing', async () => {
272
+ const packageDir = path.join(tempDir, 'app2')
273
+ await fs.ensureDir(packageDir)
274
+ await fs.writeJson(path.join(packageDir, 'package.json'), {
275
+ name: 'app2',
276
+ version: '1.0.0',
277
+ })
278
+
279
+ const result = await (service as any).checkExistingInstallation('test-plugin', packageDir, mockLogger)
280
+ expect(result).toBeNull()
281
+ })
282
+
283
+ test('should return null when installedPlugins is missing', async () => {
284
+ const packageDir = path.join(tempDir, 'app3')
285
+ await fs.ensureDir(packageDir)
286
+ await fs.writeJson(path.join(packageDir, 'package.json'), {
287
+ name: 'app3',
288
+ version: '1.0.0',
289
+ launch77: {},
290
+ })
291
+
292
+ const result = await (service as any).checkExistingInstallation('test-plugin', packageDir, mockLogger)
293
+ expect(result).toBeNull()
294
+ })
295
+
296
+ test('should return null when installedPlugins exists but plugin not in it', async () => {
297
+ const packageDir = path.join(tempDir, 'app4')
298
+ await fs.ensureDir(packageDir)
299
+ await fs.writeJson(path.join(packageDir, 'package.json'), {
300
+ name: 'app4',
301
+ version: '1.0.0',
302
+ launch77: {
303
+ installedPlugins: {
304
+ 'other-plugin': {
305
+ package: 'other-plugin',
306
+ version: '1.0.0',
307
+ installedAt: '2024-01-01T00:00:00.000Z',
308
+ source: 'local',
309
+ },
310
+ },
311
+ },
312
+ })
313
+
314
+ const result = await (service as any).checkExistingInstallation('test-plugin', packageDir, mockLogger)
315
+ expect(result).toBeNull()
316
+ })
317
+
318
+ // Already installed - return result object
319
+ test('should return early-exit result with correct structure when plugin is installed', async () => {
320
+ const packageDir = path.join(tempDir, 'app5')
321
+ await fs.ensureDir(packageDir)
322
+ await fs.writeJson(path.join(packageDir, 'package.json'), {
323
+ name: 'app5',
324
+ version: '1.0.0',
325
+ launch77: {
326
+ installedPlugins: {
327
+ 'test-plugin': {
328
+ package: 'test-plugin',
329
+ version: '1.5.0',
330
+ installedAt: '2024-01-15T10:30:00.000Z',
331
+ source: 'local',
332
+ },
333
+ },
334
+ },
335
+ })
336
+
337
+ const result = await (service as any).checkExistingInstallation('test-plugin', packageDir, mockLogger)
338
+ expect(result).toEqual({
339
+ pluginName: 'test-plugin',
340
+ filesInstalled: false,
341
+ packageJsonUpdated: false,
342
+ dependenciesInstalled: false,
343
+ })
344
+ })
345
+
346
+ test('should log correct message for local plugin (package name matches plugin name)', async () => {
347
+ const packageDir = path.join(tempDir, 'app6')
348
+ await fs.ensureDir(packageDir)
349
+ await fs.writeJson(path.join(packageDir, 'package.json'), {
350
+ name: 'app6',
351
+ version: '1.0.0',
352
+ launch77: {
353
+ installedPlugins: {
354
+ release: {
355
+ package: 'release',
356
+ version: '2.0.0',
357
+ installedAt: '2024-02-01T12:00:00.000Z',
358
+ source: 'local',
359
+ },
360
+ },
361
+ },
362
+ })
363
+
364
+ const logs: string[] = []
365
+ const captureLogger = (message: string) => logs.push(message)
366
+
367
+ const result = await (service as any).checkExistingInstallation('release', packageDir, captureLogger)
368
+ expect(result).not.toBeNull()
369
+ expect(logs.some((log) => log.includes("Plugin 'release' is already installed"))).toBe(true)
370
+ expect(logs.some((log) => log.includes('release') && log.includes('local'))).toBe(true)
371
+ })
372
+
373
+ test('should log correct message for npm plugin (package name is scoped)', async () => {
374
+ const packageDir = path.join(tempDir, 'app7')
375
+ await fs.ensureDir(packageDir)
376
+ await fs.writeJson(path.join(packageDir, 'package.json'), {
377
+ name: 'app7',
378
+ version: '1.0.0',
379
+ launch77: {
380
+ installedPlugins: {
381
+ analytics: {
382
+ package: '@myorg/analytics-plugin',
383
+ version: '3.1.0',
384
+ installedAt: '2024-03-01T15:45:00.000Z',
385
+ source: 'npm',
386
+ },
387
+ },
388
+ },
389
+ })
390
+
391
+ const logs: string[] = []
392
+ const captureLogger = (message: string) => logs.push(message)
393
+
394
+ const result = await (service as any).checkExistingInstallation('analytics', packageDir, captureLogger)
395
+ expect(result).not.toBeNull()
396
+ expect(logs.some((log) => log.includes("Plugin 'analytics' is already installed"))).toBe(true)
397
+ expect(logs.some((log) => log.includes('@myorg/analytics-plugin') && log.includes('npm'))).toBe(true)
398
+ })
399
+
400
+ // Edge cases
401
+ test('should handle malformed package.json gracefully (invalid JSON)', async () => {
402
+ const packageDir = path.join(tempDir, 'app8')
403
+ await fs.ensureDir(packageDir)
404
+ await fs.writeFile(path.join(packageDir, 'package.json'), '{ invalid json }')
405
+
406
+ const result = await (service as any).checkExistingInstallation('test-plugin', packageDir, mockLogger)
407
+ expect(result).toBeNull()
408
+ })
409
+
410
+ test('should handle package.json read errors (permissions, etc.)', async () => {
411
+ const packageDir = path.join(tempDir, 'app9')
412
+ // Don't create the directory - simulate permission/access error
413
+
414
+ const result = await (service as any).checkExistingInstallation('test-plugin', packageDir, mockLogger)
415
+ expect(result).toBeNull()
416
+ })
417
+ })
418
+ })