laneyard 0.6.1 → 0.8.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 -497
- package/dist/src/cli/adopt.js +24 -8
- package/dist/src/cli/remove.js +52 -34
- package/dist/src/cli/reset.js +2 -2
- package/dist/src/cli/setup.js +85 -40
- package/dist/src/cli/uninstall.js +5 -21
- package/dist/src/config/env-file-setting.js +57 -0
- package/dist/src/config/resolve.js +8 -5
- package/dist/src/config/schema.js +29 -0
- package/dist/src/db/credentials.js +18 -54
- package/dist/src/db/migrate-global-scope.js +75 -0
- package/dist/src/db/open.js +21 -0
- package/dist/src/db/schema.sql +10 -5
- package/dist/src/db/secrets.js +44 -65
- package/dist/src/fastfile/adoption.js +19 -4
- package/dist/src/git/workspace.js +28 -0
- package/dist/src/heuristics/readiness.js +80 -135
- package/dist/src/main.js +31 -2
- package/dist/src/runner/env-file.js +144 -0
- package/dist/src/runner/orchestrate.js +39 -5
- package/dist/src/secrets/vault.js +75 -40
- package/dist/src/server/app.js +4 -0
- package/dist/src/server/routes/credentials.js +0 -13
- package/dist/src/server/routes/fastfile.js +1 -1
- package/dist/src/server/routes/projects.js +59 -16
- package/dist/src/server/routes/readiness.js +2 -3
- package/dist/src/server/routes/secrets.js +111 -39
- package/dist/src/sidecar/fastlane-dir.js +18 -8
- package/dist/src/sidecar/lanes.js +1 -1
- package/dist/src/sidecar/uses.js +1 -1
- package/dist/web/assets/{Editor-CMa4-4N3.js → Editor-BK-ZRKlI.js} +1 -1
- package/dist/web/assets/index-DnUdqjT4.js +62 -0
- package/dist/web/assets/{index-DsjxZtsO.css → index-r3IyRuO4.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B8lAQPEM.js +0 -62
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
|
|
|
@@ -41,8 +45,8 @@ async function readCounts(home, slug) {
|
|
|
41
45
|
return {
|
|
42
46
|
activeRun: runs.hasActiveRun(slug),
|
|
43
47
|
runs: runs.listByProject(slug, -1).length,
|
|
44
|
-
secrets: new SecretStore(db).
|
|
45
|
-
signingBlocks: new CredentialStore(db).
|
|
48
|
+
secrets: new SecretStore(db).list(slug).length,
|
|
49
|
+
signingBlocks: new CredentialStore(db).list(slug).length,
|
|
46
50
|
};
|
|
47
51
|
}
|
|
48
52
|
finally {
|
|
@@ -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/reset.js
CHANGED
|
@@ -31,8 +31,8 @@ function renderInventory(inv) {
|
|
|
31
31
|
out += field("database", inv.db === null ? dim("not there") : dim("could not be read")) + "\n";
|
|
32
32
|
}
|
|
33
33
|
else {
|
|
34
|
-
out += field("secrets",
|
|
35
|
-
out += field("signing",
|
|
34
|
+
out += field("secrets", plural(v.secrets, "secret")) + "\n";
|
|
35
|
+
out += field("signing", plural(v.blocks, "signing block")) + "\n";
|
|
36
36
|
out += field("history", plural(v.runs, "run")) + "\n";
|
|
37
37
|
}
|
|
38
38
|
if (inv.folders.length === 0) {
|
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");
|
|
@@ -103,13 +103,8 @@ async function readVault(dbPath) {
|
|
|
103
103
|
const count = (sql) => db.prepare(sql).get().n;
|
|
104
104
|
return {
|
|
105
105
|
vault: {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// counted apart: a global secret belongs to every project.
|
|
109
|
-
projectSecrets: count("SELECT COUNT(*) AS n FROM secret WHERE project_slug != ''"),
|
|
110
|
-
globalSecrets: count("SELECT COUNT(*) AS n FROM secret WHERE project_slug = ''"),
|
|
111
|
-
projectBlocks: count("SELECT COUNT(*) AS n FROM credential WHERE project_slug != ''"),
|
|
112
|
-
globalBlocks: count("SELECT COUNT(*) AS n FROM credential WHERE project_slug = ''"),
|
|
106
|
+
secrets: count("SELECT COUNT(*) AS n FROM secret"),
|
|
107
|
+
blocks: count("SELECT COUNT(*) AS n FROM credential"),
|
|
113
108
|
runs: count("SELECT COUNT(*) AS n FROM run"),
|
|
114
109
|
},
|
|
115
110
|
unreadable: null,
|
|
@@ -221,19 +216,8 @@ export function renderInventory(inv) {
|
|
|
221
216
|
}
|
|
222
217
|
else {
|
|
223
218
|
const v = inv.db.vault;
|
|
224
|
-
out += field("secrets",
|
|
225
|
-
|
|
226
|
-
// above: a global secret is read by every project on this machine, and
|
|
227
|
-
// "in scope" is the fact someone needs before they answer the question.
|
|
228
|
-
out +=
|
|
229
|
-
field("", v.globalSecrets === 0
|
|
230
|
-
? dim("no global secret")
|
|
231
|
-
: `${plural(v.globalSecrets, "global secret")} — shared by every project, ${bold("removed too")}`) + "\n";
|
|
232
|
-
out += field("signing", `${plural(v.projectBlocks, "project signing block")}`) + "\n";
|
|
233
|
-
out +=
|
|
234
|
-
field("", v.globalBlocks === 0
|
|
235
|
-
? dim("no global signing block")
|
|
236
|
-
: `${plural(v.globalBlocks, "global signing block")} — shared by every project, ${bold("removed too")}`) + "\n";
|
|
219
|
+
out += field("secrets", plural(v.secrets, "secret")) + "\n";
|
|
220
|
+
out += field("signing", plural(v.blocks, "signing block")) + "\n";
|
|
237
221
|
out += field("history", plural(v.runs, "run")) + "\n";
|
|
238
222
|
}
|
|
239
223
|
}
|
|
@@ -267,7 +251,7 @@ export function renderInventory(inv) {
|
|
|
267
251
|
*/
|
|
268
252
|
export function renderIrreversible(inv) {
|
|
269
253
|
const v = inv.db?.vault;
|
|
270
|
-
const stored = v === undefined || v === null ? null : v.
|
|
254
|
+
const stored = v === undefined || v === null ? null : v.secrets + v.blocks;
|
|
271
255
|
return (heading("what cannot be undone") +
|
|
272
256
|
warn(`The vault key is the one thing here that has no other copy.\n`) +
|
|
273
257
|
dim(" Every secret and every signing block is encrypted under " + (inv.key?.path ?? join(inv.home, "key")) + ".\n") +
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, normalize } from "node:path";
|
|
3
|
+
import { parseDocument, YAMLMap, YAMLSeq } from "yaml";
|
|
4
|
+
import { serializeYaml } from "./yaml.js";
|
|
5
|
+
/**
|
|
6
|
+
* Sets or clears a project's `env_file`, in `config.yml`, leaving the rest of
|
|
7
|
+
* the file alone.
|
|
8
|
+
*
|
|
9
|
+
* The setting is configuration, so it lives in a file — that is what makes
|
|
10
|
+
* backing Laneyard up a matter of copying one. But it is also the only way to
|
|
11
|
+
* turn the environment file on, and a feature reachable exclusively by editing
|
|
12
|
+
* YAML by hand is one nobody finds. So the screen writes the file, the way the
|
|
13
|
+
* accounts screen already writes accounts and the removal screen already
|
|
14
|
+
* removes a project's block.
|
|
15
|
+
*
|
|
16
|
+
* A `laneyard.yml` in the repository still wins, unchanged: that precedence is
|
|
17
|
+
* the same for every setting, and this function is not the place to invent an
|
|
18
|
+
* exception to it. The screen reports it rather than offering to fight it.
|
|
19
|
+
*
|
|
20
|
+
* Returns false when no project carries that slug, so the caller can answer 404
|
|
21
|
+
* rather than rewrite the file to say what it already said.
|
|
22
|
+
*/
|
|
23
|
+
export async function setEnvFileSetting(path, slug, envFile) {
|
|
24
|
+
const doc = parseDocument(await readFile(path, "utf8"));
|
|
25
|
+
const projects = doc.get("projects");
|
|
26
|
+
if (!(projects instanceof YAMLSeq))
|
|
27
|
+
return false;
|
|
28
|
+
const entry = projects.items.find((item) => item instanceof YAMLMap && item.get("slug") === slug);
|
|
29
|
+
if (!entry)
|
|
30
|
+
return false;
|
|
31
|
+
if (envFile === null)
|
|
32
|
+
entry.delete("env_file");
|
|
33
|
+
else
|
|
34
|
+
entry.set("env_file", envFile);
|
|
35
|
+
await writeFile(path, serializeYaml(doc), "utf8");
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Why this path cannot be used, or null.
|
|
40
|
+
*
|
|
41
|
+
* The same rule the schema enforces at load, applied here so a bad value is
|
|
42
|
+
* refused by the screen that typed it rather than accepted and then reported as
|
|
43
|
+
* a broken configuration on the next reload. The file holds the values the vault
|
|
44
|
+
* exists to protect, and a path climbing out of the app would let it be dropped
|
|
45
|
+
* anywhere on the server.
|
|
46
|
+
*/
|
|
47
|
+
export function envFileProblem(envFile) {
|
|
48
|
+
if (envFile === "")
|
|
49
|
+
return "A path is required.";
|
|
50
|
+
if (isAbsolute(envFile))
|
|
51
|
+
return "A path inside the app, not an absolute one.";
|
|
52
|
+
const clean = normalize(envFile);
|
|
53
|
+
if (clean === ".." || clean.startsWith("../") || clean.startsWith("..\\")) {
|
|
54
|
+
return "A path inside the app, not one that climbs out of it.";
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
@@ -18,13 +18,13 @@ function underApp(appRoot, p) {
|
|
|
18
18
|
* duplicated keeps its file unchanged. But everything downstream resolves paths
|
|
19
19
|
* as repo-root-relative (`join(workspacePath, …)`, globs with `cwd:
|
|
20
20
|
* workspacePath`). So the app-relativity is collapsed here, once, at the
|
|
21
|
-
* boundary: the
|
|
21
|
+
* boundary: the path fields are prefixed with the app directory before the
|
|
22
22
|
* merge ever sees them, and nothing past this point learns a new rule.
|
|
23
23
|
*
|
|
24
|
-
* Only the path fields move
|
|
25
|
-
* `
|
|
26
|
-
*
|
|
27
|
-
* already repo-root-relative.
|
|
24
|
+
* Only the path fields move — `fastlane_dir`, `artifact_globs` and `env_file`.
|
|
25
|
+
* `platforms`, `runtime`, `timeout_minutes`, `interactive_default`,
|
|
26
|
+
* `required_secrets` and `retention` are not paths and pass through untouched. A
|
|
27
|
+
* root-level file is never handed here: its paths are already repo-root-relative.
|
|
28
28
|
*/
|
|
29
29
|
export function normaliseAppConfig(repo, appRoot) {
|
|
30
30
|
const out = { ...repo };
|
|
@@ -34,6 +34,9 @@ export function normaliseAppConfig(repo, appRoot) {
|
|
|
34
34
|
if (repo.artifact_globs !== undefined) {
|
|
35
35
|
out.artifact_globs = repo.artifact_globs.map((g) => underApp(appRoot, g));
|
|
36
36
|
}
|
|
37
|
+
if (repo.env_file !== undefined) {
|
|
38
|
+
out.env_file = underApp(appRoot, repo.env_file);
|
|
39
|
+
}
|
|
37
40
|
return out;
|
|
38
41
|
}
|
|
39
42
|
/**
|
|
@@ -1,4 +1,18 @@
|
|
|
1
|
+
import { isAbsolute, normalize } from "node:path";
|
|
1
2
|
import { z } from "zod";
|
|
3
|
+
/**
|
|
4
|
+
* Whether a relative path leaves the directory it is relative to.
|
|
5
|
+
*
|
|
6
|
+
* `normalize` collapses `a/../../b` to `../b`, so one check after it catches
|
|
7
|
+
* both the obvious `../.env` and the roundabout spelling. An absolute path is
|
|
8
|
+
* refused outright: it does not mean "inside the app" under any reading.
|
|
9
|
+
*/
|
|
10
|
+
function escapesApp(p) {
|
|
11
|
+
if (isAbsolute(p))
|
|
12
|
+
return true;
|
|
13
|
+
const clean = normalize(p);
|
|
14
|
+
return clean === ".." || clean.startsWith(`..${"/"}`) || clean.startsWith("..\\");
|
|
15
|
+
}
|
|
2
16
|
/** A slug is used as a folder name and a URL segment. */
|
|
3
17
|
const slugSchema = z
|
|
4
18
|
.string()
|
|
@@ -17,6 +31,21 @@ export const projectSettingsSchema = z.object({
|
|
|
17
31
|
interactive_default: z.boolean().default(false),
|
|
18
32
|
artifact_globs: z.array(z.string()).default([]),
|
|
19
33
|
required_secrets: z.array(z.string()).default([]),
|
|
34
|
+
// Where to write the file the build reads from disk — a gitignored `.env`, a
|
|
35
|
+
// `config.json` for `--dart-define-from-file`. No default: absent means the
|
|
36
|
+
// project wants no such file, which is almost every project, and an empty
|
|
37
|
+
// string would be a path to interpret rather than an absence to respect.
|
|
38
|
+
//
|
|
39
|
+
// Relative to the app directory, like `fastlane_dir`. A path that climbs out
|
|
40
|
+
// of it is refused here rather than at write time: the file holds the values
|
|
41
|
+
// the vault exists to protect, and a configuration must never be able to drop
|
|
42
|
+
// one anywhere on the server. Refusing at load also means the last valid
|
|
43
|
+
// configuration stays live, which is what every other bad value gets.
|
|
44
|
+
env_file: z
|
|
45
|
+
.string()
|
|
46
|
+
.min(1, "env_file: a path, or leave it out")
|
|
47
|
+
.refine((p) => !escapesApp(p), "env_file: a path inside the app, not one that climbs out of it")
|
|
48
|
+
.optional(),
|
|
20
49
|
// What this project builds for. No default: absent means "nobody said", and
|
|
21
50
|
// the readiness checklist falls back to looking at the repository. Setting it
|
|
22
51
|
// is how a repository that happens to carry an Xcode project it never builds
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
const GLOBAL = "";
|
|
2
1
|
/**
|
|
3
2
|
* Stores signing credential blocks: a file plus the fields that make it
|
|
4
3
|
* usable. Knows nothing about encryption itself: it takes and returns
|
|
5
4
|
* ciphertext, so a bug here cannot leak a plaintext value.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* One project, one block per kind. A block used to be storable under no project
|
|
7
|
+
* and read by all of them — an App Store Connect key is usually one per
|
|
8
|
+
* developer account, not one per app, so the sharing matched something real.
|
|
9
|
+
* It went anyway: it meant no screen could show what a project actually signs
|
|
10
|
+
* with. Five apps under one account now hold five copies of the key, and
|
|
11
|
+
* rotating it means replacing five.
|
|
9
12
|
*/
|
|
10
13
|
export class CredentialStore {
|
|
11
14
|
db;
|
|
@@ -22,68 +25,30 @@ export class CredentialStore {
|
|
|
22
25
|
fields_enc = excluded.fields_enc,
|
|
23
26
|
var_names = excluded.var_names,
|
|
24
27
|
updated_at = excluded.updated_at`)
|
|
25
|
-
.run(projectSlug
|
|
28
|
+
.run(projectSlug, kind, input.fileName, input.fileEnc, input.fieldsEnc, JSON.stringify(input.varNames), new Date().toISOString());
|
|
26
29
|
}
|
|
27
|
-
/**
|
|
28
|
-
|
|
30
|
+
/** This project's blocks, ciphertext included. */
|
|
31
|
+
rows(projectSlug) {
|
|
29
32
|
const rows = this.db
|
|
30
|
-
.prepare("SELECT * FROM credential WHERE project_slug
|
|
31
|
-
.all(projectSlug
|
|
32
|
-
|
|
33
|
-
for (const row of rows) {
|
|
34
|
-
const existing = byKind.get(row.kind);
|
|
35
|
-
if (!existing || row.project_slug !== GLOBAL)
|
|
36
|
-
byKind.set(row.kind, row);
|
|
37
|
-
}
|
|
38
|
-
return [...byKind.values()]
|
|
39
|
-
.sort((a, b) => a.kind.localeCompare(b.kind))
|
|
40
|
-
.map((row) => this.toSummary(row));
|
|
33
|
+
.prepare("SELECT * FROM credential WHERE project_slug = ? ORDER BY kind")
|
|
34
|
+
.all(projectSlug);
|
|
35
|
+
return rows.map((row) => this.toSummary(row));
|
|
41
36
|
}
|
|
42
|
-
/** One
|
|
37
|
+
/** One block, ciphertext included, or undefined. */
|
|
43
38
|
find(projectSlug, kind) {
|
|
44
|
-
return this.
|
|
39
|
+
return this.rows(projectSlug).find((r) => r.kind === kind);
|
|
45
40
|
}
|
|
46
41
|
list(projectSlug) {
|
|
47
|
-
return this.
|
|
42
|
+
return this.rows(projectSlug).map(({ fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary }) => summary);
|
|
48
43
|
}
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
*
|
|
52
|
-
* Same distinction as `SecretStore.listOwn`, and it matters more here: a
|
|
53
|
-
* global keystore shadowed by nothing is shared by every project on the
|
|
54
|
-
* machine, and counting it as one project's would offer to delete the one
|
|
55
|
-
* credential every other project signs with.
|
|
56
|
-
*/
|
|
57
|
-
listOwn(projectSlug) {
|
|
58
|
-
if (projectSlug === GLOBAL)
|
|
59
|
-
return [];
|
|
60
|
-
const rows = this.db
|
|
61
|
-
.prepare("SELECT * FROM credential WHERE project_slug = ? ORDER BY kind")
|
|
62
|
-
.all(projectSlug);
|
|
63
|
-
return rows.map((row) => {
|
|
64
|
-
const { fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary } = this.toSummary(row);
|
|
65
|
-
return summary;
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
/** Removes every block stored under this slug, and returns how many. */
|
|
69
|
-
removeAllOwn(projectSlug) {
|
|
70
|
-
if (projectSlug === GLOBAL)
|
|
71
|
-
return 0;
|
|
44
|
+
/** Removes every block this project holds, and returns how many. */
|
|
45
|
+
removeAll(projectSlug) {
|
|
72
46
|
return this.db.prepare("DELETE FROM credential WHERE project_slug = ?").run(projectSlug).changes;
|
|
73
47
|
}
|
|
74
|
-
listGlobal() {
|
|
75
|
-
const rows = this.db
|
|
76
|
-
.prepare("SELECT * FROM credential WHERE project_slug = ? ORDER BY kind")
|
|
77
|
-
.all(GLOBAL);
|
|
78
|
-
return rows.map((row) => {
|
|
79
|
-
const { fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary } = this.toSummary(row);
|
|
80
|
-
return summary;
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
48
|
remove(projectSlug, kind) {
|
|
84
49
|
const res = this.db
|
|
85
50
|
.prepare("DELETE FROM credential WHERE project_slug = ? AND kind = ?")
|
|
86
|
-
.run(projectSlug
|
|
51
|
+
.run(projectSlug, kind);
|
|
87
52
|
return res.changes > 0;
|
|
88
53
|
}
|
|
89
54
|
toSummary(row) {
|
|
@@ -92,7 +57,6 @@ export class CredentialStore {
|
|
|
92
57
|
fileName: row.file_name,
|
|
93
58
|
fileEnc: row.file_enc,
|
|
94
59
|
fieldsEnc: row.fields_enc,
|
|
95
|
-
scope: row.project_slug === GLOBAL ? "global" : "project",
|
|
96
60
|
varNames: JSON.parse(row.var_names),
|
|
97
61
|
updatedAt: row.updated_at,
|
|
98
62
|
};
|