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.
Files changed (36) hide show
  1. package/README.md +13 -497
  2. package/dist/src/cli/adopt.js +24 -8
  3. package/dist/src/cli/remove.js +52 -34
  4. package/dist/src/cli/reset.js +2 -2
  5. package/dist/src/cli/setup.js +85 -40
  6. package/dist/src/cli/uninstall.js +5 -21
  7. package/dist/src/config/env-file-setting.js +57 -0
  8. package/dist/src/config/resolve.js +8 -5
  9. package/dist/src/config/schema.js +29 -0
  10. package/dist/src/db/credentials.js +18 -54
  11. package/dist/src/db/migrate-global-scope.js +75 -0
  12. package/dist/src/db/open.js +21 -0
  13. package/dist/src/db/schema.sql +10 -5
  14. package/dist/src/db/secrets.js +44 -65
  15. package/dist/src/fastfile/adoption.js +19 -4
  16. package/dist/src/git/workspace.js +28 -0
  17. package/dist/src/heuristics/readiness.js +80 -135
  18. package/dist/src/main.js +31 -2
  19. package/dist/src/runner/env-file.js +144 -0
  20. package/dist/src/runner/orchestrate.js +39 -5
  21. package/dist/src/secrets/vault.js +75 -40
  22. package/dist/src/server/app.js +4 -0
  23. package/dist/src/server/routes/credentials.js +0 -13
  24. package/dist/src/server/routes/fastfile.js +1 -1
  25. package/dist/src/server/routes/projects.js +59 -16
  26. package/dist/src/server/routes/readiness.js +2 -3
  27. package/dist/src/server/routes/secrets.js +111 -39
  28. package/dist/src/sidecar/fastlane-dir.js +18 -8
  29. package/dist/src/sidecar/lanes.js +1 -1
  30. package/dist/src/sidecar/uses.js +1 -1
  31. package/dist/web/assets/{Editor-CMa4-4N3.js → Editor-BK-ZRKlI.js} +1 -1
  32. package/dist/web/assets/index-DnUdqjT4.js +62 -0
  33. package/dist/web/assets/{index-DsjxZtsO.css → index-r3IyRuO4.css} +1 -1
  34. package/dist/web/index.html +2 -2
  35. package/package.json +1 -1
  36. package/dist/web/assets/index-B8lAQPEM.js +0 -62
@@ -0,0 +1,75 @@
1
+ /** The two tables that carried a scope, and the column that identified a row in each. */
2
+ const TABLES = [
3
+ { table: "secret", column: "key", what: "secret" },
4
+ { table: "credential", column: "kind", what: "signing block" },
5
+ ];
6
+ /**
7
+ * Gives every row that belonged to everything to each project that read it.
8
+ *
9
+ * The vault used to have two scopes: a row stored under the empty slug applied
10
+ * to every project, and a row under a real slug shadowed it. That is gone — a
11
+ * secret and a signing block now belong to exactly one project — and this is
12
+ * what happens to the rows written before it went.
13
+ *
14
+ * **Copied, never dropped.** A global secret was read by every project, so
15
+ * writing it into every project preserves precisely the behaviour it had:
16
+ * nothing that built yesterday stops building today. The alternative — deleting
17
+ * rows the user never asked to delete, on an upgrade — would break working
18
+ * projects to tidy a table.
19
+ *
20
+ * **A project that overrode a global one keeps its own value.** The insert
21
+ * takes no action on conflict, which is the same precedence the merged read
22
+ * applied, made permanent rather than recomputed on every query.
23
+ *
24
+ * The cost is real and is the point of the report: someone who stored one App
25
+ * Store Connect key now has five, and rotating it means replacing five. They
26
+ * have to be told, or they will find out from a build that uploads with a key
27
+ * they thought they had replaced.
28
+ *
29
+ * With no projects configured there is nowhere to copy to, and the row is
30
+ * deleted rather than left: every query names a slug now, so the empty one is
31
+ * unreachable — not shared, just invisible and forever unread.
32
+ */
33
+ export function migrateGlobalScope(db, allSlugs) {
34
+ const report = { copied: [], dropped: [] };
35
+ // The empty slug is the sentinel this function is deleting. A configuration
36
+ // cannot produce one — `slugSchema` refuses it — but if one ever arrived, the
37
+ // copy would land on the row about to be deleted and the data would go with
38
+ // it, silently. That is the one input worth refusing by hand.
39
+ const slugs = allSlugs.filter((slug) => slug !== "");
40
+ const run = db.transaction(() => {
41
+ for (const { table, column, what } of TABLES) {
42
+ const names = db.prepare(`SELECT ${column} AS name FROM ${table} WHERE project_slug = '' ORDER BY ${column}`).all().map((row) => row.name);
43
+ if (names.length === 0)
44
+ continue;
45
+ // `SELECT ?, <rest>` rather than a read-then-write: it keeps every column
46
+ // the table has without naming them here, so a column added later travels
47
+ // with the copy instead of being silently dropped by a migration nobody
48
+ // thought to update.
49
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name);
50
+ const rest = columns.filter((c) => c !== "project_slug");
51
+ const copy = db.prepare(`INSERT INTO ${table} (project_slug, ${rest.join(", ")})
52
+ SELECT ?, ${rest.join(", ")} FROM ${table} WHERE project_slug = '' AND ${column} = ?
53
+ ON CONFLICT DO NOTHING`);
54
+ for (const name of names) {
55
+ const wrote = [];
56
+ for (const slug of slugs)
57
+ if (copy.run(slug, name).changes > 0)
58
+ wrote.push(slug);
59
+ // Dropped means "deleted and copied nowhere", which covers two cases:
60
+ // there was no project to copy into, and every project already defined
61
+ // the name and kept its own. The second changes nothing anyone could
62
+ // observe — the row was shadowed everywhere, so nothing ever read it —
63
+ // but it is still a deletion, and a migration that deletes in silence is
64
+ // one nobody can audit afterwards.
65
+ if (wrote.length > 0)
66
+ report.copied.push({ what, name, slugs: wrote });
67
+ else
68
+ report.dropped.push({ what, name });
69
+ }
70
+ db.prepare(`DELETE FROM ${table} WHERE project_slug = ''`).run();
71
+ }
72
+ });
73
+ run();
74
+ return report;
75
+ }
@@ -9,6 +9,10 @@ export function openDatabase(path) {
9
9
  migrateIntrospectionCache(db);
10
10
  const here = dirname(fileURLToPath(import.meta.url));
11
11
  db.exec(readFileSync(join(here, "schema.sql"), "utf8"));
12
+ // After the schema, not before: on a fresh database the table has to exist
13
+ // for the column check to mean anything, and there `CREATE TABLE` has already
14
+ // put the column in.
15
+ migrateEnvFileColumn(db);
12
16
  return db;
13
17
  }
14
18
  /**
@@ -25,3 +29,20 @@ function migrateIntrospectionCache(db) {
25
29
  db.exec("DROP TABLE introspection_cache");
26
30
  }
27
31
  }
32
+ /**
33
+ * Adds `secret.in_env_file` to a database written before the environment file
34
+ * existed.
35
+ *
36
+ * `CREATE TABLE IF NOT EXISTS` leaves an existing table alone, so a column added
37
+ * to the schema never reaches a database that already has the table. The cache
38
+ * above answers that by dropping and rebuilding; here that would delete the
39
+ * vault. `ALTER TABLE … ADD COLUMN` with a default is the whole migration: every
40
+ * row that predates the column reads as "not in the file", which is exactly what
41
+ * it was.
42
+ */
43
+ function migrateEnvFileColumn(db) {
44
+ const columns = db.prepare("PRAGMA table_info(secret)").all();
45
+ if (!columns.some((c) => c.name === "in_env_file")) {
46
+ db.exec("ALTER TABLE secret ADD COLUMN in_env_file INTEGER NOT NULL DEFAULT 0");
47
+ }
48
+ }
@@ -50,14 +50,19 @@ CREATE TABLE IF NOT EXISTS introspection_cache (
50
50
  PRIMARY KEY (project_slug, kind)
51
51
  );
52
52
 
53
- -- A global secret is stored with an empty project_slug rather than NULL:
54
- -- SQLite considers two NULLs distinct in a UNIQUE index, so NULL would let the
55
- -- same global name be inserted twice.
53
+ -- Every secret belongs to one project. There was a second scope once — the
54
+ -- empty slug, read by every project and `migrate-global-scope.ts` is what
55
+ -- became of the rows written under it.
56
+ -- `in_env_file` says this variable is also written into the file the build reads
57
+ -- from disk — see `runner/env-file.ts`. It is membership, not a second value: a
58
+ -- flagged secret still reaches the run as an environment variable like any
59
+ -- other. `open.ts` adds the column to a database written before it existed.
56
60
  CREATE TABLE IF NOT EXISTS secret (
57
- project_slug TEXT NOT NULL DEFAULT '',
61
+ project_slug TEXT NOT NULL,
58
62
  key TEXT NOT NULL,
59
63
  value_enc TEXT NOT NULL,
60
64
  masked INTEGER NOT NULL DEFAULT 1,
65
+ in_env_file INTEGER NOT NULL DEFAULT 0,
61
66
  updated_at TEXT NOT NULL,
62
67
  PRIMARY KEY (project_slug, key)
63
68
  );
@@ -74,7 +79,7 @@ CREATE TABLE IF NOT EXISTS secret (
74
79
  -- `var_names` is NOT encrypted. It holds variable names, never values, and the
75
80
  -- interface has to display them.
76
81
  CREATE TABLE IF NOT EXISTS credential (
77
- project_slug TEXT NOT NULL DEFAULT '',
82
+ project_slug TEXT NOT NULL,
78
83
  kind TEXT NOT NULL,
79
84
  file_name TEXT NOT NULL,
80
85
  file_enc TEXT NOT NULL,
@@ -1,50 +1,41 @@
1
- const GLOBAL = "";
1
+ const summaryOf = (row) => ({
2
+ key: row.key,
3
+ masked: row.masked === 1,
4
+ inEnvFile: row.in_env_file === 1,
5
+ });
2
6
  /**
3
7
  * Stores encrypted secrets. Knows nothing about encryption itself: it takes and
4
8
  * returns ciphertext, so a bug here cannot leak a plaintext value.
5
9
  *
6
- * A project secret shadows a global one of the same name the same precedence
7
- * as the configuration, and the least surprising rule.
10
+ * Every row belongs to exactly one project. There used to be a second scope —
11
+ * a row under no project, read by all of them, shadowed by a project's own —
12
+ * and it is gone: see `migrate-global-scope.ts`. What it cost was the answer to
13
+ * "what does this project see", which was a merge of two sets that no screen
14
+ * ever showed whole. It is now one query.
8
15
  */
9
16
  export class SecretStore {
10
17
  db;
11
18
  constructor(db) {
12
19
  this.db = db;
13
20
  }
14
- set(projectSlug, key, valueEnc, masked) {
21
+ set(projectSlug, key, valueEnc, masked, inEnvFile = false) {
15
22
  this.db
16
- .prepare(`INSERT INTO secret (project_slug, key, value_enc, masked, updated_at)
17
- VALUES (?, ?, ?, ?, ?)
23
+ .prepare(`INSERT INTO secret (project_slug, key, value_enc, masked, in_env_file, updated_at)
24
+ VALUES (?, ?, ?, ?, ?, ?)
18
25
  ON CONFLICT (project_slug, key) DO UPDATE
19
26
  SET value_enc = excluded.value_enc,
20
27
  masked = excluded.masked,
28
+ in_env_file = excluded.in_env_file,
21
29
  updated_at = excluded.updated_at`)
22
- .run(projectSlug ?? GLOBAL, key, valueEnc, masked ? 1 : 0, new Date().toISOString());
30
+ .run(projectSlug, key, valueEnc, masked ? 1 : 0, inEnvFile ? 1 : 0, new Date().toISOString());
23
31
  }
24
- /** Rows that apply to a project, project scope winning over global. */
25
- applicable(projectSlug) {
26
- const rows = this.db
27
- .prepare("SELECT * FROM secret WHERE project_slug IN (?, ?) ORDER BY key")
28
- .all(projectSlug, GLOBAL);
29
- const byKey = new Map();
30
- for (const row of rows) {
31
- const existing = byKey.get(row.key);
32
- if (!existing || row.project_slug !== GLOBAL)
33
- byKey.set(row.key, row);
34
- }
35
- return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key));
32
+ rows(projectSlug) {
33
+ return this.db.prepare("SELECT * FROM secret WHERE project_slug = ? ORDER BY key").all(projectSlug);
36
34
  }
37
- /** One applicable row, ciphertext included, or undefined. */
35
+ /** One row, ciphertext included, or undefined. */
38
36
  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;
37
+ const row = this.rows(projectSlug).find((r) => r.key === key);
38
+ return row ? { ...summaryOf(row), valueEnc: row.value_enc } : undefined;
48
39
  }
49
40
  /**
50
41
  * Flips whether a value is kept out of the logs, leaving the value alone.
@@ -60,58 +51,46 @@ export class SecretStore {
60
51
  return (this.db
61
52
  .prepare(`UPDATE secret SET masked = ?, updated_at = ?
62
53
  WHERE project_slug = ? AND key = ?`)
63
- .run(masked ? 1 : 0, new Date().toISOString(), projectSlug ?? GLOBAL, key).changes > 0);
54
+ .run(masked ? 1 : 0, new Date().toISOString(), projectSlug, key).changes > 0);
64
55
  }
65
56
  /**
66
- * The rows stored under this slug, and no global one.
57
+ * Flips whether this variable is written into the environment file, leaving
58
+ * the value and the masking alone.
67
59
  *
68
- * `list` answers "what does this project see", which is the right question
69
- * everywhere else and the wrong one when a project is going away: a global
70
- * secret three other projects also read is not this one's to count, and
71
- * certainly not its to remove.
72
- *
73
- * The empty slug is the global scope's own key, so it is refused here rather
74
- * than quietly returning every global row as if one project owned them.
60
+ * Its own operation for the same reason as `setMasked`: re-`set` would mean
61
+ * handing back a value the caller may not be able to read.
75
62
  */
76
- listOwn(projectSlug) {
77
- if (projectSlug === GLOBAL)
78
- return [];
79
- const rows = this.db
80
- .prepare("SELECT * FROM secret WHERE project_slug = ? ORDER BY key")
81
- .all(projectSlug);
82
- return rows.map((row) => ({ key: row.key, masked: row.masked === 1, scope: "project" }));
63
+ setInEnvFile(projectSlug, key, inEnvFile) {
64
+ return (this.db
65
+ .prepare(`UPDATE secret SET in_env_file = ?, updated_at = ?
66
+ WHERE project_slug = ? AND key = ?`)
67
+ .run(inEnvFile ? 1 : 0, new Date().toISOString(), projectSlug, key).changes > 0);
83
68
  }
84
- /** Removes every row stored under this slug, and returns how many. */
85
- removeAllOwn(projectSlug) {
86
- if (projectSlug === GLOBAL)
87
- return 0;
69
+ /** Removes every row this project holds, and returns how many. */
70
+ removeAll(projectSlug) {
88
71
  return this.db.prepare("DELETE FROM secret WHERE project_slug = ?").run(projectSlug).changes;
89
72
  }
90
73
  list(projectSlug) {
91
- return this.applicable(projectSlug).map((row) => ({
92
- key: row.key,
93
- masked: row.masked === 1,
94
- scope: row.project_slug === GLOBAL ? "global" : "project",
95
- }));
74
+ return this.rows(projectSlug).map(summaryOf);
96
75
  }
97
- listGlobal() {
98
- const rows = this.db
99
- .prepare("SELECT * FROM secret WHERE project_slug = ? ORDER BY key")
100
- .all(GLOBAL);
101
- return rows.map((row) => ({ key: row.key, masked: row.masked === 1, scope: "global" }));
76
+ /** The names that go in the environment file, in the order they are written. */
77
+ envFileKeys(projectSlug) {
78
+ return this.rows(projectSlug)
79
+ .filter((r) => r.in_env_file === 1)
80
+ .map((r) => r.key);
102
81
  }
103
82
  /** Ciphertext by name, for the vault to decrypt. */
104
83
  encrypted(projectSlug) {
105
- return Object.fromEntries(this.applicable(projectSlug).map((row) => [row.key, row.value_enc]));
84
+ return Object.fromEntries(this.rows(projectSlug).map((row) => [row.key, row.value_enc]));
106
85
  }
107
- /** Which of the applicable secrets should be kept out of the logs. */
86
+ /** Which of this project's secrets should be kept out of the logs. */
108
87
  maskedKeys(projectSlug) {
109
- return new Set(this.applicable(projectSlug).filter((r) => r.masked === 1).map((r) => r.key));
88
+ return new Set(this.rows(projectSlug)
89
+ .filter((r) => r.masked === 1)
90
+ .map((r) => r.key));
110
91
  }
111
92
  remove(projectSlug, key) {
112
- const res = this.db
113
- .prepare("DELETE FROM secret WHERE project_slug = ? AND key = ?")
114
- .run(projectSlug ?? GLOBAL, key);
93
+ const res = this.db.prepare("DELETE FROM secret WHERE project_slug = ? AND key = ?").run(projectSlug, key);
115
94
  return res.changes > 0;
116
95
  }
117
96
  }
@@ -53,6 +53,10 @@ export function proposalsFor(literals) {
53
53
  const main = rewriteTo(literal, name);
54
54
  const identifiers = file.kind === "apple_asc" ? appleIdentifierRewrites(appleIdentifiers) : EMPTY;
55
55
  const edits = [...(main ? [main] : []), ...identifiers.edits];
56
+ const rewrites = [
57
+ ...(main ? [{ arg: literal.arg, literal, varName: name }] : []),
58
+ ...identifiers.rewrites,
59
+ ];
56
60
  // Every edit was a no-op — the value already reads its Laneyard name, and
57
61
  // so did each identifier. Nothing to propose.
58
62
  if (edits.length === 0)
@@ -69,6 +73,7 @@ export function proposalsFor(literals) {
69
73
  },
70
74
  literal,
71
75
  edits,
76
+ rewrites,
72
77
  checked: true,
73
78
  });
74
79
  continue;
@@ -96,6 +101,7 @@ export function proposalsFor(literals) {
96
101
  },
97
102
  ...identifiers.edits,
98
103
  ],
104
+ rewrites: [{ arg: inline.becomes, literal, varName: name }, ...identifiers.rewrites],
99
105
  checked: true,
100
106
  });
101
107
  continue;
@@ -108,6 +114,7 @@ export function proposalsFor(literals) {
108
114
  literal,
109
115
  suggestedFields: {},
110
116
  edits: [{ start: literal.valueStart, length: literal.valueLength, replacement: `ENV.fetch("${name}")` }],
117
+ rewrites: [{ arg: literal.arg, literal, varName: name }],
111
118
  // Unticked on purpose. This is the one tier where a false positive is
112
119
  // likely — a non-secret URL, a placeholder — and a patch applied by
113
120
  // default to a value that was not a secret is a silent regression in
@@ -119,7 +126,11 @@ export function proposalsFor(literals) {
119
126
  return out;
120
127
  }
121
128
  /** Neither an edit nor a field: an `apple_asc` block that names no identifier. */
122
- const EMPTY = { edits: [], fields: {} };
129
+ const EMPTY = {
130
+ edits: [],
131
+ fields: {},
132
+ rewrites: [],
133
+ };
123
134
  /**
124
135
  * The rewrites and pre-filled fields for the identifiers written beside an
125
136
  * App Store Connect key file.
@@ -133,17 +144,21 @@ const EMPTY = { edits: [], fields: {} };
133
144
  function appleIdentifierRewrites(identifiers) {
134
145
  const defaults = defaultVarNames("apple_asc");
135
146
  const edits = [];
147
+ const rewrites = [];
136
148
  const fields = {};
137
149
  for (const id of identifiers) {
138
- const edit = rewriteTo(id, defaults[id.arg]);
139
- if (edit)
150
+ const name = defaults[id.arg];
151
+ const edit = rewriteTo(id, name);
152
+ if (edit) {
140
153
  edits.push(edit);
154
+ rewrites.push({ arg: id.arg, literal: id, varName: name });
155
+ }
141
156
  // Only a literal is the identifier's value; an env name is the *variable* it
142
157
  // was read from, not the id itself, so it pre-fills nothing.
143
158
  if (id.kind === "literal")
144
159
  fields[id.arg] = id.value;
145
160
  }
146
- return { edits, fields };
161
+ return { edits, fields, rewrites };
147
162
  }
148
163
  /**
149
164
  * The edit that makes one argument read `varName`, or null when it already does.
@@ -199,6 +199,34 @@ export class Workspace {
199
199
  onProgress?.(`Cloning ${this.redact(this.gitUrl)}…`);
200
200
  await this.git(["clone", this.gitUrl, this.path], process.cwd());
201
201
  }
202
+ /**
203
+ * Commits this workspace holds that `origin/<branch>` does not.
204
+ *
205
+ * The one thing `prepare` would destroy without saying so: it moves the local
206
+ * branch onto origin's, and a commit only this clone had becomes reachable
207
+ * from the reflog and nowhere a person looks. That is not hypothetical — the
208
+ * Fastfile tab commits and pushes as two separate acts on purpose, so a
209
+ * workspace sitting one commit ahead is a state the product produces itself.
210
+ *
211
+ * Counted rather than listed: the caller refuses on any number above zero, and
212
+ * a list would invite showing it, which is a screen nobody asked for.
213
+ *
214
+ * Zero when the branch does not exist on the remote yet, and zero when
215
+ * anything about the question cannot be answered — a workspace that has never
216
+ * been fetched has no `origin/<branch>` to compare against, and refusing a
217
+ * refresh because the comparison failed would break the one case the refresh
218
+ * exists for.
219
+ */
220
+ async unpushedCount(branch) {
221
+ try {
222
+ const out = await this.git(["rev-list", "--count", `origin/${branch}..HEAD`]);
223
+ const n = Number(out.trim());
224
+ return Number.isInteger(n) ? n : 0;
225
+ }
226
+ catch {
227
+ return 0;
228
+ }
229
+ }
202
230
  /**
203
231
  * Brings the workspace to the requested branch, up to date.
204
232
  * Clones on the first call, just fetches afterwards.