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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +94 -0
- data/exe/appship +6 -0
- data/lib/appship/badger.rb +175 -0
- data/lib/appship/builder.rb +295 -0
- data/lib/appship/cache.rb +207 -0
- data/lib/appship/cli.rb +800 -0
- data/lib/appship/config.rb +75 -0
- data/lib/appship/errors.rb +8 -0
- data/lib/appship/ipa.rb +51 -0
- data/lib/appship/runner.rb +55 -0
- data/lib/appship/uploader.rb +490 -0
- data/lib/appship/version.rb +5 -0
- data/lib/appship.rb +25 -0
- data/test/test_cache.rb +146 -0
- data/test/test_cli.rb +22 -0
- data/test/test_config.rb +22 -0
- data/test/test_fir_uploader.rb +93 -0
- data/test/test_multipart_body.rb +33 -0
- metadata +61 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Appship
|
|
4
|
+
class Cache
|
|
5
|
+
DEFAULT_PATH = File.expand_path("~/.appship/.config")
|
|
6
|
+
DEFAULT_BUILD_DIR = File.expand_path("~/.appship/build")
|
|
7
|
+
LEGACY_PATH = File.expand_path("~/.appship/.env")
|
|
8
|
+
|
|
9
|
+
attr_reader :path, :entries
|
|
10
|
+
|
|
11
|
+
def self.load(path = DEFAULT_PATH, project_dir: Dir.pwd, interactive: true, provider: "pgyer")
|
|
12
|
+
expanded = File.expand_path(path)
|
|
13
|
+
# 兼容早期版本的 ~/.appship/.env;发现旧缓存时迁移到新的 .config。
|
|
14
|
+
if expanded == DEFAULT_PATH && !File.file?(expanded) && File.file?(LEGACY_PATH)
|
|
15
|
+
FileUtils.mkdir_p(File.dirname(expanded))
|
|
16
|
+
FileUtils.cp(LEGACY_PATH, expanded)
|
|
17
|
+
File.chmod(0o600, expanded)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
unless File.file?(expanded)
|
|
21
|
+
create_file!(expanded)
|
|
22
|
+
return collect_interactively!(expanded, project_dir: project_dir, provider: provider) if interactive && $stdin.tty?
|
|
23
|
+
|
|
24
|
+
raise ConfigurationError, missing_credentials_message(expanded, provider, created: true)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if File.read(expanded).strip.empty?
|
|
28
|
+
return collect_interactively!(expanded, project_dir: project_dir, provider: provider) if interactive && $stdin.tty?
|
|
29
|
+
|
|
30
|
+
raise ConfigurationError, missing_credentials_message(expanded, provider, empty: true)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
new(expanded)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.create_file!(path)
|
|
37
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
38
|
+
File.chmod(0o700, File.dirname(path)) if File.directory?(File.dirname(path))
|
|
39
|
+
File.write(path, "[]\n") unless File.exist?(path)
|
|
40
|
+
File.chmod(0o600, path)
|
|
41
|
+
path
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.collect_interactively!(path, project_dir: Dir.pwd, provider: "pgyer")
|
|
45
|
+
project_name = infer_project_name!(project_dir)
|
|
46
|
+
provider_name = provider.to_s == "fir" ? "fir.im" : "pgyer"
|
|
47
|
+
credential_key = credential_key_for(provider)
|
|
48
|
+
puts ""
|
|
49
|
+
puts "首次使用 appship #{provider_name},请填写本机项目缓存。"
|
|
50
|
+
puts "缓存文件: #{path}"
|
|
51
|
+
puts ""
|
|
52
|
+
puts "✅ 已从 #{File.basename(project_name)}.xcodeproj 识别 project_name: #{project_name}"
|
|
53
|
+
app_name = prompt_required("app_name")
|
|
54
|
+
data = {
|
|
55
|
+
"project_name" => project_name,
|
|
56
|
+
"app_name" => app_name,
|
|
57
|
+
credential_key => prompt_required(credential_key)
|
|
58
|
+
}
|
|
59
|
+
password_key = provider.to_s == "fir" ? "fir_password" : "pgyer_password"
|
|
60
|
+
data[password_key] = prompt_optional(password_key)
|
|
61
|
+
File.write(path, JSON.pretty_generate([data]) + "\n")
|
|
62
|
+
File.chmod(0o600, path)
|
|
63
|
+
puts ""
|
|
64
|
+
puts "✅ 本机缓存已保存: #{path}"
|
|
65
|
+
new(path)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.infer_project_name!(project_dir)
|
|
69
|
+
xcodeprojects = Dir[File.join(File.expand_path(project_dir), "*.xcodeproj")]
|
|
70
|
+
if xcodeprojects.empty?
|
|
71
|
+
raise ConfigurationError, "当前目录找不到 .xcodeproj,无法自动设置 project_name: #{File.expand_path(project_dir)}"
|
|
72
|
+
end
|
|
73
|
+
if xcodeprojects.length > 1
|
|
74
|
+
names = xcodeprojects.map { |path| File.basename(path, ".xcodeproj") }.join(", ")
|
|
75
|
+
raise ConfigurationError, "当前目录找到多个 .xcodeproj,无法确定 project_name: #{names}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
File.basename(xcodeprojects.first, ".xcodeproj")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.prompt_required(name)
|
|
82
|
+
print "请输入 #{name}: "
|
|
83
|
+
value = $stdin.gets&.strip
|
|
84
|
+
raise ConfigurationError, "#{name} 不能为空" if value.to_s.empty?
|
|
85
|
+
|
|
86
|
+
value
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.prompt_optional(name)
|
|
90
|
+
print "请输入 #{name}(可为空,直接回车表示不设置密码): "
|
|
91
|
+
$stdin.gets.to_s.strip
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def initialize(path = DEFAULT_PATH)
|
|
95
|
+
@path = File.expand_path(path)
|
|
96
|
+
raise ConfigurationError, "本机缓存文件不存在: #{@path}\n请使用 --env-file 指定路径" unless File.file?(@path)
|
|
97
|
+
|
|
98
|
+
@entries = parse(File.read(@path))
|
|
99
|
+
raise ConfigurationError, "缓存文件必须是字典数组: #{@path}" unless @entries.is_a?(Array)
|
|
100
|
+
@entries = @entries.map { |entry| normalize(entry) }
|
|
101
|
+
raise ConfigurationError, "缓存文件中没有有效项目: #{@path}" if @entries.empty?
|
|
102
|
+
rescue Psych::SyntaxError => e
|
|
103
|
+
raise ConfigurationError, "缓存文件解析失败: #{e.message}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def resolve(project_name: nil, project_dir: Dir.pwd, provider: "pgyer", interactive: true, credential_overrides: {})
|
|
107
|
+
candidate_name = project_name.to_s.strip
|
|
108
|
+
if candidate_name.empty? && @entries.length > 1
|
|
109
|
+
directory_name = File.basename(File.expand_path(project_dir))
|
|
110
|
+
candidate_name = directory_name if @entries.any? { |entry| entry["project_name"] == directory_name }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
matches = if candidate_name.empty?
|
|
114
|
+
@entries
|
|
115
|
+
else
|
|
116
|
+
@entries.select { |entry| entry["project_name"] == candidate_name }
|
|
117
|
+
end
|
|
118
|
+
if matches.length == 1
|
|
119
|
+
update_profile!(matches.first, credential_overrides) unless credential_overrides.empty?
|
|
120
|
+
ensure_provider_credential!(matches.first, provider, interactive: interactive)
|
|
121
|
+
return matches.first
|
|
122
|
+
end
|
|
123
|
+
if matches.empty?
|
|
124
|
+
available = @entries.filter_map { |entry| entry["project_name"] }.join(", ")
|
|
125
|
+
raise ConfigurationError, "找不到项目缓存 #{candidate_name.inspect},可用项目: #{available}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
names = matches.filter_map { |entry| entry["project_name"] }.join(", ")
|
|
129
|
+
raise ConfigurationError, "本机缓存包含多个项目,请使用 --project-name 指定: #{names}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def update_profile!(entry, values)
|
|
133
|
+
values.each { |key, value| entry[key.to_s] = value unless value.nil? }
|
|
134
|
+
persist!
|
|
135
|
+
entry
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def self.credential_key_for(provider)
|
|
141
|
+
provider.to_s == "fir" ? "fir_api_key" : "pgyer_api_key"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def self.missing_credentials_message(path, provider, created: false, empty: false)
|
|
145
|
+
prefix = if created
|
|
146
|
+
"已创建本机缓存文件: #{path}"
|
|
147
|
+
elsif empty
|
|
148
|
+
"本机缓存文件为空: #{path}"
|
|
149
|
+
else
|
|
150
|
+
"本机缓存文件无效: #{path}"
|
|
151
|
+
end
|
|
152
|
+
key = credential_key_for(provider)
|
|
153
|
+
password_key = key == "pgyer_api_key" ? "pgyer_password" : "fir_password"
|
|
154
|
+
suffix = ";#{password_key} 可为空"
|
|
155
|
+
"#{prefix}\n请填写 project_name、app_name、#{key}#{suffix}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def parse(content)
|
|
159
|
+
JSON.parse(content)
|
|
160
|
+
rescue JSON::ParserError
|
|
161
|
+
YAML.safe_load(content, permitted_classes: [], aliases: false)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def normalize(entry)
|
|
165
|
+
raise ConfigurationError, "缓存数组中的每一项必须是字典" unless entry.is_a?(Hash)
|
|
166
|
+
|
|
167
|
+
entry.transform_keys(&:to_s)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def ensure_provider_credential!(entry, provider, interactive:)
|
|
171
|
+
key = self.class.send(:credential_key_for, provider)
|
|
172
|
+
if key == "fir_api_key" && entry[key].to_s.empty? && !entry["fir_api_token"].to_s.empty?
|
|
173
|
+
entry[key] = entry["fir_api_token"]
|
|
174
|
+
persist!
|
|
175
|
+
end
|
|
176
|
+
unless entry[key].to_s.empty?
|
|
177
|
+
ensure_provider_password!(entry, provider, interactive: interactive)
|
|
178
|
+
return entry
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
unless interactive && $stdin.tty?
|
|
182
|
+
raise ConfigurationError, "缓存缺少 #{key}: #{@path}\n请填写 #{key} 后重试"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
entry[key] = self.class.prompt_required(key)
|
|
186
|
+
persist!
|
|
187
|
+
puts "✅ 已补充 #{key}: #{@path}"
|
|
188
|
+
ensure_provider_password!(entry, provider, interactive: interactive)
|
|
189
|
+
entry
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def ensure_provider_password!(entry, provider, interactive:)
|
|
193
|
+
password_key = provider.to_s == "fir" ? "fir_password" : "pgyer_password"
|
|
194
|
+
return if entry.key?(password_key)
|
|
195
|
+
return unless interactive && $stdin.tty?
|
|
196
|
+
|
|
197
|
+
entry[password_key] = self.class.prompt_optional(password_key)
|
|
198
|
+
persist!
|
|
199
|
+
puts "✅ 已补充 #{password_key}: #{@path}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def persist!
|
|
203
|
+
File.write(@path, JSON.pretty_generate(@entries) + "\n")
|
|
204
|
+
File.chmod(0o600, @path)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|