laneyard 0.3.0 → 0.4.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 (44) hide show
  1. package/README.md +186 -18
  2. package/dist/src/cli/detect.js +12 -3
  3. package/dist/src/cli/prompt.js +28 -3
  4. package/dist/src/cli/secret-import.js +162 -0
  5. package/dist/src/cli/secret.js +162 -1
  6. package/dist/src/cli/setup.js +44 -7
  7. package/dist/src/credentials/kinds.js +120 -0
  8. package/dist/src/db/credentials.js +75 -0
  9. package/dist/src/db/schema.sql +39 -0
  10. package/dist/src/db/secrets.js +28 -0
  11. package/dist/src/db/sessions.js +61 -0
  12. package/dist/src/git/workspace.js +32 -6
  13. package/dist/src/heuristics/android-root.js +49 -0
  14. package/dist/src/heuristics/android-signing.js +98 -0
  15. package/dist/src/heuristics/appfile.js +60 -0
  16. package/dist/src/heuristics/blocking-actions.js +2 -0
  17. package/dist/src/heuristics/env-example.js +36 -0
  18. package/dist/src/heuristics/platforms.js +69 -10
  19. package/dist/src/heuristics/readiness.js +554 -25
  20. package/dist/src/main.js +10 -1
  21. package/dist/src/runner/gradle-properties.js +187 -0
  22. package/dist/src/runner/materialise.js +92 -0
  23. package/dist/src/runner/orchestrate.js +91 -2
  24. package/dist/src/secrets/vault.js +106 -5
  25. package/dist/src/server/app.js +30 -3
  26. package/dist/src/server/auth.js +50 -10
  27. package/dist/src/server/permissions.js +2 -0
  28. package/dist/src/server/required-secrets.js +30 -0
  29. package/dist/src/server/routes/account.js +57 -0
  30. package/dist/src/server/routes/credentials.js +108 -0
  31. package/dist/src/server/routes/readiness.js +162 -6
  32. package/dist/src/server/routes/secrets.js +101 -0
  33. package/dist/src/sidecar/bridge.js +21 -1
  34. package/dist/src/sidecar/fastlane-dir.js +23 -0
  35. package/dist/src/sidecar/lanes.js +6 -0
  36. package/dist/src/sidecar/uses.js +21 -5
  37. package/dist/web/assets/{Editor-DNFBA4gv.js → Editor-DynuBC2l.js} +1 -1
  38. package/dist/web/assets/index-CpwrNE-K.css +1 -0
  39. package/dist/web/assets/index-lyZs-Y7J.js +62 -0
  40. package/dist/web/index.html +2 -2
  41. package/package.json +1 -1
  42. package/ruby/introspect.rb +122 -9
  43. package/dist/web/assets/index-De6sxx6G.css +0 -1
  44. package/dist/web/assets/index-TWRQ1cJg.js +0 -62
@@ -4,8 +4,8 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>laneyard</title>
7
- <script type="module" crossorigin src="/assets/index-TWRQ1cJg.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-De6sxx6G.css">
7
+ <script type="module" crossorigin src="/assets/index-lyZs-Y7J.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-CpwrNE-K.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "laneyard",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "laneyard": "dist/src/main.js"
@@ -100,31 +100,144 @@ def literal_args(call)
100
100
  end
101
101
  end
102
102
 
103
+ # Every keyword this call was given, whether or not its value could be read.
104
+ #
105
+ # `args` deliberately holds literals only — a checklist that guessed at
106
+ # `ENV["X"]` would be believed. But dropping the whole entry loses something the
107
+ # literal value was never needed for: that the argument *was passed at all*.
108
+ #
109
+ # The two are not the same question, and treating them as one produced a
110
+ # visible inconsistency. A lane calling `app_store_connect_api_key(...)` was
111
+ # recognised by the action's name and reported as "could not tell", while the
112
+ # very same lane calling `upload_to_play_store(json_key: ENV.fetch("..."))` left
113
+ # no trace of `json_key` and was reported as having no credential at all — a
114
+ # warning and a shrug, for one situation.
115
+ def arg_names(call)
116
+ hash = (call.arguments&.arguments || []).find { |a| a.is_a?(Prism::KeywordHashNode) }
117
+ return [] unless hash
118
+
119
+ hash.elements.filter_map do |el|
120
+ el.key.unescaped if el.is_a?(Prism::AssocNode) && el.key.is_a?(Prism::SymbolNode)
121
+ end
122
+ end
123
+
124
+ # How far a chain of helper methods is followed, and how the same method being
125
+ # called twice is kept from being walked twice. Eight is far past anything a
126
+ # real Fastfile does; the limit is there so that a pathological file costs a
127
+ # bounded walk rather than the checklist.
128
+ MAX_CALL_DEPTH = 8
129
+
130
+ # Every method this Fastfile defines, indexed by the name a call would use.
131
+ #
132
+ # `def deploy_ios` under "deploy_ios", and `def self.ship` inside `module
133
+ # Helpers` under "Helpers.ship". Both exist because both are how a real Fastfile
134
+ # is factored, and a Fastfile that factors its lanes is a well-written one — not
135
+ # an edge case. Reading only the lane bodies meant a project whose every action
136
+ # sat one method call away looked like a project that called no actions at all,
137
+ # and the Play Store check answered "no lane uploads to the Play Store" for a
138
+ # project that uploads to the Play Store on every run. A confident tick is worse
139
+ # than a warning: it is the one nobody checks.
140
+ def collect_defs(node, scope = nil, out = {})
141
+ node.compact_child_nodes.each do |child|
142
+ case child
143
+ when Prism::ModuleNode, Prism::ClassNode
144
+ # Its own scope, and nothing outside it needs to be walked again.
145
+ collect_defs(child, child.constant_path.slice, out)
146
+ next
147
+ when Prism::DefNode
148
+ key = child.receiver.nil? ? child.name.to_s : (scope ? "#{scope}.#{child.name}" : nil)
149
+ out[key] = child.body if key
150
+ end
151
+ collect_defs(child, scope, out)
152
+ end
153
+ out
154
+ end
155
+
156
+ # The name a call would be indexed under, or nil when it cannot be one: a
157
+ # receiver that is itself computed (`thing.ship`) is not something a file can
158
+ # resolve, and guessing would be worse than the silence.
159
+ def resolvable_name(call)
160
+ return call.name.to_s if call.receiver.nil?
161
+ call.receiver.is_a?(Prism::ConstantReadNode) ? "#{call.receiver.slice}.#{call.name}" : nil
162
+ end
163
+
103
164
  # Every call inside a lane's block, however deeply nested: a call inside an `if`
104
165
  # is still a call the lane may make, and the checklist cares about what could
105
166
  # happen, not only about what always happens.
106
- def calls_within(node, out = [])
167
+ #
168
+ # A call to a method this Fastfile defines is followed into that method, so the
169
+ # actions it makes are the lane's actions too — which is what they are. `seen`
170
+ # is the cycle guard: two helpers that call each other must cost one walk, not
171
+ # a stack overflow inside a checklist that promised never to throw.
172
+ # The name of the environment variable this call reads, or nil.
173
+ #
174
+ # `ENV.fetch("X")`, `ENV["X"]` and `ENV.fetch("X") { … }` are the forms that
175
+ # occur. A computed name — `ENV[some_var]` — is not a question this can answer,
176
+ # and is left out rather than guessed at.
177
+ def env_read(call)
178
+ return nil unless call.receiver.is_a?(Prism::ConstantReadNode)
179
+ return nil unless call.receiver.slice == "ENV"
180
+ return nil unless %w[fetch []].include?(call.name.to_s)
181
+
182
+ first = call.arguments&.arguments&.first
183
+ first.is_a?(Prism::StringNode) ? first.unescaped : nil
184
+ end
185
+
186
+ def calls_within(node, defs, seen = [], out = [], env = [])
187
+ return out if node.nil?
188
+
107
189
  node.compact_child_nodes.each do |child|
108
- if child.is_a?(Prism::CallNode) && child.receiver.nil?
109
- out << { name: child.name.to_s, args: literal_args(child) }
190
+ if child.is_a?(Prism::CallNode)
191
+ # Only receiverless calls are reported: `UI.message` and `File.join` are
192
+ # Ruby, not fastlane, and a checklist reading them would be reading noise.
193
+ if child.receiver.nil?
194
+ out << { name: child.name.to_s, args: literal_args(child), given: arg_names(child) }
195
+ end
196
+
197
+ # The exception to the receiverless rule above, because it is not noise: a
198
+ # variable the lane reads is a variable the run needs, and a run that meets
199
+ # an absent one either stops or builds the wrong thing. It is the only
200
+ # thing a Fastfile says about what the project requires from outside it.
201
+ name = env_read(child)
202
+ env << name if name
203
+
204
+ key = resolvable_name(child)
205
+ if key && defs.key?(key) && !seen.include?(key) && seen.length < MAX_CALL_DEPTH
206
+ calls_within(defs[key], defs, seen + [key], out, env)
207
+ end
110
208
  end
111
- calls_within(child, out)
209
+ calls_within(child, defs, seen, out, env)
112
210
  end
113
211
  out
114
212
  end
115
213
 
214
+ # Does this Fastfile pull in lanes from somewhere else?
215
+ #
216
+ # `import` and `import_from_git` bring in another file's lanes, and no amount of
217
+ # reading this one will say what they call. Reported rather than resolved: the
218
+ # checklist turns it into "could not tell" instead of a tick, which is the whole
219
+ # difference between a checklist that is quiet and one that is wrong.
220
+ def imports_elsewhere?(source)
221
+ source.lines.any? { |line| line.match?(/^\s*import(_from_git)?[\s(]/) }
222
+ end
223
+
116
224
  def collect_uses(fastfile_path)
117
- result = Prism.parse(File.read(fastfile_path))
225
+ source = File.read(fastfile_path)
226
+ result = Prism.parse(source)
118
227
  raise "Fastfile could not be parsed" unless result.success?
119
228
 
229
+ defs = collect_defs(result.value)
120
230
  lanes = []
121
231
  walk = lambda do |node|
122
232
  node.compact_child_nodes.each do |child|
123
233
  if child.is_a?(Prism::CallNode) && %w[lane private_lane].include?(child.name.to_s)
124
234
  name = child.arguments&.arguments&.first
235
+ env = []
236
+ actions = child.block ? calls_within(child.block, defs, [], [], env) : []
125
237
  lanes << {
126
238
  lane: name.is_a?(Prism::SymbolNode) ? name.unescaped : "?",
127
- actions: child.block ? calls_within(child.block) : []
239
+ actions: actions,
240
+ env: env.uniq.sort
128
241
  }
129
242
  else
130
243
  # Not a lane: keep descending, so a `platform :ios do` block is seen through.
@@ -133,7 +246,7 @@ def collect_uses(fastfile_path)
133
246
  end
134
247
  end
135
248
  walk.call(result.value)
136
- lanes
249
+ { lanes: lanes, imports: imports_elsewhere?(source) }
137
250
  end
138
251
 
139
252
  case command
@@ -155,13 +268,13 @@ when "lanes"
155
268
  respond({ ok: true, lanes: lanes })
156
269
  when "uses"
157
270
  begin
158
- lanes = collect_uses(fastfile_path)
271
+ used = collect_uses(fastfile_path)
159
272
  rescue Exception => e # rubocop:disable Lint/RescueException
160
273
  # Same shape as "lanes" above, and the same reason: parsing can raise
161
274
  # anything, and `respond`'s `exit 0` must not be caught by this rescue.
162
275
  fail_with("Could not parse the Fastfile: #{e.message}")
163
276
  end
164
- respond({ ok: true, lanes: lanes })
277
+ respond({ ok: true, lanes: used[:lanes], imports: used[:imports] })
165
278
  else
166
279
  fail_with("Unknown command: #{command.inspect}")
167
280
  end
@@ -1 +0,0 @@
1
- :root{--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;--bg: #14161a;--bg-raised: #1b1e24;--bg-inset: #171a1f;--border: #262a31;--text: #c8ccd4;--text-dim: #676e7b;--text-bright: #e6e9ee;--accent: #7ee787;--ok: #7ee787;--running: #e3b341;--error: #f8746a;--info: #79c0ff;--term-bg: #0e1013;--term-text: #c9d1d9}:root[data-theme=light]{--bg: #f2efe6;--bg-raised: #e7e2d5;--bg-inset: #ebe7dc;--border: #d9d3c4;--text: #2f2b25;--text-dim: #8b8375;--text-bright: #14110c;--accent: #3f7d3f;--ok: #3f7d3f;--running: #a76c14;--error: #b03a34;--info: #2f6f9e}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:var(--font-mono);font-size:13px;line-height:1.6}.panel{background:var(--bg-raised);border:1px solid var(--border)}.status-success{color:var(--ok)}.status-running{color:var(--running)}.status-failed,.status-interrupted{color:var(--error)}.status-queued,.status-preparing,.dim{color:var(--text-dim)}.bright{color:var(--text-bright)}.accent{color:var(--accent)}.status-cancelled{color:var(--text-dim)}a{color:inherit;text-decoration:none}a:hover{color:var(--text-bright)}h1,h2,h3{font-size:13px;font-weight:400;margin:0}.section{color:var(--text-dim);text-transform:uppercase;letter-spacing:.14em;font-size:11px}button,input,select{font:inherit;color:var(--text);background:var(--bg-inset);border:1px solid var(--border);border-radius:0;padding:2px 8px}button{cursor:pointer}button:hover:not(:disabled){color:var(--text-bright);border-color:var(--text-dim)}button:disabled,input:disabled{color:var(--text-dim);cursor:default}input:focus,button:focus-visible{outline:1px solid var(--accent);outline-offset:-1px}ul{list-style:none;margin:0;padding:0}code{color:var(--text-bright)}.shell{display:grid;grid-template-columns:240px minmax(0,1fr);grid-template-rows:32px 1fr;height:100vh}.shell>header{grid-column:1 / -1;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 12px;border-bottom:1px solid var(--border);background:var(--bg-raised)}.brand{color:var(--accent);letter-spacing:.12em}.who{display:flex;align-items:baseline;gap:8px}.shell>nav{border-right:1px solid var(--border);background:var(--bg-raised);overflow-y:auto;padding:12px 0}.shell>main{min-width:0;overflow:auto;padding:16px}.nav-head{padding:0 12px 6px}.nav-item{display:block;padding:2px 12px;border-left:2px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.nav-item:hover{background:var(--bg-inset)}.nav-item.current{border-left-color:var(--accent);background:var(--bg-inset);color:var(--text-bright)}.login{height:100vh;display:flex;align-items:center;justify-content:center}.login form{width:320px;padding:20px;display:flex;flex-direction:column;gap:12px}.login input{width:100%;padding:4px 8px}.rows>li{display:flex;align-items:baseline;gap:8px;padding:4px 0;border-bottom:1px solid var(--border)}.rows>li:last-child{border-bottom:none}.grow{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mark{width:1ch;display:inline-block}.tabs{display:flex;gap:16px;border-bottom:1px solid var(--border);margin-bottom:12px}.tabs a{color:var(--text-dim);text-transform:uppercase;letter-spacing:.14em;font-size:11px;padding-bottom:4px;border-bottom:2px solid transparent}.tabs a:hover{color:var(--text)}.tabs a.current{color:var(--text-bright);border-bottom-color:var(--accent)}.secret-form{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;margin:8px 0 4px}.secret-form input[type=text],.secret-form input[type=password]{width:24ch}.secret-form label{display:flex;align-items:baseline;gap:6px;color:var(--text-dim)}.secret-form input[type=checkbox]{accent-color:var(--accent);width:auto;padding:0}.file-chosen{display:flex;align-items:baseline;gap:6px}.file-pick{cursor:pointer}.file-pick input[type=file]{display:none}.file-pick:hover .accent{color:var(--text-bright)}table{border-collapse:collapse;width:100%}td,th{text-align:left;font-weight:400;padding:2px 12px 2px 0}.checks .grow,.consequences .grow{white-space:normal;overflow:visible}.consequences .path{color:var(--text-bright);word-break:break-all}.confirm-form{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;margin:8px 0 4px}.confirm-form input{width:24ch}.confirm-form button:not(:disabled){color:var(--error);border-color:var(--error)}.editor-bar,.commit-bar{display:flex;align-items:baseline;gap:8px;margin:8px 0 4px}.commit-bar input{flex:1;max-width:48ch}.editor{height:calc(100vh - 420px);min-height:240px;overflow:hidden}.save-error{white-space:normal;margin:8px 0}.diff{margin:8px 0 0;padding:8px 12px;max-height:40vh;overflow:auto;background:var(--term-bg);color:var(--term-text);white-space:pre;font:inherit}.run-head{display:flex;align-items:baseline;flex-wrap:wrap;gap:6px 20px;padding:8px 12px;margin-bottom:12px}.run-body{display:grid;grid-template-columns:260px minmax(0,1fr);gap:12px;align-items:start;min-width:0}.steps>li{display:flex;gap:8px;padding:2px 8px}.steps>li.clickable{cursor:pointer}.steps>li.clickable:hover{background:var(--bg-inset);color:var(--text-bright)}.steps>li.inert{color:var(--text-dim)}.pane-title{padding:6px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;gap:12px}.terminal{background:var(--term-bg);color:var(--term-text);display:flex;flex-direction:column;height:calc(100vh - 190px);min-height:260px}.terminal pre{margin:0;flex:1;overflow:auto;padding:8px 12px;white-space:pre;font:inherit}.terminal .a-black{color:#6e7681}.terminal .a-red{color:#ff7b72}.terminal .a-green{color:#7ee787}.terminal .a-yellow{color:#d29922}.terminal .a-blue{color:#79c0ff}.terminal .a-magenta{color:#d2a8ff}.terminal .a-cyan{color:#56d4dd}.terminal .a-white{color:#b1bac4}.terminal .a-bright-black{color:#8b949e}.terminal .a-bright-red{color:#ffa198}.terminal .a-bright-green{color:#56d364}.terminal .a-bright-yellow{color:#e3b341}.terminal .a-bright-blue{color:#a5d6ff}.terminal .a-bright-magenta{color:#e2c5ff}.terminal .a-bright-cyan{color:#b3f0ff}.terminal .a-bright-white{color:#f0f6fc}.terminal .a-bold{font-weight:700}.terminal .a-dim{opacity:.7}.terminal-input{display:flex;align-items:center;gap:8px;padding:4px 12px;border-top:1px solid var(--border)}.terminal-input input{flex:1;background:transparent;border:none;color:var(--term-text);padding:0}.terminal-input input:disabled{color:#4c525c}.terminal-input .prompt{color:var(--accent)}.terminal-input .reason{color:#676e7b}@media (max-width: 820px){.shell{grid-template-columns:1fr;grid-template-rows:32px auto 1fr;height:auto;min-height:100vh}.shell>nav{border-right:none;border-bottom:1px solid var(--border);display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;padding:6px 12px}.nav-head{padding:0;width:100%}.nav-item{border-left:none;border-bottom:2px solid transparent;padding:0 2px}.nav-item.current{border-left-color:transparent;border-bottom-color:var(--accent)}.run-body{grid-template-columns:1fr}.terminal{height:60vh}}