laneyard 0.4.1 → 0.6.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 +312 -282
- package/dist/src/cli/adopt.js +221 -0
- package/dist/src/cli/home.js +46 -0
- package/dist/src/cli/remove.js +195 -0
- package/dist/src/cli/reset.js +117 -0
- package/dist/src/cli/setup.js +123 -11
- package/dist/src/cli/uninstall.js +2 -24
- package/dist/src/config/accounts.js +103 -39
- package/dist/src/config/load.js +3 -27
- package/dist/src/config/resolve.js +36 -0
- package/dist/src/config/schema.js +25 -9
- package/dist/src/config/store.js +27 -3
- package/dist/src/data/remove-project.js +70 -0
- package/dist/src/db/runs.js +11 -0
- package/dist/src/fastfile/adoption.js +142 -0
- package/dist/src/fastfile/splice.js +38 -0
- package/dist/src/logs/store.js +9 -1
- package/dist/src/main.js +24 -16
- package/dist/src/secrets/vault.js +6 -5
- package/dist/src/server/app.js +17 -12
- package/dist/src/server/permissions.js +50 -0
- package/dist/src/server/routes/account.js +56 -1
- package/dist/src/server/routes/fastfile.js +7 -0
- package/dist/src/server/routes/projects.js +95 -86
- package/dist/src/server/routes/users.js +47 -4
- package/dist/src/sidecar/bridge.js +5 -4
- package/dist/src/sidecar/fastlane-dir.js +8 -8
- package/dist/src/sidecar/prism-ruby.js +42 -0
- package/dist/src/sidecar/scan.js +44 -0
- package/dist/web/assets/{Editor-9nLYwtg7.js → Editor-CMa4-4N3.js} +1 -1
- package/dist/web/assets/index-B8lAQPEM.js +62 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/ruby/scan.rb +176 -0
- package/dist/web/assets/index-pzPa9EZr.js +0 -62
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { removeProjectFromAccounts } from "../config/accounts.js";
|
|
4
|
+
import { removeProjectFromConfig } from "../cli/setup.js";
|
|
5
|
+
/**
|
|
6
|
+
* Removes everything Laneyard holds for one project, and reports what went.
|
|
7
|
+
*
|
|
8
|
+
* The block leaves config.yml, through the YAML document so the rest of a
|
|
9
|
+
* hand-written file is untouched; the clone is deleted; every artifact folder
|
|
10
|
+
* goes; the run history — the rows and their logs — is deleted; and the
|
|
11
|
+
* project's own secrets and signing blocks are forgotten from the vault. The
|
|
12
|
+
* history is the one thing here that cannot be made again.
|
|
13
|
+
*
|
|
14
|
+
* What it does not reach, and why each is out of scope:
|
|
15
|
+
*
|
|
16
|
+
* - the git remote. The repository is on the host and the user's disk. It is
|
|
17
|
+
* theirs, not Laneyard's, and nothing here reads or writes it.
|
|
18
|
+
* - the credential originals. Laneyard removes its own encrypted copy of a
|
|
19
|
+
* `.p8` or a keystore; the file that went in is still in the password manager
|
|
20
|
+
* or the safe it came from.
|
|
21
|
+
* - global secrets and global signing blocks. They are read by every project on
|
|
22
|
+
* the machine — `vault.forget` touches only slug-scoped rows.
|
|
23
|
+
*
|
|
24
|
+
* It removes; it does not confirm and it does not shape a reply. The callers do
|
|
25
|
+
* that: the route behind a slug typed back, the CLI behind the same. The one
|
|
26
|
+
* irreversible thing must not be reachable without one of them.
|
|
27
|
+
*/
|
|
28
|
+
export async function removeProjectData(deps, slug) {
|
|
29
|
+
const clonePath = deps.workspacePath(slug);
|
|
30
|
+
// Read before anything is touched. -1 is SQLite's "no limit": every run of the
|
|
31
|
+
// project, because each one names an artifact folder and a log file to remove.
|
|
32
|
+
const runs = deps.runs.listByProject(slug, -1);
|
|
33
|
+
// The config block first: once it is gone the project cannot be started, so
|
|
34
|
+
// nothing new begins reading the files the rest of this is about to remove.
|
|
35
|
+
const removed = await removeProjectFromConfig(deps.configPath, slug);
|
|
36
|
+
if (!removed) {
|
|
37
|
+
return { found: false, runs: 0, artifacts: 0, workspace: false, clonePath, secrets: 0, signingBlocks: 0 };
|
|
38
|
+
}
|
|
39
|
+
// The slug also leaves every account's grants, in the same file: a grant onto
|
|
40
|
+
// a project that no longer exists is dead data, and a slug re-used later must
|
|
41
|
+
// not silently inherit it.
|
|
42
|
+
await removeProjectFromAccounts(deps.configPath, slug);
|
|
43
|
+
await deps.reloadConfig();
|
|
44
|
+
// The clone.
|
|
45
|
+
const workspace = existsSync(clonePath);
|
|
46
|
+
await rm(clonePath, { recursive: true, force: true });
|
|
47
|
+
// The artifacts and the logs, one of each per run that produced them.
|
|
48
|
+
let artifacts = 0;
|
|
49
|
+
for (const run of runs) {
|
|
50
|
+
const dir = deps.artifactsDir(run.id);
|
|
51
|
+
if (existsSync(dir))
|
|
52
|
+
artifacts += 1;
|
|
53
|
+
await rm(dir, { recursive: true, force: true });
|
|
54
|
+
await deps.logs.remove(run.id);
|
|
55
|
+
}
|
|
56
|
+
// The run history: the rows, and their steps and artifact records by cascade.
|
|
57
|
+
deps.runs.removeByProject(slug);
|
|
58
|
+
// The project's own secrets and signing blocks. Slug-scoped only: a global
|
|
59
|
+
// secret three other projects read is not this one's to take.
|
|
60
|
+
const forgotten = deps.vault.forget(slug);
|
|
61
|
+
return {
|
|
62
|
+
found: true,
|
|
63
|
+
runs: runs.length,
|
|
64
|
+
artifacts,
|
|
65
|
+
workspace,
|
|
66
|
+
clonePath,
|
|
67
|
+
secrets: forgotten.secrets,
|
|
68
|
+
signingBlocks: forgotten.credentials,
|
|
69
|
+
};
|
|
70
|
+
}
|
package/dist/src/db/runs.js
CHANGED
|
@@ -39,6 +39,17 @@ export class RunStore {
|
|
|
39
39
|
.all(slug, limit);
|
|
40
40
|
return rows.map(toRun);
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Deletes every run of a project, and returns how many rows went.
|
|
44
|
+
*
|
|
45
|
+
* The steps and the artifact records go with them, by the `ON DELETE CASCADE`
|
|
46
|
+
* on their foreign keys — with `foreign_keys = ON`, which `openDatabase` sets.
|
|
47
|
+
* The log files and the artifact folders live on disk, not here: the caller
|
|
48
|
+
* removes those, which is why it reads the run ids before calling this.
|
|
49
|
+
*/
|
|
50
|
+
removeByProject(slug) {
|
|
51
|
+
return this.db.prepare("DELETE FROM run WHERE project_slug = ?").run(slug).changes;
|
|
52
|
+
}
|
|
42
53
|
setStatus(id, status) {
|
|
43
54
|
this.db.prepare("UPDATE run SET status = ? WHERE id = ?").run(status, id);
|
|
44
55
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { defaultVarNames } from "../credentials/kinds.js";
|
|
2
|
+
/** Which action arguments name a credential file, and which block they mean. */
|
|
3
|
+
const FILE_ARGS = [
|
|
4
|
+
{ action: /^app_store_connect_api_key$/, arg: "key_filepath", kind: "apple_asc" },
|
|
5
|
+
{ action: /^(supply|upload_to_play_store|validate_play_store_json_key)$/, arg: "json_key", kind: "play_service_account" },
|
|
6
|
+
];
|
|
7
|
+
/**
|
|
8
|
+
* Arguments holding a credential's *contents* inline, and the argument they
|
|
9
|
+
* must become.
|
|
10
|
+
*
|
|
11
|
+
* The rename is not cosmetic. `materialise.ts` stores a block as a file and
|
|
12
|
+
* exports its *path*; there is no slot that exports contents. Replacing the
|
|
13
|
+
* value alone would hand a filesystem path to an argument expecting PEM text,
|
|
14
|
+
* and fastlane's complaint would point nowhere near here.
|
|
15
|
+
*/
|
|
16
|
+
const INLINE_ARGS = [
|
|
17
|
+
{ action: /^app_store_connect_api_key$/, arg: "key_content", becomes: "key_filepath", kind: "apple_asc" },
|
|
18
|
+
{ action: /^(supply|upload_to_play_store)$/, arg: "json_key_data", becomes: "json_key", kind: "play_service_account" },
|
|
19
|
+
];
|
|
20
|
+
/** What a literal secret looks like when nothing more specific matched. */
|
|
21
|
+
const SECRET_ARG = /(^|_)(token|password|secret|api_key|url)$/;
|
|
22
|
+
/** `AuthKey_<KEY ID>.p8` is the name fastlane's own documentation uses. */
|
|
23
|
+
const P8_KEY_ID = /(?:^|\/)AuthKey_([A-Z0-9]+)\.p8$/;
|
|
24
|
+
/**
|
|
25
|
+
* Turns what a Fastfile literally says into what could be done about it.
|
|
26
|
+
*
|
|
27
|
+
* Pure, and deliberately so: every judgement about credentials is here, in one
|
|
28
|
+
* function, beside the one table that describes them. `ruby/scan.rb` reports
|
|
29
|
+
* what the file says and knows nothing of any of this.
|
|
30
|
+
*
|
|
31
|
+
* The order of the three passes is the order of confidence. A `json_key`
|
|
32
|
+
* pointing at a literal path is unambiguous; an `api_token` holding a literal
|
|
33
|
+
* might be a real token or might be a placeholder, so it falls through to the
|
|
34
|
+
* unchecked tier rather than being claimed by a rule that was almost right.
|
|
35
|
+
*/
|
|
36
|
+
export function proposalsFor(literals) {
|
|
37
|
+
const out = [];
|
|
38
|
+
// The App Store Connect identifiers written inline, to be carried by whatever
|
|
39
|
+
// proposal creates the `apple_asc` block. Attributed by action alone: the
|
|
40
|
+
// literals arrive as a flat list with no call identity, and the vault holds
|
|
41
|
+
// one `apple_asc` block per project — so a second `app_store_connect_api_key`
|
|
42
|
+
// call's key is already unrepresentable, and gathering both calls' identifiers
|
|
43
|
+
// here does not widen a limit the block model does not already impose.
|
|
44
|
+
const appleIdentifiers = literals.filter((l) => l.action === "app_store_connect_api_key" &&
|
|
45
|
+
(l.arg === "key_id" || l.arg === "issuer_id") &&
|
|
46
|
+
l.value.trim() !== "");
|
|
47
|
+
for (const literal of literals) {
|
|
48
|
+
if (literal.value.trim() === "")
|
|
49
|
+
continue;
|
|
50
|
+
const file = FILE_ARGS.find((r) => r.action.test(literal.action) && r.arg === literal.arg);
|
|
51
|
+
if (file) {
|
|
52
|
+
const name = defaultVarNames(file.kind)["path"];
|
|
53
|
+
const identifiers = file.kind === "apple_asc" ? appleIdentifierRewrites(appleIdentifiers) : EMPTY;
|
|
54
|
+
out.push({
|
|
55
|
+
tier: "file",
|
|
56
|
+
kind: file.kind,
|
|
57
|
+
varName: name,
|
|
58
|
+
// The explicit literal wins over the Key ID read from the filename.
|
|
59
|
+
suggestedFields: { ...suggestedFields(file.kind, literal.value), ...identifiers.fields },
|
|
60
|
+
literal,
|
|
61
|
+
edits: [
|
|
62
|
+
{ start: literal.valueStart, length: literal.valueLength, replacement: `ENV.fetch("${name}")` },
|
|
63
|
+
...identifiers.edits,
|
|
64
|
+
],
|
|
65
|
+
checked: true,
|
|
66
|
+
});
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const inline = INLINE_ARGS.find((r) => r.action.test(literal.action) && r.arg === literal.arg);
|
|
70
|
+
if (inline) {
|
|
71
|
+
const name = defaultVarNames(inline.kind)["path"];
|
|
72
|
+
const identifiers = inline.kind === "apple_asc" ? appleIdentifierRewrites(appleIdentifiers) : EMPTY;
|
|
73
|
+
out.push({
|
|
74
|
+
tier: "inline",
|
|
75
|
+
kind: inline.kind,
|
|
76
|
+
varName: name,
|
|
77
|
+
suggestedFields: identifiers.fields,
|
|
78
|
+
literal,
|
|
79
|
+
// The whole pair, because the keyword changes too.
|
|
80
|
+
edits: [
|
|
81
|
+
{
|
|
82
|
+
start: literal.pairStart,
|
|
83
|
+
length: literal.pairLength,
|
|
84
|
+
replacement: `${inline.becomes}: ENV.fetch("${name}")`,
|
|
85
|
+
},
|
|
86
|
+
...identifiers.edits,
|
|
87
|
+
],
|
|
88
|
+
checked: true,
|
|
89
|
+
});
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (SECRET_ARG.test(literal.arg)) {
|
|
93
|
+
const name = `${literal.action}_${literal.arg}`.toUpperCase();
|
|
94
|
+
out.push({
|
|
95
|
+
tier: "secret",
|
|
96
|
+
varName: name,
|
|
97
|
+
literal,
|
|
98
|
+
suggestedFields: {},
|
|
99
|
+
edits: [{ start: literal.valueStart, length: literal.valueLength, replacement: `ENV.fetch("${name}")` }],
|
|
100
|
+
// Unticked on purpose. This is the one tier where a false positive is
|
|
101
|
+
// likely — a non-secret URL, a placeholder — and a patch applied by
|
|
102
|
+
// default to a value that was not a secret is a silent regression in
|
|
103
|
+
// someone's build.
|
|
104
|
+
checked: false,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/** Neither an edit nor a field: an `apple_asc` block that names no identifier. */
|
|
111
|
+
const EMPTY = { edits: [], fields: {} };
|
|
112
|
+
/**
|
|
113
|
+
* The rewrites and pre-filled fields for the identifiers written beside an
|
|
114
|
+
* App Store Connect key file.
|
|
115
|
+
*
|
|
116
|
+
* The variable names come from `defaultVarNames("apple_asc")`, the same table
|
|
117
|
+
* `materialise.ts` exports the block's fields through — so the name the Fastfile
|
|
118
|
+
* is patched to read is the name the run will set. These never form a proposal
|
|
119
|
+
* of their own: an identifier is only ever lifted when the key file that anchors
|
|
120
|
+
* its block is, because nothing else exports the variable it would come to read.
|
|
121
|
+
*/
|
|
122
|
+
function appleIdentifierRewrites(identifiers) {
|
|
123
|
+
const defaults = defaultVarNames("apple_asc");
|
|
124
|
+
const edits = [];
|
|
125
|
+
const fields = {};
|
|
126
|
+
for (const id of identifiers) {
|
|
127
|
+
edits.push({
|
|
128
|
+
start: id.valueStart,
|
|
129
|
+
length: id.valueLength,
|
|
130
|
+
replacement: `ENV.fetch("${defaults[id.arg]}")`,
|
|
131
|
+
});
|
|
132
|
+
fields[id.arg] = id.value;
|
|
133
|
+
}
|
|
134
|
+
return { edits, fields };
|
|
135
|
+
}
|
|
136
|
+
/** What the credential's filename gives away, so the prompt starts filled in. */
|
|
137
|
+
function suggestedFields(kind, path) {
|
|
138
|
+
if (kind !== "apple_asc")
|
|
139
|
+
return {};
|
|
140
|
+
const keyId = P8_KEY_ID.exec(path)?.[1];
|
|
141
|
+
return keyId ? { key_id: keyId } : {};
|
|
142
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replaces ranges of a source, leaving every other byte exactly as it was.
|
|
3
|
+
*
|
|
4
|
+
* The same requirement `fastfile/store.ts` documents: a file written by hand
|
|
5
|
+
* must never come back reformatted, reordered, or with its trailing newline
|
|
6
|
+
* fixed. Someone may have spent a long time on that file.
|
|
7
|
+
*
|
|
8
|
+
* **Byte offsets, not string indices.** Prism reports positions in bytes, and
|
|
9
|
+
* one accented character above the literal would put every later offset off by
|
|
10
|
+
* one — a patch landing in the middle of a string, on a build file, silently.
|
|
11
|
+
* So the work happens on a Buffer.
|
|
12
|
+
*
|
|
13
|
+
* Edits are applied last-first so an earlier replacement cannot shift the
|
|
14
|
+
* offsets of the ones after it, and may be handed in in any order. Overlapping
|
|
15
|
+
* edits throw: two rules that both claim the same bytes is a bug in the rule
|
|
16
|
+
* table, and applying one of them arbitrarily would hide it.
|
|
17
|
+
*/
|
|
18
|
+
export function splice(source, edits) {
|
|
19
|
+
if (edits.length === 0)
|
|
20
|
+
return source;
|
|
21
|
+
const ordered = [...edits].sort((a, b) => a.start - b.start);
|
|
22
|
+
for (let i = 1; i < ordered.length; i += 1) {
|
|
23
|
+
const previous = ordered[i - 1];
|
|
24
|
+
if (previous.start + previous.length > ordered[i].start) {
|
|
25
|
+
throw new Error("edits overlap");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
let buffer = Buffer.from(source, "utf8");
|
|
29
|
+
for (let i = ordered.length - 1; i >= 0; i -= 1) {
|
|
30
|
+
const edit = ordered[i];
|
|
31
|
+
buffer = Buffer.concat([
|
|
32
|
+
buffer.subarray(0, edit.start),
|
|
33
|
+
Buffer.from(edit.replacement, "utf8"),
|
|
34
|
+
buffer.subarray(edit.start + edit.length),
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
return buffer.toString("utf8");
|
|
38
|
+
}
|
package/dist/src/logs/store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createReadStream } from "node:fs";
|
|
2
|
-
import { mkdir, open, readFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, open, readFile, rm } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
/**
|
|
5
5
|
* Append-only writer for a run.
|
|
@@ -63,4 +63,12 @@ export class LogStore {
|
|
|
63
63
|
stream(runId, fromOffset = 0) {
|
|
64
64
|
return createReadStream(this.pathFor(runId), { start: fromOffset });
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Removes a run's log file. A run that never opened one is not an error:
|
|
68
|
+
* `force` makes a missing file a no-op, which is exactly the case for a run
|
|
69
|
+
* that failed before it wrote a line.
|
|
70
|
+
*/
|
|
71
|
+
async remove(runId) {
|
|
72
|
+
await rm(this.pathFor(runId), { force: true });
|
|
73
|
+
}
|
|
66
74
|
}
|
package/dist/src/main.js
CHANGED
|
@@ -7,7 +7,8 @@ import { bold, dim, field, heading } from "./cli/style.js";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { PromptAborted } from "./cli/prompt.js";
|
|
9
9
|
import { runSetupCommand } from "./cli/setup.js";
|
|
10
|
-
import {
|
|
10
|
+
import { runRemoveCommand } from "./cli/remove.js";
|
|
11
|
+
import { runResetCommand } from "./cli/reset.js";
|
|
11
12
|
import { runUninstallCommand } from "./cli/uninstall.js";
|
|
12
13
|
import { runUserCommand } from "./cli/user.js";
|
|
13
14
|
import { ConfigStore } from "./config/store.js";
|
|
@@ -21,7 +22,7 @@ import { Vault } from "./secrets/vault.js";
|
|
|
21
22
|
import { makeInvoke } from "./sidecar/bridge.js";
|
|
22
23
|
import { LaneReader } from "./sidecar/lanes.js";
|
|
23
24
|
import { UsesReader } from "./sidecar/uses.js";
|
|
24
|
-
export const version = "0.
|
|
25
|
+
export const version = "0.5.0";
|
|
25
26
|
/** Assembles the server from a data folder. */
|
|
26
27
|
export async function createServerFromConfig(root) {
|
|
27
28
|
const config = new ConfigStore(join(root, "config.yml"));
|
|
@@ -108,10 +109,13 @@ const USAGE = `laneyard — a self-hosted web UI for fastlane
|
|
|
108
109
|
|
|
109
110
|
laneyard setup set up the project in the current directory
|
|
110
111
|
--yes accepts every detected value without asking
|
|
111
|
-
laneyard secret set NAME [--project <slug>]
|
|
112
|
-
store a secret, its value read from standard input
|
|
113
112
|
laneyard user add NAME [--role admin|builder]
|
|
114
113
|
create an account, its password read from standard input
|
|
114
|
+
laneyard remove remove the project of the current directory, and its
|
|
115
|
+
laneyard.yml — run from where that file lives
|
|
116
|
+
--dry-run shows what would go and stops
|
|
117
|
+
laneyard reset wipe the data, keeping the accounts and the vault key
|
|
118
|
+
--dry-run shows what would go and stops
|
|
115
119
|
laneyard uninstall remove Laneyard's data folder, after showing what is in it
|
|
116
120
|
--dry-run shows the inventory and stops
|
|
117
121
|
laneyard start the server
|
|
@@ -152,7 +156,7 @@ if (invokedDirectly()) {
|
|
|
152
156
|
// this before. Interactive is the default because the values are guesses.
|
|
153
157
|
const yes = rest.includes("--yes") || rest.includes("-y");
|
|
154
158
|
try {
|
|
155
|
-
process.exit(await runSetupCommand(process.cwd(), join(home, "config.yml"), { slug, yes }));
|
|
159
|
+
process.exit(await runSetupCommand(process.cwd(), join(home, "config.yml"), { slug, yes, home }));
|
|
156
160
|
}
|
|
157
161
|
catch (cause) {
|
|
158
162
|
// Ctrl-C in the middle of the questions. Nothing is written before the
|
|
@@ -168,17 +172,6 @@ if (invokedDirectly()) {
|
|
|
168
172
|
process.exit(1);
|
|
169
173
|
}
|
|
170
174
|
}
|
|
171
|
-
// Above the catch-all below, which would otherwise reject it as unknown.
|
|
172
|
-
if (command === "secret") {
|
|
173
|
-
const home = homeDir();
|
|
174
|
-
await mkdir(home, { recursive: true });
|
|
175
|
-
process.exit(await runSecretCommand(home, rest, {
|
|
176
|
-
stdin: process.stdin,
|
|
177
|
-
interactive: process.stdin.isTTY === true,
|
|
178
|
-
out: (text) => process.stdout.write(text),
|
|
179
|
-
err: (text) => process.stderr.write(text),
|
|
180
|
-
}));
|
|
181
|
-
}
|
|
182
175
|
if (command === "user") {
|
|
183
176
|
const home = homeDir();
|
|
184
177
|
await mkdir(home, { recursive: true });
|
|
@@ -198,6 +191,21 @@ if (invokedDirectly()) {
|
|
|
198
191
|
err: (text) => process.stderr.write(text),
|
|
199
192
|
}));
|
|
200
193
|
}
|
|
194
|
+
// No `mkdir` either: like `uninstall`, these read what is already there.
|
|
195
|
+
if (command === "remove") {
|
|
196
|
+
process.exit(await runRemoveCommand(homeDir(), process.cwd(), rest, {
|
|
197
|
+
stdin: process.stdin,
|
|
198
|
+
out: (text) => process.stdout.write(text),
|
|
199
|
+
err: (text) => process.stderr.write(text),
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
if (command === "reset") {
|
|
203
|
+
process.exit(await runResetCommand(homeDir(), rest, {
|
|
204
|
+
stdin: process.stdin,
|
|
205
|
+
out: (text) => process.stdout.write(text),
|
|
206
|
+
err: (text) => process.stderr.write(text),
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
201
209
|
// 0.1.0 shipped this as `add`. Anyone who learned that name deserves a
|
|
202
210
|
// sentence rather than "Unknown command".
|
|
203
211
|
if (command === "add") {
|
|
@@ -51,12 +51,13 @@ export class Vault {
|
|
|
51
51
|
/**
|
|
52
52
|
* Forgets everything stored under one project's name.
|
|
53
53
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
54
|
+
* Part of removing a project: that act clears the vault along with the clone,
|
|
55
|
+
* the artifacts and the run history, behind the one typed confirmation that
|
|
56
|
+
* covers all of it. What it forgets is Laneyard's own encrypted copy — the
|
|
57
|
+
* `.p8` and the keystore that went in are still wherever the user keeps them.
|
|
58
58
|
*
|
|
59
|
-
* Scoped by slug, so a global secret or a global signing block survives it
|
|
59
|
+
* Scoped by slug, so a global secret or a global signing block survives it:
|
|
60
|
+
* those are read by every project and are never one project's to remove.
|
|
60
61
|
*/
|
|
61
62
|
forget(projectSlug) {
|
|
62
63
|
return {
|
package/dist/src/server/app.js
CHANGED
|
@@ -4,7 +4,6 @@ import Fastify from "fastify";
|
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { LEGACY_ADMIN_NAME } from "../config/load.js";
|
|
8
7
|
import { RunStore } from "../db/runs.js";
|
|
9
8
|
import { SessionRecords } from "../db/sessions.js";
|
|
10
9
|
import { Workspace } from "../git/workspace.js";
|
|
@@ -13,7 +12,7 @@ import { materialiseCredentials } from "../runner/materialise.js";
|
|
|
13
12
|
import { executeRun } from "../runner/orchestrate.js";
|
|
14
13
|
import { RunQueue } from "../runner/queue.js";
|
|
15
14
|
import { authenticate, LoginThrottle, COOKIE_OPTIONS, SESSION_COOKIE, SessionStore } from "./auth.js";
|
|
16
|
-
import { requiresAdmin } from "./permissions.js";
|
|
15
|
+
import { accountMayReach, projectSlugOfRequest, requiresAdmin } from "./permissions.js";
|
|
17
16
|
import { registerCredentialRoutes } from "./routes/credentials.js";
|
|
18
17
|
import { registerFastfileRoutes } from "./routes/fastfile.js";
|
|
19
18
|
import { registerProjectRoutes } from "./routes/projects.js";
|
|
@@ -130,13 +129,7 @@ export async function buildApp(deps) {
|
|
|
130
129
|
const throttle = new LoginThrottle();
|
|
131
130
|
app.post("/api/login", async (req, reply) => {
|
|
132
131
|
const { name, password } = (req.body ?? {});
|
|
133
|
-
|
|
134
|
-
// It authenticates as `admin`, which is precisely the account a lone
|
|
135
|
-
// `server.password_hash` is loaded as — so an upgraded install keeps
|
|
136
|
-
// working, and the loader still owes nobody an answer about which form
|
|
137
|
-
// the file used.
|
|
138
|
-
const account = name ?? LEGACY_ADMIN_NAME;
|
|
139
|
-
const waitMs = throttle.retryAfterMs(account);
|
|
132
|
+
const waitMs = name ? throttle.retryAfterMs(name) : 0;
|
|
140
133
|
if (waitMs > 0) {
|
|
141
134
|
return reply
|
|
142
135
|
.code(429)
|
|
@@ -144,15 +137,16 @@ export async function buildApp(deps) {
|
|
|
144
137
|
.send({ error: `Too many attempts. Try again in ${Math.ceil(waitMs / 1000)}s.` });
|
|
145
138
|
}
|
|
146
139
|
const users = deps.config.server()?.users ?? [];
|
|
147
|
-
const identity = password ? await authenticate(users,
|
|
140
|
+
const identity = name && password ? await authenticate(users, name, password) : null;
|
|
148
141
|
if (!identity) {
|
|
149
|
-
|
|
142
|
+
if (name)
|
|
143
|
+
throttle.recordFailure(name);
|
|
150
144
|
// One message for a wrong password and for a name that does not exist:
|
|
151
145
|
// telling them apart is telling a stranger which accounts are worth
|
|
152
146
|
// attacking.
|
|
153
147
|
return reply.code(401).send({ error: "Incorrect name or password" });
|
|
154
148
|
}
|
|
155
|
-
throttle.recordSuccess(
|
|
149
|
+
throttle.recordSuccess(identity.name);
|
|
156
150
|
const token = ctx.sessions.issue(identity);
|
|
157
151
|
return reply
|
|
158
152
|
.setCookie(SESSION_COOKIE, token, COOKIE_OPTIONS)
|
|
@@ -180,6 +174,17 @@ export async function buildApp(deps) {
|
|
|
180
174
|
if (identity.role !== "admin" && requiresAdmin(req.method, req.url)) {
|
|
181
175
|
return reply.code(403).send({ error: "This action requires the admin role" });
|
|
182
176
|
}
|
|
177
|
+
// Per-project reach, for a non-admin only: a project this account may not
|
|
178
|
+
// reach is invisible, not shown-and-locked, so it answers 404 with the very
|
|
179
|
+
// body a genuinely unknown project gives. A 403 would confirm the project
|
|
180
|
+
// exists. The slug is resolved here, in the one hook, rather than in each
|
|
181
|
+
// run and project handler — the same reason `requiresAdmin` lives here.
|
|
182
|
+
if (identity.role !== "admin") {
|
|
183
|
+
const slug = projectSlugOfRequest(req.url, (id) => ctx.runs.get(id)?.projectSlug ?? null);
|
|
184
|
+
if (slug !== null && !accountMayReach(account, slug)) {
|
|
185
|
+
return reply.code(404).send({ error: "Unknown project" });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
183
188
|
});
|
|
184
189
|
/**
|
|
185
190
|
* Signing out ends this session and no other.
|
|
@@ -73,3 +73,53 @@ export function requiresAdmin(method, url) {
|
|
|
73
73
|
return expected.every((seg, i) => seg.startsWith(":") || seg === parts[i]);
|
|
74
74
|
});
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* May this account reach that project?
|
|
78
|
+
*
|
|
79
|
+
* The other half of the auth answer, and data-driven rather than a role: it is
|
|
80
|
+
* still two roles, with the builder's reach narrowed by a list. An `admin`
|
|
81
|
+
* reaches everything — managing the server is the whole role. A `builder` with
|
|
82
|
+
* no `projects` field reaches everything too (an old config, untouched by this
|
|
83
|
+
* feature); an empty list reaches nothing; a list reaches its slugs.
|
|
84
|
+
*
|
|
85
|
+
* Named and beside `REQUIRES_ADMIN` on purpose: the reach check is a permission,
|
|
86
|
+
* and a permission expressed as an `if` inside a handler is one nobody finds
|
|
87
|
+
* during an audit.
|
|
88
|
+
*/
|
|
89
|
+
export function accountMayReach(account, slug) {
|
|
90
|
+
if (account.role === "admin")
|
|
91
|
+
return true;
|
|
92
|
+
if (account.projects === undefined)
|
|
93
|
+
return true;
|
|
94
|
+
return account.projects.includes(slug);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The project a request concerns, or null when it concerns none.
|
|
98
|
+
*
|
|
99
|
+
* Two shapes carry a project:
|
|
100
|
+
*
|
|
101
|
+
* `/api/projects/:slug/*` — the slug is in the path. The bare `/api/projects`
|
|
102
|
+
* listing carries none: it is filtered in its own handler, per account.
|
|
103
|
+
* `/api/runs/:id/*` — a run addresses a project by id, not a slug, so the id
|
|
104
|
+
* is mapped through `runSlug`. An id that names no run yields null, and the
|
|
105
|
+
* handler answers its own 404 for the unknown run.
|
|
106
|
+
*
|
|
107
|
+
* Everything else — the vault, the accounts, `/api/me` — concerns no single
|
|
108
|
+
* project and returns null, passing the reach check untouched.
|
|
109
|
+
*
|
|
110
|
+
* `runSlug` is handed in rather than the run store reached into, so the mapping
|
|
111
|
+
* this depends on stays a one-line lookup the caller owns and this stays a pure,
|
|
112
|
+
* testable function.
|
|
113
|
+
*/
|
|
114
|
+
export function projectSlugOfRequest(url, runSlug) {
|
|
115
|
+
const parts = segments(url);
|
|
116
|
+
if (parts[0] !== "api")
|
|
117
|
+
return null;
|
|
118
|
+
if (parts[1] === "projects" && parts.length >= 3)
|
|
119
|
+
return parts[2] ?? null;
|
|
120
|
+
if (parts[1] === "runs" && parts.length >= 3) {
|
|
121
|
+
const id = Number(parts[2]);
|
|
122
|
+
return Number.isInteger(id) ? runSlug(id) : null;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MIN_PASSWORD_LENGTH, upsertUserInConfig } from "../../config/accounts.js";
|
|
1
|
+
import { MIN_PASSWORD_LENGTH, VALID_NAME, renameUserInConfig, upsertUserInConfig } from "../../config/accounts.js";
|
|
2
2
|
import { COOKIE_OPTIONS, SESSION_COOKIE, authenticate } from "../auth.js";
|
|
3
3
|
/**
|
|
4
4
|
* What someone may do to their own account, whatever their role.
|
|
@@ -54,4 +54,59 @@ export async function registerAccountRoutes(app, ctx) {
|
|
|
54
54
|
.code(204)
|
|
55
55
|
.send();
|
|
56
56
|
});
|
|
57
|
+
/**
|
|
58
|
+
* Changing your own name — the other thing only you can do for yourself.
|
|
59
|
+
*
|
|
60
|
+
* Everything the password route is careful about is careful here for the same
|
|
61
|
+
* reasons: the current password is asked for because a session is a cookie in
|
|
62
|
+
* a browser that may have been left open; the role comes from the session and
|
|
63
|
+
* never from the body, so a rename cannot smuggle in a promotion; and the
|
|
64
|
+
* account this touches is only ever the session's own, there being no name in
|
|
65
|
+
* the body to point anywhere else.
|
|
66
|
+
*
|
|
67
|
+
* The one thing that is not like the password route is the write. A rename is
|
|
68
|
+
* not `upsertUserInConfig`, which keys by name and would add a second account
|
|
69
|
+
* under the new name and leave the old one — and its `projects` grants —
|
|
70
|
+
* behind. `renameUserInConfig` edits the `name` field of the existing entry in
|
|
71
|
+
* place, so role, hash and grants ride along untouched.
|
|
72
|
+
*/
|
|
73
|
+
app.post("/api/account/name", async (req, reply) => {
|
|
74
|
+
const { name, role } = req.identity;
|
|
75
|
+
const { current, next } = (req.body ?? {});
|
|
76
|
+
if (typeof next !== "string" || !VALID_NAME.test(next)) {
|
|
77
|
+
return reply.code(400).send({
|
|
78
|
+
error: "A name is letters, digits, dot, dash and underscore, starting with a letter or a digit.",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const users = ctx.config.server()?.users ?? [];
|
|
82
|
+
if (typeof current !== "string" || !(await authenticate(users, name, current))) {
|
|
83
|
+
// No throttle, for the same reason as the password route: reaching here
|
|
84
|
+
// already costs a valid session.
|
|
85
|
+
return reply.code(401).send({ error: "That is not your current password." });
|
|
86
|
+
}
|
|
87
|
+
if (next === name) {
|
|
88
|
+
return reply.code(400).send({ error: "That is the name you already have." });
|
|
89
|
+
}
|
|
90
|
+
// A collision is refused rather than allowed to collapse two accounts into
|
|
91
|
+
// one. The comparison is case-sensitive, because the stored names are: `Ci`
|
|
92
|
+
// and `ci` are two accounts, and a rename to either is a rename to that one.
|
|
93
|
+
if (users.some((u) => u.name === next)) {
|
|
94
|
+
return reply.code(409).send({ error: `${next} is already the name of another account.` });
|
|
95
|
+
}
|
|
96
|
+
await renameUserInConfig(ctx.config.configPath(), name, next);
|
|
97
|
+
// Every session under the old name goes — the auth hook matches a session's
|
|
98
|
+
// name against a live account, and after this there is no account by that
|
|
99
|
+
// name for it to match. A fresh session under the new name is issued at once,
|
|
100
|
+
// so the person who just did it stays signed in on this page.
|
|
101
|
+
ctx.sessions.revokeAllFor(name);
|
|
102
|
+
const token = ctx.sessions.issue({ name: next, role });
|
|
103
|
+
// The file is watched on a debounce; loading here is what makes the very next
|
|
104
|
+
// request see the account under its new name rather than its old one.
|
|
105
|
+
await ctx.config.load();
|
|
106
|
+
// The new name is returned so the interface can update the header and this
|
|
107
|
+
// page, now that the cookie it carries belongs to a different name.
|
|
108
|
+
return reply
|
|
109
|
+
.setCookie(SESSION_COOKIE, token, COOKIE_OPTIONS)
|
|
110
|
+
.send({ name: next, role });
|
|
111
|
+
});
|
|
57
112
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { FastfileStore } from "../../fastfile/store.js";
|
|
3
3
|
import { Workspace } from "../../git/workspace.js";
|
|
4
|
+
import { assertFastlaneDir } from "../../sidecar/fastlane-dir.js";
|
|
4
5
|
export async function registerFastfileRoutes(app, ctx) {
|
|
5
6
|
// Stateless: the previous content it keeps in memory during a write lives
|
|
6
7
|
// on the call stack, not on the instance, so sharing one across requests
|
|
@@ -40,6 +41,12 @@ export async function registerFastfileRoutes(app, ctx) {
|
|
|
40
41
|
if (!r)
|
|
41
42
|
return;
|
|
42
43
|
try {
|
|
44
|
+
// The common case is not a hand-deleted Fastfile but a `fastlane_dir`
|
|
45
|
+
// that never reached the clone — a path only in someone's working copy,
|
|
46
|
+
// uncommitted or gitignored, or a stray local copy. `store.read` would
|
|
47
|
+
// surface that as a bare ENOENT; this turns it into the same sentence the
|
|
48
|
+
// sidecar gives, naming the directory and how to fix it.
|
|
49
|
+
await assertFastlaneDir(join(r.workspacePath, r.fastlaneDir), r.fastlaneDir);
|
|
43
50
|
const [content, dirty, diff] = await Promise.all([
|
|
44
51
|
store.read(r.workspacePath, r.fastlaneDir),
|
|
45
52
|
r.workspace.isDirty(),
|