@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
@@ -1,34 +1,144 @@
1
1
  import * as path from 'path'
2
+ import * as fs from 'fs/promises'
2
3
 
4
+ import chalk from 'chalk'
3
5
  import { execa } from 'execa'
6
+ import { readPluginMetadata } from '@launch77/plugin-runtime'
4
7
 
5
- import { PluginInstallationError, PluginNotFoundError, InvalidPluginContextError, createInvalidContextError } from '../errors/plugin-errors.js'
6
- import { pluginExists, getPluginPath } from '../lib/plugin-registry.js'
8
+ import { PluginInstallationError, InvalidPluginContextError, createInvalidContextError, MissingPluginTargetsError, createInvalidTargetError, NpmInstallationError, PluginResolutionError } from '../errors/plugin-errors.js'
9
+ import { validatePluginInput, resolvePluginLocation } from '../lib/plugin-resolver.js'
7
10
 
8
- import type { Launch77Context } from '../../../utils/launch77-context.js'
11
+ import type { Launch77Context, Launch77LocationType, Launch77PackageManifest, InstalledPluginMetadata, PluginMetadata } from '@launch77/plugin-runtime'
9
12
  import type { InstallPluginRequest, InstallPluginResult } from '../types/plugin-types.js'
10
13
 
14
+ /**
15
+ * Map location type to target string
16
+ */
17
+ function locationTypeToTarget(locationType: Launch77LocationType): string | null {
18
+ switch (locationType) {
19
+ case 'workspace-app':
20
+ return 'app'
21
+ case 'workspace-library':
22
+ return 'library'
23
+ case 'workspace-plugin':
24
+ return 'plugin'
25
+ case 'workspace-app-template':
26
+ return 'app-template'
27
+ default:
28
+ return null
29
+ }
30
+ }
31
+
11
32
  export class PluginService {
12
33
  /**
13
- * Install a plugin to the current app
34
+ * Validate that we're in a valid package directory and return the target type
14
35
  */
15
- async installPlugin(request: InstallPluginRequest, context: Launch77Context): Promise<InstallPluginResult> {
16
- const { pluginName } = request
36
+ private validateContext(context: Launch77Context): string {
37
+ const currentTarget = locationTypeToTarget(context.locationType)
38
+ if (!currentTarget) throw createInvalidContextError(context.locationType)
39
+ if (!context.appName) throw new InvalidPluginContextError('Could not determine package name. This is a bug. Please report it.')
40
+ return currentTarget
41
+ }
42
+
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 }> {
47
+ logger(chalk.blue(`\nšŸ” Resolving plugin "${pluginName}"...`))
48
+ logger(` ā”œā”€ Validating plugin name...`)
49
+
50
+ const validation = validatePluginInput(pluginName)
51
+ if (!validation.isValid) {
52
+ throw new PluginResolutionError(pluginName, validation.error || 'Invalid plugin name')
53
+ }
54
+ logger(` │ └─ ${chalk.green('āœ“')} Valid plugin name`)
55
+
56
+ logger(` ā”œā”€ Checking local workspace: ${chalk.dim(`plugins/${pluginName}`)}`)
57
+ const resolution = await resolvePluginLocation(pluginName, workspaceRoot)
58
+
59
+ let pluginPath: string
60
+
61
+ if (resolution.source === 'local') {
62
+ logger(` │ └─ ${chalk.green('āœ“')} Found local plugin`)
63
+ pluginPath = resolution.localPath!
64
+ } else {
65
+ logger(` │ └─ ${chalk.dim('Not found locally')}`)
66
+ logger(` ā”œā”€ Resolving to npm package: ${chalk.cyan(resolution.npmPackage)}`)
67
+
68
+ pluginPath = await this.downloadNpmPlugin(resolution.npmPackage!, workspaceRoot, logger)
69
+ }
70
+
71
+ logger(` └─ ${chalk.green('āœ“')} Plugin resolved\n`)
72
+
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> {
84
+ const metadata = await readPluginMetadata(pluginPath)
85
+
86
+ if (!metadata.targets || metadata.targets.length === 0) {
87
+ throw new MissingPluginTargetsError(pluginName)
88
+ }
89
+
90
+ if (!metadata.targets.includes(currentTarget)) {
91
+ throw createInvalidTargetError(pluginName, currentTarget, metadata.targets)
92
+ }
17
93
 
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.')
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
21
124
 
22
- // Get app directory path
23
- const appPath = path.join(context.appsDir, context.appName)
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)
24
128
 
25
- // Check if plugin exists
26
- if (!(await pluginExists(pluginName))) throw new PluginNotFoundError(pluginName)
129
+ const packagePath = this.getPackagePath(context)
130
+ const earlyExit = await this.checkExistingInstallation(pluginName, packagePath, logger)
131
+ if (earlyExit) return earlyExit
27
132
 
28
- const pluginPath = getPluginPath(pluginName)
133
+ await this.runGenerator(pluginPath, packagePath, context)
29
134
 
30
- // Run generator
31
- await this.runGenerator(pluginPath, appPath, context)
135
+ const packageName = source === 'npm' ? npmPackage! : pluginName
136
+ await this.writePluginManifest(packagePath, {
137
+ pluginName,
138
+ packageName,
139
+ version: metadata.version,
140
+ source,
141
+ })
32
142
 
33
143
  return {
34
144
  pluginName,
@@ -38,11 +148,50 @@ export class PluginService {
38
148
  }
39
149
  }
40
150
 
151
+ /**
152
+ * Download and install an npm plugin package
153
+ */
154
+ private async downloadNpmPlugin(npmPackage: string, workspaceRoot: string, logger: (message: string) => void): Promise<string> {
155
+ logger(` └─ Installing from npm: ${chalk.cyan(npmPackage)}...`)
156
+
157
+ try {
158
+ // Install the npm package to the workspace
159
+ await execa('npm', ['install', npmPackage, '--save-dev'], {
160
+ cwd: workspaceRoot,
161
+ stdio: 'pipe', // Capture output for clean logging
162
+ })
163
+
164
+ // Return path to installed plugin in node_modules
165
+ const pluginPath = path.join(workspaceRoot, 'node_modules', npmPackage)
166
+ return pluginPath
167
+ } catch (error) {
168
+ throw new NpmInstallationError(npmPackage, error instanceof Error ? error : undefined)
169
+ }
170
+ }
171
+
172
+ private getPackagePath(context: Launch77Context): string {
173
+ // Determine the base directory based on location type
174
+ switch (context.locationType) {
175
+ case 'workspace-app':
176
+ return path.join(context.appsDir, context.appName!)
177
+ case 'workspace-library':
178
+ return path.join(context.workspaceRoot, 'libraries', context.appName!)
179
+ case 'workspace-plugin':
180
+ return path.join(context.workspaceRoot, 'plugins', context.appName!)
181
+ case 'workspace-app-template':
182
+ return path.join(context.workspaceRoot, 'app-templates', context.appName!)
183
+ default:
184
+ throw new InvalidPluginContextError(`Cannot install plugin from ${context.locationType}`)
185
+ }
186
+ }
187
+
41
188
  private async runGenerator(pluginPath: string, appPath: string, context: Launch77Context): Promise<void> {
42
189
  try {
43
190
  const generatorPath = path.join(pluginPath, 'dist/generator.js')
44
191
 
45
- 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, {
46
195
  cwd: pluginPath,
47
196
  stdio: 'inherit',
48
197
  })
@@ -50,4 +199,55 @@ export class PluginService {
50
199
  throw new PluginInstallationError(`Generator failed: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined)
51
200
  }
52
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
+ }
53
253
  }
@@ -0,0 +1,102 @@
1
+ import chalk from 'chalk'
2
+ import { Command } from 'commander'
3
+ import ora from 'ora'
4
+
5
+ import { detectLaunch77Context } from '@launch77/plugin-runtime'
6
+ import { GitService, GitHubService, GitHubCLINotInstalledError, GitHubNotAuthenticatedError, NotInWorkspaceError, GitHubNotConnectedError } from '../../git/index.js'
7
+ import { ReleaseService } from '../services/release-service.js'
8
+ import { ChangesetNotInitializedError } from '../errors/release-errors.js'
9
+
10
+ export function releaseInitCommand(): Command {
11
+ return new Command('release:init').description('Initialize complete release workflow setup').action(async () => {
12
+ console.log(chalk.blue('\nšŸš€ Initializing release workflow...\n'))
13
+
14
+ const cwd = process.cwd()
15
+ const gitService = new GitService()
16
+ const githubService = new GitHubService()
17
+ const releaseService = new ReleaseService(githubService)
18
+
19
+ try {
20
+ // 1. Verify we're in a workspace root
21
+ const context = await detectLaunch77Context(cwd)
22
+ if (!context.isValid || context.locationType !== 'workspace-root') {
23
+ throw new NotInWorkspaceError(cwd)
24
+ }
25
+
26
+ // 2. Verify prerequisites (GitHub CLI installed and authenticated)
27
+ const spinner = ora('Checking prerequisites...').start()
28
+ await githubService.verifyPrerequisites()
29
+ spinner.succeed('Prerequisites verified')
30
+
31
+ // 3. Ensure connected to GitHub
32
+ try {
33
+ await gitService.ensureConnectedToGitHub(context.workspaceRoot)
34
+ } catch (error) {
35
+ if (error instanceof GitHubNotConnectedError) {
36
+ console.error(chalk.red(`\nāŒ ${error.message}\n`))
37
+ console.log(chalk.gray(` Connect to GitHub first: ${chalk.cyan('launch77 git:connect')}\n`))
38
+ process.exit(1)
39
+ }
40
+ throw error
41
+ }
42
+
43
+ // 4. Get repository information
44
+ const repoSpinner = ora('Detecting repository...').start()
45
+ const { owner, repo } = await githubService.getCurrentRepository(context.workspaceRoot)
46
+ repoSpinner.succeed(`Repository: ${owner}/${repo}`)
47
+
48
+ // 5. Fix changeset config access setting
49
+ console.log(chalk.cyan('\nšŸ“ Configuring changesets...\n'))
50
+ try {
51
+ const changesetSpinner = ora('Checking changeset configuration...').start()
52
+ const wasChanged = await releaseService.fixChangesetAccess(context.workspaceRoot)
53
+
54
+ if (wasChanged) {
55
+ changesetSpinner.succeed('Updated .changeset/config.json access to "public"')
56
+ } else {
57
+ changesetSpinner.succeed('Changeset already configured correctly')
58
+ }
59
+ } catch (error) {
60
+ if (error instanceof ChangesetNotInitializedError) {
61
+ console.error(chalk.red(`\nāŒ ${error.message}\n`))
62
+ console.log(chalk.gray(' Changesets should be initialized during workspace creation.'))
63
+ console.log(chalk.gray(` Run manually: ${chalk.cyan('npx changeset init')}\n`))
64
+ process.exit(1)
65
+ }
66
+ throw error
67
+ }
68
+
69
+ // 6. Setup RELEASE_TOKEN
70
+ releaseService.explainReleaseToken()
71
+ const token = await releaseService.promptForReleaseToken()
72
+ await releaseService.setupReleaseToken(owner, repo, token)
73
+
74
+ // 7. Guide npm Trusted Publishing setup
75
+ releaseService.explainNpmTrustedPublishing()
76
+
77
+ // 8. Show success summary
78
+ releaseService.showSuccessSummary()
79
+ } catch (error) {
80
+ if (error instanceof GitHubCLINotInstalledError) {
81
+ console.error(chalk.red(`\nāŒ ${error.message}\n`))
82
+ console.log(chalk.gray(` Install with: ${chalk.cyan('brew install gh')}\n`))
83
+ console.log(chalk.gray(` Or visit: ${chalk.cyan('https://cli.github.com/')}\n`))
84
+ process.exit(1)
85
+ }
86
+
87
+ if (error instanceof GitHubNotAuthenticatedError) {
88
+ console.error(chalk.red(`\nāŒ ${error.message}\n`))
89
+ console.log(chalk.gray(` Authenticate with: ${chalk.cyan('gh auth login')}\n`))
90
+ process.exit(1)
91
+ }
92
+
93
+ if (error instanceof NotInWorkspaceError) {
94
+ console.error(chalk.red(`\nāŒ ${error.message}\n`))
95
+ console.log(chalk.gray(` This command must be run from a Launch77 workspace root directory\n`))
96
+ process.exit(1)
97
+ }
98
+
99
+ throw error
100
+ }
101
+ })
102
+ }
@@ -0,0 +1,13 @@
1
+ export class InvalidReleaseTokenError extends Error {
2
+ constructor(message: string) {
3
+ super(message)
4
+ this.name = 'InvalidReleaseTokenError'
5
+ }
6
+ }
7
+
8
+ export class ChangesetNotInitializedError extends Error {
9
+ constructor() {
10
+ super('Changesets are not initialized in this workspace')
11
+ this.name = 'ChangesetNotInitializedError'
12
+ }
13
+ }
@@ -0,0 +1,8 @@
1
+ // Services
2
+ export { ReleaseService } from './services/release-service.js'
3
+
4
+ // Errors
5
+ export { InvalidReleaseTokenError, ChangesetNotInitializedError } from './errors/release-errors.js'
6
+
7
+ // Commands
8
+ export { releaseInitCommand } from './commands/release-init.js'
@@ -0,0 +1,170 @@
1
+ import chalk from 'chalk'
2
+ import { password, confirm } from '@inquirer/prompts'
3
+ import ora from 'ora'
4
+ import fs from 'fs/promises'
5
+ import path from 'path'
6
+
7
+ import { GitHubService } from '../../git/index.js'
8
+ import { InvalidReleaseTokenError, ChangesetNotInitializedError } from '../errors/release-errors.js'
9
+
10
+ export class ReleaseService {
11
+ constructor(private githubService: GitHubService = new GitHubService()) {}
12
+
13
+ /**
14
+ * Validate GitHub Personal Access Token format
15
+ */
16
+ validateReleaseToken(token: string): void {
17
+ if (!token || token.trim().length === 0) {
18
+ throw new InvalidReleaseTokenError('Token cannot be empty')
19
+ }
20
+
21
+ // GitHub PATs start with specific prefixes
22
+ if (!token.startsWith('ghp_') && !token.startsWith('github_pat_')) {
23
+ throw new InvalidReleaseTokenError('Invalid token format. GitHub PATs should start with "ghp_" or "github_pat_"')
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Explain what RELEASE_TOKEN is and why it's needed
29
+ */
30
+ explainReleaseToken(): void {
31
+ console.log(chalk.cyan('\nšŸ“‹ About RELEASE_TOKEN:\n'))
32
+ console.log(chalk.white('The RELEASE_TOKEN is a GitHub Personal Access Token (PAT) that allows'))
33
+ console.log(chalk.white('the Changesets action to create Pull Requests for version updates.\n'))
34
+ console.log(chalk.white('Why is this needed?'))
35
+ console.log(chalk.gray(' • The default GITHUB_TOKEN has limited permissions'))
36
+ console.log(chalk.gray(' • Creating PRs that trigger CI requires a PAT'))
37
+ console.log(chalk.gray(' • This enables automated release workflows\n'))
38
+ console.log(chalk.white('Required permissions:'))
39
+ console.log(chalk.gray(' • Contents: Read and write'))
40
+ console.log(chalk.gray(' • Pull requests: Read and write\n'))
41
+
42
+ // Provide link to create PAT
43
+ const tokenUrl = 'https://github.com/settings/personal-access-tokens/new'
44
+ console.log(chalk.cyan('šŸ”— Create your token:'))
45
+ console.log(chalk.gray(` ${chalk.cyan(tokenUrl)}`))
46
+ console.log(chalk.gray(` Name: Launch77 Release Token`))
47
+ console.log(chalk.gray(` Permissions: Contents (Read and write), Pull requests (Read and write)\n`))
48
+ }
49
+
50
+ /**
51
+ * Prompt user for RELEASE_TOKEN
52
+ */
53
+ async promptForReleaseToken(): Promise<string> {
54
+ return password({
55
+ message: 'Paste your Personal Access Token (PAT):',
56
+ mask: '*',
57
+ validate: (value) => {
58
+ try {
59
+ this.validateReleaseToken(value)
60
+ return true
61
+ } catch (error) {
62
+ if (error instanceof InvalidReleaseTokenError) {
63
+ return error.message
64
+ }
65
+ return 'Invalid token'
66
+ }
67
+ },
68
+ })
69
+ }
70
+
71
+ /**
72
+ * Setup RELEASE_TOKEN secret in GitHub repository
73
+ */
74
+ async setupReleaseToken(owner: string, repo: string, token: string): Promise<void> {
75
+ this.validateReleaseToken(token)
76
+
77
+ // Confirm before setting
78
+ console.log(chalk.yellow('\n⚠ Note: This will overwrite any existing RELEASE_TOKEN secret\n'))
79
+ const shouldContinue = await confirm({
80
+ message: 'Continue and set RELEASE_TOKEN?',
81
+ default: true,
82
+ })
83
+
84
+ if (!shouldContinue) {
85
+ console.log(chalk.green('\nāœ… No changes made.\n'))
86
+ process.exit(0)
87
+ }
88
+
89
+ // Set the secret
90
+ const setSpinner = ora('Setting RELEASE_TOKEN secret...').start()
91
+ try {
92
+ await this.githubService.setRepositorySecret(owner, repo, 'RELEASE_TOKEN', token)
93
+ setSpinner.succeed('RELEASE_TOKEN configured successfully!')
94
+ } catch (error) {
95
+ setSpinner.fail('Failed to set RELEASE_TOKEN')
96
+ throw error
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Fix changeset config to set access to "public"
102
+ */
103
+ async fixChangesetAccess(workspaceRoot: string): Promise<boolean> {
104
+ const configPath = path.join(workspaceRoot, '.changeset', 'config.json')
105
+
106
+ try {
107
+ // Check if config exists
108
+ const configContent = await fs.readFile(configPath, 'utf-8')
109
+ const config = JSON.parse(configContent)
110
+
111
+ // Check if already public
112
+ if (config.access === 'public') {
113
+ return false // No change needed
114
+ }
115
+
116
+ // Update to public
117
+ config.access = 'public'
118
+
119
+ // Write back
120
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8')
121
+
122
+ return true // Changed
123
+ } catch (error) {
124
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
125
+ throw new ChangesetNotInitializedError()
126
+ }
127
+ throw error
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Explain npm Trusted Publishing (OIDC)
133
+ */
134
+ explainNpmTrustedPublishing(): void {
135
+ console.log(chalk.cyan('\nšŸ“¦ Setting up npm publishing with Trusted Publishers (OIDC):\n'))
136
+ console.log(chalk.white('Trusted Publishing uses OpenID Connect (OIDC) for secure, token-free publishing.'))
137
+ console.log(chalk.white('This is the recommended approach (no tokens to manage or expire).\n'))
138
+
139
+ console.log(chalk.white('Steps to configure:'))
140
+ console.log(chalk.gray(' 1. Visit: https://www.npmjs.com/settings/~/publishers'))
141
+ console.log(chalk.gray(' 2. Click "Add a trusted publisher"'))
142
+ console.log(chalk.gray(' 3. Select "GitHub Actions"'))
143
+ console.log(chalk.gray(' 4. Enter your repository information:'))
144
+ console.log(chalk.gray(' - Repository owner (your GitHub username or org)'))
145
+ console.log(chalk.gray(' - Repository name'))
146
+ console.log(chalk.gray(' - Workflow file: .github/workflows/ci.yml'))
147
+ console.log(chalk.gray(' 5. Save the configuration\n'))
148
+
149
+ console.log(chalk.white('Your GitHub workflow already has the required permission:'))
150
+ console.log(chalk.gray(' āœ“ id-token: write\n'))
151
+
152
+ console.log(chalk.cyan('šŸ“š Learn more:'))
153
+ console.log(chalk.gray(' https://docs.npmjs.com/trusted-publishers/\n'))
154
+ }
155
+
156
+ /**
157
+ * Show success summary for release setup
158
+ */
159
+ showSuccessSummary(): void {
160
+ console.log(chalk.green('\nāœ… Release automation is ready!\n'))
161
+ console.log(chalk.white('What happens now:'))
162
+ console.log(chalk.gray(' • When you push to main, CI runs as usual'))
163
+ console.log(chalk.gray(' • Changesets detects version changes'))
164
+ console.log(chalk.gray(' • A "Version Packages" PR is created automatically'))
165
+ console.log(chalk.gray(' • Merge the PR to publish your packages\n'))
166
+
167
+ console.log(chalk.cyan('šŸ“š Learn more:'))
168
+ console.log(chalk.gray(` ${chalk.cyan('https://github.com/changesets/changesets')}\n`))
169
+ }
170
+ }
@@ -2,7 +2,7 @@ import * as path from 'path'
2
2
 
3
3
  import fs from 'fs-extra'
4
4
 
5
- export type Launch77LocationType = 'workspace-root' | 'workspace-app' | 'unknown'
5
+ export type Launch77LocationType = 'workspace-root' | 'workspace-app' | 'workspace-library' | 'workspace-plugin' | 'workspace-app-template' | 'unknown'
6
6
 
7
7
  export interface Launch77Context {
8
8
  isValid: boolean
@@ -64,6 +64,9 @@ interface ParsedLocation {
64
64
  * Parse the directory structure to determine location context
65
65
  * Based on patterns:
66
66
  * - apps/[name] → workspace-app
67
+ * - libraries/[name] → workspace-library
68
+ * - plugins/[name] → workspace-plugin
69
+ * - app-templates/[name] → workspace-app-template
67
70
  * - (empty or root) → workspace-root
68
71
  */
69
72
  function parseLocationFromPath(cwdPath: string, workspaceRoot: string): ParsedLocation {
@@ -84,8 +87,31 @@ function parseLocationFromPath(cwdPath: string, workspaceRoot: string): ParsedLo
84
87
  }
85
88
  }
86
89
 
87
- // Somewhere else in workspace (libraries, plugins, etc.)
88
- // Still considered workspace-root for now
90
+ // libraries/[lib-name]/...
91
+ if (parts[0] === 'libraries' && parts.length >= 2) {
92
+ return {
93
+ locationType: 'workspace-library',
94
+ appName: parts[1],
95
+ }
96
+ }
97
+
98
+ // plugins/[plugin-name]/...
99
+ if (parts[0] === 'plugins' && parts.length >= 2) {
100
+ return {
101
+ locationType: 'workspace-plugin',
102
+ appName: parts[1],
103
+ }
104
+ }
105
+
106
+ // app-templates/[template-name]/...
107
+ if (parts[0] === 'app-templates' && parts.length >= 2) {
108
+ return {
109
+ locationType: 'workspace-app-template',
110
+ appName: parts[1],
111
+ }
112
+ }
113
+
114
+ // Somewhere else in workspace
89
115
  return { locationType: 'workspace-root' }
90
116
  }
91
117
 
@@ -1,4 +1,4 @@
1
- import type { Launch77Context } from './launch77-context.js'
1
+ import type { Launch77Context } from '@launch77/plugin-runtime'
2
2
 
3
3
  /**
4
4
  * Validation result with helpful error messages
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Convert a kebab-case or snake_case string to PascalCase
3
+ *
4
+ * @param str - The string to convert (e.g., "my-plugin" or "my_plugin")
5
+ * @returns PascalCase string (e.g., "MyPlugin")
6
+ *
7
+ * @example
8
+ * toPascalCase('my-plugin') // 'MyPlugin'
9
+ * toPascalCase('release') // 'Release'
10
+ * toPascalCase('my-awesome-plugin') // 'MyAwesomePlugin'
11
+ */
12
+ export function toPascalCase(str: string): string {
13
+ return str
14
+ .split(/[-_]/)
15
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
16
+ .join('')
17
+ }
@@ -0,0 +1,39 @@
1
+ # {{pluginNamePascal}} Plugin
2
+
3
+ {{description}}
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ launch77 plugin:install {{pluginName}}
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ After installation, the plugin will:
14
+
15
+ - TODO: Describe what the plugin does
16
+ - TODO: List any files created or modified
17
+ - TODO: Explain configuration options
18
+
19
+ ## Development
20
+
21
+ ### Building
22
+
23
+ ```bash
24
+ npm run build
25
+ ```
26
+
27
+ ### Testing
28
+
29
+ ```bash
30
+ npm run typecheck
31
+ ```
32
+
33
+ ## Template Files
34
+
35
+ The `templates/` directory contains files that will be copied to the target application when this plugin is installed. Add any template files your plugin needs here.
36
+
37
+ ## License
38
+
39
+ UNLICENSED