laneyard 0.6.0 → 0.7.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.
- package/README.md +13 -491
- package/dist/src/cli/adopt.js +68 -19
- package/dist/src/cli/remove.js +50 -32
- package/dist/src/cli/setup.js +85 -40
- package/dist/src/fastfile/adoption.js +59 -16
- package/dist/src/git/workspace.js +28 -0
- package/dist/src/heuristics/readiness.js +80 -135
- package/dist/src/main.js +24 -3
- package/dist/src/runner/orchestrate.js +25 -4
- package/dist/src/secrets/vault.js +52 -15
- package/dist/src/server/routes/fastfile.js +1 -1
- package/dist/src/server/routes/projects.js +59 -0
- package/dist/src/server/routes/secrets.js +20 -17
- package/dist/src/sidecar/fastlane-dir.js +18 -8
- package/dist/src/sidecar/lanes.js +1 -1
- package/dist/src/sidecar/scan.js +1 -0
- package/dist/src/sidecar/uses.js +1 -1
- package/dist/web/assets/{Editor-CMa4-4N3.js → Editor-wZdJUk7D.js} +1 -1
- package/dist/web/assets/index-CPKV0yx1.css +1 -0
- package/dist/web/assets/index-Cq1Ze8dP.js +62 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/ruby/scan.rb +44 -13
- package/dist/web/assets/index-B8lAQPEM.js +0 -62
- package/dist/web/assets/index-DsjxZtsO.css +0 -1
package/dist/src/cli/adopt.js
CHANGED
|
@@ -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
|
|
33
|
-
//
|
|
34
|
-
// would trade one broken build for another.
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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,20 @@ 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
|
-
|
|
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
|
+
// Plural when more than one argument changes — the mapping is on screen
|
|
59
|
+
// above, so the question counts them rather than repeating three long names.
|
|
60
|
+
const all = proposal.rewrites.length;
|
|
61
|
+
const question = rewriteOnly
|
|
62
|
+
? all > 1
|
|
63
|
+
? ` Rewrite all ${all} to the names the block exports?`
|
|
64
|
+
: ` Rewrite it to ${bold(proposal.varName)}?`
|
|
65
|
+
: all > 1
|
|
66
|
+
? ` Store it here and rewrite all ${all}?`
|
|
67
|
+
: ` Store it here and use ${bold(proposal.varName)}?`;
|
|
68
|
+
if (!(await asker.confirm(question, proposal.checked))) {
|
|
55
69
|
continue;
|
|
56
70
|
}
|
|
57
71
|
accepted.push(proposal.tier === "secret" ? await named(asker, proposal) : proposal);
|
|
@@ -117,19 +131,31 @@ function describe(fastlaneDir, proposal) {
|
|
|
117
131
|
// or a password, and setup's output is pasted into bug reports and kept in CI
|
|
118
132
|
// transcripts — printing it there would be this feature leaking the very
|
|
119
133
|
// thing it exists to put away. The file and line above say where to look.
|
|
120
|
-
const shown =
|
|
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."
|
|
134
|
+
const shown = (r) => r.literal.kind === "env"
|
|
135
|
+
? `ENV["${r.literal.value}"]`
|
|
127
136
|
: proposal.tier === "file"
|
|
128
|
-
? "
|
|
129
|
-
:
|
|
137
|
+
? `"${r.literal.value}"`
|
|
138
|
+
: proposal.tier === "inline"
|
|
139
|
+
? dim("(a key, inline in the file)")
|
|
140
|
+
: dim("(a literal value, masked)");
|
|
141
|
+
const many = proposal.rewrites.length > 1;
|
|
142
|
+
const why = literal.kind === "env"
|
|
143
|
+
? `Renamed to the variable${many ? "s" : ""} Laneyard's signing block exports.`
|
|
144
|
+
: proposal.tier === "inline"
|
|
145
|
+
? "This key is in your repository in cleartext."
|
|
146
|
+
: proposal.tier === "file"
|
|
147
|
+
? "That path does not survive the clone: Laneyard builds from your remote."
|
|
148
|
+
: "A literal secret in a build file is a secret in your history.";
|
|
149
|
+
// Every argument that changes, not just the one the proposal is anchored on:
|
|
150
|
+
// an `apple_asc` block rewrites its key file *and* both identifiers, and
|
|
151
|
+
// naming one of the three made the other two look like they were not touched.
|
|
152
|
+
const width = Math.max(...proposal.rewrites.map((r) => r.arg.length));
|
|
153
|
+
const lines = proposal.rewrites
|
|
154
|
+
.map((r) => ` ${`${r.arg}:`.padEnd(width + 2)} ${shown(r)} → ${bold(r.varName)}\n`)
|
|
155
|
+
.join("");
|
|
130
156
|
return ("\n" +
|
|
131
|
-
` ${bold(`${fastlaneDir}/Fastfile:${literal.line}`)} ${literal.action}
|
|
132
|
-
|
|
157
|
+
` ${bold(`${fastlaneDir}/Fastfile:${literal.line}`)} ${literal.action}\n` +
|
|
158
|
+
lines +
|
|
133
159
|
dim(` ${why}\n`));
|
|
134
160
|
}
|
|
135
161
|
/**
|
|
@@ -152,6 +178,7 @@ async function named(asker, proposal) {
|
|
|
152
178
|
...proposal,
|
|
153
179
|
varName,
|
|
154
180
|
edits: [{ ...proposal.edits[0], replacement: `ENV.fetch("${varName}")` }],
|
|
181
|
+
rewrites: [{ ...proposal.rewrites[0], varName }],
|
|
155
182
|
};
|
|
156
183
|
}
|
|
157
184
|
/** Lifts one accepted proposal into the vault. */
|
|
@@ -160,6 +187,11 @@ async function store(vault, slug, asker, proposal, found) {
|
|
|
160
187
|
await vault.set(slug, proposal.varName, proposal.literal.value, true);
|
|
161
188
|
return;
|
|
162
189
|
}
|
|
190
|
+
// A rewrite-only proposal — a `file` arg that was already a variable — lifts
|
|
191
|
+
// nothing: there is no file on disk, and the block it points at is the user's
|
|
192
|
+
// to upload. The report tells them so; here there is simply nothing to store.
|
|
193
|
+
if (proposal.tier === "file" && found === undefined)
|
|
194
|
+
return;
|
|
163
195
|
const bytes = proposal.tier === "inline" ? Buffer.from(proposal.literal.value, "utf8") : found.bytes;
|
|
164
196
|
// The original name is kept: some tools read meaning from it, and
|
|
165
197
|
// `materialise.ts` already relies on `AuthKey_<KEY ID>.p8` surviving intact.
|
|
@@ -177,8 +209,15 @@ async function store(vault, slug, asker, proposal, found) {
|
|
|
177
209
|
}
|
|
178
210
|
/** What is left for the user to do, including the part Laneyard will not do. */
|
|
179
211
|
async function report(cwd, fastlaneDir, accepted, found) {
|
|
212
|
+
// A rewrite-only proposal — an arg that was already a variable — stored
|
|
213
|
+
// nothing; counting it as stored would claim a credential is in the vault when
|
|
214
|
+
// it is the user's still to upload.
|
|
215
|
+
const stored = accepted.filter((p) => p.tier !== "file" || found.has(p));
|
|
216
|
+
const normalised = accepted.filter((p) => p.tier === "file" && !found.has(p));
|
|
180
217
|
process.stdout.write("\n" +
|
|
181
|
-
|
|
218
|
+
(stored.length > 0
|
|
219
|
+
? ok(`Stored ${stored.length} credential${stored.length > 1 ? "s" : ""} in this machine's vault.\n`)
|
|
220
|
+
: "") +
|
|
182
221
|
ok(`Patched ${fastlaneDir}/Fastfile.\n`) +
|
|
183
222
|
"\n" +
|
|
184
223
|
// Said plainly because it is the trap `addProjectToConfig` already
|
|
@@ -186,6 +225,16 @@ async function report(cwd, fastlaneDir, accepted, found) {
|
|
|
186
225
|
// the working copy reaches a run until it is pushed.
|
|
187
226
|
warn("Commit and push it, or your runs still read the old file.\n") +
|
|
188
227
|
dim(" git diff -- " + join(fastlaneDir, "Fastfile") + "\n"));
|
|
228
|
+
// The rewrites now read names a signing block exports. Nothing was stored for
|
|
229
|
+
// them, so the block is what makes them real — and the old variables the lane
|
|
230
|
+
// used to read are free to go.
|
|
231
|
+
if (normalised.length > 0) {
|
|
232
|
+
const names = [...new Set(normalised.map((p) => p.varName))].sort();
|
|
233
|
+
process.stdout.write("\n" +
|
|
234
|
+
warn(`Supply ${names.join(", ")} from a signing block.\n`) +
|
|
235
|
+
dim(" Upload the .p8 or service account there; the block exports these names,\n") +
|
|
236
|
+
dim(" and the variables you set as secrets for them can go.\n"));
|
|
237
|
+
}
|
|
189
238
|
// Said, never done. Removing a file from someone's repository is not
|
|
190
239
|
// setup's to decide, and `git rm --cached` does not take it out of the
|
|
191
240
|
// history anyway — so the honest thing is to name it.
|
package/dist/src/cli/remove.js
CHANGED
|
@@ -13,14 +13,18 @@ import { LogStore } from "../logs/store.js";
|
|
|
13
13
|
import { Vault } from "../secrets/vault.js";
|
|
14
14
|
import { readLine } from "./home.js";
|
|
15
15
|
import { bad, bold, dim, field, heading, ok, warn } from "./style.js";
|
|
16
|
-
export const REMOVE_USAGE = `laneyard remove [--dry-run]
|
|
16
|
+
export const REMOVE_USAGE = `laneyard remove [<slug>] [--dry-run]
|
|
17
17
|
|
|
18
|
-
Run from
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
Run from the directory that holds laneyard.yml — the repository root for most
|
|
19
|
+
projects, the app's own folder in a monorepo — and the project is the one that
|
|
20
|
+
file names. Give a slug instead when there is no such file.
|
|
21
|
+
|
|
22
|
+
Removes everything Laneyard holds for that project: its block in config.yml, its
|
|
23
|
+
clone, its artifacts, its run history and logs, and its own secrets and signing
|
|
24
|
+
blocks in the vault. It also removes that laneyard.yml, which you then commit —
|
|
25
|
+
one naming another project is left alone. It does not touch the git remote, the
|
|
26
|
+
credential originals, or the global secrets and signing blocks other projects
|
|
27
|
+
share.
|
|
24
28
|
|
|
25
29
|
laneyard remove --dry-run show what would go and stop
|
|
26
30
|
|
|
@@ -65,7 +69,7 @@ function renderInventory(slug, name, home, counts, clonePath, ymlPath) {
|
|
|
65
69
|
field("secrets", plural(counts.secrets, "project secret")) + "\n" +
|
|
66
70
|
field("signing", plural(counts.signingBlocks, "project signing block")) + "\n" +
|
|
67
71
|
// The one thing removed outside `home`: the repository's own file.
|
|
68
|
-
field("laneyard.yml", ymlPath) + "\n" +
|
|
72
|
+
(ymlPath === null ? "" : field("laneyard.yml", ymlPath) + "\n") +
|
|
69
73
|
heading("what will not be touched") +
|
|
70
74
|
dim(" the git remote — the repository is yours, on your host and your disk.\n") +
|
|
71
75
|
dim(" the credential originals — Laneyard removes only its own encrypted copy;\n") +
|
|
@@ -73,43 +77,56 @@ function renderInventory(slug, name, home, counts, clonePath, ymlPath) {
|
|
|
73
77
|
dim(" global secrets and global signing blocks — shared by every project.\n"));
|
|
74
78
|
}
|
|
75
79
|
/**
|
|
76
|
-
* Entry point for `laneyard remove`, run from the
|
|
80
|
+
* Entry point for `laneyard remove`, run from wherever the project's
|
|
81
|
+
* `laneyard.yml` sits — the repository root unless the app is one folder of a
|
|
82
|
+
* monorepo.
|
|
83
|
+
*
|
|
84
|
+
* The project is the one whose `laneyard.yml` sits in `cwd`, so the command names
|
|
85
|
+
* what the directory already is. A slug given outright overrides that, and is the
|
|
86
|
+
* only way to reach a project whose file was never written, lost its slug, or
|
|
87
|
+
* whose repository is no longer on this machine.
|
|
77
88
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* not go, and only then asks for the slug typed back — the same gate the web
|
|
82
|
-
* route uses, for the same reason: the run history it deletes is the one thing
|
|
83
|
-
* here nothing can rebuild.
|
|
89
|
+
* It reads the inventory first, prints what will and will not go, and only then
|
|
90
|
+
* asks for the slug typed back — the same gate the web route uses, for the same
|
|
91
|
+
* reason: the run history it deletes is the one thing here nothing can rebuild.
|
|
84
92
|
*/
|
|
85
93
|
export async function runRemoveCommand(home, cwd, args, io) {
|
|
86
94
|
let dryRun = false;
|
|
95
|
+
let given = null;
|
|
87
96
|
for (const arg of args) {
|
|
88
97
|
if (arg === "--dry-run" || arg === "-n")
|
|
89
98
|
dryRun = true;
|
|
90
|
-
else {
|
|
99
|
+
else if (arg.startsWith("-")) {
|
|
91
100
|
io.err(`Unknown option: ${arg}\n\n${REMOVE_USAGE}`);
|
|
92
101
|
return 1;
|
|
93
102
|
}
|
|
103
|
+
else if (given === null)
|
|
104
|
+
given = arg;
|
|
105
|
+
else {
|
|
106
|
+
io.err(`One project at a time. Give a single slug.\n\n${REMOVE_USAGE}`);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
94
109
|
}
|
|
95
|
-
// The project is the one whose `laneyard.yml` is here — the file setup
|
|
96
|
-
// and the slug it recorded in it.
|
|
97
|
-
// to name, which is the whole point: a project is removed from where it lives.
|
|
110
|
+
// The project is normally the one whose `laneyard.yml` is here — the file setup
|
|
111
|
+
// wrote, and the slug it recorded in it.
|
|
98
112
|
const ymlPath = join(cwd, "laneyard.yml");
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
const slug = repo.ok ? repo.config.slug : undefined;
|
|
113
|
+
const inFile = existsSync(ymlPath)
|
|
114
|
+
? await loadRepoConfig(ymlPath).then((r) => (r.ok ? r.config.slug : undefined))
|
|
115
|
+
: undefined;
|
|
116
|
+
// A slug given outright is the way out when there is no such file: one that
|
|
117
|
+
// was never written, lost its slug, or whose repository is not on this machine
|
|
118
|
+
// any more. Without it those projects could only be removed from the web.
|
|
119
|
+
const slug = given ?? inFile;
|
|
107
120
|
if (slug === undefined || slug === "") {
|
|
108
121
|
io.err("\n" +
|
|
109
|
-
bad("This laneyard.yml has no slug.") +
|
|
110
|
-
" Run `laneyard
|
|
122
|
+
bad(existsSync(ymlPath) ? "This laneyard.yml has no slug." : "No laneyard.yml here.") +
|
|
123
|
+
" Run `laneyard remove` from the directory that holds it — usually the repository" +
|
|
124
|
+
" root — or name the project: `laneyard remove <slug>`.\n");
|
|
111
125
|
return 1;
|
|
112
126
|
}
|
|
127
|
+
// Removed only when it is this project's file. Given a slug explicitly, a
|
|
128
|
+
// `laneyard.yml` naming a different project is somebody else's and stays.
|
|
129
|
+
const ownYml = inFile !== undefined && inFile === slug ? ymlPath : null;
|
|
113
130
|
const configPath = join(home, "config.yml");
|
|
114
131
|
const config = new ConfigStore(configPath);
|
|
115
132
|
const loaded = await config.load();
|
|
@@ -136,7 +153,7 @@ export async function runRemoveCommand(home, cwd, args, io) {
|
|
|
136
153
|
" Wait for it to finish, or cancel it, then remove the project. Nothing was removed.\n");
|
|
137
154
|
return 1;
|
|
138
155
|
}
|
|
139
|
-
io.out(renderInventory(slug, entry.name, home, counts, clonePath,
|
|
156
|
+
io.out(renderInventory(slug, entry.name, home, counts, clonePath, ownYml));
|
|
140
157
|
if (dryRun) {
|
|
141
158
|
io.out("\n" +
|
|
142
159
|
dim("Nothing was removed. Run `laneyard remove` without --dry-run to remove it.") +
|
|
@@ -172,7 +189,8 @@ export async function runRemoveCommand(home, cwd, args, io) {
|
|
|
172
189
|
// The machine data went first, so a file removed here is one whose project
|
|
173
190
|
// is already gone — never the other way round. `force` because the removal
|
|
174
191
|
// must still report success if the file was deleted by hand meanwhile.
|
|
175
|
-
|
|
192
|
+
if (ownYml !== null)
|
|
193
|
+
await rm(ownYml, { force: true }).catch(() => { });
|
|
176
194
|
io.out(heading("removed") +
|
|
177
195
|
ok(`"${slug}": ${plural(result.runs, "run")}, ${plural(result.secrets, "secret")}, ` +
|
|
178
196
|
`${plural(result.signingBlocks, "signing block")}.\n`) +
|
|
@@ -180,7 +198,7 @@ export async function runRemoveCommand(home, cwd, args, io) {
|
|
|
180
198
|
? dim(` ${result.clonePath} is gone.\n`)
|
|
181
199
|
: dim(" There was no clone to remove.\n")) +
|
|
182
200
|
dim(` The block is out of ${configPath}.\n`) +
|
|
183
|
-
dim(` ${
|
|
201
|
+
(ownYml === null ? "" : dim(` ${ownYml} is gone.\n`)) +
|
|
184
202
|
"\n" +
|
|
185
203
|
// The file is committed, so deleting the working copy is not the end of
|
|
186
204
|
// it — the same trap adoption's report names about a patched Fastfile.
|
package/dist/src/cli/setup.js
CHANGED
|
@@ -151,16 +151,6 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
|
|
|
151
151
|
dim(" Continuing updates its entry, keeping anything you added by hand.\n") +
|
|
152
152
|
dim(" Give it another name to keep both.\n"));
|
|
153
153
|
}
|
|
154
|
-
// The repository file lives in the app's own directory, so a monorepo of N
|
|
155
|
-
// apps carries N of them — one beside each app's fastlane folder. `appRoot`
|
|
156
|
-
// is the fastlane dir's parent; for a plain app whose fastlane sits at the
|
|
157
|
-
// root it is `.`, and the file lands at the repository root as it always did.
|
|
158
|
-
const appRoot = appRootOf(d.fastlaneDir);
|
|
159
|
-
const repoConfigPath = join(repoRoot(cwd, d.subPath), appRoot, LANEYARD_YML);
|
|
160
|
-
if (await fileExists(repoConfigPath)) {
|
|
161
|
-
process.stdout.write("\n" + warn(`${LANEYARD_YML} already exists in the repository.\n`) +
|
|
162
|
-
dim(" Its values win over anything written here; it will be left alone.\n"));
|
|
163
|
-
}
|
|
164
154
|
// A machine with no account gets its first admin here. Asked rather than
|
|
165
155
|
// assumed: the name is typed into a login form every day afterwards, and
|
|
166
156
|
// `admin` is a poor thing to call a person once there are two of them.
|
|
@@ -187,7 +177,24 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
|
|
|
187
177
|
}
|
|
188
178
|
const gitUrl = await asker.ask("repository", d.gitUrl);
|
|
189
179
|
const branch = await asker.ask("default branch", d.defaultBranch, dim("A run uses this branch unless you pick another one when you start it."));
|
|
190
|
-
|
|
180
|
+
// Asked as it is written from where the command ran: from `app/`, the folder
|
|
181
|
+
// is `fastlane`, not `app/fastlane`. The repository-root-relative form is
|
|
182
|
+
// what the clone has and what `config.yml` stores, but prefixing it is
|
|
183
|
+
// Laneyard's job — asking someone to type a path they are standing inside
|
|
184
|
+
// reads like a mistake, and is the one people correct wrongly.
|
|
185
|
+
const fastlaneDir = fromRepoRoot(d.subPath, await asker.ask("fastlane directory", fromHere(d.subPath, d.fastlaneDir), dim("Relative to this directory.")));
|
|
186
|
+
// Computed from the answer, never from the detection: correcting the folder
|
|
187
|
+
// moves the app root with it, and with it where `laneyard.yml` is written and
|
|
188
|
+
// which prefix its paths drop. Read before the prompt, a correction was
|
|
189
|
+
// silently ignored and the file landed beside the wrong app.
|
|
190
|
+
const appRoot = appRootOf(fastlaneDir);
|
|
191
|
+
const repoConfigPath = join(repoRoot(cwd, d.subPath), appRoot, LANEYARD_YML);
|
|
192
|
+
const repoConfigExists = await fileExists(repoConfigPath);
|
|
193
|
+
if (repoConfigExists) {
|
|
194
|
+
process.stdout.write("\n" + warn(`${LANEYARD_YML} already exists in the repository.\n`) +
|
|
195
|
+
dim(" Its values win over anything written here and are left alone.\n") +
|
|
196
|
+
dim(" Only a missing `slug:` is added, so this project can be removed later.\n"));
|
|
197
|
+
}
|
|
191
198
|
// A fastlane folder that is on disk but not in git will not survive the
|
|
192
199
|
// clone Laneyard builds from — the case that started this: a stray
|
|
193
200
|
// `app copie/` macOS made, detected here and absent from the remote.
|
|
@@ -209,10 +216,14 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
|
|
|
209
216
|
// prompt is a miserable way to start, the detected ones are nearly always
|
|
210
217
|
// right, and the file is there to be edited when they are not.
|
|
211
218
|
const globs = d.artifactGlobs;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
// No final "and write those?" here, on purpose. It was a rubber stamp: by
|
|
220
|
+
// this line every value has been proposed and accepted one at a time, so the
|
|
221
|
+
// answer was always yes — and a question nobody ever says no to teaches
|
|
222
|
+
// people to press Return without reading the ones that matter. Nothing here
|
|
223
|
+
// destroys anything either: the project block is additive and `laneyard
|
|
224
|
+
// remove` undoes it, an existing `laneyard.yml` keeps its values, and the
|
|
225
|
+
// report below says exactly what was written and where. Ctrl-C still works
|
|
226
|
+
// at every prompt above.
|
|
216
227
|
// The account first, so the server block is written before the project list
|
|
217
228
|
// it sits above. `null` when this machine already has one, and then nothing
|
|
218
229
|
// is written and nothing is printed.
|
|
@@ -271,7 +282,6 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
|
|
|
271
282
|
// checklist cannot end up disagreeing about the same repository.
|
|
272
283
|
...(d.platforms.length > 0 ? { platforms: d.platforms } : {}),
|
|
273
284
|
});
|
|
274
|
-
const port = await configuredPort(configPath);
|
|
275
285
|
process.stdout.write(heading(`Project "${slug}" is set up`) +
|
|
276
286
|
field("repository", `${gitUrl} (${branch})`) + "\n" +
|
|
277
287
|
field("fastlane", fastlaneDir) + "\n" +
|
|
@@ -282,9 +292,12 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
|
|
|
282
292
|
// one line of the file just written.
|
|
283
293
|
field("platforms", d.platforms.join(", ") || dim("none detected")) + "\n" +
|
|
284
294
|
"\n" +
|
|
285
|
-
(wroteRepoConfig
|
|
295
|
+
(wroteRepoConfig === "written"
|
|
286
296
|
? ok(`Wrote ${bold(LANEYARD_YML)} — ${bold("commit it")} so your team builds the same way.\n`)
|
|
287
|
-
:
|
|
297
|
+
: wroteRepoConfig === "slug-added"
|
|
298
|
+
? ok(`Added ${bold(`slug: ${slug}`)} to the existing ${LANEYARD_YML}, and left the rest ` +
|
|
299
|
+
`alone — ${bold("commit it")}.\n`)
|
|
300
|
+
: warn(`Left the existing ${LANEYARD_YML} alone.\n`)) +
|
|
288
301
|
ok(`Registered in ${configPath}\n`) +
|
|
289
302
|
// Last thing before the invitation to start the server, because it is
|
|
290
303
|
// the one line here that cannot be read again anywhere: the file holds
|
|
@@ -298,11 +311,14 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
|
|
|
298
311
|
"\n" +
|
|
299
312
|
warn("Write the password down — it is not shown again, and it is not stored.\n") +
|
|
300
313
|
dim(" Add a colleague later with `laneyard user add <name> --role builder`.\n")) +
|
|
301
|
-
|
|
314
|
+
// An instruction, not a status. `Start Laneyard` over a command and a URL
|
|
315
|
+
// read as "started, and here is the address" — the URL especially, which
|
|
316
|
+
// nothing was serving yet. The verb says who does it, and the address is
|
|
317
|
+
// the server's to print once it is listening.
|
|
318
|
+
heading("The server is not running yet — start it with") +
|
|
302
319
|
` ${bold("laneyard")}\n` +
|
|
303
|
-
` ${dim(`http://localhost:${port}`)}\n` +
|
|
304
320
|
"\n" +
|
|
305
|
-
dim("Already running? The configuration is watched —
|
|
321
|
+
dim("Already running? The configuration is watched — this project appears on its own.\n"));
|
|
306
322
|
// The second act. After the success message, never before it: declining
|
|
307
323
|
// everything here must leave exactly the project the lines above just
|
|
308
324
|
// announced. See `cli/adopt.ts`.
|
|
@@ -332,17 +348,6 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
|
|
|
332
348
|
asker.close();
|
|
333
349
|
}
|
|
334
350
|
}
|
|
335
|
-
/**
|
|
336
|
-
* The port the server will actually listen on.
|
|
337
|
-
*
|
|
338
|
-
* Read back from the file rather than assumed: telling someone to open a port
|
|
339
|
-
* their configuration does not use is the kind of small wrongness that makes a
|
|
340
|
-
* tool feel unreliable on first contact.
|
|
341
|
-
*/
|
|
342
|
-
async function configuredPort(configPath) {
|
|
343
|
-
const res = await loadServerConfig(configPath);
|
|
344
|
-
return res.ok ? res.config.server.port : 7890;
|
|
345
|
-
}
|
|
346
351
|
/** The repository file, named once so the message and the write cannot disagree. */
|
|
347
352
|
const LANEYARD_YML = "laneyard.yml";
|
|
348
353
|
/** What the first account is called when nobody says otherwise. */
|
|
@@ -401,14 +406,30 @@ async function existingProject(configPath, slug) {
|
|
|
401
406
|
* values win anyway.
|
|
402
407
|
*/
|
|
403
408
|
async function writeRepoConfigIfAbsent(path, settings) {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
409
|
+
const existing = await readFile(path, "utf8").catch(() => null);
|
|
410
|
+
if (existing === null) {
|
|
411
|
+
const doc = new Document(settings);
|
|
412
|
+
doc.commentBefore =
|
|
413
|
+
" How this project builds. Committed, so everyone builds it the same way.\n" +
|
|
414
|
+
" Values here win over the project's block in the server's config.yml.";
|
|
415
|
+
await writeFile(path, doc.toString(), "utf8");
|
|
416
|
+
return "written";
|
|
417
|
+
}
|
|
418
|
+
// The one edit an existing file gets: the slug, and only when it has none.
|
|
419
|
+
//
|
|
420
|
+
// A file written before slugs existed has no way to gain one otherwise, and
|
|
421
|
+
// `laneyard remove` refuses a slug-less file while telling you to run setup
|
|
422
|
+
// again — which left it untouched. That was a project nothing could remove.
|
|
423
|
+
//
|
|
424
|
+
// Through the YAML document, like `addProjectToConfig`: the comments, the key
|
|
425
|
+
// order and every value the file carried come back out unchanged. An existing
|
|
426
|
+
// slug is never touched, whatever it says.
|
|
427
|
+
const doc = parseDocument(existing);
|
|
428
|
+
if (doc.getIn(["slug"]) !== undefined)
|
|
429
|
+
return "left";
|
|
430
|
+
doc.setIn(["slug"], settings.slug);
|
|
431
|
+
await writeFile(path, serialize(doc), "utf8");
|
|
432
|
+
return "slug-added";
|
|
412
433
|
}
|
|
413
434
|
/**
|
|
414
435
|
* A repo-root-relative path read as relative to the app directory.
|
|
@@ -425,6 +446,30 @@ function appRelative(appRoot, p) {
|
|
|
425
446
|
const prefix = `${appRoot}/`;
|
|
426
447
|
return p.startsWith(prefix) ? p.slice(prefix.length) : p;
|
|
427
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* A repository-root-relative path as it is written from where setup ran.
|
|
451
|
+
*
|
|
452
|
+
* `app/fastlane` is `fastlane` when standing in `app/`. A path outside that
|
|
453
|
+
* directory has no shorter form and is shown as it is.
|
|
454
|
+
*/
|
|
455
|
+
function fromHere(subPath, repoRelative) {
|
|
456
|
+
if (subPath === "")
|
|
457
|
+
return repoRelative;
|
|
458
|
+
const prefix = `${subPath}/`;
|
|
459
|
+
return repoRelative.startsWith(prefix) ? repoRelative.slice(prefix.length) : repoRelative;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* The inverse: back to repository-root-relative, which is the shape of the clone.
|
|
463
|
+
*
|
|
464
|
+
* Tolerant of an answer that already carries the prefix — someone who types
|
|
465
|
+
* `app/fastlane` from `app/` means the same folder, and double-prefixing it into
|
|
466
|
+
* `app/app/fastlane` would be a worse answer than the one they gave.
|
|
467
|
+
*/
|
|
468
|
+
function fromRepoRoot(subPath, here) {
|
|
469
|
+
if (subPath === "" || here === "")
|
|
470
|
+
return here;
|
|
471
|
+
return here === subPath || here.startsWith(`${subPath}/`) ? here : `${subPath}/${here}`;
|
|
472
|
+
}
|
|
428
473
|
/** `git@github.com:you/thing.git` reads better as `you/thing` in a sentence. */
|
|
429
474
|
function repositoryLabel(url) {
|
|
430
475
|
return url.replace(/^.*[:/]([^/:]+\/[^/]+?)(\.git)?$/, "$1");
|
|
@@ -50,23 +50,39 @@ 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
|
+
const rewrites = [
|
|
57
|
+
...(main ? [{ arg: literal.arg, literal, varName: name }] : []),
|
|
58
|
+
...identifiers.rewrites,
|
|
59
|
+
];
|
|
60
|
+
// Every edit was a no-op — the value already reads its Laneyard name, and
|
|
61
|
+
// so did each identifier. Nothing to propose.
|
|
62
|
+
if (edits.length === 0)
|
|
63
|
+
continue;
|
|
54
64
|
out.push({
|
|
55
65
|
tier: "file",
|
|
56
66
|
kind: file.kind,
|
|
57
67
|
varName: name,
|
|
58
|
-
// The
|
|
59
|
-
|
|
68
|
+
// The filename gives a Key ID only for a literal path; an env name is not
|
|
69
|
+
// one. The explicit literal identifier wins over the filename either way.
|
|
70
|
+
suggestedFields: {
|
|
71
|
+
...(literal.kind === "literal" ? suggestedFields(file.kind, literal.value) : {}),
|
|
72
|
+
...identifiers.fields,
|
|
73
|
+
},
|
|
60
74
|
literal,
|
|
61
|
-
edits
|
|
62
|
-
|
|
63
|
-
...identifiers.edits,
|
|
64
|
-
],
|
|
75
|
+
edits,
|
|
76
|
+
rewrites,
|
|
65
77
|
checked: true,
|
|
66
78
|
});
|
|
67
79
|
continue;
|
|
68
80
|
}
|
|
69
|
-
|
|
81
|
+
// Inline contents and a literal secret are, by definition, literals: an
|
|
82
|
+
// `ENV[...]` here is a variable, handled by the file pass above or left alone.
|
|
83
|
+
const inline = literal.kind === "literal"
|
|
84
|
+
? INLINE_ARGS.find((r) => r.action.test(literal.action) && r.arg === literal.arg)
|
|
85
|
+
: undefined;
|
|
70
86
|
if (inline) {
|
|
71
87
|
const name = defaultVarNames(inline.kind)["path"];
|
|
72
88
|
const identifiers = inline.kind === "apple_asc" ? appleIdentifierRewrites(appleIdentifiers) : EMPTY;
|
|
@@ -85,11 +101,12 @@ export function proposalsFor(literals) {
|
|
|
85
101
|
},
|
|
86
102
|
...identifiers.edits,
|
|
87
103
|
],
|
|
104
|
+
rewrites: [{ arg: inline.becomes, literal, varName: name }, ...identifiers.rewrites],
|
|
88
105
|
checked: true,
|
|
89
106
|
});
|
|
90
107
|
continue;
|
|
91
108
|
}
|
|
92
|
-
if (SECRET_ARG.test(literal.arg)) {
|
|
109
|
+
if (literal.kind === "literal" && SECRET_ARG.test(literal.arg)) {
|
|
93
110
|
const name = `${literal.action}_${literal.arg}`.toUpperCase();
|
|
94
111
|
out.push({
|
|
95
112
|
tier: "secret",
|
|
@@ -97,6 +114,7 @@ export function proposalsFor(literals) {
|
|
|
97
114
|
literal,
|
|
98
115
|
suggestedFields: {},
|
|
99
116
|
edits: [{ start: literal.valueStart, length: literal.valueLength, replacement: `ENV.fetch("${name}")` }],
|
|
117
|
+
rewrites: [{ arg: literal.arg, literal, varName: name }],
|
|
100
118
|
// Unticked on purpose. This is the one tier where a false positive is
|
|
101
119
|
// likely — a non-secret URL, a placeholder — and a patch applied by
|
|
102
120
|
// default to a value that was not a secret is a silent regression in
|
|
@@ -108,7 +126,11 @@ export function proposalsFor(literals) {
|
|
|
108
126
|
return out;
|
|
109
127
|
}
|
|
110
128
|
/** Neither an edit nor a field: an `apple_asc` block that names no identifier. */
|
|
111
|
-
const EMPTY = {
|
|
129
|
+
const EMPTY = {
|
|
130
|
+
edits: [],
|
|
131
|
+
fields: {},
|
|
132
|
+
rewrites: [],
|
|
133
|
+
};
|
|
112
134
|
/**
|
|
113
135
|
* The rewrites and pre-filled fields for the identifiers written beside an
|
|
114
136
|
* App Store Connect key file.
|
|
@@ -122,16 +144,37 @@ const EMPTY = { edits: [], fields: {} };
|
|
|
122
144
|
function appleIdentifierRewrites(identifiers) {
|
|
123
145
|
const defaults = defaultVarNames("apple_asc");
|
|
124
146
|
const edits = [];
|
|
147
|
+
const rewrites = [];
|
|
125
148
|
const fields = {};
|
|
126
149
|
for (const id of identifiers) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
150
|
+
const name = defaults[id.arg];
|
|
151
|
+
const edit = rewriteTo(id, name);
|
|
152
|
+
if (edit) {
|
|
153
|
+
edits.push(edit);
|
|
154
|
+
rewrites.push({ arg: id.arg, literal: id, varName: name });
|
|
155
|
+
}
|
|
156
|
+
// Only a literal is the identifier's value; an env name is the *variable* it
|
|
157
|
+
// was read from, not the id itself, so it pre-fills nothing.
|
|
158
|
+
if (id.kind === "literal")
|
|
159
|
+
fields[id.arg] = id.value;
|
|
133
160
|
}
|
|
134
|
-
return { edits, fields };
|
|
161
|
+
return { edits, fields, rewrites };
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The edit that makes one argument read `varName`, or null when it already does.
|
|
165
|
+
*
|
|
166
|
+
* A literal is always rewritten — a path or a value is never the variable name.
|
|
167
|
+
* An `ENV[...]` already reading that exact name is left alone: the point is to
|
|
168
|
+
* reach Laneyard's name, and it is already there.
|
|
169
|
+
*/
|
|
170
|
+
function rewriteTo(literal, varName) {
|
|
171
|
+
if (literal.kind === "env" && literal.value === varName)
|
|
172
|
+
return null;
|
|
173
|
+
return {
|
|
174
|
+
start: literal.valueStart,
|
|
175
|
+
length: literal.valueLength,
|
|
176
|
+
replacement: `ENV.fetch("${varName}")`,
|
|
177
|
+
};
|
|
135
178
|
}
|
|
136
179
|
/** What the credential's filename gives away, so the prompt starts filled in. */
|
|
137
180
|
function suggestedFields(kind, path) {
|
|
@@ -199,6 +199,34 @@ export class Workspace {
|
|
|
199
199
|
onProgress?.(`Cloning ${this.redact(this.gitUrl)}…`);
|
|
200
200
|
await this.git(["clone", this.gitUrl, this.path], process.cwd());
|
|
201
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Commits this workspace holds that `origin/<branch>` does not.
|
|
204
|
+
*
|
|
205
|
+
* The one thing `prepare` would destroy without saying so: it moves the local
|
|
206
|
+
* branch onto origin's, and a commit only this clone had becomes reachable
|
|
207
|
+
* from the reflog and nowhere a person looks. That is not hypothetical — the
|
|
208
|
+
* Fastfile tab commits and pushes as two separate acts on purpose, so a
|
|
209
|
+
* workspace sitting one commit ahead is a state the product produces itself.
|
|
210
|
+
*
|
|
211
|
+
* Counted rather than listed: the caller refuses on any number above zero, and
|
|
212
|
+
* a list would invite showing it, which is a screen nobody asked for.
|
|
213
|
+
*
|
|
214
|
+
* Zero when the branch does not exist on the remote yet, and zero when
|
|
215
|
+
* anything about the question cannot be answered — a workspace that has never
|
|
216
|
+
* been fetched has no `origin/<branch>` to compare against, and refusing a
|
|
217
|
+
* refresh because the comparison failed would break the one case the refresh
|
|
218
|
+
* exists for.
|
|
219
|
+
*/
|
|
220
|
+
async unpushedCount(branch) {
|
|
221
|
+
try {
|
|
222
|
+
const out = await this.git(["rev-list", "--count", `origin/${branch}..HEAD`]);
|
|
223
|
+
const n = Number(out.trim());
|
|
224
|
+
return Number.isInteger(n) ? n : 0;
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
202
230
|
/**
|
|
203
231
|
* Brings the workspace to the requested branch, up to date.
|
|
204
232
|
* Clones on the first call, just fetches afterwards.
|