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,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