laneyard 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -18
- package/dist/src/cli/detect.js +12 -3
- package/dist/src/cli/prompt.js +28 -3
- package/dist/src/cli/secret-import.js +162 -0
- package/dist/src/cli/secret.js +162 -1
- package/dist/src/cli/setup.js +44 -7
- package/dist/src/credentials/kinds.js +120 -0
- package/dist/src/db/credentials.js +75 -0
- package/dist/src/db/schema.sql +39 -0
- package/dist/src/db/secrets.js +28 -0
- package/dist/src/db/sessions.js +61 -0
- package/dist/src/git/workspace.js +32 -6
- package/dist/src/heuristics/android-root.js +49 -0
- package/dist/src/heuristics/android-signing.js +98 -0
- package/dist/src/heuristics/appfile.js +60 -0
- package/dist/src/heuristics/blocking-actions.js +2 -0
- package/dist/src/heuristics/env-example.js +36 -0
- package/dist/src/heuristics/platforms.js +69 -10
- package/dist/src/heuristics/readiness.js +554 -25
- package/dist/src/main.js +10 -1
- package/dist/src/runner/gradle-properties.js +187 -0
- package/dist/src/runner/materialise.js +92 -0
- package/dist/src/runner/orchestrate.js +91 -2
- package/dist/src/secrets/vault.js +106 -5
- package/dist/src/server/app.js +30 -3
- package/dist/src/server/auth.js +50 -10
- package/dist/src/server/permissions.js +2 -0
- package/dist/src/server/required-secrets.js +30 -0
- package/dist/src/server/routes/account.js +57 -0
- package/dist/src/server/routes/credentials.js +108 -0
- package/dist/src/server/routes/readiness.js +162 -6
- package/dist/src/server/routes/secrets.js +101 -0
- package/dist/src/sidecar/bridge.js +21 -1
- package/dist/src/sidecar/fastlane-dir.js +23 -0
- package/dist/src/sidecar/lanes.js +6 -0
- package/dist/src/sidecar/uses.js +21 -5
- package/dist/web/assets/{Editor-DNFBA4gv.js → Editor-DynuBC2l.js} +1 -1
- package/dist/web/assets/index-CpwrNE-K.css +1 -0
- package/dist/web/assets/index-lyZs-Y7J.js +62 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/ruby/introspect.rb +122 -9
- package/dist/web/assets/index-De6sxx6G.css +0 -1
- package/dist/web/assets/index-TWRQ1cJg.js +0 -62
package/dist/src/cli/secret.js
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
1
2
|
import { join } from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { applyImport, envFilesIn, parseEnvFile, planImport } from "./secret-import.js";
|
|
2
6
|
import { ConfigStore } from "../config/store.js";
|
|
3
7
|
import { openDatabase } from "../db/open.js";
|
|
8
|
+
import { CredentialStore } from "../db/credentials.js";
|
|
4
9
|
import { SecretStore } from "../db/secrets.js";
|
|
5
10
|
import { MIN_LENGTH as MIN_REDACTABLE } from "../logs/redact.js";
|
|
6
11
|
import { Vault } from "../secrets/vault.js";
|
|
7
12
|
/** Same rule as the API: what cannot become an environment variable is refused here too. */
|
|
8
13
|
const VALID_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
9
14
|
export const SECRET_USAGE = `laneyard secret set <NAME> [--project <slug>] [--no-mask]
|
|
15
|
+
laneyard secret import --project <slug> [--yes]
|
|
10
16
|
|
|
11
17
|
The value is read from standard input, never from an argument:
|
|
12
18
|
|
|
13
19
|
laneyard secret set MATCH_PASSWORD --project app
|
|
14
20
|
echo "$TOKEN" | laneyard secret set GITHUB_TOKEN
|
|
21
|
+
|
|
22
|
+
\`import\` reads this project's fastlane/.env and stores what it finds. A
|
|
23
|
+
variable naming a service account JSON has the *file* stored, under the name
|
|
24
|
+
fastlane looks for — a path does not travel to a build machine. A variable
|
|
25
|
+
naming a .p8 is left alone and reported instead: that credential belongs in a
|
|
26
|
+
signing block from the secrets tab, not in a loose variable.
|
|
15
27
|
`;
|
|
16
28
|
/**
|
|
17
29
|
* Reads standard input whole.
|
|
@@ -69,6 +81,9 @@ function parse(args) {
|
|
|
69
81
|
*/
|
|
70
82
|
export async function runSecretCommand(home, args, io) {
|
|
71
83
|
const [subcommand, ...rest] = args;
|
|
84
|
+
if (subcommand === "import") {
|
|
85
|
+
return runSecretImport(home, rest, io);
|
|
86
|
+
}
|
|
72
87
|
if (subcommand !== "set") {
|
|
73
88
|
io.err(subcommand === undefined ? `${SECRET_USAGE}` : `Unknown subcommand: ${subcommand}\n\n${SECRET_USAGE}`);
|
|
74
89
|
return 1;
|
|
@@ -121,7 +136,7 @@ export async function runSecretCommand(home, args, io) {
|
|
|
121
136
|
}
|
|
122
137
|
const db = openDatabase(join(home, "laneyard.db"));
|
|
123
138
|
try {
|
|
124
|
-
const vault = await Vault.open(home, new SecretStore(db));
|
|
139
|
+
const vault = await Vault.open(home, new SecretStore(db), new CredentialStore(db));
|
|
125
140
|
await vault.set(parsed.project, parsed.key, value, parsed.masked);
|
|
126
141
|
}
|
|
127
142
|
finally {
|
|
@@ -131,3 +146,149 @@ export async function runSecretCommand(home, args, io) {
|
|
|
131
146
|
` ${parsed.masked ? "kept out of the logs" : "shown in the logs"}\n`);
|
|
132
147
|
return 0;
|
|
133
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* `laneyard secret import` — the existing `.env` into the vault.
|
|
151
|
+
*
|
|
152
|
+
* Shows what it would do before doing it, in names only. An import that reads
|
|
153
|
+
* eight files and writes eight secrets before saying a word is one nobody can
|
|
154
|
+
* check, and this is the one command that handles every credential a project
|
|
155
|
+
* has at once.
|
|
156
|
+
*/
|
|
157
|
+
async function runSecretImport(home, args, io) {
|
|
158
|
+
let project = null;
|
|
159
|
+
let yes = false;
|
|
160
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
161
|
+
const arg = args[i];
|
|
162
|
+
if (arg === "--project" || arg === "-p") {
|
|
163
|
+
const next = args[i + 1];
|
|
164
|
+
if (next === undefined || next.startsWith("-")) {
|
|
165
|
+
io.err("--project needs a slug.\n");
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
project = next;
|
|
169
|
+
i += 1;
|
|
170
|
+
}
|
|
171
|
+
else if (arg === "--yes" || arg === "-y") {
|
|
172
|
+
yes = true;
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
io.err(`Unknown option: ${arg}\n\n${SECRET_USAGE}`);
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (project === null) {
|
|
180
|
+
io.err("Which project? Give it with --project <slug>.\n");
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
const config = new ConfigStore(join(home, "config.yml"));
|
|
184
|
+
const loaded = await config.load();
|
|
185
|
+
if (!loaded.ok) {
|
|
186
|
+
io.err(`Unreadable configuration in ${join(home, "config.yml")}: ${loaded.error}\n`);
|
|
187
|
+
return 1;
|
|
188
|
+
}
|
|
189
|
+
const entry = config.project(project);
|
|
190
|
+
if (!entry) {
|
|
191
|
+
const known = config.projects().map((p) => p.slug);
|
|
192
|
+
io.err(`Unknown project: "${project}".` +
|
|
193
|
+
(known.length > 0 ? ` Known projects: ${known.join(", ")}.` : " No project is declared yet.") +
|
|
194
|
+
"\n");
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
// Read from the working copy this command was run in — the only place a
|
|
198
|
+
// gitignored `.env` exists; the server's clone never has one.
|
|
199
|
+
//
|
|
200
|
+
// Resolved against the repository root, not the current directory:
|
|
201
|
+
// `fastlane_dir` is recorded relative to the root, so running this from
|
|
202
|
+
// `app/` would otherwise look for `app/app/fastlane/.env`. The same
|
|
203
|
+
// confusion, one command along, as the one that produced an ENOENT in the
|
|
204
|
+
// lane list.
|
|
205
|
+
const fastlaneDir = entry.fastlane_dir ?? "fastlane";
|
|
206
|
+
const root = await repositoryRoot(process.cwd());
|
|
207
|
+
const env = new Map();
|
|
208
|
+
const readFrom = [];
|
|
209
|
+
for (const file of envFilesIn(fastlaneDir)) {
|
|
210
|
+
const text = await readFile(join(root, file), "utf8").catch(() => null);
|
|
211
|
+
if (text === null)
|
|
212
|
+
continue;
|
|
213
|
+
readFrom.push(file);
|
|
214
|
+
for (const [k, v] of parseEnvFile(text))
|
|
215
|
+
env.set(k, v);
|
|
216
|
+
}
|
|
217
|
+
if (readFrom.length === 0) {
|
|
218
|
+
io.err(`No ${fastlaneDir}/.env under ${root}. Run this from the working copy that has one — ` +
|
|
219
|
+
"it is the file that never reaches a clone, which is the whole reason for this command.\n");
|
|
220
|
+
return 1;
|
|
221
|
+
}
|
|
222
|
+
const db = openDatabase(join(home, "laneyard.db"));
|
|
223
|
+
try {
|
|
224
|
+
const vault = await Vault.open(home, new SecretStore(db), new CredentialStore(db));
|
|
225
|
+
// Paths inside a `.env` are written relative to where fastlane runs, which
|
|
226
|
+
// is the fastlane directory's parent — the app, not the repository root.
|
|
227
|
+
const plan = await planImport(env, join(root, fastlaneDir, ".."), vault.list(project).map((s) => s.key));
|
|
228
|
+
if (plan.planned.length === 0) {
|
|
229
|
+
io.out(`Nothing to import from ${readFrom.join(" and ")}.\n`);
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
io.out(`\nFrom ${readFrom.join(" and ")}, into "${project}":\n\n`);
|
|
233
|
+
for (const item of plan.planned) {
|
|
234
|
+
if (item.kind === "unresolved-path") {
|
|
235
|
+
io.out(` ✗ ${item.key} — names ${item.path}, which is not there. Skipped.\n`);
|
|
236
|
+
}
|
|
237
|
+
else if (item.kind === "suggest-block") {
|
|
238
|
+
io.out(` ▸ ${item.key} names ${item.path} — no action in fastlane reads that variable. ` +
|
|
239
|
+
"Upload the `.p8` as an App Store Connect key block from the secrets tab instead, with " +
|
|
240
|
+
"its key id and issuer id. Not imported here.\n");
|
|
241
|
+
}
|
|
242
|
+
else if (item.kind === "file-contents") {
|
|
243
|
+
io.out(` ● ${item.key}${item.from ? ` (the contents of ${item.from})` : ""}\n`);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
io.out(` ● ${item.key}\n`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (plan.replacing.length > 0) {
|
|
250
|
+
io.out(`\n ${plan.replacing.join(", ")} already in the vault — they will be replaced.\n`);
|
|
251
|
+
}
|
|
252
|
+
const skipped = plan.planned.filter((p) => p.kind === "unresolved-path");
|
|
253
|
+
if (skipped.length > 0) {
|
|
254
|
+
io.out("\n A skipped file is the credential this project most needs. Check the path.\n");
|
|
255
|
+
}
|
|
256
|
+
if (!yes) {
|
|
257
|
+
io.out("\nNothing is written yet. Run it again with --yes to store these.\n");
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
const stored = await applyImport(vault, project, plan);
|
|
261
|
+
io.out(`\n✓ Stored ${stored} ${stored === 1 ? "secret" : "secrets"}, all kept out of the logs.\n`);
|
|
262
|
+
// Said here because it is easy to assume an import leaves lanes with
|
|
263
|
+
// homework. It does not: `key_filepath:` and `json_key:` are the supported
|
|
264
|
+
// forms, not a stopgap. A signing credential block writes its file to disk
|
|
265
|
+
// for the length of a run, so a lane that names a path keeps reading a
|
|
266
|
+
// real one, unedited.
|
|
267
|
+
io.out("\nNothing in your lanes needs to change. `key_filepath:` and `json_key:` keep working as " +
|
|
268
|
+
"written. From here, a `.p8` or a service account JSON belongs in a signing credential block " +
|
|
269
|
+
"from the secrets tab, not as a loose variable in this file.\n");
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
db.close();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The repository root, or the directory given if this is not a repository.
|
|
278
|
+
*
|
|
279
|
+
* `fastlane_dir` is recorded relative to the root — that is what the clone is
|
|
280
|
+
* measured from — so anything resolving it has to agree about where the root is,
|
|
281
|
+
* whichever sub-directory the command happens to be run from.
|
|
282
|
+
*/
|
|
283
|
+
async function repositoryRoot(from) {
|
|
284
|
+
try {
|
|
285
|
+
const { stdout } = await promisify(execFile)("git", ["rev-parse", "--show-toplevel"], {
|
|
286
|
+
cwd: from,
|
|
287
|
+
timeout: 5_000,
|
|
288
|
+
});
|
|
289
|
+
return stdout.trim() || from;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return from;
|
|
293
|
+
}
|
|
294
|
+
}
|
package/dist/src/cli/setup.js
CHANGED
|
@@ -33,13 +33,25 @@ export async function addProjectToConfig(path, entry) {
|
|
|
33
33
|
const seq = projects instanceof YAMLSeq ? projects : new YAMLSeq();
|
|
34
34
|
if (!(projects instanceof YAMLSeq))
|
|
35
35
|
doc.setIn(["projects"], seq);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
// An entry of the same name is updated, not refused.
|
|
37
|
+
//
|
|
38
|
+
// Setup prints "Continuing replaces its entry" before asking anything, and
|
|
39
|
+
// then this threw — so the one way to correct a stale entry, running setup
|
|
40
|
+
// again, was the one thing that could not be done. A project written by an
|
|
41
|
+
// older version and missing a field it now needs was unfixable except by hand.
|
|
42
|
+
//
|
|
43
|
+
// Field by field rather than wholesale, though the warning says "replaces":
|
|
44
|
+
// an entry may carry things setup knows nothing about — a `git_auth` pointing
|
|
45
|
+
// at an SSH key, a raised `timeout_minutes` — and losing those silently, on a
|
|
46
|
+
// command someone ran to fix something else, would be its own bug.
|
|
47
|
+
const existing = seq.items.find((item) => item.get?.("slug") === entry.slug);
|
|
48
|
+
if (existing?.set) {
|
|
49
|
+
for (const [key, value] of Object.entries(entry))
|
|
50
|
+
existing.set(key, value);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
seq.add(doc.createNode(entry));
|
|
41
54
|
}
|
|
42
|
-
seq.add(doc.createNode(entry));
|
|
43
55
|
await writeFile(path, serialize(doc), "utf8");
|
|
44
56
|
}
|
|
45
57
|
/**
|
|
@@ -105,7 +117,8 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
|
|
|
105
117
|
if (existing) {
|
|
106
118
|
process.stdout.write("\n" +
|
|
107
119
|
warn(`This machine already knows a project called ${bold(existing)}.\n`) +
|
|
108
|
-
dim(" Continuing
|
|
120
|
+
dim(" Continuing updates its entry, keeping anything you added by hand.\n") +
|
|
121
|
+
dim(" Give it another name to keep both.\n"));
|
|
109
122
|
}
|
|
110
123
|
const repoConfigPath = join(repoRoot(cwd, d.subPath), LANEYARD_YML);
|
|
111
124
|
if (await fileExists(repoConfigPath)) {
|
|
@@ -163,6 +176,30 @@ export async function runSetupCommand(cwd, configPath, options = {}) {
|
|
|
163
176
|
name: slug,
|
|
164
177
|
git_url: gitUrl,
|
|
165
178
|
default_branch: branch,
|
|
179
|
+
// Written on this machine too, and only when it is not the default,
|
|
180
|
+
// because of the gap between the two files. Laneyard builds from a clone
|
|
181
|
+
// of the remote, so nothing written into the working copy reaches it
|
|
182
|
+
// until `laneyard.yml` is committed and pushed — and until then
|
|
183
|
+
// `fastlane_dir` falls back to `fastlane`, which in a monorepo is not
|
|
184
|
+
// where anything is. The project was unreadable from the moment setup
|
|
185
|
+
// finished until a git push, with an ENOENT for an explanation.
|
|
186
|
+
//
|
|
187
|
+
// This is not a second source of truth: the repository file wins the
|
|
188
|
+
// moment it lands, which is the precedence `config.yml` already
|
|
189
|
+
// documents and the schema already allows. It is the value setup just
|
|
190
|
+
// proposed and the user just accepted, kept where it is useful until the
|
|
191
|
+
// authoritative copy arrives.
|
|
192
|
+
//
|
|
193
|
+
// Omitted when it *is* the default, so an ordinary project's block stays
|
|
194
|
+
// about how the project is reached and nothing else.
|
|
195
|
+
// Both of the fields the sidecar needs before it can read anything, and
|
|
196
|
+
// only when they are not already the default. `runtime` belongs here for
|
|
197
|
+
// exactly the same reason as `fastlane_dir`: without it the sidecar is
|
|
198
|
+
// launched under `bundle exec` and a project that uses a system fastlane
|
|
199
|
+
// fails with "Could not locate Gemfile" — the same bootstrap gap, one
|
|
200
|
+
// field along.
|
|
201
|
+
...(fastlaneDir === "fastlane" ? {} : { fastlane_dir: fastlaneDir }),
|
|
202
|
+
...(runtime === "bundle" ? {} : { runtime }),
|
|
166
203
|
});
|
|
167
204
|
// The repository half: how it builds. This is the part that was going into
|
|
168
205
|
// the machine's file and therefore never being committed — which is exactly
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each kind of credential block is made of — the one table the server,
|
|
3
|
+
* runner, readiness checks, and web UI all read, so an agreement kept in one
|
|
4
|
+
* place instead of copied four times.
|
|
5
|
+
*
|
|
6
|
+
* Apple and Play defaults are the names fastlane itself reads — verified
|
|
7
|
+
* against fastlane 2.237: `app_store_connect_api_key` declares
|
|
8
|
+
* `APP_STORE_CONNECT_API_KEY_KEY_FILEPATH` / `_KEY_ID` / `..._ISSUER_ID`,
|
|
9
|
+
* and `supply` declares `SUPPLY_JSON_KEY`.
|
|
10
|
+
*
|
|
11
|
+
* Android defaults are Laneyard's own — nothing in fastlane reads a keystore
|
|
12
|
+
* by convention. `ANDROID_KEYSTORE_PASSWORD` was not chosen freely: it
|
|
13
|
+
* matches the `/(^|_)(KEYSTORE|STORE)_PASSWORD$/` pattern that
|
|
14
|
+
* `src/heuristics/readiness.ts` already recognises, so the check and the
|
|
15
|
+
* block agree by construction rather than by coincidence.
|
|
16
|
+
*/
|
|
17
|
+
export const CREDENTIAL_KINDS = [
|
|
18
|
+
{
|
|
19
|
+
kind: "apple_asc",
|
|
20
|
+
platform: "ios",
|
|
21
|
+
what: "app store connect key",
|
|
22
|
+
accept: ".p8",
|
|
23
|
+
fields: [
|
|
24
|
+
{ name: "key_id", secret: false, label: "Key ID" },
|
|
25
|
+
{ name: "issuer_id", secret: false, label: "Issuer ID" },
|
|
26
|
+
],
|
|
27
|
+
defaults: {
|
|
28
|
+
path: "APP_STORE_CONNECT_API_KEY_KEY_FILEPATH",
|
|
29
|
+
key_id: "APP_STORE_CONNECT_API_KEY_KEY_ID",
|
|
30
|
+
issuer_id: "APP_STORE_CONNECT_API_KEY_ISSUER_ID",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
kind: "android_keystore",
|
|
35
|
+
platform: "android",
|
|
36
|
+
what: "android upload keystore",
|
|
37
|
+
accept: ".jks,.keystore",
|
|
38
|
+
fields: [
|
|
39
|
+
{ name: "key_alias", secret: false, label: "Key alias" },
|
|
40
|
+
{ name: "store_password", secret: true, label: "Store password" },
|
|
41
|
+
{ name: "key_password", secret: true, label: "Key password" },
|
|
42
|
+
// The two things about a gradle properties file that cannot be deduced.
|
|
43
|
+
// `runner/gradle-properties.ts` writes that file where a build script
|
|
44
|
+
// falls back to the debug key; the script names the file, and says
|
|
45
|
+
// nothing about where the name is resolved when the receiver is a
|
|
46
|
+
// variable, nor about the keys read out of it afterwards. Both are asked
|
|
47
|
+
// here, pre-filled from what detection could tell, and left empty rather
|
|
48
|
+
// than guessed when it could tell nothing.
|
|
49
|
+
{
|
|
50
|
+
name: "properties_path",
|
|
51
|
+
secret: false,
|
|
52
|
+
optional: true,
|
|
53
|
+
label: "Properties file, relative to the app",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "property_names",
|
|
57
|
+
secret: false,
|
|
58
|
+
optional: true,
|
|
59
|
+
suggested: "storeFile,storePassword,keyPassword,keyAlias",
|
|
60
|
+
label: "Names your build reads inside it",
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
defaults: {
|
|
64
|
+
path: "ANDROID_KEYSTORE_PATH",
|
|
65
|
+
store_password: "ANDROID_KEYSTORE_PASSWORD",
|
|
66
|
+
key_alias: "ANDROID_KEY_ALIAS",
|
|
67
|
+
key_password: "ANDROID_KEY_PASSWORD",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
kind: "play_service_account",
|
|
72
|
+
platform: "android",
|
|
73
|
+
what: "play store service account",
|
|
74
|
+
accept: ".json,application/json",
|
|
75
|
+
fields: [],
|
|
76
|
+
defaults: {
|
|
77
|
+
path: "SUPPLY_JSON_KEY",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
function specOf(kind) {
|
|
82
|
+
const spec = CREDENTIAL_KINDS.find((k) => k.kind === kind);
|
|
83
|
+
if (!spec)
|
|
84
|
+
throw new Error(`unknown credential kind: ${kind}`);
|
|
85
|
+
return spec;
|
|
86
|
+
}
|
|
87
|
+
export function defaultVarNames(kind) {
|
|
88
|
+
return specOf(kind).defaults;
|
|
89
|
+
}
|
|
90
|
+
export function fieldsOf(kind) {
|
|
91
|
+
return specOf(kind).fields;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The variable names a set of stored blocks will export into a run.
|
|
95
|
+
*
|
|
96
|
+
* Read off the summaries — the kind and the names stored beside it — so nothing
|
|
97
|
+
* here decrypts anything. That is what lets the readiness check and the secrets
|
|
98
|
+
* screen both ask the question: a name a block supplies is a name the project
|
|
99
|
+
* already has, and asking someone to type it by hand is asking them for what
|
|
100
|
+
* Laneyard is about to hand the run itself.
|
|
101
|
+
*
|
|
102
|
+
* Every name in the block is counted, and no value is looked at. `path` is
|
|
103
|
+
* always exported, and every other slot belongs to a field the block could not
|
|
104
|
+
* be stored without — `credentials.ts` refuses a block with a required field
|
|
105
|
+
* empty, and the optional two have no slot at all. So a name here is a name
|
|
106
|
+
* `runner/materialise.ts` will set.
|
|
107
|
+
*
|
|
108
|
+
* The blocks passed in are the ones that apply, project shadowing global, which
|
|
109
|
+
* is `Vault.listCredentials` and the precedence a run uses.
|
|
110
|
+
*/
|
|
111
|
+
export function exportedVarNames(blocks) {
|
|
112
|
+
const names = new Set();
|
|
113
|
+
for (const block of blocks) {
|
|
114
|
+
for (const name of Object.values({ ...defaultVarNames(block.kind), ...block.varNames })) {
|
|
115
|
+
if (name)
|
|
116
|
+
names.add(name);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return [...names].sort();
|
|
120
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const GLOBAL = "";
|
|
2
|
+
/**
|
|
3
|
+
* Stores signing credential blocks: a file plus the fields that make it
|
|
4
|
+
* usable. Knows nothing about encryption itself: it takes and returns
|
|
5
|
+
* ciphertext, so a bug here cannot leak a plaintext value.
|
|
6
|
+
*
|
|
7
|
+
* A project credential shadows a global one of the same kind — the same
|
|
8
|
+
* precedence as `SecretStore`, and the least surprising rule.
|
|
9
|
+
*/
|
|
10
|
+
export class CredentialStore {
|
|
11
|
+
db;
|
|
12
|
+
constructor(db) {
|
|
13
|
+
this.db = db;
|
|
14
|
+
}
|
|
15
|
+
set(projectSlug, kind, input) {
|
|
16
|
+
this.db
|
|
17
|
+
.prepare(`INSERT INTO credential (project_slug, kind, file_name, file_enc, fields_enc, var_names, updated_at)
|
|
18
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
19
|
+
ON CONFLICT (project_slug, kind) DO UPDATE
|
|
20
|
+
SET file_name = excluded.file_name,
|
|
21
|
+
file_enc = excluded.file_enc,
|
|
22
|
+
fields_enc = excluded.fields_enc,
|
|
23
|
+
var_names = excluded.var_names,
|
|
24
|
+
updated_at = excluded.updated_at`)
|
|
25
|
+
.run(projectSlug ?? GLOBAL, kind, input.fileName, input.fileEnc, input.fieldsEnc, JSON.stringify(input.varNames), new Date().toISOString());
|
|
26
|
+
}
|
|
27
|
+
/** Rows that apply to a project, project scope winning over global. */
|
|
28
|
+
applicable(projectSlug) {
|
|
29
|
+
const rows = this.db
|
|
30
|
+
.prepare("SELECT * FROM credential WHERE project_slug IN (?, ?) ORDER BY kind")
|
|
31
|
+
.all(projectSlug, GLOBAL);
|
|
32
|
+
const byKind = new Map();
|
|
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));
|
|
41
|
+
}
|
|
42
|
+
/** One applicable block, ciphertext included, or undefined. */
|
|
43
|
+
find(projectSlug, kind) {
|
|
44
|
+
return this.applicable(projectSlug).find((r) => r.kind === kind);
|
|
45
|
+
}
|
|
46
|
+
list(projectSlug) {
|
|
47
|
+
return this.applicable(projectSlug).map(({ fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary }) => summary);
|
|
48
|
+
}
|
|
49
|
+
listGlobal() {
|
|
50
|
+
const rows = this.db
|
|
51
|
+
.prepare("SELECT * FROM credential WHERE project_slug = ? ORDER BY kind")
|
|
52
|
+
.all(GLOBAL);
|
|
53
|
+
return rows.map((row) => {
|
|
54
|
+
const { fileEnc: _fileEnc, fieldsEnc: _fieldsEnc, ...summary } = this.toSummary(row);
|
|
55
|
+
return summary;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
remove(projectSlug, kind) {
|
|
59
|
+
const res = this.db
|
|
60
|
+
.prepare("DELETE FROM credential WHERE project_slug = ? AND kind = ?")
|
|
61
|
+
.run(projectSlug ?? GLOBAL, kind);
|
|
62
|
+
return res.changes > 0;
|
|
63
|
+
}
|
|
64
|
+
toSummary(row) {
|
|
65
|
+
return {
|
|
66
|
+
kind: row.kind,
|
|
67
|
+
fileName: row.file_name,
|
|
68
|
+
fileEnc: row.file_enc,
|
|
69
|
+
fieldsEnc: row.fields_enc,
|
|
70
|
+
scope: row.project_slug === GLOBAL ? "global" : "project",
|
|
71
|
+
varNames: JSON.parse(row.var_names),
|
|
72
|
+
updatedAt: row.updated_at,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
package/dist/src/db/schema.sql
CHANGED
|
@@ -61,3 +61,42 @@ CREATE TABLE IF NOT EXISTS secret (
|
|
|
61
61
|
updated_at TEXT NOT NULL,
|
|
62
62
|
PRIMARY KEY (project_slug, key)
|
|
63
63
|
);
|
|
64
|
+
|
|
65
|
+
-- A signing credential: a file plus the fields that make it usable. Separate
|
|
66
|
+
-- from `secret` because these are not key/value pairs — stored as loose rows,
|
|
67
|
+
-- nothing knew the parts belonged together, and deleting one left a half-dead
|
|
68
|
+
-- group no check could detect.
|
|
69
|
+
--
|
|
70
|
+
-- `fields_enc` is one encrypted JSON object rather than a column per field: the
|
|
71
|
+
-- three kinds do not share a shape, and a column per field would mean a
|
|
72
|
+
-- migration every time a kind gains one.
|
|
73
|
+
--
|
|
74
|
+
-- `var_names` is NOT encrypted. It holds variable names, never values, and the
|
|
75
|
+
-- interface has to display them.
|
|
76
|
+
CREATE TABLE IF NOT EXISTS credential (
|
|
77
|
+
project_slug TEXT NOT NULL DEFAULT '',
|
|
78
|
+
kind TEXT NOT NULL,
|
|
79
|
+
file_name TEXT NOT NULL,
|
|
80
|
+
file_enc TEXT NOT NULL,
|
|
81
|
+
fields_enc TEXT NOT NULL,
|
|
82
|
+
var_names TEXT NOT NULL,
|
|
83
|
+
updated_at TEXT NOT NULL,
|
|
84
|
+
PRIMARY KEY (project_slug, kind)
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
-- Sessions outlive a restart, which is the whole reason they are here rather
|
|
88
|
+
-- than in a Map. What is stored is a SHA-256 of the token, never the token: the
|
|
89
|
+
-- cookie is a bearer credential, and a stolen `laneyard.db` must not hand
|
|
90
|
+
-- anybody a live session the way a table of raw tokens would.
|
|
91
|
+
--
|
|
92
|
+
-- `expires_at` is an ISO timestamp compared as text, which sorts correctly
|
|
93
|
+
-- because ISO-8601 does. A session with no end is not a convenience, it is a
|
|
94
|
+
-- credential nobody can lose track of.
|
|
95
|
+
CREATE TABLE IF NOT EXISTS session (
|
|
96
|
+
token_hash TEXT PRIMARY KEY,
|
|
97
|
+
name TEXT NOT NULL,
|
|
98
|
+
role TEXT NOT NULL,
|
|
99
|
+
created_at TEXT NOT NULL,
|
|
100
|
+
expires_at TEXT NOT NULL
|
|
101
|
+
);
|
|
102
|
+
CREATE INDEX IF NOT EXISTS session_by_name ON session (name);
|
package/dist/src/db/secrets.js
CHANGED
|
@@ -34,6 +34,34 @@ export class SecretStore {
|
|
|
34
34
|
}
|
|
35
35
|
return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
36
36
|
}
|
|
37
|
+
/** One applicable row, ciphertext included, or undefined. */
|
|
38
|
+
find(projectSlug, key) {
|
|
39
|
+
const row = this.applicable(projectSlug).find((r) => r.key === key);
|
|
40
|
+
return row
|
|
41
|
+
? {
|
|
42
|
+
key: row.key,
|
|
43
|
+
masked: row.masked === 1,
|
|
44
|
+
scope: row.project_slug === GLOBAL ? "global" : "project",
|
|
45
|
+
valueEnc: row.value_enc,
|
|
46
|
+
}
|
|
47
|
+
: undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Flips whether a value is kept out of the logs, leaving the value alone.
|
|
51
|
+
*
|
|
52
|
+
* Its own operation rather than a re-`set`, because of a circle: to reveal a
|
|
53
|
+
* value you must first declare it not secret, and declaring that by storing it
|
|
54
|
+
* again would mean typing the value you were trying to read.
|
|
55
|
+
*
|
|
56
|
+
* Returns false when no row matches, so a caller can answer 404 rather than
|
|
57
|
+
* report a change that did not happen.
|
|
58
|
+
*/
|
|
59
|
+
setMasked(projectSlug, key, masked) {
|
|
60
|
+
return (this.db
|
|
61
|
+
.prepare(`UPDATE secret SET masked = ?, updated_at = ?
|
|
62
|
+
WHERE project_slug = ? AND key = ?`)
|
|
63
|
+
.run(masked ? 1 : 0, new Date().toISOString(), projectSlug ?? GLOBAL, key).changes > 0);
|
|
64
|
+
}
|
|
37
65
|
list(projectSlug) {
|
|
38
66
|
return this.applicable(projectSlug).map((row) => ({
|
|
39
67
|
key: row.key,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* A token is never stored, only its digest.
|
|
4
|
+
*
|
|
5
|
+
* SHA-256 without a salt on purpose, unlike a password: the token is 32 random
|
|
6
|
+
* bytes from `randomBytes`, so there is no dictionary to build and no cheaper
|
|
7
|
+
* attack than guessing the token itself. What this buys is that a copy of
|
|
8
|
+
* `laneyard.db` is a list of digests rather than a ring of working keys.
|
|
9
|
+
*/
|
|
10
|
+
const digest = (token) => createHash("sha256").update(token).digest("hex");
|
|
11
|
+
/**
|
|
12
|
+
* Where sessions live between restarts.
|
|
13
|
+
*
|
|
14
|
+
* They used to live in a Map, with a comment saying they did not survive a
|
|
15
|
+
* restart "and that's just fine". It was not fine: a server restarted to pick
|
|
16
|
+
* up a configuration change signed everybody out, and on a machine you are
|
|
17
|
+
* still setting up that is several times an hour.
|
|
18
|
+
*/
|
|
19
|
+
export class SessionRecords {
|
|
20
|
+
db;
|
|
21
|
+
constructor(db) {
|
|
22
|
+
this.db = db;
|
|
23
|
+
}
|
|
24
|
+
insert(token, owner, expiresAt) {
|
|
25
|
+
this.db
|
|
26
|
+
.prepare(`INSERT INTO session (token_hash, name, role, created_at, expires_at)
|
|
27
|
+
VALUES (?, ?, ?, ?, ?)`)
|
|
28
|
+
.run(digest(token), owner.name, owner.role, new Date().toISOString(), expiresAt.toISOString());
|
|
29
|
+
}
|
|
30
|
+
/** The owner, or undefined when the token is unknown or its time is up. */
|
|
31
|
+
find(token, now = new Date()) {
|
|
32
|
+
const row = this.db
|
|
33
|
+
.prepare(`SELECT name, role FROM session WHERE token_hash = ? AND expires_at > ?`)
|
|
34
|
+
.get(digest(token), now.toISOString());
|
|
35
|
+
return row ? { name: row.name, role: row.role } : undefined;
|
|
36
|
+
}
|
|
37
|
+
remove(token) {
|
|
38
|
+
this.db.prepare(`DELETE FROM session WHERE token_hash = ?`).run(digest(token));
|
|
39
|
+
}
|
|
40
|
+
removeAllFor(name) {
|
|
41
|
+
this.db.prepare(`DELETE FROM session WHERE name = ?`).run(name);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Drops what has already expired.
|
|
45
|
+
*
|
|
46
|
+
* Called at startup rather than on a timer: the rows are harmless — `find`
|
|
47
|
+
* already refuses them — so this is housekeeping, not correctness, and a
|
|
48
|
+
* timer would be a moving part earning nothing.
|
|
49
|
+
*/
|
|
50
|
+
prune(now = new Date()) {
|
|
51
|
+
return this.db.prepare(`DELETE FROM session WHERE expires_at <= ?`).run(now.toISOString())
|
|
52
|
+
.changes;
|
|
53
|
+
}
|
|
54
|
+
/** Number of live sessions. Exposed so expiry can be tested without sleeping. */
|
|
55
|
+
count(now = new Date()) {
|
|
56
|
+
const row = this.db
|
|
57
|
+
.prepare(`SELECT COUNT(*) AS n FROM session WHERE expires_at > ?`)
|
|
58
|
+
.get(now.toISOString());
|
|
59
|
+
return row.n;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -7,6 +7,27 @@ const exec = promisify(execFile);
|
|
|
7
7
|
* A clone managed by Laneyard, kept between runs.
|
|
8
8
|
* All git commands go through here to share the authentication environment.
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* The environment git needs to work without a person at the keyboard.
|
|
12
|
+
*
|
|
13
|
+
* Lifted out of `Workspace` because it is not only Workspace's problem. A lane
|
|
14
|
+
* may run git itself — pushing a bumped build number is a common and reasonable
|
|
15
|
+
* thing for a Fastfile to do — and that `sh("git push")` inherited none of
|
|
16
|
+
* this. The result was the worst possible failure: a push that needed a
|
|
17
|
+
* credential did not fail, it *waited*, and the run sat there until it hit its
|
|
18
|
+
* timeout with nothing in the log to say what it was waiting for.
|
|
19
|
+
*
|
|
20
|
+
* `GIT_TERMINAL_PROMPT=0` is what turns that wait into an error. The SSH
|
|
21
|
+
* command is what makes the push succeed instead — the key configured for
|
|
22
|
+
* cloning is by definition the right one for pushing to the same remote.
|
|
23
|
+
*/
|
|
24
|
+
export function gitEnvFor(auth) {
|
|
25
|
+
const env = { GIT_TERMINAL_PROMPT: "0" };
|
|
26
|
+
if (auth.kind === "ssh_key" && auth.ref) {
|
|
27
|
+
env["GIT_SSH_COMMAND"] = `ssh -i ${auth.ref} -o IdentitiesOnly=yes -o BatchMode=yes`;
|
|
28
|
+
}
|
|
29
|
+
return env;
|
|
30
|
+
}
|
|
10
31
|
export class Workspace {
|
|
11
32
|
path;
|
|
12
33
|
gitUrl;
|
|
@@ -17,12 +38,17 @@ export class Workspace {
|
|
|
17
38
|
this.auth = auth;
|
|
18
39
|
}
|
|
19
40
|
env() {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
return { ...process.env, ...gitEnvFor(this.auth) };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The git identity this workspace would commit under, or null.
|
|
45
|
+
*
|
|
46
|
+
* Exposed because a lane that runs `git commit` itself needs the same answer,
|
|
47
|
+
* and a clone Laneyard made has no identity of its own — so the question is
|
|
48
|
+
* whether the *server* has one.
|
|
49
|
+
*/
|
|
50
|
+
async identity() {
|
|
51
|
+
return this.gitIdentity();
|
|
26
52
|
}
|
|
27
53
|
/**
|
|
28
54
|
* Replaces the repository URL with a neutral token in a piece of text.
|