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
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment file a build reads from disk.
|
|
3
|
+
*
|
|
4
|
+
* A project's own `.env` — the one its app reads, not the one fastlane reads —
|
|
5
|
+
* is gitignored, and Laneyard builds from a clone, so it is never there. The
|
|
6
|
+
* vault does not answer that on its own: a stored secret becomes an environment
|
|
7
|
+
* variable of the run, which is enough for fastlane and enough for a Fastfile
|
|
8
|
+
* that forwards values itself, and no use at all to anything that reads a
|
|
9
|
+
* *file*. `flutter_dotenv` bundles `.env` as an asset,
|
|
10
|
+
* `--dart-define-from-file=config.json` reads a path at compile time, an
|
|
11
|
+
* `.xcconfig` is a file by definition. None of them looks at the environment,
|
|
12
|
+
* and none of them fails loudly — they produce an app configured with nothing.
|
|
13
|
+
*
|
|
14
|
+
* So the variables a project ticks are rendered into a file at the path its
|
|
15
|
+
* `laneyard.yml` names, for the length of one run, and removed afterwards. The
|
|
16
|
+
* ticking decides membership of this file and nothing else: a ticked secret
|
|
17
|
+
* still reaches the run as an environment variable like every other one.
|
|
18
|
+
*/
|
|
19
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { LANEYARD_MARKER } from "./gradle-properties.js";
|
|
22
|
+
/**
|
|
23
|
+
* Whether a value can be written bare.
|
|
24
|
+
*
|
|
25
|
+
* Anything outside this set is quoted. The set is deliberately narrow — a bare
|
|
26
|
+
* line is a convenience, and being wrong about it costs a value silently read
|
|
27
|
+
* back short. `#` starts a comment, whitespace at either end is trimmed by most
|
|
28
|
+
* readers, and a quote or backslash is an escape somebody has to interpret.
|
|
29
|
+
*/
|
|
30
|
+
const BARE = /^[A-Za-z0-9_./:@+-]*$/;
|
|
31
|
+
/**
|
|
32
|
+
* Escapes a value for a double-quoted dotenv line.
|
|
33
|
+
*
|
|
34
|
+
* Backslash first, or every escape this function then writes would be escaped
|
|
35
|
+
* again by its own output. Newlines become `\n` rather than being written
|
|
36
|
+
* literally: a line break inside a value ends the line, and the parser reads a
|
|
37
|
+
* truncated value and a second variable that does not exist. A private key
|
|
38
|
+
* pasted into a variable is where that happens for real.
|
|
39
|
+
*/
|
|
40
|
+
function escape(value) {
|
|
41
|
+
return value
|
|
42
|
+
.replace(/\\/g, "\\\\")
|
|
43
|
+
.replace(/"/g, '\\"')
|
|
44
|
+
.replace(/\r/g, "\\r")
|
|
45
|
+
.replace(/\n/g, "\\n");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Renders variables as dotenv text.
|
|
49
|
+
*
|
|
50
|
+
* Sorted by name, so a diff between two runs shows what changed rather than
|
|
51
|
+
* what moved. Pure, and tested against a parser rather than against itself:
|
|
52
|
+
* the property worth having is "a reader gets back what went in", and a test
|
|
53
|
+
* written against this function's own idea of the format would pass however
|
|
54
|
+
* wrong that idea was.
|
|
55
|
+
*/
|
|
56
|
+
export function renderDotenv(values) {
|
|
57
|
+
return Object.keys(values)
|
|
58
|
+
.sort()
|
|
59
|
+
.map((key) => dotenvLine(key, values[key]))
|
|
60
|
+
.join("");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* One `KEY=value` line, quoted if the value needs it.
|
|
64
|
+
*
|
|
65
|
+
* Exported for the preview the secrets screen shows, which renders a stand-in
|
|
66
|
+
* for every masked value rather than the value. It has to compose its own lines
|
|
67
|
+
* — a row of dots is not ASCII, so it would come back quoted, and the quotes
|
|
68
|
+
* would be a claim about the real value that nobody can check. It still goes
|
|
69
|
+
* through this function for everything it does show, so the preview and the
|
|
70
|
+
* file cannot drift apart in how they write a line.
|
|
71
|
+
*/
|
|
72
|
+
export function dotenvLine(key, value) {
|
|
73
|
+
return `${key}=${BARE.test(value) ? value : `"${escape(value)}"`}\n`;
|
|
74
|
+
}
|
|
75
|
+
/** The first line of a file, or null when there is no file to read. */
|
|
76
|
+
async function firstLine(path) {
|
|
77
|
+
const text = await readFile(path, "utf8").catch(() => null);
|
|
78
|
+
return text === null ? null : (text.split("\n")[0] ?? "");
|
|
79
|
+
}
|
|
80
|
+
/** Whether this file is one Laneyard wrote, and may therefore replace or remove. */
|
|
81
|
+
async function ours(path) {
|
|
82
|
+
const line = await firstLine(path);
|
|
83
|
+
return line === null || line.trimEnd() === LANEYARD_MARKER;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Writes the environment file, and returns its path — or null, having written
|
|
87
|
+
* nothing.
|
|
88
|
+
*
|
|
89
|
+
* Null has two causes and neither is an error: the project names no `env_file`,
|
|
90
|
+
* or a file is already there that Laneyard did not write. That second one is
|
|
91
|
+
* the important guard. The path points into a clone the user can work in by
|
|
92
|
+
* hand, and a `.env` they put there themselves is their build's real
|
|
93
|
+
* configuration — overwriting it would replace something that works with
|
|
94
|
+
* something assembled from a checklist. It is left exactly as it is.
|
|
95
|
+
*
|
|
96
|
+
* The marker is the first line, so the file says what it is to anyone who opens
|
|
97
|
+
* it, and so this function can tell its own output from a person's on the next
|
|
98
|
+
* run. `#` is a comment in dotenv, which is the reason dotenv is the only
|
|
99
|
+
* format for now: JSON has nowhere to put a line like this.
|
|
100
|
+
*/
|
|
101
|
+
export async function writeEnvFile(appRoot, envFile, values) {
|
|
102
|
+
if (envFile === undefined)
|
|
103
|
+
return null;
|
|
104
|
+
const path = join(appRoot, envFile);
|
|
105
|
+
if (!(await ours(path)))
|
|
106
|
+
return null;
|
|
107
|
+
await mkdir(dirname(path), { recursive: true });
|
|
108
|
+
// The mode is set twice, as in `materialise.ts` and `gradle-properties.ts`:
|
|
109
|
+
// `writeFile`'s mode is masked by the process umask, and ignored outright for
|
|
110
|
+
// a file that already exists. This one holds whatever the vault held.
|
|
111
|
+
await writeFile(path, `${LANEYARD_MARKER}\n${renderDotenv(values)}`, { mode: 0o600 });
|
|
112
|
+
await chmod(path, 0o600);
|
|
113
|
+
return path;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Removes a file this run wrote, and only such a file.
|
|
117
|
+
*
|
|
118
|
+
* Called from the run's `finally`, so the values do not outlive the build that
|
|
119
|
+
* needed them. Takes null so the caller can hand it whatever `writeEnvFile`
|
|
120
|
+
* returned without a branch of its own.
|
|
121
|
+
*/
|
|
122
|
+
export async function removeEnvFile(path) {
|
|
123
|
+
if (path === null)
|
|
124
|
+
return;
|
|
125
|
+
if ((await firstLine(path))?.trimEnd() !== LANEYARD_MARKER)
|
|
126
|
+
return;
|
|
127
|
+
await rm(path, { force: true }).catch(() => { });
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Removes a marked file left where this run would write one.
|
|
131
|
+
*
|
|
132
|
+
* A run killed between writing the file and reaching its `finally` — an
|
|
133
|
+
* unplugged server, a `kill -9` — leaves values in a working tree that is kept
|
|
134
|
+
* between runs. Nothing at that moment can clean up after itself, so the next
|
|
135
|
+
* run does it first.
|
|
136
|
+
*
|
|
137
|
+
* Silent about everything: a workspace that was never cloned has nothing to
|
|
138
|
+
* sweep, and a sweep that failed must not be the reason a build did not start.
|
|
139
|
+
*/
|
|
140
|
+
export async function sweepEnvFile(appRoot, envFile) {
|
|
141
|
+
if (envFile === undefined)
|
|
142
|
+
return;
|
|
143
|
+
await removeEnvFile(join(appRoot, envFile)).catch(() => { });
|
|
144
|
+
}
|
|
@@ -4,8 +4,10 @@ import { gitEnvFor, Workspace } from "../git/workspace.js";
|
|
|
4
4
|
import { summarizeFailure } from "../heuristics/error-summary.js";
|
|
5
5
|
import { appRootOf, searchDir } from "../heuristics/platforms.js";
|
|
6
6
|
import { Redactor, scrub } from "../logs/redact.js";
|
|
7
|
+
import { assertFastlaneDir } from "../sidecar/fastlane-dir.js";
|
|
7
8
|
import { collectArtifacts } from "./artifacts.js";
|
|
8
9
|
import { removeGradleProperties, sweepGradleProperties, writeGradleProperties, } from "./gradle-properties.js";
|
|
10
|
+
import { removeEnvFile, sweepEnvFile, writeEnvFile } from "./env-file.js";
|
|
9
11
|
import { LiveStepTracker } from "./live-steps.js";
|
|
10
12
|
import { startPty } from "./pty.js";
|
|
11
13
|
import { readReport } from "./report.js";
|
|
@@ -30,13 +32,14 @@ import { readReport } from "./report.js";
|
|
|
30
32
|
* inside the run and has to be visible to a `finally` wrapped around all of it.
|
|
31
33
|
*/
|
|
32
34
|
export async function executeRun(opts) {
|
|
33
|
-
const wrote = { properties: null };
|
|
35
|
+
const wrote = { properties: null, envFile: null };
|
|
34
36
|
try {
|
|
35
37
|
return await execute(opts, wrote);
|
|
36
38
|
}
|
|
37
39
|
finally {
|
|
38
40
|
// Guarded by the marker, so a file the user replaced mid-run survives.
|
|
39
41
|
await removeGradleProperties(wrote.properties).catch(() => { });
|
|
42
|
+
await removeEnvFile(wrote.envFile).catch(() => { });
|
|
40
43
|
// Deliberately swallowed: by now the log writer is closed and the run's
|
|
41
44
|
// verdict is recorded, so there is nowhere left to report this without
|
|
42
45
|
// rewriting a finished run's outcome as a failure it did not have. A
|
|
@@ -122,10 +125,25 @@ async function execute(opts, wrote) {
|
|
|
122
125
|
// the next run is the first moment anything can. Its failure is not reported
|
|
123
126
|
// — a leftover that resists removal is a broken disk, not a broken build, and
|
|
124
127
|
// saying so here would fail a run for a file it has not needed yet.
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
//
|
|
129
|
+
// The app's own directory, which is the parent of the configured fastlane
|
|
130
|
+
// folder: the repository root for a plain app, `app/` for one in a monorepo.
|
|
131
|
+
// It is where the properties file goes and, below, where fastlane is started.
|
|
132
|
+
const appRoot = searchDir(opts.workspacePath, appRootOf(settings.fastlane_dir));
|
|
133
|
+
// Before any of that, and before fastlane: a clone without the folder is the
|
|
134
|
+
// one failure fastlane reports worst. It asks whether to set a project up,
|
|
135
|
+
// finds nobody to answer, and prints a Ruby backtrace and a list of GitHub
|
|
136
|
+
// issues over the sentence that mattered. This says it in one line instead.
|
|
137
|
+
try {
|
|
138
|
+
await assertFastlaneDir(opts.workspacePath, settings.fastlane_dir);
|
|
139
|
+
}
|
|
140
|
+
catch (cause) {
|
|
141
|
+
return fail(cause.message);
|
|
142
|
+
}
|
|
143
|
+
await sweepGradleProperties(appRoot, opts.androidKeystore).catch(() => { });
|
|
144
|
+
await sweepEnvFile(appRoot, settings.env_file).catch(() => { });
|
|
127
145
|
try {
|
|
128
|
-
wrote.properties = await writeGradleProperties(
|
|
146
|
+
wrote.properties = await writeGradleProperties(appRoot, opts.androidKeystore);
|
|
129
147
|
}
|
|
130
148
|
catch (cause) {
|
|
131
149
|
// Failing the run rather than carrying on. Carrying on is what produces the
|
|
@@ -133,6 +151,16 @@ async function execute(opts, wrote) {
|
|
|
133
151
|
// succeeds, signed with the debug key, rejected by the store days later.
|
|
134
152
|
return fail(`Could not write the signing properties file: ${cause.message}`);
|
|
135
153
|
}
|
|
154
|
+
try {
|
|
155
|
+
wrote.envFile = await writeEnvFile(appRoot, settings.env_file, opts.envFileValues ?? {});
|
|
156
|
+
}
|
|
157
|
+
catch (cause) {
|
|
158
|
+
// Failing for the same reason, and it is the same shape of failure. A build
|
|
159
|
+
// that carries on without the file it was configured to read does not stop:
|
|
160
|
+
// it produces an app pointed at nothing, which is discovered by a person
|
|
161
|
+
// opening it rather than by this run.
|
|
162
|
+
return fail(`Could not write the environment file: ${cause.message}`);
|
|
163
|
+
}
|
|
136
164
|
// Cancelled during preparation: fastlane never gets to start.
|
|
137
165
|
if (opts.signal?.aborted) {
|
|
138
166
|
return finishAs("cancelled", "Cancelled");
|
|
@@ -162,7 +190,13 @@ async function execute(opts, wrote) {
|
|
|
162
190
|
args: useBundle
|
|
163
191
|
? ["exec", "fastlane", ...laneArgs(opts)]
|
|
164
192
|
: laneArgs(opts),
|
|
165
|
-
|
|
193
|
+
// The app's directory, not the repository root. fastlane looks for
|
|
194
|
+
// `fastlane/` in the directory it was started from and nowhere else — no
|
|
195
|
+
// walking up, no looking down — so a project whose Fastfile sits in
|
|
196
|
+
// `app/fastlane` has to be started from `app`. Started from the root it
|
|
197
|
+
// finds nothing and offers to set fastlane up, which on a build server
|
|
198
|
+
// means a crash: there is nobody there to answer the question.
|
|
199
|
+
cwd: appRoot,
|
|
166
200
|
env: {
|
|
167
201
|
...opts.env,
|
|
168
202
|
...(opts.secrets ?? {}),
|
|
@@ -21,8 +21,8 @@ export class Vault {
|
|
|
21
21
|
static async open(home, store, credentials) {
|
|
22
22
|
return new Vault(await loadOrCreateKey(home), store, credentials);
|
|
23
23
|
}
|
|
24
|
-
async set(projectSlug, key, value, masked) {
|
|
25
|
-
this.store.set(projectSlug, key, encrypt(value, this.key), masked);
|
|
24
|
+
async set(projectSlug, key, value, masked, inEnvFile = false) {
|
|
25
|
+
this.store.set(projectSlug, key, encrypt(value, this.key), masked, inEnvFile);
|
|
26
26
|
}
|
|
27
27
|
remove(projectSlug, key) {
|
|
28
28
|
return this.store.remove(projectSlug, key);
|
|
@@ -30,23 +30,41 @@ export class Vault {
|
|
|
30
30
|
list(projectSlug) {
|
|
31
31
|
return this.store.list(projectSlug);
|
|
32
32
|
}
|
|
33
|
-
listGlobal() {
|
|
34
|
-
return this.store.listGlobal();
|
|
35
|
-
}
|
|
36
33
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
34
|
+
* The same listing, with the value attached wherever there is nothing to hide.
|
|
35
|
+
*
|
|
36
|
+
* The rule is the user's own tick box and nothing else: `masked` means "keep
|
|
37
|
+
* this out of the build logs", and a value carrying it is never returned,
|
|
38
|
+
* whoever asks. A value without it is printed verbatim in every log the lane
|
|
39
|
+
* produces — hiding it on the one screen where you might want to check it
|
|
40
|
+
* protects nothing and costs the check.
|
|
41
|
+
*
|
|
42
|
+
* That is why this is not `list`. Everything else in the server asks a
|
|
43
|
+
* question about names — is this one stored, is that one missing — and it
|
|
44
|
+
* would be a poor trade to hand all of them plaintext for it. One method, one
|
|
45
|
+
* caller, and `list` stays the answer to "what is here".
|
|
39
46
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
47
|
+
* A masked value stays out even though `reveal` would now return it: a secret
|
|
48
|
+
* is readable on request, one key at a time, and that is not the same as
|
|
49
|
+
* putting every secret a project holds into a browser because someone opened
|
|
50
|
+
* a tab.
|
|
51
|
+
*
|
|
52
|
+
* A row that will not decrypt loses its value and keeps its name, the same
|
|
53
|
+
* leniency `resolve` takes: a rotated key should cost one line of one screen,
|
|
54
|
+
* not the screen.
|
|
44
55
|
*/
|
|
45
|
-
|
|
46
|
-
return {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
listWithValues(projectSlug) {
|
|
57
|
+
return this.store.list(projectSlug).map((summary) => {
|
|
58
|
+
if (summary.masked)
|
|
59
|
+
return summary;
|
|
60
|
+
try {
|
|
61
|
+
const value = this.reveal(projectSlug, summary.key);
|
|
62
|
+
return value === null ? summary : { ...summary, value };
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return summary;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
50
68
|
}
|
|
51
69
|
/**
|
|
52
70
|
* Forgets everything stored under one project's name.
|
|
@@ -56,13 +74,15 @@ export class Vault {
|
|
|
56
74
|
* covers all of it. What it forgets is Laneyard's own encrypted copy — the
|
|
57
75
|
* `.p8` and the keystore that went in are still wherever the user keeps them.
|
|
58
76
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
77
|
+
* There is nothing it has to leave behind. Everything a project sees is a row
|
|
78
|
+
* under its own slug, so "what would a run see" and "what would be left if the
|
|
79
|
+
* project went away" are now the same question — which is most of the point of
|
|
80
|
+
* having one scope.
|
|
61
81
|
*/
|
|
62
82
|
forget(projectSlug) {
|
|
63
83
|
return {
|
|
64
|
-
secrets: this.store.
|
|
65
|
-
credentials: this.credentials.
|
|
84
|
+
secrets: this.store.removeAll(projectSlug),
|
|
85
|
+
credentials: this.credentials.removeAll(projectSlug),
|
|
66
86
|
};
|
|
67
87
|
}
|
|
68
88
|
/**
|
|
@@ -75,7 +95,7 @@ export class Vault {
|
|
|
75
95
|
return scrub(text, this.maskedValues(projectSlug));
|
|
76
96
|
}
|
|
77
97
|
/**
|
|
78
|
-
* Every secret
|
|
98
|
+
* Every secret a project holds, ready to become environment variables.
|
|
79
99
|
*
|
|
80
100
|
* A row that will not decrypt is skipped rather than thrown: a key that was
|
|
81
101
|
* rotated or a corrupted row should cost one variable, not the whole build.
|
|
@@ -94,35 +114,53 @@ export class Vault {
|
|
|
94
114
|
return out;
|
|
95
115
|
}
|
|
96
116
|
/**
|
|
97
|
-
* One value, in the clear
|
|
117
|
+
* One value, in the clear, whether or not it is masked.
|
|
98
118
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
119
|
+
* This used to refuse a masked value outright: the vault was write-only, no
|
|
120
|
+
* route ever sent one back, and the secrets screen offered nothing to press.
|
|
121
|
+
* The property was real and it was worth less than it looked. `masked` means
|
|
122
|
+
* "keep this out of the build logs" — it is about what a run prints, and it
|
|
123
|
+
* was doing double duty as "and nobody may ever look at it again", which
|
|
124
|
+
* nobody asked for. A passphrase stored six months ago and now suspected of a
|
|
125
|
+
* typo could only be replaced, never checked, and replacing a credential you
|
|
126
|
+
* cannot read is how the wrong one gets stored twice.
|
|
103
127
|
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
128
|
+
* What the refusal actually bought was narrow, because everything around it
|
|
129
|
+
* still holds: the screen is admin-only, one key is read per request, and a
|
|
130
|
+
* masked value is still absent from `listWithValues`, so opening the page puts
|
|
131
|
+
* none of them in a browser. What is gone is only the guarantee that a value
|
|
132
|
+
* never leaves the server — which was never what protected it. Redaction of
|
|
133
|
+
* the logs is untouched and is the property that matters.
|
|
109
134
|
*
|
|
110
|
-
* Returns null for an unknown key
|
|
111
|
-
* that forgot to check must fail loudly rather than leak.
|
|
135
|
+
* Returns null for an unknown key.
|
|
112
136
|
*/
|
|
113
137
|
reveal(projectSlug, key) {
|
|
114
138
|
const row = this.store.find(projectSlug, key);
|
|
115
139
|
if (!row)
|
|
116
140
|
return null;
|
|
117
|
-
if (row.masked) {
|
|
118
|
-
throw new Error(`${key} is kept out of the logs, so its value is never sent back.`);
|
|
119
|
-
}
|
|
120
141
|
return decrypt(row.valueEnc, this.key);
|
|
121
142
|
}
|
|
122
143
|
/** Flips whether a value is kept out of the logs, leaving the value alone. */
|
|
123
144
|
setMasked(projectSlug, key, masked) {
|
|
124
145
|
return this.store.setMasked(projectSlug, key, masked);
|
|
125
146
|
}
|
|
147
|
+
/** Flips whether a value is written into the environment file. */
|
|
148
|
+
setInEnvFile(projectSlug, key, inEnvFile) {
|
|
149
|
+
return this.store.setInEnvFile(projectSlug, key, inEnvFile);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The variables that go in the environment file, in the clear, ready to be
|
|
153
|
+
* rendered.
|
|
154
|
+
*
|
|
155
|
+
* Decrypted here rather than in the writer, so plaintext stays inside this one
|
|
156
|
+
* file — the property the whole module exists to make checkable by reading.
|
|
157
|
+
* A row that will not decrypt is skipped, the same leniency `resolve` takes:
|
|
158
|
+
* the build then fails on its own terms rather than on a rotated key.
|
|
159
|
+
*/
|
|
160
|
+
envFileValues(projectSlug) {
|
|
161
|
+
const wanted = new Set(this.store.envFileKeys(projectSlug));
|
|
162
|
+
return Object.fromEntries(Object.entries(this.resolve(projectSlug)).filter(([key]) => wanted.has(key)));
|
|
163
|
+
}
|
|
126
164
|
/**
|
|
127
165
|
* Stores a signing block: the file, and the fields that make it usable.
|
|
128
166
|
*
|
|
@@ -140,7 +178,7 @@ export class Vault {
|
|
|
140
178
|
});
|
|
141
179
|
}
|
|
142
180
|
/**
|
|
143
|
-
* One
|
|
181
|
+
* One of a project's blocks, in the clear, or undefined if there is none.
|
|
144
182
|
*
|
|
145
183
|
* Unlike `resolve`, an unreadable row throws. The leniency there is earned: a
|
|
146
184
|
* missing variable makes fastlane stop and say which one. A block that quietly
|
|
@@ -168,9 +206,6 @@ export class Vault {
|
|
|
168
206
|
listCredentials(projectSlug) {
|
|
169
207
|
return this.credentials.list(projectSlug);
|
|
170
208
|
}
|
|
171
|
-
listGlobalCredentials() {
|
|
172
|
-
return this.credentials.listGlobal();
|
|
173
|
-
}
|
|
174
209
|
removeCredential(projectSlug, kind) {
|
|
175
210
|
return this.credentials.remove(projectSlug, kind);
|
|
176
211
|
}
|
package/dist/src/server/app.js
CHANGED
|
@@ -98,6 +98,10 @@ export async function buildApp(deps) {
|
|
|
98
98
|
},
|
|
99
99
|
env: process.env,
|
|
100
100
|
secrets: ctx.vault.resolve(slug),
|
|
101
|
+
// A subset of the line above, not a second source. The tick decides which
|
|
102
|
+
// variables are also written into the file the build reads from disk;
|
|
103
|
+
// every one of them still reaches the run through the environment.
|
|
104
|
+
envFileValues: ctx.vault.envFileValues(slug),
|
|
101
105
|
credentialEnv: credentials.env,
|
|
102
106
|
androidKeystore: credentials.keystore,
|
|
103
107
|
cleanup: credentials.cleanup,
|
|
@@ -14,7 +14,6 @@ import { CREDENTIAL_KINDS, defaultVarNames, fieldsOf } from "../../credentials/k
|
|
|
14
14
|
/** POSIX environment variable names. Anything else would never reach fastlane. */
|
|
15
15
|
const VALID_VAR = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
16
|
export async function registerCredentialRoutes(app, ctx) {
|
|
17
|
-
app.get("/api/credentials", async () => ctx.vault.listGlobalCredentials());
|
|
18
17
|
app.get("/api/projects/:slug/credentials", async (req, reply) => {
|
|
19
18
|
const { slug } = req.params;
|
|
20
19
|
if (!ctx.config.project(slug))
|
|
@@ -82,24 +81,12 @@ export async function registerCredentialRoutes(app, ctx) {
|
|
|
82
81
|
await ctx.vault.setCredential(slug, spec.kind, { fileName, fileBytes, fields: kept, varNames: names });
|
|
83
82
|
return reply.code(204).send();
|
|
84
83
|
};
|
|
85
|
-
app.put("/api/credentials/:kind", async (req, reply) => put(null, req.params.kind, req.body, reply));
|
|
86
84
|
app.put("/api/projects/:slug/credentials/:kind", async (req, reply) => {
|
|
87
85
|
const { slug, kind } = req.params;
|
|
88
86
|
if (!ctx.config.project(slug))
|
|
89
87
|
return reply.code(404).send({ error: "Unknown project" });
|
|
90
88
|
return put(slug, kind, req.body, reply);
|
|
91
89
|
});
|
|
92
|
-
app.delete("/api/credentials/:kind", async (req, reply) => {
|
|
93
|
-
const { kind } = req.params;
|
|
94
|
-
const removed = ctx.vault.removeCredential(null, kind);
|
|
95
|
-
return removed ? reply.code(204).send() : reply.code(404).send({ error: "Unknown credential" });
|
|
96
|
-
});
|
|
97
|
-
/**
|
|
98
|
-
* Removes this project's own block, and only that one. A global block that
|
|
99
|
-
* was shadowed comes back into view, which is the deletion someone asked for:
|
|
100
|
-
* they are undoing an override, not deleting everyone's key from inside one
|
|
101
|
-
* project.
|
|
102
|
-
*/
|
|
103
90
|
app.delete("/api/projects/:slug/credentials/:kind", async (req, reply) => {
|
|
104
91
|
const { slug, kind } = req.params;
|
|
105
92
|
const removed = ctx.vault.removeCredential(slug, kind);
|
|
@@ -46,7 +46,7 @@ export async function registerFastfileRoutes(app, ctx) {
|
|
|
46
46
|
// uncommitted or gitignored, or a stray local copy. `store.read` would
|
|
47
47
|
// surface that as a bare ENOENT; this turns it into the same sentence the
|
|
48
48
|
// sidecar gives, naming the directory and how to fix it.
|
|
49
|
-
await assertFastlaneDir(
|
|
49
|
+
await assertFastlaneDir(r.workspacePath, r.fastlaneDir);
|
|
50
50
|
const [content, dirty, diff] = await Promise.all([
|
|
51
51
|
store.read(r.workspacePath, r.fastlaneDir),
|
|
52
52
|
r.workspace.isDirty(),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { accountMayReach } from "../permissions.js";
|
|
2
|
+
import { Workspace } from "../../git/workspace.js";
|
|
2
3
|
import { removeProjectData } from "../../data/remove-project.js";
|
|
3
4
|
export async function registerProjectRoutes(app, ctx) {
|
|
4
5
|
// The listing every account is shown, filtered to what it may reach. This is
|
|
@@ -41,9 +42,6 @@ export async function registerProjectRoutes(app, ctx) {
|
|
|
41
42
|
* `.p8` or a keystore; the file that went in is still in the password
|
|
42
43
|
* manager or the safe it came from. The answer says so, the way
|
|
43
44
|
* `uninstall` does, so nobody is left thinking their keystore is gone.
|
|
44
|
-
* - global secrets and global signing blocks. They are read by every project
|
|
45
|
-
* on the machine, not this one's to take — `vault.forget` touches only
|
|
46
|
-
* slug-scoped rows, and the answer counts the global ones it left alone.
|
|
47
45
|
*
|
|
48
46
|
* The confirmation is the project's own slug, sent back as `?confirm=`.
|
|
49
47
|
* Without a match nothing is removed: a bare DELETE is a refusal, not a
|
|
@@ -72,12 +70,6 @@ export async function registerProjectRoutes(app, ctx) {
|
|
|
72
70
|
error: `"${slug}" has a run in flight. Wait for it to finish, or cancel it, then remove the project.`,
|
|
73
71
|
});
|
|
74
72
|
}
|
|
75
|
-
// The global counts are read now — the last moment anyone is looking — so
|
|
76
|
-
// the answer can say what it left alone. They are shared by every project
|
|
77
|
-
// and survive one of them going away, which is why the removal never takes
|
|
78
|
-
// them and the reply names them apart.
|
|
79
|
-
const globalSecrets = ctx.vault.listGlobal().length;
|
|
80
|
-
const globalSigningBlocks = ctx.vault.listGlobalCredentials().length;
|
|
81
73
|
// The removal itself lives in one place, shared with `laneyard remove`: the
|
|
82
74
|
// route confirms and shapes the reply, the core does the deleting.
|
|
83
75
|
const result = await removeProjectData({
|
|
@@ -108,13 +100,6 @@ export async function registerProjectRoutes(app, ctx) {
|
|
|
108
100
|
secrets: result.secrets,
|
|
109
101
|
signingBlocks: result.signingBlocks,
|
|
110
102
|
},
|
|
111
|
-
// Named, not removed. The git remote and the credential originals are the
|
|
112
|
-
// user's and are never touched here; the global rows are shared by every
|
|
113
|
-
// project and survive one of them going away.
|
|
114
|
-
untouched: {
|
|
115
|
-
globalSecrets,
|
|
116
|
-
globalSigningBlocks,
|
|
117
|
-
},
|
|
118
103
|
});
|
|
119
104
|
});
|
|
120
105
|
app.get("/api/projects/:slug/lanes", async (req, reply) => {
|
|
@@ -135,6 +120,64 @@ export async function registerProjectRoutes(app, ctx) {
|
|
|
135
120
|
return reply.code(503).send({ error: cause.message });
|
|
136
121
|
}
|
|
137
122
|
});
|
|
123
|
+
/**
|
|
124
|
+
* Brings the clone up to the remote, without building anything.
|
|
125
|
+
*
|
|
126
|
+
* Everything that reads the repository outside a run — the lanes, the
|
|
127
|
+
* checklist, the names a lane is missing — goes through `ensureCloned`, which
|
|
128
|
+
* does nothing at all once the directory exists. Only a run fetched. So a
|
|
129
|
+
* project whose first run failed early answered from that first commit
|
|
130
|
+
* indefinitely, and the screens said so with the confidence of something just
|
|
131
|
+
* looked up: a variable a Fastfile had stopped reading was still asked for,
|
|
132
|
+
* days after the commit that stopped reading it.
|
|
133
|
+
*
|
|
134
|
+
* Deliberately not a fetch hidden inside those reads. Going out to a git
|
|
135
|
+
* remote is seconds and a network, and putting it behind opening a tab would
|
|
136
|
+
* make every one of them slow and occasionally fail for a reason that has
|
|
137
|
+
* nothing to do with what was asked. It is a button, and this is what it
|
|
138
|
+
* presses.
|
|
139
|
+
*
|
|
140
|
+
* `prepare` and not a bare fetch: the point is to show what the next run would
|
|
141
|
+
* see, and `prepare` is exactly what the next run calls. It clones when there
|
|
142
|
+
* is nothing yet, which makes the button a way in for a project between
|
|
143
|
+
* `setup` and its first build rather than a refusal.
|
|
144
|
+
*
|
|
145
|
+
* Not admin-only. A builder starts runs, and a run fetches — refusing them the
|
|
146
|
+
* same fetch on its own would protect nothing.
|
|
147
|
+
*/
|
|
148
|
+
app.post("/api/projects/:slug/fetch", async (req, reply) => {
|
|
149
|
+
const { slug } = req.params;
|
|
150
|
+
const entry = ctx.config.project(slug);
|
|
151
|
+
if (!entry)
|
|
152
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
153
|
+
// A run that has begun is reading the very files this would move under it —
|
|
154
|
+
// the same reason a Fastfile write is refused while one is in flight.
|
|
155
|
+
if (ctx.runs.hasActiveRun(slug)) {
|
|
156
|
+
return reply.code(409).send({
|
|
157
|
+
error: "A run of this project is in flight. Its workspace is in use until it finishes.",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
const branch = entry.default_branch;
|
|
161
|
+
const workspace = new Workspace(ctx.workspacePath(slug), entry.git_url, entry.git_auth);
|
|
162
|
+
// Refused rather than lost. `prepare` moves the branch onto origin's, and
|
|
163
|
+
// the Fastfile tab commits without pushing by design, so this is a state the
|
|
164
|
+
// product hands people itself.
|
|
165
|
+
const unpushed = await workspace.unpushedCount(branch);
|
|
166
|
+
if (unpushed > 0) {
|
|
167
|
+
return reply.code(409).send({
|
|
168
|
+
error: `The workspace holds ${unpushed} commit${unpushed === 1 ? "" : "s"} that ${unpushed === 1 ? "has" : "have"} not been pushed. ` + "Push from the fastfile tab, or they would be left behind.",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const commitSha = await workspace.prepare(branch);
|
|
173
|
+
return { branch, commitSha };
|
|
174
|
+
}
|
|
175
|
+
catch (cause) {
|
|
176
|
+
// A dirty workspace, an unreachable remote, a branch that is not there:
|
|
177
|
+
// git's own sentence is the only thing that explains any of them.
|
|
178
|
+
return reply.code(409).send({ error: cause.message });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
138
181
|
app.get("/api/projects/:slug/runs", async (req, reply) => {
|
|
139
182
|
const { slug } = req.params;
|
|
140
183
|
if (!ctx.config.project(slug))
|
|
@@ -228,9 +228,8 @@ export async function registerReadinessRoutes(app, ctx) {
|
|
|
228
228
|
},
|
|
229
229
|
// Names only: the vault never hands a value to anything but a run.
|
|
230
230
|
secretKeys: ctx.vault.list(slug).map((s) => s.key),
|
|
231
|
-
// Which blocks
|
|
232
|
-
//
|
|
233
|
-
// disagree about whether a credential exists.
|
|
231
|
+
// Which blocks this project holds, read the way a run reads them, so the
|
|
232
|
+
// checklist and the run cannot disagree about whether a credential exists.
|
|
234
233
|
blocks: ctx.vault.listCredentials(slug).map((c) => c.kind),
|
|
235
234
|
// And the names those blocks will export, which the environment check
|
|
236
235
|
// counts as supplied — Laneyard writes the file and sets the variable
|