appship 0.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.
@@ -0,0 +1,800 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appship
4
+ class CLI
5
+ def self.start(argv)
6
+ new.start(argv)
7
+ rescue Error => e
8
+ warn "❌ #{e.message}"
9
+ exit 1
10
+ rescue OptionParser::ParseError => e
11
+ warn "❌ #{e.message}"
12
+ exit 2
13
+ end
14
+
15
+ def start(argv)
16
+ args = argv.dup
17
+ global = extract_global_options!(args)
18
+ command = args.shift
19
+
20
+ case command
21
+ when "init"
22
+ init(args)
23
+ when "doctor"
24
+ doctor
25
+ when "build"
26
+ build(args, global)
27
+ when "package"
28
+ package(args, global)
29
+ when "upload"
30
+ upload(args, global)
31
+ when "pgyer"
32
+ pgyer(args, global)
33
+ when "fir"
34
+ fir(args, global)
35
+ when "version", "--version", "-v"
36
+ puts "appship #{VERSION}"
37
+ when nil, "help", "--help", "-h"
38
+ puts help
39
+ else
40
+ raise ConfigurationError, "未知命令: #{command}\n\n#{help}"
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def extract_global_options!(args)
47
+ config_path = nil
48
+ verbose = false
49
+ loop do
50
+ case args.first
51
+ when "--verbose"
52
+ verbose = true
53
+ args.shift
54
+ when "--config"
55
+ args.shift
56
+ config_path = args.shift || raise(OptionParser::MissingArgument, "--config")
57
+ when ->(value) { value&.start_with?("--config=") }
58
+ config_path = args.shift.split("=", 2).last
59
+ else
60
+ break
61
+ end
62
+ end
63
+ { config_path: config_path, verbose: verbose }
64
+ end
65
+
66
+ def normalize_provider_short_options!(args, provider)
67
+ key_option = provider.to_s == "fir" ? "--fir-api-key" : "--api-key"
68
+ password_option = provider.to_s == "fir" ? "--fir-password" : "--password"
69
+ args.map! do |arg|
70
+ case arg
71
+ when "-key" then key_option
72
+ when "-password" then password_option
73
+ else
74
+ arg = "#{key_option}=#{arg.delete_prefix("-key=")}" if arg.start_with?("-key=")
75
+ arg = "#{password_option}=#{arg.delete_prefix("-password=")}" if arg.start_with?("-password=")
76
+ arg
77
+ end
78
+ end
79
+ end
80
+
81
+ def init(args)
82
+ path = File.join(Dir.pwd, ".appship.yml")
83
+ force = false
84
+ parser = OptionParser.new do |opts|
85
+ opts.banner = "用法: appship init [选项]"
86
+ opts.on("--path PATH", "配置文件路径(默认 .appship.yml)") { |value| path = value }
87
+ opts.on("--force", "覆盖已有配置文件") { force = true }
88
+ opts.on("-h", "--help", "显示帮助") { puts opts; exit 0 }
89
+ end
90
+ parser.parse!(args)
91
+ puts "✅ 已创建配置文件: #{Config.write_template(path, force: force)}"
92
+ end
93
+
94
+ def doctor
95
+ checks = {
96
+ "ruby" => "运行 CLI",
97
+ "xcodebuild" => "归档和导出",
98
+ "xcrun" => "actool 和代码工具",
99
+ "codesign" => "重签名角标后的 App",
100
+ "security" => "读取代码签名身份",
101
+ "zip" => "生成 IPA",
102
+ "unzip" => "修改已有 IPA",
103
+ "magick/convert" => "生成图标角标"
104
+ }
105
+ puts "appship doctor"
106
+ checks.each do |command, purpose|
107
+ available = if command == "magick/convert"
108
+ Runner.which("magick") || Runner.which("convert")
109
+ else
110
+ Runner.which(command)
111
+ end
112
+ puts "#{available ? "✅" : "⚠️"} #{command.ljust(16)} #{available || "未安装"} - #{purpose}"
113
+ end
114
+ config = Config.find
115
+ puts "#{config.path ? "✅" : "ℹ️"} 配置文件 #{config.path || "当前目录没有 .appship.yml"}"
116
+ end
117
+
118
+ def build(args, global)
119
+ config = Config.find(global[:config_path])
120
+ options = build_options(config)
121
+ parser = build_parser(options)
122
+ parser.parse!(args)
123
+ Builder.new(runner: Runner.new(verbose: global[:verbose])).build!(options)
124
+ end
125
+
126
+ def package(args, global)
127
+ config = Config.find(global[:config_path])
128
+ options = upload_options(config).merge(
129
+ app: nil,
130
+ output: nil,
131
+ badge: config.get("badge"),
132
+ assets: config.get("assets"),
133
+ app_icon: config.get("app_icon", default: "AppIcon")
134
+ )
135
+ parser = package_parser(options)
136
+ parser.parse!(args)
137
+ Builder.new(runner: Runner.new(verbose: global[:verbose])).package!(options)
138
+ end
139
+
140
+ def upload(args, global)
141
+ config = Config.find(global[:config_path])
142
+ options = upload_options(config).merge(file: nil)
143
+ parser = upload_parser(options)
144
+ parser.parse!(args)
145
+ options[:file] ||= args.shift
146
+ raise ConfigurationError, "请指定 IPA/APK 文件路径" unless options[:file]
147
+ raise ConfigurationError, "upload 不接受多余参数: #{args.join(" ")}" unless args.empty?
148
+
149
+ Builder.new(runner: Runner.new(verbose: global[:verbose])).upload!(options)
150
+ end
151
+
152
+ def pgyer(args, global)
153
+ options = {
154
+ project_dir: Dir.pwd,
155
+ env_file: ENV.fetch("APPSHIP_ENV_FILE", Cache::DEFAULT_PATH),
156
+ project_name: nil,
157
+ app_name: nil,
158
+ workspace: nil,
159
+ project: nil,
160
+ scheme: nil,
161
+ configuration: nil,
162
+ configuration_explicit: false,
163
+ sdk: "iphoneos",
164
+ output: nil,
165
+ archive_path: nil,
166
+ export_path: nil,
167
+ export_options_plist: nil,
168
+ export_method: "development",
169
+ signing_style: "automatic",
170
+ team_id: nil,
171
+ build_mode: "rebuild",
172
+ build_mode_explicit: false,
173
+ badge: nil,
174
+ badge_explicit: false,
175
+ assets: nil,
176
+ app_icon: "AppIcon",
177
+ upload: true,
178
+ api_key: nil,
179
+ api_key_env: "PGYER_API_KEY",
180
+ install_type: nil,
181
+ password: nil,
182
+ password_env: nil,
183
+ description: nil,
184
+ description_explicit: false,
185
+ channel: nil,
186
+ poll_attempts: 60,
187
+ clean: true,
188
+ keep_temporary: false,
189
+ allow_provisioning_updates: true,
190
+ skip_package_plugin_validation: true,
191
+ skip_macro_validation: true
192
+ }
193
+ parser = pgyer_parser(options)
194
+ normalize_provider_short_options!(args, "pgyer")
195
+ parser.parse!(args)
196
+ raise ConfigurationError, "pgyer 不接受多余参数: #{args.join(" ")}" unless args.empty?
197
+
198
+ project_dir = File.expand_path(options[:project_dir])
199
+ raise ConfigurationError, "项目目录不存在: #{project_dir}" unless File.directory?(project_dir)
200
+ env_file = File.expand_path(options[:env_file], Dir.pwd)
201
+
202
+ Dir.chdir(project_dir) do
203
+ cache = Cache.load(env_file, project_dir: project_dir, interactive: true, provider: "pgyer")
204
+ profile = cache.resolve(
205
+ project_name: options[:project_name],
206
+ project_dir: project_dir,
207
+ provider: "pgyer",
208
+ credential_overrides: { "pgyer_api_key" => options[:api_key], "pgyer_password" => options[:password] }.compact
209
+ )
210
+ apply_cached_profile!(options, profile, provider: "pgyer")
211
+ infer_pgyer_target!(options)
212
+ options[:output] ||= File.join(Cache::DEFAULT_BUILD_DIR, "#{options[:app_name]}.ipa")
213
+
214
+ runner = Runner.new(verbose: global[:verbose])
215
+ builder = Builder.new(runner: runner)
216
+ prepare_pgyer_flow!(options, builder)
217
+ if options[:build_mode] == "cache"
218
+ options[:app] ||= builder.find_cached_app!(options)
219
+ builder.package!(options)
220
+ else
221
+ builder.build!(options)
222
+ end
223
+ end
224
+ end
225
+
226
+ def fir(args, global)
227
+ options = {
228
+ project_dir: Dir.pwd,
229
+ env_file: ENV.fetch("APPSHIP_ENV_FILE", Cache::DEFAULT_PATH),
230
+ project_name: nil,
231
+ app_name: nil,
232
+ workspace: nil,
233
+ project: nil,
234
+ scheme: nil,
235
+ configuration: nil,
236
+ configuration_explicit: false,
237
+ sdk: "iphoneos",
238
+ output: nil,
239
+ archive_path: nil,
240
+ export_path: nil,
241
+ export_options_plist: nil,
242
+ export_method: "adhoc",
243
+ signing_style: "automatic",
244
+ team_id: nil,
245
+ build_mode: "rebuild",
246
+ build_mode_explicit: false,
247
+ badge: nil,
248
+ badge_explicit: false,
249
+ assets: nil,
250
+ app_icon: "AppIcon",
251
+ upload: true,
252
+ provider: "fir",
253
+ fir_api_token: nil,
254
+ fir_api_token_env: "FIR_API_TOKEN",
255
+ fir_password: nil,
256
+ fir_password_env: "FIR_PASSWORD",
257
+ bundle_id: nil,
258
+ app_version: nil,
259
+ build_number: nil,
260
+ release_type: "Adhoc",
261
+ icon: nil,
262
+ description: nil,
263
+ description_explicit: false,
264
+ clean: true,
265
+ keep_temporary: false,
266
+ allow_provisioning_updates: true,
267
+ skip_package_plugin_validation: true,
268
+ skip_macro_validation: true
269
+ }
270
+ parser = fir_parser(options)
271
+ normalize_provider_short_options!(args, "fir")
272
+ parser.parse!(args)
273
+ raise ConfigurationError, "fir 不接受多余参数: #{args.join(" ")}" unless args.empty?
274
+
275
+ project_dir = File.expand_path(options[:project_dir])
276
+ raise ConfigurationError, "项目目录不存在: #{project_dir}" unless File.directory?(project_dir)
277
+ env_file = File.expand_path(options[:env_file], Dir.pwd)
278
+
279
+ Dir.chdir(project_dir) do
280
+ cache = Cache.load(env_file, project_dir: project_dir, interactive: true, provider: "fir")
281
+ profile = cache.resolve(
282
+ project_name: options[:project_name],
283
+ project_dir: project_dir,
284
+ provider: "fir",
285
+ credential_overrides: { "fir_api_key" => options[:fir_api_token], "fir_password" => options[:fir_password] }.compact
286
+ )
287
+ apply_cached_profile!(options, profile, provider: "fir")
288
+ infer_pgyer_target!(options)
289
+ options[:output] ||= File.join(Cache::DEFAULT_BUILD_DIR, "#{options[:app_name]}.ipa")
290
+
291
+ runner = Runner.new(verbose: global[:verbose])
292
+ builder = Builder.new(runner: runner)
293
+ prepare_pgyer_flow!(options, builder)
294
+ if options[:build_mode] == "cache"
295
+ options[:app] ||= builder.find_cached_app!(options)
296
+ builder.package!(options)
297
+ else
298
+ builder.build!(options)
299
+ end
300
+ end
301
+ end
302
+
303
+ def apply_cached_profile!(options, profile, provider: "pgyer")
304
+ options[:project_name] ||= profile["project_name"]
305
+ options[:app_name] ||= profile["app_name"]
306
+ if provider.to_s == "fir"
307
+ options[:fir_api_token] ||= profile["fir_api_key"] || profile["fir_api_token"]
308
+ options[:fir_password] ||= profile["fir_password"]
309
+ options[:fir_password] ||= ENV[options[:fir_password_env]] if options[:fir_password_env]
310
+ options[:bundle_id] ||= profile["bundle_id"]
311
+ options[:app_version] ||= profile["app_version"]
312
+ options[:build_number] ||= profile["build_number"]
313
+ options[:release_type] ||= profile["fir_release_type"] || "Adhoc"
314
+ options[:description] ||= profile["fir_update_description"]
315
+ else
316
+ options[:api_key] ||= profile["pgyer_api_key"]
317
+ end
318
+ options[:workspace] ||= profile["workspace"]
319
+ options[:project] ||= profile["project"]
320
+ options[:scheme] ||= profile["scheme"] || options[:project_name]
321
+ options[:configuration] ||= profile["configuration"] || "Debug"
322
+ options[:assets] ||= profile["assets"]
323
+ options[:app_icon] ||= profile["app_icon"] || "AppIcon"
324
+ options[:badge] ||= profile["badge"]
325
+ if provider.to_s != "fir"
326
+ options[:password_env] ||= profile["pgyer_password_env"]
327
+ options[:password] ||= profile["pgyer_password"]
328
+ options[:password] ||= ENV[options[:password_env]] if options[:password_env]
329
+ options[:password] ||= ENV["PGYER_INSTALL_PASSWORD"]
330
+ options[:install_type] ||= profile["pgyer_install_type"] || 2
331
+ if options[:password].to_s.empty? && options[:install_type].to_i == 2
332
+ options[:install_type] = 1
333
+ puts "ℹ️ pgyer_password 为空,将使用公开安装模式(不设置密码)"
334
+ end
335
+ options[:description] ||= profile["pgyer_update_description"]
336
+ end
337
+
338
+ raise ConfigurationError, "缓存缺少 project_name" if options[:project_name].to_s.empty?
339
+ raise ConfigurationError, "缓存缺少 app_name" if options[:app_name].to_s.empty?
340
+ end
341
+
342
+ def infer_pgyer_target!(options)
343
+ return if options[:workspace] || options[:project]
344
+
345
+ workspace = "#{options[:project_name]}.xcworkspace"
346
+ project = "#{options[:project_name]}.xcodeproj"
347
+ if File.exist?(workspace)
348
+ options[:workspace] = workspace
349
+ elsif File.exist?(project)
350
+ options[:project] = project
351
+ else
352
+ candidates = Dir["*.xcworkspace", "*.xcodeproj"]
353
+ if candidates.length == 1
354
+ options[File.extname(candidates.first) == ".xcworkspace" ? :workspace : :project] = candidates.first
355
+ else
356
+ raise ConfigurationError, "找不到 #{options[:project_name]}.xcworkspace 或 #{options[:project_name]}.xcodeproj"
357
+ end
358
+ end
359
+
360
+ if options[:assets].to_s.empty?
361
+ icon_set = Dir["**/AppIcon.appiconset"].reject { |path| path.include?("Pods/") || path.include?("build/") || path.include?("DerivedData/") }.first
362
+ options[:assets] = File.dirname(icon_set) if icon_set
363
+ end
364
+ end
365
+
366
+ def default_description(options)
367
+ branch = git_value(["git", "rev-parse", "--abbrev-ref", "HEAD"], "unknown")
368
+ user = git_value(["git", "config", "user.name"], "unknown")
369
+ content = options[:interactive_content].to_s
370
+ "自动编译打包 | 分支:#{branch} | 构建人:#{user} | 配置:#{options[:configuration]} | 更新内容:#{content}"
371
+ end
372
+
373
+ def prepare_pgyer_flow!(options, builder)
374
+ interactive = $stdin.tty? && $stdout.tty?
375
+ unless options[:build_mode_explicit]
376
+ options[:build_mode] = choose_build_mode if interactive
377
+ end
378
+
379
+ if options[:build_mode] == "rebuild" && !options[:configuration_explicit] && interactive
380
+ options[:configuration] = choose_configuration
381
+ end
382
+
383
+ if options[:build_mode] == "cache"
384
+ options[:badge] ||= badge_text(options[:configuration]) unless options[:badge_explicit]
385
+ elsif !options[:badge_explicit] && interactive
386
+ options[:badge] = choose_badge ? badge_text(options[:configuration]) : nil
387
+ end
388
+
389
+ unless options[:description_explicit] || !interactive
390
+ print "请输入更新内容(输入完回车):"
391
+ options[:interactive_content] = $stdin.gets.to_s.chomp
392
+ puts "✅ 更新内容: #{options[:interactive_content]}"
393
+ end
394
+
395
+ if options[:build_mode] == "cache"
396
+ begin
397
+ puts ""
398
+ puts "📦 使用缓存模式"
399
+ options[:app] ||= with_spinner("正在查找编译缓存中的 .app 文件") do
400
+ builder.find_cached_app!(options)
401
+ end
402
+ puts "✅ 找到缓存 App: #{options[:app]}"
403
+ rescue CommandError => e
404
+ puts "⚠️ #{e.message},将切换到重新构建模式"
405
+ options[:build_mode] = "rebuild"
406
+ options[:app] = nil
407
+ end
408
+ end
409
+
410
+ options[:description] ||= default_description(options)
411
+ end
412
+
413
+ def with_spinner(message)
414
+ return yield unless $stdout.tty?
415
+
416
+ frames = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏]
417
+ finished = false
418
+ spinner = Thread.new do
419
+ index = 0
420
+ until finished
421
+ print "\r#{frames[index % frames.length]} #{message}"
422
+ $stdout.flush
423
+ index += 1
424
+ sleep 0.12
425
+ end
426
+ end
427
+
428
+ yield
429
+ ensure
430
+ finished = true
431
+ spinner&.join
432
+ if $stdout.tty?
433
+ print "\r\e[2K"
434
+ $stdout.flush
435
+ end
436
+ end
437
+
438
+ def choose_build_mode
439
+ choices = [
440
+ "使用缓存(从 DerivedData 读取已有的 .app,快速打包)",
441
+ "重新构建(archive + export 导出已签名 IPA)"
442
+ ]
443
+ selected = gum_choose("请选择构建方式:", choices)
444
+ if selected == choices[0]
445
+ puts "✅ 已选择构建方式: 使用缓存"
446
+ return "cache"
447
+ end
448
+ if selected == choices[1]
449
+ puts "✅ 已选择构建方式: 重新构建"
450
+ return "rebuild"
451
+ end
452
+
453
+ puts ""
454
+ puts "请选择构建方式:"
455
+ puts "1) #{choices[0]}"
456
+ puts "2) #{choices[1]}"
457
+ print "请输入选项 [1/2,默认 2]: "
458
+ answer = $stdin.gets.to_s.strip
459
+ result = answer == "1" ? "cache" : "rebuild"
460
+ puts "✅ 已选择构建方式: #{result == "cache" ? "使用缓存" : "重新构建"}"
461
+ result
462
+ end
463
+
464
+ def choose_badge
465
+ choices = [
466
+ "添加 Badge(显示环境标记)",
467
+ "不添加 Badge(使用原始图标)"
468
+ ]
469
+ selected = gum_choose("请选择是否在 App Icon 上添加 Badge:", choices)
470
+ if selected == choices[0]
471
+ puts "✅ 已选择 Badge: 添加 Badge"
472
+ return true
473
+ end
474
+ if selected == choices[1]
475
+ puts "✅ 已选择 Badge: 不添加 Badge"
476
+ return false
477
+ end
478
+
479
+ puts ""
480
+ puts "请选择是否在 App Icon 上添加 Badge:"
481
+ puts "1) #{choices[0]}"
482
+ puts "2) #{choices[1]}"
483
+ print "请输入选项 [1/2,默认 2]: "
484
+ result = $stdin.gets.to_s.strip == "1"
485
+ puts "✅ 已选择 Badge: #{result ? "添加 Badge" : "不添加 Badge"}"
486
+ result
487
+ end
488
+
489
+ def choose_configuration
490
+ choices = ["Debug", "Release"]
491
+ selected = gum_choose("请选择构建配置:", choices)
492
+ if choices.include?(selected)
493
+ puts "✅ 已选择构建配置: #{selected}"
494
+ return selected
495
+ end
496
+
497
+ puts ""
498
+ puts "请选择构建配置:"
499
+ puts "1) Debug"
500
+ puts "2) Release"
501
+ print "请输入选项 [1/2,默认 1]: "
502
+ result = $stdin.gets.to_s.strip == "2" ? "Release" : "Debug"
503
+ puts "✅ 已选择构建配置: #{result}"
504
+ result
505
+ end
506
+
507
+ def gum_choose(header, choices)
508
+ gum = ensure_gum
509
+ return nil unless gum
510
+
511
+ puts ""
512
+ output = IO.popen([gum, "choose", "--header", header, *choices], "r", &:read)
513
+ selected = output.to_s.strip
514
+ selected unless selected.empty?
515
+ rescue Errno::ENOENT
516
+ warn "⚠️ gum 不可用,将使用普通终端选择"
517
+ nil
518
+ end
519
+
520
+ def ensure_gum
521
+ existing = Runner.which("gum")
522
+ return existing if existing
523
+
524
+ brew = Runner.which("brew")
525
+ unless brew
526
+ warn "⚠️ 未找到 Homebrew,无法自动安装 gum,将使用普通终端选择"
527
+ return nil
528
+ end
529
+
530
+ puts "⚙️ gum 未安装,正在通过 Homebrew 安装..."
531
+ Runner.new.run!([brew, "install", "gum"])
532
+ installed = Runner.which("gum")
533
+ return installed if installed
534
+
535
+ warn "⚠️ gum 安装命令已完成,但仍找不到 gum,将使用普通终端选择"
536
+ nil
537
+ rescue Error => e
538
+ warn "⚠️ gum 安装失败:#{e.message}"
539
+ nil
540
+ end
541
+
542
+ def badge_text(configuration)
543
+ configuration.to_s.casecmp("release").zero? ? "RELEASE" : "DEBUG"
544
+ end
545
+
546
+ def git_value(argv, fallback)
547
+ output, status = Open3.capture2(*argv)
548
+ status.success? && !output.strip.empty? ? output.strip : fallback
549
+ end
550
+
551
+ def build_options(config)
552
+ upload_options(config).merge(
553
+ workspace: config.get("workspace"),
554
+ project: config.get("project"),
555
+ scheme: config.get("scheme"),
556
+ configuration: config.get("configuration", default: "Debug"),
557
+ sdk: config.get("sdk", default: "iphoneos"),
558
+ output: config.get("output"),
559
+ archive_path: config.get("archive_path"),
560
+ export_path: config.get("export_path"),
561
+ export_options_plist: config.get("export_options_plist"),
562
+ export_method: config.get("export_method", default: "development"),
563
+ signing_style: config.get("signing_style", default: "automatic"),
564
+ team_id: config.get("team_id"),
565
+ badge: config.get("badge"),
566
+ assets: config.get("assets"),
567
+ app_icon: config.get("app_icon", default: "AppIcon"),
568
+ upload: false,
569
+ clean: true,
570
+ keep_temporary: false,
571
+ allow_provisioning_updates: true,
572
+ skip_package_plugin_validation: true,
573
+ skip_macro_validation: true
574
+ )
575
+ end
576
+
577
+ def upload_options(config)
578
+ {
579
+ provider: config.get("upload", "provider", default: "pgyer"),
580
+ api_key: nil,
581
+ api_key_env: config.get("upload", "api_key_env", default: "PGYER_API_KEY"),
582
+ fir_api_token: nil,
583
+ fir_api_token_env: config.get("upload", "fir_api_token_env", default: "FIR_API_TOKEN"),
584
+ fir_password: config.get("upload", "fir_password"),
585
+ fir_password_env: config.get("upload", "fir_password_env", default: "FIR_PASSWORD"),
586
+ bundle_id: config.get("upload", "bundle_id"),
587
+ app_name: config.get("upload", "app_name"),
588
+ app_version: config.get("upload", "app_version"),
589
+ build_number: config.get("upload", "build_number"),
590
+ release_type: config.get("upload", "release_type"),
591
+ icon: config.get("upload", "icon"),
592
+ install_type: config.get("upload", "install_type", default: 1),
593
+ password: nil,
594
+ password_env: config.get("upload", "password_env"),
595
+ description: config.get("upload", "description"),
596
+ install_date: config.get("upload", "install_date"),
597
+ start_date: config.get("upload", "start_date"),
598
+ end_date: config.get("upload", "end_date"),
599
+ channel: config.get("upload", "channel"),
600
+ poll_attempts: config.get("upload", "poll_attempts", default: 60),
601
+ upload: false
602
+ }
603
+ end
604
+
605
+ def build_parser(options)
606
+ OptionParser.new do |opts|
607
+ opts.banner = "用法: appship [--config FILE] build [选项]"
608
+ opts.separator ""
609
+ opts.separator "工程与构建"
610
+ opts.on("--workspace PATH", "Xcode workspace 路径") { |value| options[:workspace] = value }
611
+ opts.on("--project PATH", "Xcode project 路径") { |value| options[:project] = value }
612
+ opts.on("--scheme NAME", "Scheme 名称") { |value| options[:scheme] = value }
613
+ opts.on("--configuration NAME", "构建配置(默认 Debug)") { |value| options[:configuration] = value }
614
+ opts.on("--sdk NAME", "SDK(默认 iphoneos)") { |value| options[:sdk] = value }
615
+ opts.on("--output PATH", "最终 IPA 路径") { |value| options[:output] = value }
616
+ opts.on("--archive-path PATH", "自定义 xcarchive 路径") { |value| options[:archive_path] = value }
617
+ opts.on("--export-path PATH", "自定义 export 目录") { |value| options[:export_path] = value }
618
+ opts.on("--export-options-plist PATH", "使用已有 ExportOptions.plist") { |value| options[:export_options_plist] = value }
619
+ opts.on("--export-method NAME", "development/adhoc/app-store 等") { |value| options[:export_method] = value }
620
+ opts.on("--team-id ID", "签名 Team ID") { |value| options[:team_id] = value }
621
+ opts.on("--no-clean", "不清理已有 archive/export 路径") { options[:clean] = false }
622
+ opts.on("--keep-temporary", "保留工具生成的临时目录") { options[:keep_temporary] = true }
623
+ opts.on("--no-allow-provisioning-updates", "不允许 xcodebuild 自动更新签名") { options[:allow_provisioning_updates] = false }
624
+ opts.separator ""
625
+ opts.separator "图标角标"
626
+ opts.on("--badge TEXT", "添加角标,例如 DEBUG 或 RELEASE") { |value| options[:badge] = value }
627
+ opts.on("--no-badge", "禁用配置文件里的角标") { options[:badge] = nil }
628
+ opts.on("--assets PATH", "Assets.xcassets 路径") { |value| options[:assets] = value }
629
+ opts.on("--app-icon NAME", "AppIcon 名称(默认 AppIcon)") { |value| options[:app_icon] = value }
630
+ opts.separator ""
631
+ add_upload_options(opts, options)
632
+ opts.on("-h", "--help", "显示帮助") { puts opts; exit 0 }
633
+ end
634
+ end
635
+
636
+ def package_parser(options)
637
+ OptionParser.new do |opts|
638
+ opts.banner = "用法: appship [--config FILE] package [选项]"
639
+ opts.on("--app PATH", "已编译 .app 路径") { |value| options[:app] = value }
640
+ opts.on("--output PATH", "最终 IPA 路径") { |value| options[:output] = value }
641
+ opts.on("--badge TEXT", "添加角标") { |value| options[:badge] = value }
642
+ opts.on("--no-badge", "禁用角标") { options[:badge] = nil }
643
+ opts.on("--assets PATH", "Assets.xcassets 路径") { |value| options[:assets] = value }
644
+ opts.on("--app-icon NAME", "AppIcon 名称") { |value| options[:app_icon] = value }
645
+ add_upload_options(opts, options)
646
+ opts.on("-h", "--help", "显示帮助") { puts opts; exit 0 }
647
+ end
648
+ end
649
+
650
+ def upload_parser(options)
651
+ OptionParser.new do |opts|
652
+ opts.banner = "用法: appship [--config FILE] upload IPA_OR_APK [选项]"
653
+ opts.on("--file PATH", "IPA/APK 路径,也可作为位置参数传入") { |value| options[:file] = value }
654
+ add_upload_options(opts, options)
655
+ opts.on("-h", "--help", "显示帮助") { puts opts; exit 0 }
656
+ end
657
+ end
658
+
659
+ def pgyer_parser(options)
660
+ OptionParser.new do |opts|
661
+ opts.banner = "用法: appship pgyer [选项]"
662
+ opts.separator ""
663
+ opts.separator "项目与缓存"
664
+ opts.on("--project-dir PATH", "项目目录(默认当前目录)") { |value| options[:project_dir] = value }
665
+ opts.on("--env-file PATH", "本机缓存文件(默认 ~/.appship/.config)") { |value| options[:env_file] = value }
666
+ opts.on("--project-name NAME", "缓存中的 project_name") { |value| options[:project_name] = value }
667
+ opts.on("--app-name NAME", "缓存中的 app_name") { |value| options[:app_name] = value }
668
+ opts.on("--workspace PATH", "Xcode workspace 路径") { |value| options[:workspace] = value }
669
+ opts.on("--project PATH", "Xcode project 路径") { |value| options[:project] = value }
670
+ opts.on("--scheme NAME", "Scheme 名称") { |value| options[:scheme] = value }
671
+ opts.on("--configuration NAME", "构建配置(Debug/Release)") { |value| options[:configuration] = value; options[:configuration_explicit] = true }
672
+ opts.on("--output PATH", "最终 IPA 路径") { |value| options[:output] = value }
673
+ opts.on("--cache", "复用 DerivedData 中已有的 .app") { options[:build_mode] = "cache"; options[:build_mode_explicit] = true }
674
+ opts.on("--rebuild", "重新 archive/export 构建(默认)") { options[:build_mode] = "rebuild"; options[:build_mode_explicit] = true }
675
+ opts.separator ""
676
+ opts.separator "角标与导出"
677
+ opts.on("--badge TEXT", "添加图标角标,例如 DEBUG") { |value| options[:badge] = value; options[:badge_explicit] = true }
678
+ opts.on("--no-badge", "不添加图标角标") { options[:badge] = nil; options[:badge_explicit] = true }
679
+ opts.on("--assets PATH", "Assets.xcassets 路径") { |value| options[:assets] = value }
680
+ opts.on("--app-icon NAME", "AppIcon 名称") { |value| options[:app_icon] = value }
681
+ opts.on("--export-method NAME", "development/adhoc/app-store 等") { |value| options[:export_method] = value }
682
+ opts.on("--team-id ID", "签名 Team ID") { |value| options[:team_id] = value }
683
+ opts.on("--no-allow-provisioning-updates", "不允许自动更新签名") { options[:allow_provisioning_updates] = false }
684
+ opts.separator ""
685
+ opts.separator "蒲公英"
686
+ opts.separator "简写:-key KEY、-password PASSWORD 会同时更新本机缓存"
687
+ opts.on("--api-key KEY", "覆盖并保存缓存中的 pgyer_api_key") { |value| options[:api_key] = value }
688
+ opts.on("--install-type N", Integer, "1=公开,2=密码,3=邀请") { |value| options[:install_type] = value }
689
+ opts.on("--password PASSWORD", "覆盖并保存缓存中的 pgyer_password") { |value| options[:password] = value }
690
+ opts.on("--password-env NAME", "安装密码环境变量名") { |value| options[:password_env] = value }
691
+ opts.on("--description TEXT", "更新描述") { |value| options[:description] = value; options[:description_explicit] = true }
692
+ opts.on("--channel NAME", "渠道短链接") { |value| options[:channel] = value }
693
+ opts.on("--poll-attempts N", Integer, "轮询次数,默认 60") { |value| options[:poll_attempts] = value }
694
+ opts.on("-h", "--help", "显示帮助") { puts opts; exit 0 }
695
+ end
696
+ end
697
+
698
+ def fir_parser(options)
699
+ OptionParser.new do |opts|
700
+ opts.banner = "用法: appship fir [选项]"
701
+ opts.separator ""
702
+ opts.separator "项目与缓存"
703
+ opts.on("--project-dir PATH", "项目目录(默认当前目录)") { |value| options[:project_dir] = value }
704
+ opts.on("--env-file PATH", "本机缓存文件(默认 ~/.appship/.config)") { |value| options[:env_file] = value }
705
+ opts.on("--project-name NAME", "缓存中的 project_name") { |value| options[:project_name] = value }
706
+ opts.on("--app-name NAME", "fir.im 应用名称") { |value| options[:app_name] = value }
707
+ opts.on("--workspace PATH", "Xcode workspace 路径") { |value| options[:workspace] = value }
708
+ opts.on("--project PATH", "Xcode project 路径") { |value| options[:project] = value }
709
+ opts.on("--scheme NAME", "Scheme 名称") { |value| options[:scheme] = value }
710
+ opts.on("--configuration NAME", "构建配置(Debug/Release)") { |value| options[:configuration] = value; options[:configuration_explicit] = true }
711
+ opts.on("--output PATH", "最终 IPA 路径") { |value| options[:output] = value }
712
+ opts.on("--cache", "复用 DerivedData 中已有的 .app") { options[:build_mode] = "cache"; options[:build_mode_explicit] = true }
713
+ opts.on("--rebuild", "重新 archive/export 构建(默认)") { options[:build_mode] = "rebuild"; options[:build_mode_explicit] = true }
714
+ opts.separator ""
715
+ opts.separator "角标与导出"
716
+ opts.on("--badge TEXT", "添加图标角标,例如 DEBUG") { |value| options[:badge] = value; options[:badge_explicit] = true }
717
+ opts.on("--no-badge", "不添加角标") { options[:badge] = nil; options[:badge_explicit] = true }
718
+ opts.on("--assets PATH", "Assets.xcassets 路径") { |value| options[:assets] = value }
719
+ opts.on("--app-icon NAME", "AppIcon 名称") { |value| options[:app_icon] = value }
720
+ opts.on("--export-method NAME", "adhoc/inhouse 等") { |value| options[:export_method] = value }
721
+ opts.on("--team-id ID", "签名 Team ID") { |value| options[:team_id] = value }
722
+ opts.on("--no-allow-provisioning-updates", "不允许自动更新签名") { options[:allow_provisioning_updates] = false }
723
+ opts.separator ""
724
+ opts.separator "fir.im"
725
+ opts.separator "简写:-key KEY、-password PASSWORD 会同时更新本机缓存"
726
+ opts.on("--fir-api-key KEY", "覆盖并保存缓存中的 fir_api_key") { |value| options[:fir_api_token] = value }
727
+ opts.on("--fir-api-token TOKEN", "fir.im API Token(不建议写入配置文件)") { |value| options[:fir_api_token] = value }
728
+ opts.on("--fir-api-token-env NAME", "fir.im API Token 环境变量名") { |value| options[:fir_api_token_env] = value }
729
+ opts.on("--fir-password PASSWORD", "覆盖并保存缓存中的 fir_password") { |value| options[:fir_password] = value }
730
+ opts.on("--fir-password-env NAME", "fir.im 访客密码环境变量名") { |value| options[:fir_password_env] = value }
731
+ opts.on("--bundle-id ID", "应用 Bundle ID(IPA 可自动读取)") { |value| options[:bundle_id] = value }
732
+ opts.on("--app-version VERSION", "应用版本号") { |value| options[:app_version] = value }
733
+ opts.on("--build-number NUMBER", "Build 号") { |value| options[:build_number] = value }
734
+ opts.on("--release-type TYPE", "iOS 打包类型(Adhoc 或 Inhouse)") { |value| options[:release_type] = value }
735
+ opts.on("--icon PATH", "应用图标路径(可选)") { |value| options[:icon] = value }
736
+ opts.on("--description TEXT", "更新描述") { |value| options[:description] = value; options[:description_explicit] = true }
737
+ opts.on("--no-clean", "不清理已有 archive/export 路径") { options[:clean] = false }
738
+ opts.on("--keep-temporary", "保留工具生成的临时目录") { options[:keep_temporary] = true }
739
+ opts.on("-h", "--help", "显示帮助") { puts opts; exit 0 }
740
+ end
741
+ end
742
+
743
+ def add_upload_options(opts, options)
744
+ opts.separator ""
745
+ opts.separator "分发平台"
746
+ opts.on("--provider NAME", "分发平台(pgyer 或 fir,默认 pgyer)") { |value| options[:provider] = value }
747
+ opts.separator "蒲公英上传"
748
+ opts.on("--upload", "构建/打包后上传到所选分发平台") { options[:upload] = true }
749
+ opts.on("--api-key KEY", "蒲公英 API Key(不建议写入配置文件)") { |value| options[:api_key] = value }
750
+ opts.on("--api-key-env NAME", "API Key 环境变量名") { |value| options[:api_key_env] = value }
751
+ opts.on("--install-type N", Integer, "1=公开,2=密码,3=邀请") { |value| options[:install_type] = value }
752
+ opts.on("--password PASSWORD", "安装密码(建议使用 --password-env)") { |value| options[:password] = value }
753
+ opts.on("--password-env NAME", "安装密码环境变量名") { |value| options[:password_env] = value }
754
+ opts.on("--description TEXT", "更新描述") { |value| options[:description] = value }
755
+ opts.on("--channel NAME", "渠道短链接") { |value| options[:channel] = value }
756
+ opts.on("--poll-attempts N", Integer, "轮询次数,默认 60") { |value| options[:poll_attempts] = value }
757
+ opts.separator ""
758
+ opts.separator "fir.im 上传"
759
+ opts.on("--fir-api-token TOKEN", "fir.im API Token(不建议写入配置文件)") { |value| options[:fir_api_token] = value }
760
+ opts.on("--fir-api-token-env NAME", "fir.im API Token 环境变量名") { |value| options[:fir_api_token_env] = value }
761
+ opts.on("--fir-password PASSWORD", "fir.im 访客密码") { |value| options[:fir_password] = value }
762
+ opts.on("--fir-password-env NAME", "fir.im 访客密码环境变量名") { |value| options[:fir_password_env] = value }
763
+ opts.on("--bundle-id ID", "fir.im 应用 Bundle ID(IPA 可自动读取)") { |value| options[:bundle_id] = value }
764
+ opts.on("--app-name NAME", "fir.im 应用名称") { |value| options[:app_name] = value }
765
+ opts.on("--app-version VERSION", "fir.im 应用版本号") { |value| options[:app_version] = value }
766
+ opts.on("--build-number NUMBER", "fir.im Build 号") { |value| options[:build_number] = value }
767
+ opts.on("--release-type TYPE", "iOS 打包类型(Adhoc 或 Inhouse)") { |value| options[:release_type] = value }
768
+ opts.on("--icon PATH", "fir.im 应用图标路径(可选)") { |value| options[:icon] = value }
769
+ end
770
+
771
+ def help
772
+ <<~HELP
773
+ appship #{VERSION} - 通用移动应用构建、打包和分发工具
774
+
775
+ 用法:
776
+ appship init [--path .appship.yml]
777
+ appship doctor
778
+ appship pgyer
779
+ appship fir
780
+ appship build [选项]
781
+ appship package --app path/to/App.app [选项]
782
+ appship upload path/to/App.ipa [选项]
783
+
784
+ 常用示例:
785
+ appship init
786
+ cd /path/to/project && appship pgyer
787
+ FIR_API_TOKEN=xxx appship fir --workspace MyApp.xcworkspace --scheme MyApp
788
+ appship build --workspace MyApp.xcworkspace --scheme MyApp --configuration Debug
789
+ appship build --project MyApp.xcodeproj --scheme MyApp --badge DEBUG --upload
790
+ appship package --app build/Debug-iphoneos/MyApp.app --output build/MyApp.ipa
791
+ PGYER_API_KEY=xxx appship upload build/MyApp.ipa --install-type 2 --password-env PGYER_INSTALL_PASSWORD
792
+ FIR_API_TOKEN=xxx appship upload build/MyApp.ipa --provider fir --bundle-id com.example.MyApp
793
+
794
+ 全局选项必须放在命令前:
795
+ --config FILE 使用指定配置文件
796
+ --verbose 输出执行的底层命令
797
+ HELP
798
+ end
799
+ end
800
+ end