@launch77/cli 1.4.0 → 1.4.3

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.
@@ -1,4 +1,5 @@
1
1
  import * as path from 'path'
2
+ import * as fs from 'fs/promises'
2
3
 
3
4
  import chalk from 'chalk'
4
5
  import { execa } from 'execa'
@@ -7,7 +8,7 @@ import { readPluginMetadata } from '@launch77/plugin-runtime'
7
8
  import { PluginInstallationError, InvalidPluginContextError, createInvalidContextError, MissingPluginTargetsError, createInvalidTargetError, NpmInstallationError, PluginResolutionError } from '../errors/plugin-errors.js'
8
9
  import { validatePluginInput, resolvePluginLocation } from '../lib/plugin-resolver.js'
9
10
 
10
- import type { Launch77Context, Launch77LocationType } from '@launch77/plugin-runtime'
11
+ import type { Launch77Context, Launch77LocationType, Launch77PackageManifest, InstalledPluginMetadata, PluginMetadata } from '@launch77/plugin-runtime'
11
12
  import type { InstallPluginRequest, InstallPluginResult } from '../types/plugin-types.js'
12
13
 
13
14
  /**
@@ -30,17 +31,19 @@ function locationTypeToTarget(locationType: Launch77LocationType): string | null
30
31
 
31
32
  export class PluginService {
32
33
  /**
33
- * Install a plugin to the current package
34
+ * Validate that we're in a valid package directory and return the target type
34
35
  */
35
- async installPlugin(request: InstallPluginRequest, context: Launch77Context, logger: (message: string) => void = console.log): Promise<InstallPluginResult> {
36
- const { pluginName } = request
37
-
38
- // Must be in a package directory (app, library, plugin, or app-template)
36
+ private validateContext(context: Launch77Context): string {
39
37
  const currentTarget = locationTypeToTarget(context.locationType)
40
38
  if (!currentTarget) throw createInvalidContextError(context.locationType)
41
39
  if (!context.appName) throw new InvalidPluginContextError('Could not determine package name. This is a bug. Please report it.')
40
+ return currentTarget
41
+ }
42
42
 
43
- // Step 1: Validate plugin input
43
+ /**
44
+ * Validate plugin name, resolve its location, and download if needed
45
+ */
46
+ private async validateAndResolvePlugin(pluginName: string, workspaceRoot: string, logger: (message: string) => void): Promise<{ pluginPath: string; source: 'local' | 'npm'; npmPackage?: string }> {
44
47
  logger(chalk.blue(`\nšŸ” Resolving plugin "${pluginName}"...`))
45
48
  logger(` ā”œā”€ Validating plugin name...`)
46
49
 
@@ -50,9 +53,8 @@ export class PluginService {
50
53
  }
51
54
  logger(` │ └─ ${chalk.green('āœ“')} Valid plugin name`)
52
55
 
53
- // Step 2: Resolve plugin location
54
56
  logger(` ā”œā”€ Checking local workspace: ${chalk.dim(`plugins/${pluginName}`)}`)
55
- const resolution = await resolvePluginLocation(pluginName, context.workspaceRoot)
57
+ const resolution = await resolvePluginLocation(pluginName, workspaceRoot)
56
58
 
57
59
  let pluginPath: string
58
60
 
@@ -63,13 +65,22 @@ export class PluginService {
63
65
  logger(` │ └─ ${chalk.dim('Not found locally')}`)
64
66
  logger(` ā”œā”€ Resolving to npm package: ${chalk.cyan(resolution.npmPackage)}`)
65
67
 
66
- // Download npm plugin
67
- pluginPath = await this.downloadNpmPlugin(resolution.npmPackage!, context.workspaceRoot, logger)
68
+ pluginPath = await this.downloadNpmPlugin(resolution.npmPackage!, workspaceRoot, logger)
68
69
  }
69
70
 
70
71
  logger(` └─ ${chalk.green('āœ“')} Plugin resolved\n`)
71
72
 
72
- // Step 3: Read plugin metadata and validate targets
73
+ return {
74
+ pluginPath,
75
+ source: resolution.source,
76
+ npmPackage: resolution.npmPackage,
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Read plugin metadata and validate it supports the current target
82
+ */
83
+ private async validatePluginTargets(pluginPath: string, pluginName: string, currentTarget: string): Promise<PluginMetadata> {
73
84
  const metadata = await readPluginMetadata(pluginPath)
74
85
 
75
86
  if (!metadata.targets || metadata.targets.length === 0) {
@@ -80,12 +91,55 @@ export class PluginService {
80
91
  throw createInvalidTargetError(pluginName, currentTarget, metadata.targets)
81
92
  }
82
93
 
83
- // Step 4: Get package directory path
94
+ return metadata
95
+ }
96
+
97
+ /**
98
+ * Check if plugin is already installed and return early-exit result if so
99
+ */
100
+ private async checkExistingInstallation(pluginName: string, packagePath: string, logger: (message: string) => void): Promise<InstallPluginResult | null> {
101
+ const existingInstallation = await this.isPluginInstalled(pluginName, packagePath)
102
+ if (existingInstallation) {
103
+ logger(chalk.yellow(`\nā„¹ļø Plugin '${pluginName}' is already installed in this package.\n`))
104
+ logger(`Package: ${chalk.cyan(existingInstallation.package)} (${existingInstallation.source})`)
105
+ logger(`Version: ${existingInstallation.version}`)
106
+ logger(`Installed: ${existingInstallation.installedAt}\n`)
107
+ logger(chalk.gray('To reinstall: Remove from package.json launch77.installedPlugins'))
108
+ logger(chalk.gray('(plugin:remove command coming soon)\n'))
109
+ return {
110
+ pluginName,
111
+ filesInstalled: false,
112
+ packageJsonUpdated: false,
113
+ dependenciesInstalled: false,
114
+ }
115
+ }
116
+ return null
117
+ }
118
+
119
+ /**
120
+ * Install a plugin to the current package
121
+ */
122
+ async installPlugin(request: InstallPluginRequest, context: Launch77Context, logger: (message: string) => void = console.log): Promise<InstallPluginResult> {
123
+ const { pluginName } = request
124
+
125
+ const currentTarget = this.validateContext(context)
126
+ const { pluginPath, source, npmPackage } = await this.validateAndResolvePlugin(pluginName, context.workspaceRoot, logger)
127
+ const metadata = await this.validatePluginTargets(pluginPath, pluginName, currentTarget)
128
+
84
129
  const packagePath = this.getPackagePath(context)
130
+ const earlyExit = await this.checkExistingInstallation(pluginName, packagePath, logger)
131
+ if (earlyExit) return earlyExit
85
132
 
86
- // Step 5: Run generator
87
133
  await this.runGenerator(pluginPath, packagePath, context)
88
134
 
135
+ const packageName = source === 'npm' ? npmPackage! : pluginName
136
+ await this.writePluginManifest(packagePath, {
137
+ pluginName,
138
+ packageName,
139
+ version: metadata.version,
140
+ source,
141
+ })
142
+
89
143
  return {
90
144
  pluginName,
91
145
  filesInstalled: true,
@@ -135,7 +189,9 @@ export class PluginService {
135
189
  try {
136
190
  const generatorPath = path.join(pluginPath, 'dist/generator.js')
137
191
 
138
- await execa('node', [generatorPath, `--appPath=${appPath}`, `--appName=${context.appName}`, `--workspaceName=${context.workspaceName}`, `--pluginPath=${pluginPath}`], {
192
+ const args = [generatorPath, `--appPath=${appPath}`, `--appName=${context.appName}`, `--workspaceName=${context.workspaceName}`, `--pluginPath=${pluginPath}`]
193
+
194
+ await execa('node', args, {
139
195
  cwd: pluginPath,
140
196
  stdio: 'inherit',
141
197
  })
@@ -143,4 +199,55 @@ export class PluginService {
143
199
  throw new PluginInstallationError(`Generator failed: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined)
144
200
  }
145
201
  }
202
+
203
+ private async isPluginInstalled(pluginName: string, packagePath: string): Promise<InstalledPluginMetadata | null> {
204
+ try {
205
+ const packageJsonPath = path.join(packagePath, 'package.json')
206
+ const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8')
207
+ const packageJson = JSON.parse(packageJsonContent)
208
+
209
+ const manifest = packageJson.launch77 as Launch77PackageManifest | undefined
210
+
211
+ if (manifest?.installedPlugins?.[pluginName]) {
212
+ return manifest.installedPlugins[pluginName]
213
+ }
214
+
215
+ return null
216
+ } catch (error) {
217
+ // If package.json doesn't exist or can't be read, assume not installed
218
+ return null
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Write plugin installation metadata to the target package's package.json
224
+ */
225
+ private async writePluginManifest(packagePath: string, installationInfo: { pluginName: string; packageName: string; version: string; source: 'local' | 'npm' }): Promise<void> {
226
+ try {
227
+ const packageJsonPath = path.join(packagePath, 'package.json')
228
+ const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8')
229
+ const packageJson = JSON.parse(packageJsonContent)
230
+
231
+ if (!packageJson.launch77) {
232
+ packageJson.launch77 = {}
233
+ }
234
+
235
+ if (!packageJson.launch77.installedPlugins) {
236
+ packageJson.launch77.installedPlugins = {}
237
+ }
238
+
239
+ const manifest = packageJson.launch77 as Launch77PackageManifest
240
+
241
+ manifest.installedPlugins![installationInfo.pluginName] = {
242
+ package: installationInfo.packageName,
243
+ version: installationInfo.version,
244
+ installedAt: new Date().toISOString(),
245
+ source: installationInfo.source,
246
+ }
247
+
248
+ await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf-8')
249
+ } catch (error) {
250
+ throw new PluginInstallationError(`Failed to write plugin manifest: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined)
251
+ }
252
+ }
146
253
  }
@@ -19,7 +19,8 @@
19
19
  "changeset": "changeset",
20
20
  "version-packages": "changeset version",
21
21
  "release": "turbo run build lint typecheck test && changeset publish",
22
- "prepare": "husky"
22
+ "prepare": "husky",
23
+ "clean": "rm -rf node_modules apps/*/node_modules libraries/*/node_modules plugins/*/node_modules app-templates/*/node_modules package-lock.json apps/*/package-lock.json libraries/*/package-lock.json plugins/*/package-lock.json app-templates/*/package-lock.json dist apps/*/dist libraries/*/dist plugins/*/dist app-templates/*/dist .next apps/*/.next .turbo apps/*/.turbo libraries/*/.turbo plugins/*/.turbo app-templates/*/.turbo build apps/*/build out apps/*/out coverage apps/*/coverage .cache *.log .eslintcache"
23
24
  },
24
25
  "devDependencies": {
25
26
  "@changesets/cli": "^2.29.8",