appship 0.1.0 → 0.2.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.
data/lib/appship/cli.rb CHANGED
@@ -13,13 +13,17 @@ module Appship
13
13
  end
14
14
 
15
15
  def start(argv)
16
+ @notification_options = nil
16
17
  args = argv.dup
17
18
  global = extract_global_options!(args)
18
19
  command = args.shift
20
+ @notification_options = {} if %w[build package upload pgyer fir].include?(command)
19
21
 
20
22
  case command
21
23
  when "init"
22
24
  init(args)
25
+ when "webhook"
26
+ webhook(args)
23
27
  when "doctor"
24
28
  doctor
25
29
  when "build"
@@ -39,10 +43,45 @@ module Appship
39
43
  else
40
44
  raise ConfigurationError, "未知命令: #{command}\n\n#{help}"
41
45
  end
46
+ rescue StandardError, Interrupt => e
47
+ notify_failure(e)
48
+ raise
42
49
  end
43
50
 
44
51
  private
45
52
 
53
+ def notify_failure(error)
54
+ return unless @notification_options
55
+
56
+ options = @notification_options.dup
57
+ unless options.values_at(*Webhook::URL_FIELDS).any? { |url| !url.to_s.strip.empty? }
58
+ path = File.expand_path(options[:env_file] || ENV.fetch("APPSHIP_ENV_FILE", Cache::DEFAULT_PATH))
59
+ directory = File.expand_path(options[:project_dir] || Dir.pwd)
60
+ local_path = ProjectConfig.path(directory)
61
+ return unless File.file?(path) || File.exist?(local_path) || File.symlink?(local_path)
62
+
63
+ if options[:project_name].to_s.empty? && !File.file?(local_path) && Dir[File.join(directory, "*.xcodeproj"), File.join(directory, "*.xcworkspace")].empty?
64
+ return
65
+ end
66
+ begin
67
+ profile = ProjectConfig.load(path, project_dir: directory, interactive: false)
68
+ .resolve(project_name: options[:project_name], project_dir: directory,
69
+ interactive: false, credentials: false)
70
+ rescue Error, SystemCallError
71
+ return
72
+ end
73
+ options = {
74
+ app_name: profile["app_name"], project_name: profile["project_name"],
75
+ api_key: profile["pgyer_api_key"], password: profile["pgyer_password"],
76
+ fir_api_token: profile["fir_api_key"], fir_password: profile["fir_password"]
77
+ }.merge(options.compact)
78
+ Webhook::OPTION_FIELDS.each { |key| options[key] = profile[key.to_s] }
79
+ end
80
+ Webhook.new(options).failure(error)
81
+ rescue StandardError => e
82
+ warn "⚠️ 失败通知未能发送(#{e.class}),保留原始错误"
83
+ end
84
+
46
85
  def extract_global_options!(args)
47
86
  config_path = nil
48
87
  verbose = false
@@ -79,16 +118,47 @@ module Appship
79
118
  end
80
119
 
81
120
  def init(args)
82
- path = File.join(Dir.pwd, ".appship.yml")
83
- force = false
121
+ project_dir = Dir.pwd
84
122
  parser = OptionParser.new do |opts|
85
123
  opts.banner = "用法: appship init [选项]"
86
- opts.on("--path PATH", "配置文件路径(默认 .appship.yml)") { |value| path = value }
87
- opts.on("--force", "覆盖已有配置文件") { force = true }
124
+ opts.on("--project-dir PATH", "工程目录(默认当前目录)") { |value| project_dir = value }
88
125
  opts.on("-h", "--help", "显示帮助") { puts opts; exit 0 }
89
126
  end
90
127
  parser.parse!(args)
91
- puts "✅ 已创建配置文件: #{Config.write_template(path, force: force)}"
128
+ raise ConfigurationError, "init 不接受多余参数: #{args.join(" ")}" unless args.empty?
129
+
130
+ ProjectConfig.init!(project_dir: File.expand_path(project_dir)).each do |path|
131
+ puts "✅ 项目示例已就绪(已有内容不会覆盖): #{path}"
132
+ end
133
+ puts "复制为 appship/config.yml 和 appship/message.yml 后编辑,示例文件不会参与运行。"
134
+ end
135
+
136
+ def webhook(args)
137
+ options = { project_dir: Dir.pwd, env_file: ENV.fetch("APPSHIP_ENV_FILE", Cache::DEFAULT_PATH), project_name: nil }
138
+ parser = OptionParser.new do |opts|
139
+ opts.banner = '用法: appship webhook wechat|dingtalk|feishu URL [选项](URL 传 "" 可关闭通知)'
140
+ opts.on("--project-dir PATH", "项目目录(默认当前目录)") { |value| options[:project_dir] = value }
141
+ opts.on("--env-file PATH", "本机缓存文件(默认 ~/.appship/.config)") { |value| options[:env_file] = value }
142
+ opts.on("--project-name NAME", "配置中的 project_name") { |value| options[:project_name] = value }
143
+ opts.on("-h", "--help", "显示帮助") { puts opts; return }
144
+ end
145
+ parser.parse!(args)
146
+ return puts(parser) if args.empty?
147
+ unless args.length == 2 && Webhook::PLATFORMS.key?(args.first)
148
+ raise ConfigurationError, "请指定 wechat、dingtalk 或 feishu,以及完整的 Webhook URL\n#{parser}"
149
+ end
150
+
151
+ platform, url = args
152
+ url = url.strip
153
+ Webhook.parse_url!(url) unless url.empty?
154
+ project_dir = File.expand_path(options[:project_dir])
155
+ raise ConfigurationError, "项目目录不存在: #{project_dir}" unless File.directory?(project_dir)
156
+
157
+ cache = ProjectConfig.load(options[:env_file], project_dir: project_dir, interactive: false)
158
+ profile = cache.resolve(project_name: options[:project_name], project_dir: project_dir, interactive: false, credentials: false)
159
+ cache.update_profile!(profile, "#{platform}_webhook" => url)
160
+ action = url.empty? ? "已关闭" : "已保存"
161
+ puts "✅ #{Webhook::PLATFORMS.fetch(platform)}通知#{action}(#{profile["project_name"]}): #{cache.path}"
92
162
  end
93
163
 
94
164
  def doctor
@@ -112,14 +182,16 @@ module Appship
112
182
  puts "#{available ? "✅" : "⚠️"} #{command.ljust(16)} #{available || "未安装"} - #{purpose}"
113
183
  end
114
184
  config = Config.find
115
- puts "#{config.path ? "✅" : "ℹ️"} 配置文件 #{config.path || "当前目录没有 .appship.yml"}"
185
+ puts "#{config.path ? "✅" : "ℹ️"} 配置文件 #{config.path || "当前目录没有 appship/config.yml 或 .appship.yml"}"
116
186
  end
117
187
 
118
188
  def build(args, global)
119
189
  config = Config.find(global[:config_path])
120
190
  options = build_options(config)
191
+ @notification_options = options
121
192
  parser = build_parser(options)
122
193
  parser.parse!(args)
194
+ apply_badge_config!(options, config.data)
123
195
  Builder.new(runner: Runner.new(verbose: global[:verbose])).build!(options)
124
196
  end
125
197
 
@@ -128,18 +200,21 @@ module Appship
128
200
  options = upload_options(config).merge(
129
201
  app: nil,
130
202
  output: nil,
131
- badge: config.get("badge"),
203
+ configuration: config.get("configuration"),
132
204
  assets: config.get("assets"),
133
205
  app_icon: config.get("app_icon", default: "AppIcon")
134
206
  )
207
+ @notification_options = options
135
208
  parser = package_parser(options)
136
209
  parser.parse!(args)
210
+ apply_badge_config!(options, config.data)
137
211
  Builder.new(runner: Runner.new(verbose: global[:verbose])).package!(options)
138
212
  end
139
213
 
140
214
  def upload(args, global)
141
215
  config = Config.find(global[:config_path])
142
216
  options = upload_options(config).merge(file: nil)
217
+ @notification_options = options
143
218
  parser = upload_parser(options)
144
219
  parser.parse!(args)
145
220
  options[:file] ||= args.shift
@@ -190,6 +265,7 @@ module Appship
190
265
  skip_package_plugin_validation: true,
191
266
  skip_macro_validation: true
192
267
  }
268
+ @notification_options = options
193
269
  parser = pgyer_parser(options)
194
270
  normalize_provider_short_options!(args, "pgyer")
195
271
  parser.parse!(args)
@@ -200,7 +276,7 @@ module Appship
200
276
  env_file = File.expand_path(options[:env_file], Dir.pwd)
201
277
 
202
278
  Dir.chdir(project_dir) do
203
- cache = Cache.load(env_file, project_dir: project_dir, interactive: true, provider: "pgyer")
279
+ cache = ProjectConfig.load(env_file, project_dir: project_dir, interactive: true, provider: "pgyer")
204
280
  profile = cache.resolve(
205
281
  project_name: options[:project_name],
206
282
  project_dir: project_dir,
@@ -213,7 +289,7 @@ module Appship
213
289
 
214
290
  runner = Runner.new(verbose: global[:verbose])
215
291
  builder = Builder.new(runner: runner)
216
- prepare_pgyer_flow!(options, builder)
292
+ prepare_pgyer_flow!(options, builder, profile)
217
293
  if options[:build_mode] == "cache"
218
294
  options[:app] ||= builder.find_cached_app!(options)
219
295
  builder.package!(options)
@@ -267,6 +343,7 @@ module Appship
267
343
  skip_package_plugin_validation: true,
268
344
  skip_macro_validation: true
269
345
  }
346
+ @notification_options = options
270
347
  parser = fir_parser(options)
271
348
  normalize_provider_short_options!(args, "fir")
272
349
  parser.parse!(args)
@@ -277,7 +354,7 @@ module Appship
277
354
  env_file = File.expand_path(options[:env_file], Dir.pwd)
278
355
 
279
356
  Dir.chdir(project_dir) do
280
- cache = Cache.load(env_file, project_dir: project_dir, interactive: true, provider: "fir")
357
+ cache = ProjectConfig.load(env_file, project_dir: project_dir, interactive: true, provider: "fir")
281
358
  profile = cache.resolve(
282
359
  project_name: options[:project_name],
283
360
  project_dir: project_dir,
@@ -290,7 +367,7 @@ module Appship
290
367
 
291
368
  runner = Runner.new(verbose: global[:verbose])
292
369
  builder = Builder.new(runner: runner)
293
- prepare_pgyer_flow!(options, builder)
370
+ prepare_pgyer_flow!(options, builder, profile)
294
371
  if options[:build_mode] == "cache"
295
372
  options[:app] ||= builder.find_cached_app!(options)
296
373
  builder.package!(options)
@@ -321,7 +398,7 @@ module Appship
321
398
  options[:configuration] ||= profile["configuration"] || "Debug"
322
399
  options[:assets] ||= profile["assets"]
323
400
  options[:app_icon] ||= profile["app_icon"] || "AppIcon"
324
- options[:badge] ||= profile["badge"]
401
+ Webhook::OPTION_FIELDS.each { |key| options[key] = profile[key.to_s] }
325
402
  if provider.to_s != "fir"
326
403
  options[:password_env] ||= profile["pgyer_password_env"]
327
404
  options[:password] ||= profile["pgyer_password"]
@@ -349,12 +426,16 @@ module Appship
349
426
  elsif File.exist?(project)
350
427
  options[:project] = project
351
428
  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
429
+ names = Dir["*.xcworkspace", "*.xcodeproj"].select { |path| File.directory?(path) }
430
+ .map { |path| File.basename(path, File.extname(path)) }.uniq
431
+ config_path = File.expand_path(options[:env_file] || Cache::DEFAULT_PATH)
432
+ hint = if names.empty?
433
+ "请先 cd 到包含 .xcodeproj/.xcworkspace 的项目目录,或使用 --project-dir 指定目录。"
434
+ else
435
+ "当前目录可配置的 project_name: #{names.join(", ")}(不含文件后缀)"
436
+ end
437
+ raise ConfigurationError, "找不到 #{workspace} 或 #{project}\n当前目录: #{Dir.pwd}\n" \
438
+ "请在 #{config_path} 的配置数组中增加当前项目配置,设置正确的 project_name。\n#{hint}"
358
439
  end
359
440
 
360
441
  if options[:assets].to_s.empty?
@@ -370,7 +451,7 @@ module Appship
370
451
  "自动编译打包 | 分支:#{branch} | 构建人:#{user} | 配置:#{options[:configuration]} | 更新内容:#{content}"
371
452
  end
372
453
 
373
- def prepare_pgyer_flow!(options, builder)
454
+ def prepare_pgyer_flow!(options, builder, profile)
374
455
  interactive = $stdin.tty? && $stdout.tty?
375
456
  unless options[:build_mode_explicit]
376
457
  options[:build_mode] = choose_build_mode if interactive
@@ -380,10 +461,8 @@ module Appship
380
461
  options[:configuration] = choose_configuration
381
462
  end
382
463
 
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
464
+ if options[:build_mode] != "cache"
465
+ apply_badge_config!(options, profile) { interactive && choose_badge }
387
466
  end
388
467
 
389
468
  unless options[:description_explicit] || !interactive
@@ -393,18 +472,13 @@ module Appship
393
472
  end
394
473
 
395
474
  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
475
+ puts ""
476
+ puts "📦 使用缓存模式"
477
+ options[:app] ||= with_spinner("正在查找编译缓存中的 .app 文件") do
478
+ builder.find_cached_app!(options)
407
479
  end
480
+ puts "✅ 找到缓存 App: #{options[:app]}"
481
+ apply_badge_config!(options, profile) { true }
408
482
  end
409
483
 
410
484
  options[:description] ||= default_description(options)
@@ -539,6 +613,22 @@ module Appship
539
613
  nil
540
614
  end
541
615
 
616
+ def apply_badge_config!(options, profile)
617
+ return if options[:badge_explicit]
618
+
619
+ %w[app_icon_badge app_icon_badge_environment].each do |key|
620
+ next if [nil, true, false].include?(profile[key])
621
+
622
+ raise ConfigurationError, "#{key} 必须是布尔值 true 或 false"
623
+ end
624
+ enabled = profile["app_icon_badge"]
625
+ environment = profile["app_icon_badge_environment"]
626
+ options[:badge] = enabled == false ? nil : profile["badge"]
627
+ return if enabled == false || environment == false || options[:badge]
628
+
629
+ options[:badge] = badge_text(options[:configuration]) if enabled || environment || (block_given? && yield)
630
+ end
631
+
542
632
  def badge_text(configuration)
543
633
  configuration.to_s.casecmp("release").zero? ? "RELEASE" : "DEBUG"
544
634
  end
@@ -562,7 +652,6 @@ module Appship
562
652
  export_method: config.get("export_method", default: "development"),
563
653
  signing_style: config.get("signing_style", default: "automatic"),
564
654
  team_id: config.get("team_id"),
565
- badge: config.get("badge"),
566
655
  assets: config.get("assets"),
567
656
  app_icon: config.get("app_icon", default: "AppIcon"),
568
657
  upload: false,
@@ -576,27 +665,28 @@ module Appship
576
665
 
577
666
  def upload_options(config)
578
667
  {
579
- provider: config.get("upload", "provider", default: "pgyer"),
580
- api_key: nil,
668
+ provider: config.get("upload", "provider", default: config.get("provider", default: "pgyer")),
669
+ api_key: config.get("pgyer_api_key"),
581
670
  api_key_env: config.get("upload", "api_key_env", default: "PGYER_API_KEY"),
582
- fir_api_token: nil,
671
+ fir_api_token: config.get("fir_api_key"),
583
672
  fir_api_token_env: config.get("upload", "fir_api_token_env", default: "FIR_API_TOKEN"),
584
- fir_password: config.get("upload", "fir_password"),
673
+ fir_password: config.get("upload", "fir_password", default: config.get("fir_password")),
585
674
  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"),
675
+ bundle_id: config.get("upload", "bundle_id", default: config.get("bundle_id")),
676
+ app_name: config.get("upload", "app_name", default: config.get("app_name")),
677
+ app_version: config.get("upload", "app_version", default: config.get("app_version")),
678
+ build_number: config.get("upload", "build_number", default: config.get("build_number")),
590
679
  release_type: config.get("upload", "release_type"),
591
680
  icon: config.get("upload", "icon"),
592
- install_type: config.get("upload", "install_type", default: 1),
593
- password: nil,
681
+ install_type: config.get("upload", "install_type", default: config.get("pgyer_install_type", default: config.get("pgyer_password", default: "").to_s.empty? ? 1 : 2)),
682
+ password: config.get("pgyer_password"),
594
683
  password_env: config.get("upload", "password_env"),
595
684
  description: config.get("upload", "description"),
596
685
  install_date: config.get("upload", "install_date"),
597
686
  start_date: config.get("upload", "start_date"),
598
687
  end_date: config.get("upload", "end_date"),
599
688
  channel: config.get("upload", "channel"),
689
+ **Webhook::OPTION_FIELDS.to_h { |key| [key, config.get("upload", key, default: config.get(key))] },
600
690
  poll_attempts: config.get("upload", "poll_attempts", default: 60),
601
691
  upload: false
602
692
  }
@@ -623,8 +713,8 @@ module Appship
623
713
  opts.on("--no-allow-provisioning-updates", "不允许 xcodebuild 自动更新签名") { options[:allow_provisioning_updates] = false }
624
714
  opts.separator ""
625
715
  opts.separator "图标角标"
626
- opts.on("--badge TEXT", "添加角标,例如 DEBUG 或 RELEASE") { |value| options[:badge] = value }
627
- opts.on("--no-badge", "禁用配置文件里的角标") { options[:badge] = nil }
716
+ opts.on("--badge TEXT", "添加角标,例如 DEBUG 或 RELEASE") { |value| options[:badge] = value; options[:badge_explicit] = true }
717
+ opts.on("--no-badge", "禁用配置文件里的角标") { options[:badge] = nil; options[:badge_explicit] = true }
628
718
  opts.on("--assets PATH", "Assets.xcassets 路径") { |value| options[:assets] = value }
629
719
  opts.on("--app-icon NAME", "AppIcon 名称(默认 AppIcon)") { |value| options[:app_icon] = value }
630
720
  opts.separator ""
@@ -638,8 +728,8 @@ module Appship
638
728
  opts.banner = "用法: appship [--config FILE] package [选项]"
639
729
  opts.on("--app PATH", "已编译 .app 路径") { |value| options[:app] = value }
640
730
  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 }
731
+ opts.on("--badge TEXT", "添加角标") { |value| options[:badge] = value; options[:badge_explicit] = true }
732
+ opts.on("--no-badge", "禁用角标") { options[:badge] = nil; options[:badge_explicit] = true }
643
733
  opts.on("--assets PATH", "Assets.xcassets 路径") { |value| options[:assets] = value }
644
734
  opts.on("--app-icon NAME", "AppIcon 名称") { |value| options[:app_icon] = value }
645
735
  add_upload_options(opts, options)
@@ -773,7 +863,10 @@ module Appship
773
863
  appship #{VERSION} - 通用移动应用构建、打包和分发工具
774
864
 
775
865
  用法:
776
- appship init [--path .appship.yml]
866
+ appship init [--project-dir PATH]
867
+ appship webhook wechat "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
868
+ appship webhook dingtalk "https://oapi.dingtalk.com/robot/send?access_token=xxx"
869
+ appship webhook feishu "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
777
870
  appship doctor
778
871
  appship pgyer
779
872
  appship fir
@@ -29,9 +29,11 @@ module Appship
29
29
 
30
30
  attr_reader :path, :data
31
31
 
32
- def initialize(path = nil)
32
+ def initialize(path = nil, data: nil)
33
33
  @path = path && File.expand_path(path)
34
- @data = if @path && File.file?(@path)
34
+ @data = if data
35
+ data
36
+ elsif @path && File.file?(@path)
35
37
  YAML.safe_load(File.read(@path), permitted_classes: [], aliases: false) || {}
36
38
  else
37
39
  {}
@@ -51,6 +53,20 @@ module Appship
51
53
  def self.find(path = nil)
52
54
  return new(path) if path
53
55
 
56
+ local_path = ProjectConfig.path
57
+ if File.exist?(local_path) || File.symlink?(local_path)
58
+ profile = ProjectConfig.load(ENV.fetch("APPSHIP_ENV_FILE", Cache::DEFAULT_PATH), interactive: false)
59
+ .resolve(interactive: false, credentials: false)
60
+ profile["scheme"] ||= profile["project_name"]
61
+ unless profile["workspace"] || profile["project"]
62
+ workspace = "#{profile["project_name"]}.xcworkspace"
63
+ project = "#{profile["project_name"]}.xcodeproj"
64
+ profile["workspace"] = workspace if File.directory?(workspace)
65
+ profile["project"] = project if !profile["workspace"] && File.directory?(project)
66
+ end
67
+ return new(local_path, data: profile)
68
+ end
69
+
54
70
  candidates = [
55
71
  File.join(Dir.pwd, ".appship.yml"),
56
72
  File.join(Dir.pwd, ".appship.yaml"),
@@ -0,0 +1,9 @@
1
+ 应用名称: %{name}
2
+ 应用类型: %{build_type_name}
3
+ 版本信息: %{version_info}
4
+ 构建模式: %{configuration}
5
+ 应用大小: %{build_size_mb}
6
+ 更新时间: %{build_updated}
7
+ 更新描述: %{description}
8
+ 安装密码: %{password}
9
+ 下载链接: %{url}
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appship
4
+ module Message
5
+ VARIABLES = %w[name build_type_name version_info configuration build_size_mb build_updated description password url].freeze
6
+ PLACEHOLDER = /%\{([^}]*)\}/
7
+ CONTENT_FIELDS = %w[
8
+ cli_content
9
+ wechat_webhook_success_content dingtalk_webhook_success_content feishu_webhook_success_content
10
+ wechat_webhook_content dingtalk_webhook_content feishu_webhook_content
11
+ ].freeze
12
+ FAILURE_FIELDS = %w[wechat_webhook_failure_content dingtalk_webhook_failure_content feishu_webhook_failure_content].freeze
13
+ FAILURE_VARIABLES = %w[name provider configuration error_type error_message].freeze
14
+ FORMATS = {
15
+ "wechat_webhook_format" => %w[text markdown].freeze,
16
+ "feishu_webhook_format" => %w[text post].freeze
17
+ }.freeze
18
+ INSTRUCTIONS = <<~TEXT.freeze
19
+ # Appship 发布消息模板
20
+ # 复制为 appship/message.yml 后编辑。用 | 开始多行文本,正文缩进两个空格。
21
+ # 成功文案留空则沿用 cli_content;失败文案留空则使用内置失败文案。
22
+ # 失败文案可用变量:name、provider、configuration、error_type、error_message。
23
+ # %{变量名} 会替换为实际数据。缺少可选数据时,会省略对应行。
24
+ #
25
+ # 可用变量:
26
+ # name : 应用名称
27
+ # build_type_name : 应用类型,例如 iOS、Android
28
+ # version_info : 版本信息,例如 1.0.0(42)
29
+ # configuration : 构建配置,例如 Debug、Release
30
+ # build_size_mb : 应用大小,例如 39.5MB
31
+ # build_updated : 更新时间
32
+ # description : 本次更新内容
33
+ # password : 安装密码或密码设置状态
34
+ # url : 应用下载链接
35
+ TEXT
36
+
37
+ module_function
38
+
39
+ def path(project_dir = Dir.pwd)
40
+ File.join(File.expand_path(project_dir), "appship", "message.yml")
41
+ end
42
+
43
+ def default_path
44
+ File.join(__dir__, "default_message")
45
+ end
46
+
47
+ def init!(project_dir: Dir.pwd)
48
+ content = INSTRUCTIONS + <<~YAML
49
+
50
+ # 自定义终端文案时,去掉下面整段每行前的 "# ",保留正文缩进。
51
+ # cli_content: |
52
+ # 🚀 %{name} 发布成功
53
+ # 版本:%{version_info}
54
+ # 更新内容:%{description}
55
+ # 下载地址:%{url}
56
+
57
+ # wechat_webhook_success_content: "" # 企微成功文案
58
+ # wechat_webhook_failure_content: "" # 企微失败文案
59
+
60
+ # dingtalk_webhook_success_content: "" # 钉钉成功文案
61
+ # dingtalk_webhook_failure_content: "" # 钉钉失败文案
62
+
63
+ # feishu_webhook_success_content: "" # 飞书成功文案
64
+ # feishu_webhook_failure_content: "" # 飞书失败文案
65
+
66
+ # 默认发送纯文本;成功通知可按平台切换消息格式。
67
+ # wechat_webhook_format: markdown # 企微:text 或 markdown
68
+ # feishu_webhook_format: post # 飞书:text 或 post(富文本)
69
+ YAML
70
+ selected = "#{path(project_dir)}.sample"
71
+ FileUtils.mkdir_p(File.dirname(selected), mode: 0o700)
72
+ File.open(selected, "wx", 0o600) { |file| file.write(content) }
73
+ selected
74
+ rescue Errno::EEXIST
75
+ regular_file!(selected)
76
+ selected
77
+ rescue SystemCallError => e
78
+ raise ConfigurationError, "消息模板初始化失败: #{e.message}"
79
+ end
80
+
81
+ def template
82
+ templates.fetch("cli_content")
83
+ end
84
+
85
+ def templates
86
+ selected = path
87
+ unless File.exist?(selected) || File.symlink?(selected)
88
+ legacy = File.join(File.dirname(selected), "message")
89
+ selected = File.exist?(legacy) || File.symlink?(legacy) ? legacy : default_path
90
+ regular_file!(selected)
91
+ return { "cli_content" => parse(File.read(selected, encoding: "UTF-8"), selected, legacy: true) }
92
+ end
93
+
94
+ data = load_yaml(selected)
95
+ unless (data.keys - CONTENT_FIELDS - FAILURE_FIELDS - FORMATS.keys).empty?
96
+ raise ConfigurationError, "消息模板包含未知字段: #{selected}"
97
+ end
98
+
99
+ data = data.slice(*CONTENT_FIELDS, *FORMATS.keys).reject do |key, value|
100
+ next true if value.nil?
101
+ raise ConfigurationError, "消息模板 #{key} 必须是字符串: #{selected}" unless value.is_a?(String)
102
+
103
+ value.strip.empty?
104
+ end
105
+ FORMATS.each do |key, formats|
106
+ next unless data.key?(key) && !formats.include?(data[key])
107
+
108
+ raise ConfigurationError, "#{key} 仅支持 #{formats.join(" 或 ")}: #{selected}"
109
+ end
110
+ data["cli_content"] ||= File.read(default_path, encoding: "UTF-8")
111
+ CONTENT_FIELDS.each do |key|
112
+ parse(data[key], "#{selected} (#{key})") if data.key?(key)
113
+ end
114
+ data
115
+ rescue SystemCallError => e
116
+ raise ConfigurationError, "消息模板读取失败: #{e.message}"
117
+ end
118
+
119
+ def failure_templates(project_dir: Dir.pwd)
120
+ selected = path(project_dir)
121
+ return {} unless File.exist?(selected) || File.symlink?(selected)
122
+
123
+ load_yaml(selected).slice(*FAILURE_FIELDS).each_with_object({}) do |(key, body), templates|
124
+ next if body.nil? || (body.is_a?(String) && body.strip.empty?)
125
+ raise ConfigurationError unless body.is_a?(String)
126
+
127
+ templates[key] = parse(body, selected, variables: FAILURE_VARIABLES)
128
+ rescue ConfigurationError
129
+ warn "⚠️ #{key} 无效,使用内置失败文案"
130
+ end
131
+ rescue ConfigurationError
132
+ warn "⚠️ 无法读取失败消息模板,使用内置失败文案"
133
+ {}
134
+ end
135
+
136
+ def load_yaml(selected)
137
+ regular_file!(selected)
138
+ content = File.read(selected, encoding: "UTF-8")
139
+ raise ConfigurationError, "消息模板必须使用 UTF-8 编码: #{selected}" unless content.valid_encoding?
140
+
141
+ data = YAML.safe_load(content, permitted_classes: [], aliases: false)
142
+ return {} if data.nil?
143
+ raise ConfigurationError, "消息模板必须是 YAML 对象: #{selected}" unless data.is_a?(Hash)
144
+
145
+ data
146
+ rescue Psych::Exception
147
+ raise ConfigurationError, "消息模板 YAML 格式无效: #{selected}"
148
+ rescue SystemCallError => e
149
+ raise ConfigurationError, "消息模板读取失败: #{e.message}"
150
+ end
151
+
152
+ def parse(content, source, legacy: false, variables: VARIABLES)
153
+ raise ConfigurationError, "消息模板必须使用 UTF-8 编码: #{source}" unless content.valid_encoding?
154
+
155
+ body = content.delete_prefix("\uFEFF")
156
+ if legacy && body.lstrip.start_with?("/*")
157
+ comment_end = body.index("*/")
158
+ raise ConfigurationError, "消息模板备注缺少 */: #{source}" unless comment_end
159
+
160
+ body = body[(comment_end + 2)..].sub(/\A[ \t]*\r?\n/, "")
161
+ end
162
+ raise ConfigurationError, "消息模板正文不能为空: #{source}" if body.strip.empty?
163
+
164
+ unknown = body.scan(PLACEHOLDER).flatten.uniq - variables
165
+ unless unknown.empty?
166
+ raise ConfigurationError, "消息模板包含未知变量 #{unknown.join(", ")}: #{source}\n可用变量: #{variables.join(", ")}"
167
+ end
168
+ if body.gsub(PLACEHOLDER, "").include?("%{")
169
+ raise ConfigurationError, "消息模板变量缺少右花括号 }: #{source}"
170
+ end
171
+ body
172
+ end
173
+
174
+ def render(values)
175
+ render_text(template, values)
176
+ end
177
+
178
+ def render_text(body, values)
179
+ body.lines.filter_map do |line|
180
+ keys = line.scan(PLACEHOLDER).flatten
181
+ next if keys.any? { |key| values.fetch(key.to_sym).nil? }
182
+
183
+ line.gsub(PLACEHOLDER) { values.fetch(Regexp.last_match(1).to_sym).to_s }
184
+ end.join
185
+ end
186
+
187
+ def regular_file!(file)
188
+ unless File.file?(file) && !File.symlink?(file)
189
+ raise ConfigurationError, "消息模板必须是普通文件: #{file}"
190
+ end
191
+ end
192
+ end
193
+ end