laneyard 0.6.0 → 0.6.1

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.
package/README.md CHANGED
@@ -120,6 +120,12 @@ Three kinds, by how sure the reading is:
120
120
  `api_key` or `url`. **Defaults to no**, value masked: it is the one kind where a false positive is
121
121
  likely, and patching a non-secret by default is a silent regression.
122
122
 
123
+ The credential args of `app_store_connect_api_key` and Play — `key_id`, `issuer_id`, `key_filepath`,
124
+ `json_key` — are also normalised when they already read a variable: `issuer_id: ENV["ASC_ISSUER_ID"]`
125
+ becomes `ENV.fetch("APP_STORE_CONNECT_API_KEY_ISSUER_ID")`, the name a signing block exports. Store
126
+ the `.p8`/JSON once as a block and the old `ASC_*` secrets can go. A value already reading the right
127
+ name is left alone.
128
+
123
129
  The vault is written before the Fastfile: if lifting fails, nothing has been patched to read a
124
130
  missing variable. The patch is spliced by byte offset — everything outside the replaced range is
125
131
  byte-identical — and the file is re-parsed before the command returns; if it no longer parses, the
@@ -29,15 +29,16 @@ export async function runAdoption(options) {
29
29
  process.stdout.write("\n" + dim("Fastfile not analysed — no Ruby with Prism available. Nothing else changes.\n"));
30
30
  return { applied: 0 };
31
31
  }
32
- // A literal pointing at nothing is dropped rather than reported: there is no
33
- // file to lift into the vault, and patching to a variable nothing supplies
34
- // would trade one broken build for another.
35
- // Resolved once, here, so the prompt, the vault write and the git check all
36
- // speak about the same file rather than each resolving the path again.
32
+ // A literal path is looked up on disk so its bytes can go in the vault; one
33
+ // pointing at nothing is dropped, since patching to a variable nothing supplies
34
+ // would trade one broken build for another. An `env` value names no file — it
35
+ // is already a variable, only its *name* changes so it is kept without a
36
+ // lookup, a rewrite with nothing to store. Resolved once, here, so the prompt,
37
+ // the vault write and the git check all speak about the same file.
37
38
  const found = new Map();
38
39
  const proposals = [];
39
40
  for (const proposal of proposalsFor(literals)) {
40
- if (proposal.tier === "file") {
41
+ if (proposal.tier === "file" && proposal.literal.kind === "literal") {
41
42
  const hit = await readCredential(cwd, fastlaneDir, proposal);
42
43
  if (hit === null)
43
44
  continue;
@@ -51,7 +52,13 @@ export async function runAdoption(options) {
51
52
  const accepted = [];
52
53
  for (const proposal of proposals) {
53
54
  process.stdout.write(describe(fastlaneDir, proposal) + "\n");
54
- if (!(await asker.confirm(` Store it here and use ${bold(proposal.varName)}?`, proposal.checked))) {
55
+ // A rewrite-only proposal stores nothing — it renames a variable your lane
56
+ // already reads — so the question is not "store it here".
57
+ const rewriteOnly = proposal.tier === "file" && !found.has(proposal);
58
+ const question = rewriteOnly
59
+ ? ` Rewrite it to ${bold(proposal.varName)}?`
60
+ : ` Store it here and use ${bold(proposal.varName)}?`;
61
+ if (!(await asker.confirm(question, proposal.checked))) {
55
62
  continue;
56
63
  }
57
64
  accepted.push(proposal.tier === "secret" ? await named(asker, proposal) : proposal);
@@ -117,16 +124,20 @@ function describe(fastlaneDir, proposal) {
117
124
  // or a password, and setup's output is pasted into bug reports and kept in CI
118
125
  // transcripts — printing it there would be this feature leaking the very
119
126
  // thing it exists to put away. The file and line above say where to look.
120
- const shown = proposal.tier === "file"
121
- ? `"${literal.value}"`
122
- : proposal.tier === "inline"
123
- ? dim("(a key, inline in the file)")
124
- : dim("(a literal value, masked)");
125
- const why = proposal.tier === "inline"
126
- ? "This key is in your repository in cleartext."
127
+ const shown = literal.kind === "env"
128
+ ? `ENV["${literal.value}"]`
127
129
  : proposal.tier === "file"
128
- ? "That path does not survive the clone: Laneyard builds from your remote."
129
- : "A literal secret in a build file is a secret in your history.";
130
+ ? `"${literal.value}"`
131
+ : proposal.tier === "inline"
132
+ ? dim("(a key, inline in the file)")
133
+ : dim("(a literal value, masked)");
134
+ const why = literal.kind === "env"
135
+ ? "Renamed to the variable Laneyard's signing block exports."
136
+ : proposal.tier === "inline"
137
+ ? "This key is in your repository in cleartext."
138
+ : proposal.tier === "file"
139
+ ? "That path does not survive the clone: Laneyard builds from your remote."
140
+ : "A literal secret in a build file is a secret in your history.";
130
141
  return ("\n" +
131
142
  ` ${bold(`${fastlaneDir}/Fastfile:${literal.line}`)} ${literal.action}(${literal.arg}:)\n` +
132
143
  ` → ${shown}\n` +
@@ -160,6 +171,11 @@ async function store(vault, slug, asker, proposal, found) {
160
171
  await vault.set(slug, proposal.varName, proposal.literal.value, true);
161
172
  return;
162
173
  }
174
+ // A rewrite-only proposal — a `file` arg that was already a variable — lifts
175
+ // nothing: there is no file on disk, and the block it points at is the user's
176
+ // to upload. The report tells them so; here there is simply nothing to store.
177
+ if (proposal.tier === "file" && found === undefined)
178
+ return;
163
179
  const bytes = proposal.tier === "inline" ? Buffer.from(proposal.literal.value, "utf8") : found.bytes;
164
180
  // The original name is kept: some tools read meaning from it, and
165
181
  // `materialise.ts` already relies on `AuthKey_<KEY ID>.p8` surviving intact.
@@ -177,8 +193,15 @@ async function store(vault, slug, asker, proposal, found) {
177
193
  }
178
194
  /** What is left for the user to do, including the part Laneyard will not do. */
179
195
  async function report(cwd, fastlaneDir, accepted, found) {
196
+ // A rewrite-only proposal — an arg that was already a variable — stored
197
+ // nothing; counting it as stored would claim a credential is in the vault when
198
+ // it is the user's still to upload.
199
+ const stored = accepted.filter((p) => p.tier !== "file" || found.has(p));
200
+ const normalised = accepted.filter((p) => p.tier === "file" && !found.has(p));
180
201
  process.stdout.write("\n" +
181
- ok(`Stored ${accepted.length} credential${accepted.length > 1 ? "s" : ""} in this machine's vault.\n`) +
202
+ (stored.length > 0
203
+ ? ok(`Stored ${stored.length} credential${stored.length > 1 ? "s" : ""} in this machine's vault.\n`)
204
+ : "") +
182
205
  ok(`Patched ${fastlaneDir}/Fastfile.\n`) +
183
206
  "\n" +
184
207
  // Said plainly because it is the trap `addProjectToConfig` already
@@ -186,6 +209,16 @@ async function report(cwd, fastlaneDir, accepted, found) {
186
209
  // the working copy reaches a run until it is pushed.
187
210
  warn("Commit and push it, or your runs still read the old file.\n") +
188
211
  dim(" git diff -- " + join(fastlaneDir, "Fastfile") + "\n"));
212
+ // The rewrites now read names a signing block exports. Nothing was stored for
213
+ // them, so the block is what makes them real — and the old variables the lane
214
+ // used to read are free to go.
215
+ if (normalised.length > 0) {
216
+ const names = [...new Set(normalised.map((p) => p.varName))].sort();
217
+ process.stdout.write("\n" +
218
+ warn(`Supply ${names.join(", ")} from a signing block.\n`) +
219
+ dim(" Upload the .p8 or service account there; the block exports these names,\n") +
220
+ dim(" and the variables you set as secrets for them can go.\n"));
221
+ }
189
222
  // Said, never done. Removing a file from someone's repository is not
190
223
  // setup's to decide, and `git rm --cached` does not take it out of the
191
224
  // history anyway — so the honest thing is to name it.
@@ -50,23 +50,34 @@ export function proposalsFor(literals) {
50
50
  const file = FILE_ARGS.find((r) => r.action.test(literal.action) && r.arg === literal.arg);
51
51
  if (file) {
52
52
  const name = defaultVarNames(file.kind)["path"];
53
+ const main = rewriteTo(literal, name);
53
54
  const identifiers = file.kind === "apple_asc" ? appleIdentifierRewrites(appleIdentifiers) : EMPTY;
55
+ const edits = [...(main ? [main] : []), ...identifiers.edits];
56
+ // Every edit was a no-op — the value already reads its Laneyard name, and
57
+ // so did each identifier. Nothing to propose.
58
+ if (edits.length === 0)
59
+ continue;
54
60
  out.push({
55
61
  tier: "file",
56
62
  kind: file.kind,
57
63
  varName: name,
58
- // The explicit literal wins over the Key ID read from the filename.
59
- suggestedFields: { ...suggestedFields(file.kind, literal.value), ...identifiers.fields },
64
+ // The filename gives a Key ID only for a literal path; an env name is not
65
+ // one. The explicit literal identifier wins over the filename either way.
66
+ suggestedFields: {
67
+ ...(literal.kind === "literal" ? suggestedFields(file.kind, literal.value) : {}),
68
+ ...identifiers.fields,
69
+ },
60
70
  literal,
61
- edits: [
62
- { start: literal.valueStart, length: literal.valueLength, replacement: `ENV.fetch("${name}")` },
63
- ...identifiers.edits,
64
- ],
71
+ edits,
65
72
  checked: true,
66
73
  });
67
74
  continue;
68
75
  }
69
- const inline = INLINE_ARGS.find((r) => r.action.test(literal.action) && r.arg === literal.arg);
76
+ // Inline contents and a literal secret are, by definition, literals: an
77
+ // `ENV[...]` here is a variable, handled by the file pass above or left alone.
78
+ const inline = literal.kind === "literal"
79
+ ? INLINE_ARGS.find((r) => r.action.test(literal.action) && r.arg === literal.arg)
80
+ : undefined;
70
81
  if (inline) {
71
82
  const name = defaultVarNames(inline.kind)["path"];
72
83
  const identifiers = inline.kind === "apple_asc" ? appleIdentifierRewrites(appleIdentifiers) : EMPTY;
@@ -89,7 +100,7 @@ export function proposalsFor(literals) {
89
100
  });
90
101
  continue;
91
102
  }
92
- if (SECRET_ARG.test(literal.arg)) {
103
+ if (literal.kind === "literal" && SECRET_ARG.test(literal.arg)) {
93
104
  const name = `${literal.action}_${literal.arg}`.toUpperCase();
94
105
  out.push({
95
106
  tier: "secret",
@@ -124,15 +135,32 @@ function appleIdentifierRewrites(identifiers) {
124
135
  const edits = [];
125
136
  const fields = {};
126
137
  for (const id of identifiers) {
127
- edits.push({
128
- start: id.valueStart,
129
- length: id.valueLength,
130
- replacement: `ENV.fetch("${defaults[id.arg]}")`,
131
- });
132
- fields[id.arg] = id.value;
138
+ const edit = rewriteTo(id, defaults[id.arg]);
139
+ if (edit)
140
+ edits.push(edit);
141
+ // Only a literal is the identifier's value; an env name is the *variable* it
142
+ // was read from, not the id itself, so it pre-fills nothing.
143
+ if (id.kind === "literal")
144
+ fields[id.arg] = id.value;
133
145
  }
134
146
  return { edits, fields };
135
147
  }
148
+ /**
149
+ * The edit that makes one argument read `varName`, or null when it already does.
150
+ *
151
+ * A literal is always rewritten — a path or a value is never the variable name.
152
+ * An `ENV[...]` already reading that exact name is left alone: the point is to
153
+ * reach Laneyard's name, and it is already there.
154
+ */
155
+ function rewriteTo(literal, varName) {
156
+ if (literal.kind === "env" && literal.value === varName)
157
+ return null;
158
+ return {
159
+ start: literal.valueStart,
160
+ length: literal.valueLength,
161
+ replacement: `ENV.fetch("${varName}")`,
162
+ };
163
+ }
136
164
  /** What the credential's filename gives away, so the prompt starts filled in. */
137
165
  function suggestedFields(kind, path) {
138
166
  if (kind !== "apple_asc")
package/dist/src/main.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync } from "node:fs";
2
+ import { readFileSync, realpathSync } from "node:fs";
3
3
  import { mkdir } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
- import { join } from "node:path";
5
+ import { dirname, join } from "node:path";
6
6
  import { bold, dim, field, heading } from "./cli/style.js";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { PromptAborted } from "./cli/prompt.js";
@@ -22,7 +22,28 @@ import { Vault } from "./secrets/vault.js";
22
22
  import { makeInvoke } from "./sidecar/bridge.js";
23
23
  import { LaneReader } from "./sidecar/lanes.js";
24
24
  import { UsesReader } from "./sidecar/uses.js";
25
- export const version = "0.5.0";
25
+ /**
26
+ * The version, from `package.json` rather than a constant beside it. The
27
+ * constant this replaced was bumped by hand and drifted: a release moved
28
+ * `package.json` to 0.6.0 and left `laneyard --version` still saying 0.5.0.
29
+ *
30
+ * Walked up from this module so it resolves both from `dist/src` (installed) and
31
+ * `src` (dev under tsx), and finds the package's own `package.json` — the nearest
32
+ * ancestor — never a host project's when Laneyard is a dependency.
33
+ */
34
+ function readVersion() {
35
+ let dir = dirname(fileURLToPath(import.meta.url));
36
+ for (let up = 0; up < 6; up += 1, dir = dirname(dir)) {
37
+ try {
38
+ return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version;
39
+ }
40
+ catch {
41
+ // Not this directory — try its parent.
42
+ }
43
+ }
44
+ return "0.0.0";
45
+ }
46
+ export const version = readVersion();
26
47
  /** Assembles the server from a data folder. */
27
48
  export async function createServerFromConfig(root) {
28
49
  const config = new ConfigStore(join(root, "config.yml"));
@@ -30,6 +30,7 @@ export async function scanFastfile(cwd, fastlaneDir) {
30
30
  return res.literals.map((l) => ({
31
31
  action: String(l["action"]),
32
32
  arg: String(l["arg"]),
33
+ kind: l["kind"] === "env" ? "env" : "literal",
33
34
  value: String(l["value"]),
34
35
  valueStart: Number(l["value_start"]),
35
36
  valueLength: Number(l["value_length"]),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "laneyard",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "laneyard": "dist/src/main.js"
package/ruby/scan.rb CHANGED
@@ -76,6 +76,22 @@ rescue LoadError => e
76
76
  fail_with("prism is not available in this Ruby (#{e.message})")
77
77
  end
78
78
 
79
+ # The name in `ENV["X"]` or `ENV.fetch("X")`, or nil for anything else.
80
+ #
81
+ # Both spellings read the environment, and both are how a Fastfile that already
82
+ # uses variables writes a credential — the caller rewrites either to the name a
83
+ # signing block exports. Only a literal string argument is resolved: `ENV[key]`
84
+ # with a computed key names nothing this can report.
85
+ def env_lookup_name(node)
86
+ return nil unless node.is_a?(Prism::CallNode)
87
+ receiver = node.receiver
88
+ return nil unless receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :ENV
89
+ return nil unless node.name == :[] || node.name == :fetch
90
+
91
+ first = node.arguments&.arguments&.first
92
+ first.is_a?(Prism::StringNode) ? first.unescaped : nil
93
+ end
94
+
79
95
  # Every `key: "literal"` inside a call, wherever the call sits.
80
96
  #
81
97
  # Descends through everything: a call inside an `if`, inside a `def`, inside a
@@ -99,16 +115,30 @@ def literals_in(node, out = [])
99
115
  hashes.flat_map(&:elements).each do |el|
100
116
  next unless el.is_a?(Prism::AssocNode)
101
117
  next unless el.key.is_a?(Prism::SymbolNode)
102
- next unless el.value.is_a?(Prism::StringNode)
103
- # A heredoc's location covers its *marker*`<<~P` is four bytes
104
- # while its value lives on the lines below. Reporting that range would
105
- # have the caller splice `ENV.fetch("...")` over the marker and leave
106
- # the body stranded after the call: a Fastfile that no longer parses,
107
- # written silently into someone's repository. Dropping heredocs costs
108
- # nothing real, because a credential path is never written as one while
109
- # `changelog:` and `message:` routinely are. Only Ruby can tell a
110
- # heredoc from a quoted string, so the decision has to be made here.
111
- next if el.value.opening&.start_with?("<<")
118
+
119
+ # A literal string, or an environment lookup the two ways a credential
120
+ # is written into a call. `adoption.ts` decides which arguments matter;
121
+ # this stays ignorant of credentials and reports both shapes, tagged so
122
+ # the caller can tell "a path to lift" from "a variable to rename". A
123
+ # value that is neither a computed expression — has nothing the caller
124
+ # can act on and is skipped.
125
+ value = el.value
126
+ if value.is_a?(Prism::StringNode)
127
+ # A heredoc's location covers its *marker* — `<<~P` is four bytes —
128
+ # while its value lives on the lines below. Reporting that range would
129
+ # have the caller splice `ENV.fetch("...")` over the marker and leave
130
+ # the body stranded after the call: a Fastfile that no longer parses,
131
+ # written silently into someone's repository. Only Ruby can tell a
132
+ # heredoc from a quoted string, so the decision has to be made here.
133
+ next if value.opening&.start_with?("<<")
134
+ kind = "literal"
135
+ reported = value.unescaped
136
+ else
137
+ name = env_lookup_name(value)
138
+ next if name.nil?
139
+ kind = "env"
140
+ reported = name
141
+ end
112
142
 
113
143
  # `start_offset` and `length` are byte offsets, and must stay that way:
114
144
  # the caller splices them into a Buffer. Prism also offers
@@ -118,9 +148,10 @@ def literals_in(node, out = [])
118
148
  out << {
119
149
  action: child.name.to_s,
120
150
  arg: el.key.unescaped,
121
- value: el.value.unescaped,
122
- value_start: el.value.location.start_offset,
123
- value_length: el.value.location.length,
151
+ kind: kind,
152
+ value: reported,
153
+ value_start: value.location.start_offset,
154
+ value_length: value.location.length,
124
155
  pair_start: el.location.start_offset,
125
156
  pair_length: el.location.length,
126
157
  line: el.location.start_line