@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.
- package/CHANGELOG.md +19 -0
- package/dist/cli.js +6 -3
- package/dist/cli.js.map +1 -1
- package/dist/infrastructure/template-generator.d.ts +1 -1
- package/dist/infrastructure/template-generator.d.ts.map +1 -1
- package/dist/infrastructure/template.d.ts +5 -0
- package/dist/infrastructure/template.d.ts.map +1 -1
- package/dist/infrastructure/template.js +11 -0
- package/dist/infrastructure/template.js.map +1 -1
- package/dist/modules/app/commands/create-app.js +1 -1
- package/dist/modules/app/commands/create-app.js.map +1 -1
- package/dist/modules/app/commands/delete-app.js +1 -1
- package/dist/modules/app/commands/delete-app.js.map +1 -1
- package/dist/modules/app/services/app-svc.d.ts +1 -1
- package/dist/modules/app/services/app-svc.d.ts.map +1 -1
- package/dist/modules/app/services/manifest-svc.d.ts +1 -1
- package/dist/modules/app/services/manifest-svc.d.ts.map +1 -1
- package/dist/modules/catalog/config/catalog-config.test.js +1 -1
- package/dist/modules/catalog/config/catalog-config.test.js.map +1 -1
- package/dist/modules/git/commands/git-connect.js +2 -2
- package/dist/modules/git/commands/git-connect.js.map +1 -1
- package/dist/modules/git/errors/git-errors.d.ts +3 -0
- package/dist/modules/git/errors/git-errors.d.ts.map +1 -1
- package/dist/modules/git/errors/git-errors.js +6 -0
- package/dist/modules/git/errors/git-errors.js.map +1 -1
- package/dist/modules/git/index.d.ts +3 -1
- package/dist/modules/git/index.d.ts.map +1 -1
- package/dist/modules/git/index.js +6 -1
- package/dist/modules/git/index.js.map +1 -1
- package/dist/modules/git/services/git-service.d.ts +5 -0
- package/dist/modules/git/services/git-service.d.ts.map +1 -1
- package/dist/modules/git/services/git-service.js +11 -1
- package/dist/modules/git/services/git-service.js.map +1 -1
- package/dist/modules/plugin/commands/plugin-create.d.ts +3 -0
- package/dist/modules/plugin/commands/plugin-create.d.ts.map +1 -0
- package/dist/modules/plugin/commands/plugin-create.js +59 -0
- package/dist/modules/plugin/commands/plugin-create.js.map +1 -0
- package/dist/modules/plugin/commands/plugin-install.d.ts.map +1 -1
- package/dist/modules/plugin/commands/plugin-install.js +9 -24
- package/dist/modules/plugin/commands/plugin-install.js.map +1 -1
- package/dist/modules/plugin/errors/plugin-errors.d.ts +24 -1
- package/dist/modules/plugin/errors/plugin-errors.d.ts.map +1 -1
- package/dist/modules/plugin/errors/plugin-errors.js +79 -6
- package/dist/modules/plugin/errors/plugin-errors.js.map +1 -1
- package/dist/modules/plugin/index.d.ts +4 -2
- package/dist/modules/plugin/index.d.ts.map +1 -1
- package/dist/modules/plugin/index.js +4 -2
- package/dist/modules/plugin/index.js.map +1 -1
- package/dist/modules/plugin/lib/plugin-registry.d.ts +6 -12
- package/dist/modules/plugin/lib/plugin-registry.d.ts.map +1 -1
- package/dist/modules/plugin/lib/plugin-registry.js +13 -30
- package/dist/modules/plugin/lib/plugin-registry.js.map +1 -1
- package/dist/modules/plugin/lib/plugin-resolver.d.ts +76 -0
- package/dist/modules/plugin/lib/plugin-resolver.d.ts.map +1 -0
- package/dist/modules/plugin/lib/plugin-resolver.js +128 -0
- package/dist/modules/plugin/lib/plugin-resolver.js.map +1 -0
- package/dist/modules/plugin/lib/plugin-resolver.test.d.ts +2 -0
- package/dist/modules/plugin/lib/plugin-resolver.test.d.ts.map +1 -0
- package/dist/modules/plugin/lib/plugin-resolver.test.js +175 -0
- package/dist/modules/plugin/lib/plugin-resolver.test.js.map +1 -0
- package/dist/modules/plugin/services/plugin-create-service.d.ts +16 -0
- package/dist/modules/plugin/services/plugin-create-service.d.ts.map +1 -0
- package/dist/modules/plugin/services/plugin-create-service.js +47 -0
- package/dist/modules/plugin/services/plugin-create-service.js.map +1 -0
- package/dist/modules/plugin/services/plugin-svc.d.ts +29 -3
- package/dist/modules/plugin/services/plugin-svc.d.ts.map +1 -1
- package/dist/modules/plugin/services/plugin-svc.js +192 -17
- package/dist/modules/plugin/services/plugin-svc.js.map +1 -1
- package/dist/modules/plugin/services/plugin-svc.test.d.ts +2 -0
- package/dist/modules/plugin/services/plugin-svc.test.d.ts.map +1 -0
- package/dist/modules/plugin/services/plugin-svc.test.js +362 -0
- package/dist/modules/plugin/services/plugin-svc.test.js.map +1 -0
- package/dist/modules/release/commands/release-init.d.ts +3 -0
- package/dist/modules/release/commands/release-init.d.ts.map +1 -0
- package/dist/modules/release/commands/release-init.js +92 -0
- package/dist/modules/release/commands/release-init.js.map +1 -0
- package/dist/modules/release/errors/release-errors.d.ts +7 -0
- package/dist/modules/release/errors/release-errors.d.ts.map +1 -0
- package/dist/modules/release/errors/release-errors.js +13 -0
- package/dist/modules/release/errors/release-errors.js.map +1 -0
- package/dist/modules/release/index.d.ts +4 -0
- package/dist/modules/release/index.d.ts.map +1 -0
- package/dist/modules/release/index.js +7 -0
- package/dist/modules/release/index.js.map +1 -0
- package/dist/modules/release/services/release-service.d.ts +34 -0
- package/dist/modules/release/services/release-service.d.ts.map +1 -0
- package/dist/modules/release/services/release-service.js +154 -0
- package/dist/modules/release/services/release-service.js.map +1 -0
- package/dist/templates/plugin/README.md.hbs +39 -0
- package/dist/templates/plugin/package.json.hbs +34 -0
- package/dist/templates/plugin/plugin.json.hbs +7 -0
- package/dist/templates/plugin/src/generator.ts.hbs +64 -0
- package/dist/templates/plugin/templates/src/.gitkeep +0 -0
- package/dist/templates/plugin/tsconfig.json +10 -0
- package/dist/templates/plugin/tsup.config.ts +9 -0
- package/dist/templates/workspace/.github/workflows/ci.yml +8 -5
- package/dist/templates/workspace/package.json +3 -1
- package/dist/templates/workspace/turbo.json +5 -0
- package/dist/utils/launch77-context.d.ts +1 -1
- package/dist/utils/launch77-context.d.ts.map +1 -1
- package/dist/utils/launch77-context.js +25 -2
- package/dist/utils/launch77-context.js.map +1 -1
- package/dist/utils/launch77-validation.d.ts +1 -1
- package/dist/utils/launch77-validation.d.ts.map +1 -1
- package/dist/utils/string.d.ts +13 -0
- package/dist/utils/string.d.ts.map +1 -0
- package/dist/utils/string.js +18 -0
- package/dist/utils/string.js.map +1 -0
- package/package.json +6 -9
- package/src/cli.ts +7 -3
- package/src/infrastructure/template-generator.ts +1 -1
- package/src/infrastructure/template.ts +14 -0
- package/src/modules/app/commands/create-app.ts +1 -1
- package/src/modules/app/commands/delete-app.ts +1 -1
- package/src/modules/app/services/app-svc.ts +1 -1
- package/src/modules/app/services/manifest-svc.ts +1 -1
- package/src/modules/catalog/config/catalog-config.test.ts +1 -1
- package/src/modules/git/commands/git-connect.ts +2 -2
- package/src/modules/git/errors/git-errors.ts +7 -0
- package/src/modules/git/index.ts +8 -1
- package/src/modules/git/services/git-service.ts +12 -1
- package/src/modules/plugin/commands/plugin-create.ts +68 -0
- package/src/modules/plugin/commands/plugin-install.ts +9 -26
- package/src/modules/plugin/errors/plugin-errors.ts +87 -6
- package/src/modules/plugin/index.ts +4 -2
- package/src/modules/plugin/lib/plugin-registry.ts +14 -37
- package/src/modules/plugin/lib/plugin-resolver.test.ts +215 -0
- package/src/modules/plugin/lib/plugin-resolver.ts +160 -0
- package/src/modules/plugin/services/plugin-create-service.ts +69 -0
- package/src/modules/plugin/services/plugin-svc.test.ts +418 -0
- package/src/modules/plugin/services/plugin-svc.ts +217 -17
- package/src/modules/release/commands/release-init.ts +102 -0
- package/src/modules/release/errors/release-errors.ts +13 -0
- package/src/modules/release/index.ts +8 -0
- package/src/modules/release/services/release-service.ts +170 -0
- package/src/utils/launch77-context.ts +29 -3
- package/src/utils/launch77-validation.ts +1 -1
- package/src/utils/string.ts +17 -0
- package/templates/plugin/README.md.hbs +39 -0
- package/templates/plugin/package.json.hbs +34 -0
- package/templates/plugin/plugin.json.hbs +7 -0
- package/templates/plugin/src/generator.ts.hbs +64 -0
- package/templates/plugin/templates/src/.gitkeep +0 -0
- package/templates/plugin/tsconfig.json +10 -0
- package/templates/plugin/tsup.config.ts +9 -0
- package/templates/workspace/.github/workflows/ci.yml +8 -5
- package/templates/workspace/package.json +3 -1
- package/templates/workspace/turbo.json +5 -0
- package/tests/integration/cli.test.ts +25 -0
- package/tests/integration/setup.ts +20 -0
- package/vitest.config.ts +9 -0
- package/vitest.integration.config.ts +9 -0
- package/dist/modules/git/commands/git-setup-releases.d.ts +0 -3
- package/dist/modules/git/commands/git-setup-releases.d.ts.map +0 -1
- package/dist/modules/git/commands/git-setup-releases.js +0 -128
- package/dist/modules/git/commands/git-setup-releases.js.map +0 -1
- package/launch77-cli-1.2.0.tgz +0 -0
- package/src/modules/git/commands/git-setup-releases.ts +0 -148
- 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
|
+
})
|