@launch77/cli 1.4.3 → 1.4.4

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 (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/infrastructure/npm-package.d.ts +42 -0
  3. package/dist/infrastructure/npm-package.d.ts.map +1 -0
  4. package/dist/infrastructure/npm-package.js +46 -0
  5. package/dist/infrastructure/npm-package.js.map +1 -0
  6. package/dist/infrastructure/package-resolver.d.ts +107 -0
  7. package/dist/infrastructure/package-resolver.d.ts.map +1 -0
  8. package/dist/infrastructure/package-resolver.js +143 -0
  9. package/dist/infrastructure/package-resolver.js.map +1 -0
  10. package/dist/infrastructure/package-resolver.test.d.ts +2 -0
  11. package/dist/infrastructure/package-resolver.test.d.ts.map +1 -0
  12. package/dist/infrastructure/package-resolver.test.js +251 -0
  13. package/dist/infrastructure/package-resolver.test.js.map +1 -0
  14. package/dist/modules/app/commands/create-app.d.ts.map +1 -1
  15. package/dist/modules/app/commands/create-app.js +6 -2
  16. package/dist/modules/app/commands/create-app.js.map +1 -1
  17. package/dist/modules/app/lib/app-template-resolver.d.ts +14 -0
  18. package/dist/modules/app/lib/app-template-resolver.d.ts.map +1 -0
  19. package/dist/modules/app/lib/app-template-resolver.js +36 -0
  20. package/dist/modules/app/lib/app-template-resolver.js.map +1 -0
  21. package/dist/modules/app/services/app-svc.d.ts +2 -1
  22. package/dist/modules/app/services/app-svc.d.ts.map +1 -1
  23. package/dist/modules/app/services/app-svc.js +46 -11
  24. package/dist/modules/app/services/app-svc.js.map +1 -1
  25. package/dist/modules/plugin/lib/plugin-resolver.d.ts +10 -72
  26. package/dist/modules/plugin/lib/plugin-resolver.d.ts.map +1 -1
  27. package/dist/modules/plugin/lib/plugin-resolver.js +12 -115
  28. package/dist/modules/plugin/lib/plugin-resolver.js.map +1 -1
  29. package/dist/modules/plugin/lib/plugin-resolver.test.js +43 -39
  30. package/dist/modules/plugin/lib/plugin-resolver.test.js.map +1 -1
  31. package/dist/modules/plugin/services/plugin-svc.d.ts +2 -0
  32. package/dist/modules/plugin/services/plugin-svc.d.ts.map +1 -1
  33. package/dist/modules/plugin/services/plugin-svc.js +14 -17
  34. package/dist/modules/plugin/services/plugin-svc.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/infrastructure/npm-package.ts +73 -0
  37. package/src/infrastructure/package-resolver.test.ts +313 -0
  38. package/src/infrastructure/package-resolver.ts +194 -0
  39. package/src/modules/app/commands/create-app.ts +6 -2
  40. package/src/modules/app/lib/app-template-resolver.ts +40 -0
  41. package/src/modules/app/services/app-svc.ts +49 -12
  42. package/src/modules/plugin/lib/plugin-resolver.test.ts +46 -39
  43. package/src/modules/plugin/lib/plugin-resolver.ts +12 -142
  44. package/src/modules/plugin/services/plugin-svc.ts +15 -15
@@ -0,0 +1,313 @@
1
+ import * as os from 'os'
2
+ import * as path from 'path'
3
+
4
+ import fs from 'fs-extra'
5
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
6
+
7
+ import { PackageResolver } from './package-resolver.js'
8
+
9
+ /**
10
+ * Test implementation of PackageResolver for unit testing
11
+ */
12
+ class TestPackageResolver extends PackageResolver {
13
+ protected getFolderName(): string {
14
+ return 'test-packages'
15
+ }
16
+
17
+ protected getPackagePrefix(): string {
18
+ return '@test-org/test-'
19
+ }
20
+
21
+ protected async verify(localPath: string): Promise<boolean> {
22
+ // Verify by checking for package.json
23
+ const packageJsonPath = path.join(localPath, 'package.json')
24
+ return await fs.pathExists(packageJsonPath)
25
+ }
26
+ }
27
+
28
+ describe('PackageResolver', () => {
29
+ let resolver: TestPackageResolver
30
+ let tempDir: string
31
+
32
+ beforeEach(async () => {
33
+ resolver = new TestPackageResolver()
34
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'package-resolver-test-'))
35
+ })
36
+
37
+ afterEach(async () => {
38
+ await fs.remove(tempDir)
39
+ })
40
+
41
+ describe('validateInput', () => {
42
+ describe('valid inputs', () => {
43
+ it('should accept valid unscoped package names', () => {
44
+ const result = resolver.validateInput('release')
45
+ expect(result.isValid).toBe(true)
46
+ expect(result.error).toBeUndefined()
47
+ })
48
+
49
+ it('should accept unscoped names with hyphens', () => {
50
+ const result = resolver.validateInput('my-package')
51
+ expect(result.isValid).toBe(true)
52
+ })
53
+
54
+ it('should accept unscoped names with numbers', () => {
55
+ const result = resolver.validateInput('package-123')
56
+ expect(result.isValid).toBe(true)
57
+ })
58
+
59
+ it('should accept valid scoped packages', () => {
60
+ const result = resolver.validateInput('@ibm/analytics')
61
+ expect(result.isValid).toBe(true)
62
+ expect(result.error).toBeUndefined()
63
+ })
64
+
65
+ it('should accept scoped packages with hyphens', () => {
66
+ const result = resolver.validateInput('@launch77-shared/plugin-release')
67
+ expect(result.isValid).toBe(true)
68
+ })
69
+
70
+ it('should trim whitespace from valid names', () => {
71
+ const result = resolver.validateInput(' release ')
72
+ expect(result.isValid).toBe(true)
73
+ })
74
+
75
+ it('should trim whitespace from scoped names', () => {
76
+ const result = resolver.validateInput(' @ibm/analytics ')
77
+ expect(result.isValid).toBe(true)
78
+ })
79
+ })
80
+
81
+ describe('invalid inputs', () => {
82
+ it('should reject empty string', () => {
83
+ const result = resolver.validateInput('')
84
+ expect(result.isValid).toBe(false)
85
+ expect(result.error).toBe('Package name cannot be empty')
86
+ })
87
+
88
+ it('should reject whitespace-only string', () => {
89
+ const result = resolver.validateInput(' ')
90
+ expect(result.isValid).toBe(false)
91
+ expect(result.error).toBe('Package name cannot be empty')
92
+ })
93
+
94
+ it('should reject invalid scoped package without name', () => {
95
+ const result = resolver.validateInput('@invalid')
96
+ expect(result.isValid).toBe(false)
97
+ expect(result.error).toContain('Scoped package must be in format @org/package')
98
+ })
99
+
100
+ it('should reject invalid scoped package without scope', () => {
101
+ const result = resolver.validateInput('@/package')
102
+ expect(result.isValid).toBe(false)
103
+ expect(result.error).toBeDefined()
104
+ })
105
+
106
+ it('should reject invalid scoped package with missing name after slash', () => {
107
+ const result = resolver.validateInput('@org/')
108
+ expect(result.isValid).toBe(false)
109
+ expect(result.error).toContain('Scoped package must be in format @org/package')
110
+ })
111
+
112
+ it('should reject names with uppercase letters', () => {
113
+ const result = resolver.validateInput('MyPackage')
114
+ expect(result.isValid).toBe(false)
115
+ expect(result.error).toBeDefined()
116
+ })
117
+
118
+ it('should reject names starting with numbers', () => {
119
+ const result = resolver.validateInput('123package')
120
+ expect(result.isValid).toBe(false)
121
+ expect(result.error).toBeDefined()
122
+ })
123
+
124
+ it('should reject names with underscores', () => {
125
+ const result = resolver.validateInput('my_package')
126
+ expect(result.isValid).toBe(false)
127
+ expect(result.error).toBeDefined()
128
+ })
129
+
130
+ it('should reject names with spaces', () => {
131
+ const result = resolver.validateInput('my package')
132
+ expect(result.isValid).toBe(false)
133
+ expect(result.error).toBeDefined()
134
+ })
135
+ })
136
+ })
137
+
138
+ describe('toNpmPackageName', () => {
139
+ describe('unscoped names', () => {
140
+ it('should prefix unscoped package names', () => {
141
+ const result = resolver.toNpmPackageName('release')
142
+ expect(result).toBe('@test-org/test-release')
143
+ })
144
+
145
+ it('should prefix unscoped names with hyphens', () => {
146
+ const result = resolver.toNpmPackageName('my-package')
147
+ expect(result).toBe('@test-org/test-my-package')
148
+ })
149
+
150
+ it('should trim whitespace from unscoped names', () => {
151
+ const result = resolver.toNpmPackageName(' release ')
152
+ expect(result).toBe('@test-org/test-release')
153
+ })
154
+ })
155
+
156
+ describe('scoped names', () => {
157
+ it('should return scoped packages as-is', () => {
158
+ const result = resolver.toNpmPackageName('@ibm/analytics')
159
+ expect(result).toBe('@ibm/analytics')
160
+ })
161
+
162
+ it('should return scoped packages with hyphens as-is', () => {
163
+ const result = resolver.toNpmPackageName('@launch77-shared/plugin-release')
164
+ expect(result).toBe('@launch77-shared/plugin-release')
165
+ })
166
+
167
+ it('should trim whitespace from scoped names', () => {
168
+ const result = resolver.toNpmPackageName(' @ibm/analytics ')
169
+ expect(result).toBe('@ibm/analytics')
170
+ })
171
+ })
172
+ })
173
+
174
+ describe('resolveLocation', () => {
175
+ describe('local resolution (unscoped names)', () => {
176
+ it('should resolve to local when valid package exists', async () => {
177
+ const packageName = 'my-local-package'
178
+ const localPath = path.join(tempDir, 'test-packages', packageName)
179
+ await fs.ensureDir(localPath)
180
+ await fs.writeJSON(path.join(localPath, 'package.json'), { name: packageName })
181
+
182
+ const result = await resolver.resolveLocation(packageName, tempDir)
183
+
184
+ expect(result.source).toBe('local')
185
+ expect(result.resolvedName).toBe(packageName)
186
+ expect(result.localPath).toBe(localPath)
187
+ expect(result.npmPackage).toBeUndefined()
188
+ })
189
+
190
+ it('should fall back to npm when local directory exists but is invalid', async () => {
191
+ const packageName = 'invalid-local-package'
192
+ const localPath = path.join(tempDir, 'test-packages', packageName)
193
+ await fs.ensureDir(localPath)
194
+ // Don't create package.json, making it invalid
195
+
196
+ const result = await resolver.resolveLocation(packageName, tempDir)
197
+
198
+ expect(result.source).toBe('npm')
199
+ expect(result.resolvedName).toBe(packageName)
200
+ expect(result.npmPackage).toBe('@test-org/test-invalid-local-package')
201
+ expect(result.localPath).toBeUndefined()
202
+ })
203
+
204
+ it('should fall back to npm when local directory does not exist', async () => {
205
+ const packageName = 'nonexistent-package'
206
+
207
+ const result = await resolver.resolveLocation(packageName, tempDir)
208
+
209
+ expect(result.source).toBe('npm')
210
+ expect(result.resolvedName).toBe(packageName)
211
+ expect(result.npmPackage).toBe('@test-org/test-nonexistent-package')
212
+ expect(result.localPath).toBeUndefined()
213
+ })
214
+
215
+ it('should trim whitespace from package names', async () => {
216
+ const packageName = 'my-package'
217
+ const localPath = path.join(tempDir, 'test-packages', packageName)
218
+ await fs.ensureDir(localPath)
219
+ await fs.writeJSON(path.join(localPath, 'package.json'), { name: packageName })
220
+
221
+ const result = await resolver.resolveLocation(' my-package ', tempDir)
222
+
223
+ expect(result.source).toBe('local')
224
+ expect(result.resolvedName).toBe(packageName)
225
+ expect(result.localPath).toBe(localPath)
226
+ })
227
+ })
228
+
229
+ describe('npm resolution (scoped names)', () => {
230
+ it('should always resolve scoped packages to npm', async () => {
231
+ const packageName = '@ibm/analytics'
232
+
233
+ const result = await resolver.resolveLocation(packageName, tempDir)
234
+
235
+ expect(result.source).toBe('npm')
236
+ expect(result.resolvedName).toBe(packageName)
237
+ expect(result.npmPackage).toBe(packageName)
238
+ expect(result.localPath).toBeUndefined()
239
+ })
240
+
241
+ it('should resolve scoped packages to npm even if local directory exists', async () => {
242
+ const packageName = '@ibm/analytics'
243
+ // Create a local directory with the scoped package name (edge case)
244
+ const localPath = path.join(tempDir, 'test-packages', '@ibm/analytics')
245
+ await fs.ensureDir(localPath)
246
+ await fs.writeJSON(path.join(localPath, 'package.json'), { name: packageName })
247
+
248
+ const result = await resolver.resolveLocation(packageName, tempDir)
249
+
250
+ expect(result.source).toBe('npm')
251
+ expect(result.resolvedName).toBe(packageName)
252
+ expect(result.npmPackage).toBe(packageName)
253
+ expect(result.localPath).toBeUndefined()
254
+ })
255
+
256
+ it('should handle scoped packages with hyphens', async () => {
257
+ const packageName = '@launch77-shared/plugin-release'
258
+
259
+ const result = await resolver.resolveLocation(packageName, tempDir)
260
+
261
+ expect(result.source).toBe('npm')
262
+ expect(result.resolvedName).toBe(packageName)
263
+ expect(result.npmPackage).toBe(packageName)
264
+ })
265
+
266
+ it('should trim whitespace from scoped package names', async () => {
267
+ const packageName = '@ibm/analytics'
268
+
269
+ const result = await resolver.resolveLocation(' @ibm/analytics ', tempDir)
270
+
271
+ expect(result.source).toBe('npm')
272
+ expect(result.resolvedName).toBe(packageName)
273
+ expect(result.npmPackage).toBe(packageName)
274
+ })
275
+ })
276
+
277
+ describe('edge cases', () => {
278
+ it('should handle empty workspace test-packages directory', async () => {
279
+ // Ensure the directory exists but is empty
280
+ await fs.ensureDir(path.join(tempDir, 'test-packages'))
281
+
282
+ const result = await resolver.resolveLocation('some-package', tempDir)
283
+
284
+ expect(result.source).toBe('npm')
285
+ expect(result.resolvedName).toBe('some-package')
286
+ expect(result.npmPackage).toBe('@test-org/test-some-package')
287
+ })
288
+
289
+ it('should handle missing workspace test-packages directory', async () => {
290
+ // Don't create the test-packages directory at all
291
+
292
+ const result = await resolver.resolveLocation('some-package', tempDir)
293
+
294
+ expect(result.source).toBe('npm')
295
+ expect(result.resolvedName).toBe('some-package')
296
+ expect(result.npmPackage).toBe('@test-org/test-some-package')
297
+ })
298
+
299
+ it('should handle different workspace root paths', async () => {
300
+ const customRoot = path.join(tempDir, 'custom', 'workspace')
301
+ const packageName = 'my-package'
302
+ const localPath = path.join(customRoot, 'test-packages', packageName)
303
+ await fs.ensureDir(localPath)
304
+ await fs.writeJSON(path.join(localPath, 'package.json'), { name: packageName })
305
+
306
+ const result = await resolver.resolveLocation(packageName, customRoot)
307
+
308
+ expect(result.source).toBe('local')
309
+ expect(result.localPath).toBe(localPath)
310
+ })
311
+ })
312
+ })
313
+ })
@@ -0,0 +1,194 @@
1
+ import * as path from 'path'
2
+
3
+ import { parsePluginName, isValidNpmPackageName } from '@launch77/plugin-runtime'
4
+ import fs from 'fs-extra'
5
+
6
+ import type { ValidationResult } from '@launch77/plugin-runtime'
7
+
8
+ /**
9
+ * Base interface for package resolution results
10
+ */
11
+ export interface PackageResolution {
12
+ /** The source of the package */
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
+ * Abstract base class for resolving Launch77 packages
24
+ *
25
+ * Provides generic resolution logic for finding packages in:
26
+ * 1. Local workspace directory (e.g., plugins/, app-templates/)
27
+ * 2. npm packages with configured prefix (e.g., @launch77-shared/plugin-*, @launch77-shared/app-template-*)
28
+ *
29
+ * Concrete implementations must provide:
30
+ * - Folder name for local resolution
31
+ * - Package prefix for npm resolution
32
+ * - Verification logic to validate local packages
33
+ */
34
+ export abstract class PackageResolver {
35
+ /**
36
+ * Get the local folder name where packages of this type are stored
37
+ * @example 'plugins' or 'app-templates'
38
+ */
39
+ protected abstract getFolderName(): string
40
+
41
+ /**
42
+ * Get the npm package prefix for unscoped packages
43
+ * @example '@launch77-shared/plugin-' or '@launch77-shared/app-template-'
44
+ */
45
+ protected abstract getPackagePrefix(): string
46
+
47
+ /**
48
+ * Verify that a local package is valid and complete
49
+ * @param localPath - The local directory path to verify
50
+ * @returns true if the package is valid, false otherwise
51
+ */
52
+ protected abstract verify(localPath: string): Promise<boolean>
53
+
54
+ /**
55
+ * Validate package input name
56
+ *
57
+ * Accepts:
58
+ * - Unscoped names (e.g., "release", "my-package")
59
+ * - Scoped npm packages (e.g., "@ibm/package-name")
60
+ *
61
+ * Rejects:
62
+ * - Invalid formats
63
+ * - Empty strings
64
+ * - Names with invalid characters
65
+ *
66
+ * @param name - The package name to validate
67
+ * @returns ValidationResult with isValid and optional error message
68
+ *
69
+ * @example
70
+ * validateInput('release') // { isValid: true }
71
+ * validateInput('@ibm/analytics') // { isValid: true }
72
+ * validateInput('@invalid') // { isValid: false, error: '...' }
73
+ */
74
+ validateInput(name: string): ValidationResult {
75
+ if (!name || name.trim().length === 0) {
76
+ return {
77
+ isValid: false,
78
+ error: 'Package name cannot be empty',
79
+ }
80
+ }
81
+
82
+ const trimmedName = name.trim()
83
+
84
+ // Parse the name to determine type and validate
85
+ //TODO: move/rename parsePluginName() to parsePackageName()
86
+ const parsed = parsePluginName(trimmedName)
87
+
88
+ if (!parsed.isValid) {
89
+ return {
90
+ isValid: false,
91
+ error: parsed.error,
92
+ }
93
+ }
94
+
95
+ // If it's scoped, it must be a valid npm package name
96
+ if (parsed.type === 'scoped') {
97
+ return isValidNpmPackageName(trimmedName)
98
+ }
99
+
100
+ // Unscoped names are valid for both local and npm
101
+ return { isValid: true }
102
+ }
103
+
104
+ /**
105
+ * Convert an unscoped package name to an npm package name
106
+ *
107
+ * Rules:
108
+ * - Unscoped names: prefix with configured package prefix
109
+ * - Scoped names: use as-is
110
+ *
111
+ * @param name - The package name (must be validated first)
112
+ * @returns The npm package name
113
+ *
114
+ * @example
115
+ * toNpmPackageName('release') // '@launch77-shared/plugin-release' (for PluginResolver)
116
+ * toNpmPackageName('@ibm/analytics') // '@ibm/analytics'
117
+ */
118
+ toNpmPackageName(name: string): string {
119
+ const trimmedName = name.trim()
120
+
121
+ // If already scoped, use as-is
122
+ if (trimmedName.startsWith('@')) {
123
+ return trimmedName
124
+ }
125
+
126
+ // Otherwise, convert using configured prefix
127
+ return `${this.getPackagePrefix()}${trimmedName}`
128
+ }
129
+
130
+ /**
131
+ * Resolve package location from name
132
+ *
133
+ * Resolution order:
134
+ * 1. Check local workspace directory (configured by getFolderName())
135
+ * 2. Verify local package is valid (using verify())
136
+ * 3. Fall back to npm package name (with configured prefix)
137
+ *
138
+ * @param name - The package name to resolve
139
+ * @param workspaceRoot - The workspace root directory
140
+ * @returns PackageResolution with source and resolved location
141
+ *
142
+ * @example
143
+ * // Local package found
144
+ * await resolveLocation('my-package', '/workspace')
145
+ * // { source: 'local', resolvedName: 'my-package', localPath: '/workspace/plugins/my-package' }
146
+ *
147
+ * // Not found locally, resolve to npm
148
+ * await resolveLocation('release', '/workspace')
149
+ * // { source: 'npm', resolvedName: 'release', npmPackage: '@launch77-shared/plugin-release' }
150
+ *
151
+ * // Scoped package always resolves to npm
152
+ * await resolveLocation('@ibm/analytics', '/workspace')
153
+ * // { source: 'npm', resolvedName: '@ibm/analytics', npmPackage: '@ibm/analytics' }
154
+ */
155
+ async resolveLocation(name: string, workspaceRoot: string): Promise<PackageResolution> {
156
+ const trimmedName = name.trim()
157
+ const parsed = parsePluginName(trimmedName)
158
+
159
+ // If scoped, always use npm (local packages are never scoped)
160
+ if (parsed.type === 'scoped') {
161
+ return {
162
+ source: 'npm',
163
+ resolvedName: trimmedName,
164
+ npmPackage: trimmedName,
165
+ }
166
+ }
167
+
168
+ // Check local workspace directory
169
+ const localPath = path.join(workspaceRoot, this.getFolderName(), trimmedName)
170
+ const localExists = await fs.pathExists(localPath)
171
+
172
+ if (localExists) {
173
+ // Verify it's a valid package
174
+ const isValid = await this.verify(localPath)
175
+
176
+ if (isValid) {
177
+ return {
178
+ source: 'local',
179
+ resolvedName: trimmedName,
180
+ localPath,
181
+ }
182
+ }
183
+ }
184
+
185
+ // Not found locally or invalid, resolve to npm package
186
+ const npmPackage = this.toNpmPackageName(trimmedName)
187
+
188
+ return {
189
+ source: 'npm',
190
+ resolvedName: trimmedName,
191
+ npmPackage,
192
+ }
193
+ }
194
+ }
@@ -47,8 +47,12 @@ export function createAppCommand(): Command {
47
47
  }
48
48
 
49
49
  // Success message
50
- console.log(chalk.green(`\n✅ App created successfully at ${path.relative(process.cwd(), result.appPath)}`))
51
- console.log(chalk.gray(`\nRun 'npm run dev' from workspace root to start the server at http://localhost:${port}\n`))
50
+ const relativeAppPath = path.relative(process.cwd(), result.appPath)
51
+ console.log(chalk.green(`\n✅ App created successfully at ${relativeAppPath}`))
52
+ console.log(chalk.gray(`\nNext steps:`))
53
+ console.log(chalk.gray(` cd ${relativeAppPath}`))
54
+ console.log(chalk.gray(` npm run dev`))
55
+ console.log(chalk.gray(`\nServer will start at http://localhost:${port}\n`))
52
56
  } catch (error) {
53
57
  const message = error instanceof Error ? error.message : String(error)
54
58
  console.error(chalk.red('Error:'), message)
@@ -0,0 +1,40 @@
1
+ import * as path from 'path'
2
+
3
+ import fs from 'fs-extra'
4
+
5
+ import { PackageResolver } from '../../../infrastructure/package-resolver.js'
6
+
7
+ /**
8
+ * App template resolver implementation
9
+ *
10
+ * Resolves app templates from:
11
+ * - Local workspace app-templates/ directory
12
+ * - npm packages with @launch77-shared/app-template- prefix
13
+ */
14
+ export class AppTemplateResolver extends PackageResolver {
15
+ protected getFolderName(): string {
16
+ return 'app-templates'
17
+ }
18
+
19
+ protected getPackagePrefix(): string {
20
+ return '@launch77-shared/app-template-'
21
+ }
22
+
23
+ protected async verify(localPath: string): Promise<boolean> {
24
+ // Check for either:
25
+ // 1. A 'template' subdirectory (preferred structure for npm packages)
26
+ // 2. Any files at the root (simple local templates)
27
+ const hasTemplateDir = await fs.pathExists(path.join(localPath, 'template'))
28
+ if (hasTemplateDir) {
29
+ return true
30
+ }
31
+
32
+ // Check if directory has any files (not just an empty directory)
33
+ try {
34
+ const files = await fs.readdir(localPath)
35
+ return files.length > 0
36
+ } catch {
37
+ return false
38
+ }
39
+ }
40
+ }
@@ -5,19 +5,22 @@ import fs from 'fs-extra'
5
5
  import { ManifestService } from './manifest-svc.js'
6
6
  import * as filesystem from '../../../infrastructure/filesystem.js'
7
7
  import * as npm from '../../../infrastructure/npm.js'
8
- import { generateFromTemplate } from '../../../infrastructure/template-generator.js'
9
- import { templateExists } from '../../../infrastructure/template.js'
8
+ import { downloadNpmPackage } from '../../../infrastructure/npm-package.js'
9
+ import { processTemplate } from '../../../infrastructure/template.js'
10
10
  import { validateWorkspaceContext } from '../../../utils/launch77-validation.js'
11
11
  import { validateAppName } from '../../../utils/validation.js'
12
+ import { AppTemplateResolver } from '../lib/app-template-resolver.js'
12
13
 
13
- import type { Launch77Context } from '@launch77/plugin-runtime'
14
14
  import type { CreateAppRequest, CreateAppResult, DeleteAppRequest, DeleteAppResult } from '../types/app-types.js'
15
+ import type { Launch77Context } from '@launch77/plugin-runtime'
15
16
 
16
17
  export class AppService {
17
18
  private manifestService: ManifestService
19
+ private appTemplateResolver: AppTemplateResolver
18
20
 
19
21
  constructor() {
20
22
  this.manifestService = new ManifestService()
23
+ this.appTemplateResolver = new AppTemplateResolver()
21
24
  }
22
25
 
23
26
  /**
@@ -38,30 +41,64 @@ export class AppService {
38
41
  throw new Error(contextValidation.errorMessage)
39
42
  }
40
43
 
41
- // 3. Determine app path
44
+ // 3. Validate template name
45
+ const templateValidation = this.appTemplateResolver.validateInput(type)
46
+ if (!templateValidation.isValid) {
47
+ throw new Error(templateValidation.error || `Invalid template name: ${type}`)
48
+ }
49
+
50
+ // 4. Resolve template location
51
+ const templateResolution = await this.appTemplateResolver.resolveLocation(type, context.workspaceRoot)
52
+
53
+ let templatePath: string
54
+ if (templateResolution.source === 'local') {
55
+ // Use local template
56
+ templatePath = templateResolution.localPath!
57
+ } else {
58
+ // Download npm template
59
+ const downloadResult = await downloadNpmPackage({
60
+ packageName: templateResolution.npmPackage!,
61
+ workspaceRoot: context.workspaceRoot,
62
+ })
63
+
64
+ // Check if template has a 'template' subdirectory, otherwise use package root
65
+ const templateSubdir = path.join(downloadResult.packagePath, 'template')
66
+ if (await fs.pathExists(templateSubdir)) {
67
+ templatePath = templateSubdir
68
+ } else {
69
+ templatePath = downloadResult.packagePath
70
+ }
71
+ }
72
+
73
+ // 5. Determine app path
42
74
  const appPath = path.join(context.appsDir, appName)
43
75
 
44
- // 4. Check if app already exists
76
+ // 6. Check if app already exists
45
77
  if (await fs.pathExists(appPath)) {
46
78
  throw new Error(`App ${appName} already exists at ${appPath}`)
47
79
  }
48
80
 
49
- // 5. Validate template exists
50
- if (!(await templateExists(type))) {
51
- throw new Error(`Unknown app type: ${type}. Currently supported: api, webapp, marketing-site`)
81
+ // 7. Verify template path exists
82
+ if (!(await fs.pathExists(templatePath))) {
83
+ throw new Error(`Template not found at: ${templatePath}`)
52
84
  }
53
85
 
54
- // 6. Generate from template (context already has packageName computed)
55
- await generateFromTemplate(type, appPath, appName, context, { port })
86
+ // 8. Generate from template (context already has packageName computed)
87
+ await processTemplate(templatePath, appPath, {
88
+ appName,
89
+ workspaceName: context.workspaceName,
90
+ packageName: context.packageName,
91
+ port,
92
+ })
56
93
 
57
- // 7. Generate and write app manifest
94
+ // 9. Generate and write app manifest
58
95
  const manifest = this.manifestService.generateManifest(type, appName, context, { port })
59
96
  const launchDir = path.join(appPath, '.launch')
60
97
  await filesystem.ensureDir(launchDir)
61
98
  const manifestPath = path.join(launchDir, 'app.json')
62
99
  await filesystem.writeJSON(manifestPath, manifest)
63
100
 
64
- // 8. Install dependencies
101
+ // 10. Install dependencies
65
102
  await npm.install(context.workspaceRoot)
66
103
 
67
104
  return {