flu-cli 2.0.5 → 2.1.0

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 (45) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +17 -4
  3. package/config/dev.config.js +11 -11
  4. package/config/templates.js +10 -10
  5. package/index.js +554 -102
  6. package/lib/commands/add.js +365 -266
  7. package/lib/commands/assets.js +77 -78
  8. package/lib/commands/cache.js +29 -52
  9. package/lib/commands/completion.js +13 -11
  10. package/lib/commands/config.js +150 -44
  11. package/lib/commands/init-ai-base.js +89 -0
  12. package/lib/commands/newClack.js +269 -178
  13. package/lib/commands/snippets.js +58 -43
  14. package/lib/commands/template.js +98 -58
  15. package/lib/commands/templates.js +101 -57
  16. package/lib/commands/upload.js +313 -0
  17. package/lib/commands/vnext-options.js +206 -0
  18. package/lib/generators/model_generator.js +91 -88
  19. package/lib/generators/page_generator.js +100 -93
  20. package/lib/generators/service_generator.js +44 -39
  21. package/lib/generators/viewmodel_generator.js +25 -29
  22. package/lib/generators/widget_generator.js +30 -35
  23. package/lib/templates/templateCopier.js +14 -15
  24. package/lib/templates/templateManager.js +22 -21
  25. package/lib/utils/config.js +37 -20
  26. package/lib/utils/flutterHelper.js +2 -2
  27. package/lib/utils/i18n.js +3 -3
  28. package/lib/utils/index_updater.js +22 -23
  29. package/lib/utils/json-output.js +59 -0
  30. package/lib/utils/logger.js +17 -17
  31. package/lib/utils/project_detector.js +66 -66
  32. package/lib/utils/snippet_loader.js +21 -19
  33. package/lib/utils/string_helper.js +13 -13
  34. package/lib/utils/templateSelectorEnquirer.js +94 -108
  35. package/locales/en-US.json +1 -1
  36. package/locales/zh-CN.json +2 -2
  37. package/package.json +60 -57
  38. package/scripts/smoke-vnext-generate.mjs +1934 -0
  39. package/scripts/smoke-vnext-params.mjs +92 -0
  40. package/CLI.md +0 -513
  41. package/release.sh +0 -529
  42. package/scripts/e2e-state-tests.js +0 -116
  43. package/scripts/sync-base-to-templates.js +0 -108
  44. package/scripts/workspace-clone-all.sh +0 -101
  45. package/scripts/workspace-status-all.sh +0 -112
@@ -0,0 +1,313 @@
1
+ import {
2
+ intro,
3
+ outro,
4
+ select,
5
+ text,
6
+ confirm,
7
+ spinner,
8
+ note,
9
+ isCancel,
10
+ cancel,
11
+ } from '@clack/prompts'
12
+ import chalk from 'chalk'
13
+ import fs from 'fs-extra'
14
+ import path from 'path'
15
+ import {
16
+ ConfigValidator,
17
+ AndroidBuilder,
18
+ IOSBuilder,
19
+ PgyerUploader,
20
+ HuaweiUploader,
21
+ XiaomiUploader,
22
+ AppleStoreUploader,
23
+ TestFlightUploader,
24
+ AdHocUploader,
25
+ UploadManager,
26
+ } from 'flu-cli-core'
27
+
28
+ import { logger } from '../utils/logger.js'
29
+
30
+ const CONFIG_FILE = 'flu_release.yaml'
31
+
32
+ export async function uploadCommand(options) {
33
+ console.clear()
34
+ intro(chalk.bgCyan(' FLU UPLOAD '))
35
+
36
+ const cwd = process.cwd()
37
+ const configPath = path.join(cwd, CONFIG_FILE)
38
+
39
+ // 1. Check Config
40
+ if (!fs.existsSync(configPath)) {
41
+ note(`Config file not found: ${CONFIG_FILE}`, 'Info')
42
+ const shouldInit = await confirm({
43
+ message: 'Do you want to initialize a configuration file?',
44
+ })
45
+
46
+ if (isCancel(shouldInit) || !shouldInit) {
47
+ cancel('Operation cancelled.')
48
+ return process.exit(0)
49
+ }
50
+
51
+ await initConfig(configPath)
52
+ outro('Configuration file created. Please edit it and run upload again.')
53
+ return process.exit(0)
54
+ }
55
+
56
+ // 2. Validate Config
57
+ const s = spinner()
58
+ s.start('Validating configuration...')
59
+ const config = ConfigValidator.loadAndValidate(configPath)
60
+
61
+ if (!config) {
62
+ s.stop(chalk.red('Configuration invalid.'))
63
+ cancel('Please check your verification logs above.')
64
+ return process.exit(1)
65
+ }
66
+ s.stop('Configuration valid.')
67
+
68
+ // 3. Runtime Specs (Version, Changelog)
69
+ const pubspecPath = path.join(cwd, 'pubspec.yaml')
70
+ let currentVersion = '1.0.0'
71
+ let appMetadata = {
72
+ version: currentVersion,
73
+ changelog: '',
74
+ buildNumber: '1',
75
+ platform: [],
76
+ }
77
+
78
+ if (fs.existsSync(pubspecPath)) {
79
+ const pubspec = fs.readFileSync(pubspecPath, 'utf8')
80
+ const match = pubspec.match(/version:\s+([\d\.]+)(\+(\d+))?/)
81
+ if (match) {
82
+ currentVersion = match[1]
83
+ appMetadata.buildNumber = match[3] || '1'
84
+ }
85
+ }
86
+
87
+ // Version Bump (Only if building)
88
+ if (!options.onlyUpload) {
89
+ const nextVersion = await handleVersionBump(currentVersion, pubspecPath)
90
+ if (!nextVersion) return process.exit(0)
91
+ appMetadata.version = nextVersion
92
+ }
93
+
94
+ // Changelog
95
+ if (options.desc) {
96
+ appMetadata.changelog = options.desc
97
+ } else {
98
+ const desc = await text({
99
+ message: 'Update Description (Changelog)',
100
+ placeholder: 'Fixed bugs...',
101
+ initialValue: '',
102
+ })
103
+ if (isCancel(desc)) return process.exit(0)
104
+ appMetadata.changelog = desc
105
+ }
106
+
107
+ // 4. Dispatch Modes
108
+ if (options.onlyBuild) {
109
+ await runBuildPhase(config, cwd, appMetadata)
110
+ outro('Build Only Completed.')
111
+ return
112
+ }
113
+
114
+ if (options.onlyUpload) {
115
+ await runUploadPhase(config, cwd, appMetadata, options.file)
116
+ outro('Upload Only Completed.')
117
+ return
118
+ }
119
+
120
+ // Default: Build + Upload
121
+ const artifactPaths = await runBuildPhase(config, cwd, appMetadata)
122
+ // Find artifact for upload?
123
+ // runBuildPhase returns paths.
124
+ // Assuming single artifact for now or handle array.
125
+ // For now, let's assume one platform build or similar.
126
+ // Or we handle upload platform by platform.
127
+
128
+ if (artifactPaths.length > 0) {
129
+ // TODO: Pass correct file to each uploader?
130
+ // Simple logic: Upload the file generated.
131
+ // If Android & iOS both built, we have 2 files.
132
+ // Uploader needs to know which one.
133
+ // "AppMetadata" could hold a map? Or we do upload inside build loop?
134
+ // Better: passing file path to runUploadPhase.
135
+
136
+ // Let's refine runUploadPhase to accept specific files if available.
137
+ // Or runUploadPhase finds them.
138
+
139
+ // Actually, Pgyer/Huawei might take APK or IPA.
140
+ // If we have both, we probably upload both?
141
+ // Pgyer takes everything. Huawei takes APK. AppStore takes IPA.
142
+ // I will simplify for Phase 1: Support Android mostly or sequential.
143
+
144
+ // If "platform" is filtered, execute accordingly.
145
+
146
+ for (const buildRes of artifactPaths) {
147
+ await runUploadPhase(config, cwd, appMetadata, buildRes.artifactPath)
148
+ }
149
+ }
150
+
151
+ outro('All operations completed.')
152
+ }
153
+
154
+ async function handleVersionBump(currentVersion, pubspecPath) {
155
+ const bumpType = await select({
156
+ message: `Current Version: ${currentVersion}. Upgrade?`,
157
+ options: [
158
+ { value: 'patch', label: 'Patch (x.x.+1)' },
159
+ { value: 'minor', label: 'Minor (x.+1.0)' },
160
+ { value: 'major', label: 'Major (+1.0.0)' },
161
+ { value: 'manual', label: 'Manual Input' },
162
+ { value: 'skip', label: 'Skip' },
163
+ ],
164
+ })
165
+
166
+ if (isCancel(bumpType)) return null
167
+ if (bumpType === 'skip') return currentVersion
168
+
169
+ // Logic to increment version... (Simplified for now)
170
+ // We update pubspec file.
171
+ // TODO: implement actual semver logic or replace string.
172
+
173
+ return currentVersion // Placeholder
174
+ }
175
+
176
+ async function runBuildPhase(config, cwd, metadata) {
177
+ const s = spinner()
178
+ s.start('Building...')
179
+
180
+ const results = []
181
+
182
+ // Android
183
+ if (config.build?.android) {
184
+ const res = await AndroidBuilder.build(config.build.android, cwd)
185
+ if (res.success) {
186
+ note(`Android Artifact: ${res.artifactPath}`, 'SUCCESS')
187
+ results.push(res)
188
+ } else {
189
+ s.stop(chalk.red('Android Build Failed'))
190
+ // Continue or exit?
191
+ }
192
+ }
193
+
194
+ // iOS
195
+ if (config.build?.ios) {
196
+ const res = await IOSBuilder.build(config.build.ios, cwd)
197
+ if (res.success) {
198
+ note(`iOS Artifact: ${res.artifactPath}`, 'SUCCESS')
199
+ results.push(res)
200
+ }
201
+ }
202
+
203
+ s.stop(`Build Phase Completed. Generated ${results.length} artifacts.`)
204
+ return results
205
+ }
206
+
207
+ async function runUploadPhase(config, cwd, metadata, explicitFile) {
208
+ let file = explicitFile
209
+ if (!file) {
210
+ note(`No file provided for upload (Auto-find logic pending)`, 'WARN')
211
+ return
212
+ }
213
+
214
+ // Create upload manager for parallel uploads
215
+ const manager = new UploadManager({
216
+ maxConcurrent: 3,
217
+ continueOnError: true,
218
+ verbose: false,
219
+ })
220
+
221
+ const s = spinner()
222
+ s.start('Uploading to all enabled platforms...')
223
+
224
+ try {
225
+ // Execute parallel uploads
226
+ const report = await manager.uploadToMultiplePlatforms(file, config, metadata)
227
+
228
+ s.stop('Upload phase completed.')
229
+
230
+ // Display results
231
+ if (report.successCount === report.totalUploads) {
232
+ note(`✅ All platforms uploaded successfully!`, 'SUCCESS')
233
+ } else if (report.failureCount === report.totalUploads) {
234
+ note(`❌ Failed to upload to any platform`, 'ERROR')
235
+ } else {
236
+ note(`⚠️ Partial success: ${report.successCount}/${report.totalUploads} platforms`, 'WARN')
237
+ }
238
+
239
+ // Ask if user wants to retry failed platforms
240
+ if (report.failureCount > 0) {
241
+ const shouldRetry = await confirm({
242
+ message: `Retry ${report.failureCount} failed platform(s)?`,
243
+ })
244
+
245
+ if (!isCancel(shouldRetry) && shouldRetry) {
246
+ const retryReport = await manager.retryFailedPlatforms(
247
+ report.failurePlatforms,
248
+ file,
249
+ config,
250
+ metadata,
251
+ )
252
+
253
+ // Update summary
254
+ note(
255
+ `After retry: ${retryReport.successCount}/${retryReport.totalUploads} success`,
256
+ retryReport.failureCount === 0 ? 'SUCCESS' : 'WARN',
257
+ )
258
+ }
259
+ }
260
+
261
+ return report
262
+ } catch (error) {
263
+ s.stop(chalk.red('Upload failed'))
264
+ logger.error(`Upload error: ${error.message}`)
265
+ throw error
266
+ }
267
+ }
268
+
269
+ async function initConfig(path) {
270
+ const template = `appId: com.example.app
271
+ version: 1.0.0
272
+
273
+ build:
274
+ android:
275
+ type: apk
276
+ flavor: production
277
+ ios:
278
+ scheme: Runner
279
+ exportMethod: ad-hoc
280
+
281
+ publish:
282
+ pgyer:
283
+ enable: true
284
+ apiKey: \${PGYER_API_KEY}
285
+
286
+ huawei:
287
+ enable: false
288
+ appId: ""
289
+ releaseType: draft
290
+ auth:
291
+ clientId: \${HUAWEI_CLIENT_ID}
292
+ clientSecret: \${HUAWEI_CLIENT_SECRET}
293
+
294
+ app_store:
295
+ enable: false
296
+ appId: ""
297
+ auth:
298
+ # CLI Upload (Required)
299
+ appleId: \${APPLE_ID}
300
+ appPassword: \${APPLE_APP_PASSWORD}
301
+ # API (Optional, for changelog updates)
302
+ issuerId: ""
303
+ keyId: ""
304
+ privateKeyPath: ""
305
+
306
+ xiaomi:
307
+ enable: false
308
+ auth:
309
+ appId: \${XIAOMI_APP_ID}
310
+ appKey: \${XIAOMI_APP_KEY}
311
+ `
312
+ await fs.writeFile(path, template)
313
+ }
@@ -0,0 +1,206 @@
1
+ export function buildVNextProjectOptions(options = {}) {
2
+ const helpers = parseHelpers(options.helpers)
3
+ const capabilities = {}
4
+ const examples = parseExamples(options.examples)
5
+ const composition = parseComposition(options)
6
+
7
+ if (options.auth === true) {
8
+ capabilities.auth = { enabled: true }
9
+ }
10
+
11
+ const serialization = parseSerialization(options)
12
+ if (serialization) {
13
+ capabilities.serialization = serialization
14
+ }
15
+
16
+ const result = {}
17
+ if (Object.keys(capabilities).length > 0) result.capabilities = capabilities
18
+ if (Object.keys(helpers).length > 0) result.helpers = helpers
19
+ if (examples.length > 0) result.examples = examples
20
+ if (composition) result.composition = composition
21
+ if (composition?.architectureMode) result.architectureMode = composition.architectureMode
22
+ if (composition?.enableMixinOptions !== undefined) {
23
+ result.enableMixinOptions = composition.enableMixinOptions
24
+ }
25
+
26
+ const platforms = parseList(options.platforms).map(normalizePlatformName)
27
+ if (platforms.length > 0) result.platforms = platforms
28
+
29
+ const flutterSdk = parseFlutterSdk(options)
30
+ if (flutterSdk) result.flutterSdk = flutterSdk
31
+
32
+ return result
33
+ }
34
+
35
+ function parseComposition(options) {
36
+ const rawProfile = normalizeRawCompositionProfile(
37
+ options.compositionProfile || options.composition,
38
+ )
39
+ const profile = normalizeCompositionProfile(rawProfile)
40
+ const architectureMode = normalizeArchitectureMode(options.architectureMode, profile)
41
+ const enableMixinOptions = parseEnableMixinOptions(options.enableMixinOptions, rawProfile)
42
+
43
+ if (!profile && !architectureMode && enableMixinOptions === undefined) return null
44
+
45
+ return {
46
+ profile: profile || 'base',
47
+ architectureMode: architectureMode || 'mvvm',
48
+ ...(enableMixinOptions !== undefined ? { enableMixinOptions } : {}),
49
+ }
50
+ }
51
+
52
+ function parseHelpers(value) {
53
+ const helperNames = parseList(value)
54
+ const helpers = {}
55
+
56
+ for (const helperName of helperNames) {
57
+ const normalized = normalizeHelperName(helperName)
58
+ if (normalized) helpers[normalized] = true
59
+ }
60
+
61
+ return helpers
62
+ }
63
+
64
+ function parseExamples(value) {
65
+ return parseList(value)
66
+ .map(normalizeExampleName)
67
+ .filter(Boolean)
68
+ .filter((item, index, array) => array.indexOf(item) === index)
69
+ }
70
+
71
+ function parseSerialization(options) {
72
+ if (!options.serialization) return null
73
+
74
+ const strategy = String(options.serialization).trim()
75
+ const normalized = strategy.toLowerCase()
76
+ if (!normalized || ['false', 'off', 'none', 'no'].includes(normalized)) {
77
+ return { enabled: false, strategy: 'manual', buildRunner: false }
78
+ }
79
+
80
+ return {
81
+ enabled: true,
82
+ strategy: normalized,
83
+ buildRunner:
84
+ options.serializationBuildRunner === true ||
85
+ normalized === 'json_serializable' ||
86
+ normalized === 'freezed',
87
+ }
88
+ }
89
+
90
+ function parseFlutterSdk(options) {
91
+ const mode = options.flutterSdk
92
+ const version = options.flutterSdkVersion
93
+ const flutterBin = options.flutterBin
94
+
95
+ if (!mode && !version && !flutterBin) return null
96
+
97
+ return {
98
+ mode: mode || (flutterBin ? 'custom' : 'system'),
99
+ version,
100
+ flutterBin,
101
+ }
102
+ }
103
+
104
+ function parseEnableMixinOptions(value, profile) {
105
+ if (value === undefined) {
106
+ return typeof profile === 'string' ? profile.endsWith('mixins') : undefined
107
+ }
108
+
109
+ if (typeof value === 'boolean') return value
110
+
111
+ const normalized = String(value).trim().toLowerCase()
112
+ if (['true', '1', 'yes', 'on', 'enabled'].includes(normalized)) return true
113
+ if (['false', '0', 'no', 'off', 'disabled'].includes(normalized)) return false
114
+ return undefined
115
+ }
116
+
117
+ function parseList(value) {
118
+ if (!value) return []
119
+
120
+ const values = Array.isArray(value) ? value : [value]
121
+ return values
122
+ .flatMap((item) => String(item).split(','))
123
+ .map((item) => item.trim())
124
+ .filter(Boolean)
125
+ }
126
+
127
+ function normalizeHelperName(value) {
128
+ const normalized = String(value).trim().toLowerCase().replace(/[-_]/g, '')
129
+
130
+ const map = {
131
+ payment: 'payment',
132
+ pay: 'payment',
133
+ webview: 'webview',
134
+ web: 'webview',
135
+ permission: 'permission',
136
+ permissions: 'permission',
137
+ imagepicker: 'imagePicker',
138
+ image: 'imagePicker',
139
+ imagepick: 'imagePicker',
140
+ }
141
+
142
+ return map[normalized]
143
+ }
144
+
145
+ function normalizePlatformName(value) {
146
+ const normalized = String(value).trim().toLowerCase()
147
+ if (normalized === 'harmonyos') return 'harmony'
148
+ return normalized
149
+ }
150
+
151
+ function normalizeArchitectureMode(value, profile) {
152
+ const normalized = String(value || '')
153
+ .trim()
154
+ .toLowerCase()
155
+ if (normalized === 'native') return 'native'
156
+ if (normalized === 'mvvm') return 'mvvm'
157
+ return 'mvvm'
158
+ }
159
+
160
+ function normalizeRawCompositionProfile(value) {
161
+ const normalized = String(value || '')
162
+ .trim()
163
+ .toLowerCase()
164
+ if (['base', 'base_mixins', 'pure_mixins', 'pure'].includes(normalized)) {
165
+ return normalized
166
+ }
167
+ return ''
168
+ }
169
+
170
+ function normalizeCompositionProfile(value) {
171
+ return value
172
+ }
173
+
174
+ function normalizeExampleName(value) {
175
+ const normalized = String(value)
176
+ .trim()
177
+ .toLowerCase()
178
+ .replace(/[-_\s]/g, '')
179
+
180
+ const map = {
181
+ // 默认用户列表入口已属于模板基座;旧 commonListHome 入参只做兼容清理。
182
+ commonlisthome: null,
183
+ commonlist: null,
184
+ listhome: null,
185
+ networkrequest: 'networkGallery',
186
+ request: 'networkGallery',
187
+ networkexamples: 'networkGallery',
188
+ networkgallery: 'networkGallery',
189
+ gallery: 'networkGallery',
190
+ webviewbasic: 'webviewBasic',
191
+ webview: 'webviewBasic',
192
+ web: 'webviewBasic',
193
+ permissionbasic: 'permissionBasic',
194
+ permission: 'permissionBasic',
195
+ permissions: 'permissionBasic',
196
+ imagepickerbasic: 'imagePickerBasic',
197
+ imagepicker: 'imagePickerBasic',
198
+ imagepick: 'imagePickerBasic',
199
+ image: 'imagePickerBasic',
200
+ paymentshell: 'paymentShell',
201
+ payment: 'paymentShell',
202
+ pay: 'paymentShell',
203
+ }
204
+
205
+ return map[normalized]
206
+ }