flecto 3.1.0 → 4.0.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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "access": "public",
5
5
  "provenance": true
6
6
  },
7
- "version": "3.1.0",
7
+ "version": "4.0.0",
8
8
  "description": "Flecto \u2014 semantic config watcher that reports meaningful changes in plain English",
9
9
  "license": "MIT",
10
10
  "keywords": [
@@ -30,15 +30,17 @@
30
30
  "type": "module",
31
31
  "main": "index.js",
32
32
  "bin": {
33
- "flecto": "index.js"
33
+ "flecto": "index.js",
34
+ "flecto-drift": "drift.js"
34
35
  },
35
36
  "files": [
37
+ "CHANGELOG.md",
38
+ "LICENSE",
39
+ "README.md",
40
+ "drift.js",
36
41
  "index.js",
37
- "src/**/*",
38
42
  "schemas/**/*",
39
- "README.md",
40
- "LICENSE",
41
- "CHANGELOG.md"
43
+ "src/**/*"
42
44
  ],
43
45
  "scripts": {
44
46
  "test": "node --test test/*.test.js",
@@ -56,6 +58,7 @@
56
58
  "commander": "^12.1.0",
57
59
  "dotenv": "^17.4.2",
58
60
  "fast-glob": "^3.3.3",
59
- "js-yaml": "^4.3.0"
61
+ "js-yaml": "^4.3.0",
62
+ "re2js": "^2.8.6"
60
63
  }
61
64
  }
package/src/alerter.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'child_process';
2
- import { mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'fs';
3
- import { resolve } from 'path';
2
+ import { createHash } from 'crypto';
3
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync, rmSync } from 'fs';
4
+ import { join, resolve } from 'path';
4
5
  import { renderWarn } from './renderer.js';
5
6
  import { formatWebhookPayload } from './notifiers.js';
6
7
 
@@ -8,6 +9,101 @@ const ALERT_TMP_DIR = '.flecto-tmp';
8
9
  const ALERT_QUEUE_DIR = '.flecto-queue';
9
10
  const MAX_ENV_CHANGES_CHARS = 16_000;
10
11
 
12
+ /**
13
+ * A webhook URL reduced to the part that identifies it without carrying its
14
+ * credential.
15
+ *
16
+ * Webhook URLs routinely *are* credentials: a Slack incoming webhook puts its
17
+ * secret in the path (`/services/T.../B.../XXXX`), and userinfo and query
18
+ * strings carry tokens just as often. Warnings about a failed delivery are
19
+ * printed to a terminal and, in CI, into a log that is frequently world
20
+ * readable and retained — so a transient 500 was enough to copy the credential
21
+ * somewhere it outlives the run.
22
+ *
23
+ * Only the origin survives, plus a marker showing a path was elided. That is
24
+ * enough for an operator to tell *which* endpoint failed (they configured it)
25
+ * and not enough for a reader of the log to call it.
26
+ * @param {string} raw
27
+ * @returns {string}
28
+ */
29
+ export function redactWebhookUrl(raw) {
30
+ try {
31
+ const url = new URL(raw);
32
+ const path = url.pathname && url.pathname !== '/' ? '/...' : '';
33
+ return `${url.protocol}//${url.host}${path}`;
34
+ } catch {
35
+ // Not parseable as a URL, so nothing can be said about which part is the
36
+ // secret. Say nothing.
37
+ return '<webhook>';
38
+ }
39
+ }
40
+
41
+ /**
42
+ * An error's message with every URL in it redacted.
43
+ *
44
+ * Redacting the URL Flecto interpolates is not enough on its own, because
45
+ * `fetch` puts the URL into the error it throws. Two cases reach this
46
+ * deterministically, and both are precisely where redaction matters most:
47
+ * a URL carrying userinfo (`https://user:TOKEN@host/x`) fails with *"Request
48
+ * cannot be constructed from a URL that includes credentials: <the whole
49
+ * thing>"*, and an unparseable URL fails with *"Failed to parse URL from
50
+ * <the whole thing>"* -- the very case `redactWebhookUrl` answers `<webhook>`
51
+ * to, because nothing can be said about which part is the secret.
52
+ *
53
+ * So the configured URL is substituted out by value, and any other URL-shaped
54
+ * run is redacted on sight in case the engine spelled it differently.
55
+ * @param {unknown} err
56
+ * @param {string} [url] the URL this delivery was configured with
57
+ * @returns {string}
58
+ */
59
+ function redactedFailureDetail(err, url) {
60
+ let message = String(/** @type {{ message?: unknown }} */ (err)?.message ?? err);
61
+ if (url) message = message.split(url).join(redactWebhookUrl(url));
62
+ return message.replace(/[a-z][a-z0-9+.-]*:\/\/\S+/gi, (found) => redactWebhookUrl(found));
63
+ }
64
+
65
+ /**
66
+ * A stable fingerprint of everything that decides *where* a delivery goes and
67
+ * what it looks like on arrival.
68
+ *
69
+ * The persistent queue used to store the envelope alone, so a flush delivered
70
+ * it with whatever options the *current* `fireAlerts` call happened to carry.
71
+ * An event queued while `watch` pointed at one endpoint would be posted to
72
+ * whichever endpoint the next run named -- a different team's channel, a
73
+ * different vendor, a URL from a different profile -- carrying configuration
74
+ * data the operator had directed elsewhere.
75
+ *
76
+ * Hashing rather than storing keeps the URL and any auth header out of the
77
+ * queue file. It is a fingerprint, not a secret: anyone who can read
78
+ * `.flecto-queue/` could confirm a *guessed* URL against it, but they can read
79
+ * the directory only if they can already read the config that names it.
80
+ *
81
+ * Header *values* are included, so rotating a token strands the old backlog
82
+ * rather than replaying it under new credentials. That is the safe direction,
83
+ * and `warnAboutStrandedQueue` is what stops it being silent.
84
+ * @param {{ webhook?: string, webhookHeaders?: Record<string, string>, webhookFormat?: string }} options
85
+ * @returns {string}
86
+ */
87
+ function destinationId(options) {
88
+ // Normalize first, so one destination does not split into several
89
+ // directories over spellings that address the same endpoint. `new URL().href`
90
+ // settles host case and a redundant default port; HTTP header names are
91
+ // case-insensitive by spec, so they are lowercased before sorting. A trailing
92
+ // slash is left alone -- it is a different path, and a receiver may treat it
93
+ // as one.
94
+ let url = options.webhook ?? '';
95
+ try {
96
+ url = new URL(url).href;
97
+ } catch {
98
+ // Not parseable: hash it as written rather than guessing at it.
99
+ }
100
+ const headers = Object.entries(options.webhookHeaders ?? {})
101
+ .map(([key, value]) => [key.toLowerCase(), value])
102
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
103
+ const material = JSON.stringify([url, headers, options.webhookFormat ?? 'flecto']);
104
+ return createHash('sha256').update(material).digest('hex').slice(0, 32);
105
+ }
106
+
11
107
  /** @type {Promise<void>} */
12
108
  let alertQueue = Promise.resolve();
13
109
 
@@ -22,7 +118,19 @@ function enqueue(fn) {
22
118
  }
23
119
 
24
120
  /**
121
+ * Build the environment a `--command` subprocess runs with, spilling an
122
+ * oversized change set to a file.
123
+ *
124
+ * The spill file holds the complete, unmasked change set -- every value the
125
+ * diff touched, including ones `--mask-secrets` would have hidden on screen.
126
+ * It is created `0600` inside a `0700` directory and deleted once the command
127
+ * exits, so it is readable only by the user running Flecto and only while
128
+ * there is a process that needs it. Previously it inherited the process umask
129
+ * (`0644` on a typical runner) and was never removed, which left the full
130
+ * change set of every oversized event sitting in the workspace for whatever
131
+ * ran next -- a later build step, an artifact upload, a cache action.
25
132
  * @param {import('./envelope.js').FlectoEnvelope} envelope
133
+ * @returns {{ env: Record<string, string>, cleanup: () => void }}
26
134
  */
27
135
  function buildCommandEnv(envelope) {
28
136
  const json = JSON.stringify(envelope.changes);
@@ -33,24 +141,35 @@ function buildCommandEnv(envelope) {
33
141
  FLECTO_BATCH_ID: envelope.batch_id,
34
142
  FLECTO_SCHEMA_VERSION: envelope.schema_version,
35
143
  };
144
+ const noop = () => {};
36
145
 
37
146
  if (json.length <= MAX_ENV_CHANGES_CHARS) {
38
147
  env.FLECTO_CHANGES = json;
39
- return env;
148
+ return { env, cleanup: noop };
40
149
  }
41
150
 
42
151
  try {
43
- mkdirSync(ALERT_TMP_DIR, { recursive: true });
152
+ mkdirSync(ALERT_TMP_DIR, { recursive: true, mode: 0o700 });
44
153
  const outPath = resolve(`${ALERT_TMP_DIR}/changes-${Date.now()}-${envelope.event_id}.json`);
45
- writeFileSync(outPath, json, 'utf8');
154
+ writeFileSync(outPath, json, { encoding: 'utf8', mode: 0o600 });
46
155
  env.FLECTO_CHANGES_FILE = outPath;
47
156
  env.FLECTO_CHANGES = '[]';
48
- return env;
157
+ return {
158
+ env,
159
+ cleanup: () => {
160
+ try {
161
+ rmSync(outPath, { force: true });
162
+ } catch {
163
+ // The command may have moved or removed it; either way there is
164
+ // nothing left to clean and nothing useful to say.
165
+ }
166
+ },
167
+ };
49
168
  } catch (err) {
50
169
  env.FLECTO_CHANGES = '[]';
51
170
  env.FLECTO_CHANGES_TRUNCATED = '1';
52
171
  renderWarn(`Could not write changes payload to temp file: ${err.message}`);
53
- return env;
172
+ return { env, cleanup: noop };
54
173
  }
55
174
  }
56
175
 
@@ -61,7 +180,17 @@ function buildCommandEnv(envelope) {
61
180
  */
62
181
  export function runCommand(command, envelope) {
63
182
  return new Promise((resolveDone) => {
64
- const env = buildCommandEnv(envelope);
183
+ const { env, cleanup } = buildCommandEnv(envelope);
184
+ // The spill file exists for exactly as long as the subprocess that reads
185
+ // it. `settle` runs on every exit path, including the throw below, so no
186
+ // path leaves the change set on disk.
187
+ let settled = false;
188
+ const settle = (ok) => {
189
+ if (settled) return;
190
+ settled = true;
191
+ cleanup();
192
+ resolveDone(ok);
193
+ };
65
194
  try {
66
195
  const child = spawn(command, {
67
196
  shell: true,
@@ -73,19 +202,19 @@ export function runCommand(command, envelope) {
73
202
  child.stderr?.on('data', (d) => process.stderr.write(d));
74
203
  child.on('error', (err) => {
75
204
  renderWarn(`Command failed to start: ${err.message}`);
76
- resolveDone(false);
205
+ settle(false);
77
206
  });
78
207
  child.on('close', (code) => {
79
208
  if (code && code !== 0) {
80
209
  renderWarn(`Command failed (exit ${code}): ${command}`);
81
- resolveDone(false);
210
+ settle(false);
82
211
  return;
83
212
  }
84
- resolveDone(true);
213
+ settle(true);
85
214
  });
86
215
  } catch (err) {
87
216
  renderWarn(`Command execution error: ${err.message}`);
88
- resolveDone(false);
217
+ settle(false);
89
218
  }
90
219
  });
91
220
  }
@@ -95,23 +224,62 @@ function sleep(ms) {
95
224
  }
96
225
 
97
226
  /**
227
+ * The queue directory holding undelivered events for one destination.
228
+ *
229
+ * Each destination gets its own subdirectory, keyed by the fingerprint of the
230
+ * URL, headers, and format. That is what binds a queued event to the endpoint
231
+ * it was addressed to: a flush reads only its own directory, so it can no
232
+ * longer pick up an event queued for somewhere else. Keying the directory,
233
+ * rather than filtering files after reading them, also means a destination
234
+ * never even opens another's backlog.
235
+ * @param {{ webhook?: string, webhookHeaders?: Record<string, string>, webhookFormat?: string }} options
236
+ * @returns {string}
237
+ */
238
+ function queueDirFor(options) {
239
+ return resolve(join(ALERT_QUEUE_DIR, destinationId(options)));
240
+ }
241
+
242
+ /**
243
+ * `queueDirFor`, exposed so tests can assert which destinations share a queue.
244
+ * @type {typeof queueDirFor}
245
+ */
246
+ export const queueDirForTest = queueDirFor;
247
+
248
+ /**
249
+ * @param {{ webhook?: string, webhookHeaders?: Record<string, string>, webhookFormat?: string }} options
98
250
  * @param {import('./envelope.js').FlectoEnvelope} envelope
99
251
  */
100
- function enqueuePersistent(envelope) {
101
- mkdirSync(ALERT_QUEUE_DIR, { recursive: true });
102
- const path = resolve(`${ALERT_QUEUE_DIR}/${Date.now()}-${envelope.event_id}.json`);
103
- writeFileSync(path, JSON.stringify(envelope, null, 2), 'utf8');
252
+ function enqueuePersistent(options, envelope) {
253
+ const dir = queueDirFor(options);
254
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
255
+ const path = join(dir, `${Date.now()}-${envelope.event_id}.json`);
256
+ // The envelope carries the change set, so it gets the same 0600 the spill
257
+ // file gets -- an undelivered alert is the same data, just waiting.
258
+ writeFileSync(path, JSON.stringify(envelope, null, 2), { encoding: 'utf8', mode: 0o600 });
104
259
  }
105
260
 
106
261
  /**
262
+ * Deliver everything queued *for this destination*, oldest first.
263
+ *
264
+ * Stops at the first failure and leaves the rest queued, so ordering is
265
+ * preserved and at-least-once still means at least once.
266
+ * @param {{ webhook?: string, webhookHeaders?: Record<string, string>, webhookFormat?: string }} options
107
267
  * @param {(envelope: import('./envelope.js').FlectoEnvelope) => Promise<boolean>} deliver
108
268
  */
109
- async function flushPersistentQueue(deliver) {
269
+ async function flushPersistentQueue(options, deliver) {
270
+ const dir = queueDirFor(options);
110
271
  try {
111
- mkdirSync(ALERT_QUEUE_DIR, { recursive: true });
112
- const files = readdirSync(ALERT_QUEUE_DIR).filter((f) => f.endsWith('.json')).sort();
272
+ // Deliberately no mkdir: a flush that created its own directory left an
273
+ // empty one behind on every successful run, one per destination ever
274
+ // configured, never cleaned. Nothing queued means nothing to do.
275
+ let files;
276
+ try {
277
+ files = readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
278
+ } catch {
279
+ return true;
280
+ }
113
281
  for (const file of files) {
114
- const fullPath = resolve(`${ALERT_QUEUE_DIR}/${file}`);
282
+ const fullPath = join(dir, file);
115
283
  let envelope;
116
284
  try {
117
285
  envelope = JSON.parse(readFileSync(fullPath, 'utf8'));
@@ -126,6 +294,13 @@ async function flushPersistentQueue(deliver) {
126
294
  return false;
127
295
  }
128
296
  }
297
+ // Drained: take the directory with it, so the queue root does not
298
+ // accumulate one entry per destination that ever had a backlog.
299
+ try {
300
+ if (readdirSync(dir).length === 0) rmSync(dir, { recursive: false, force: true });
301
+ } catch {
302
+ // A concurrent writer may have just queued into it. Leaving it is fine.
303
+ }
129
304
  return true;
130
305
  } catch (err) {
131
306
  renderWarn(`Could not flush persistent queue: ${err.message}`);
@@ -133,6 +308,78 @@ async function flushPersistentQueue(deliver) {
133
308
  }
134
309
  }
135
310
 
311
+ /**
312
+ * Queue roots already warned about, so a watch on a churning file says this
313
+ * once rather than on every event. Keyed by path rather than a single flag:
314
+ * one process can legitimately serve more than one workspace, and each
315
+ * deserves to be told about its own backlog.
316
+ * @type {Set<string>}
317
+ */
318
+ const strandedQueuesWarned = new Set();
319
+
320
+ /**
321
+ * Warn about queued events that this run will not deliver.
322
+ *
323
+ * Two kinds, and neither may be delivered silently to the endpoint currently
324
+ * configured -- doing that is the bug this change fixes:
325
+ *
326
+ * - Events queued by Flecto 3.x sit at the top level of `.flecto-queue/` with
327
+ * no destination recorded at all.
328
+ * - Events queued for a destination that is not the one now configured sit in
329
+ * a sibling directory. Rotating an auth token on the same URL is enough to
330
+ * produce this, and it is the likelier cause in practice.
331
+ *
332
+ * Deleting either would discard an event the operator was promised
333
+ * at-least-once delivery of, so they are left alone and named. Naming them is
334
+ * the whole point: the earlier version of this warning only looked at
335
+ * top-level files, so a stranded 4.x backlog was invisible -- strictly worse
336
+ * than the 3.x case it did report.
337
+ * @param {string} currentDir the destination directory this run is flushing
338
+ */
339
+ function warnAboutStrandedQueue(currentDir) {
340
+ const root = resolve(ALERT_QUEUE_DIR);
341
+ if (strandedQueuesWarned.has(root)) return;
342
+ let entries;
343
+ try {
344
+ entries = readdirSync(ALERT_QUEUE_DIR, { withFileTypes: true });
345
+ } catch {
346
+ return;
347
+ }
348
+ const legacy = entries.filter((e) => e.isFile() && e.name.endsWith('.json')).length;
349
+ let stranded = 0;
350
+ for (const entry of entries) {
351
+ if (!entry.isDirectory()) continue;
352
+ const dir = join(ALERT_QUEUE_DIR, entry.name);
353
+ if (resolve(dir) === resolve(currentDir)) continue;
354
+ try {
355
+ stranded += readdirSync(dir).filter((f) => f.endsWith('.json')).length;
356
+ } catch {
357
+ // Unreadable: not something to report as a queued event.
358
+ }
359
+ }
360
+ if (legacy === 0 && stranded === 0) return;
361
+ strandedQueuesWarned.add(root);
362
+
363
+ const parts = [];
364
+ if (legacy > 0) {
365
+ parts.push(
366
+ `${legacy} event(s) queued by Flecto 3.x, which recorded no destination at all`,
367
+ );
368
+ }
369
+ if (stranded > 0) {
370
+ parts.push(
371
+ `${stranded} event(s) queued for a destination that is not the one configured now`
372
+ + ' (rotating a webhook token or editing the URL does this)',
373
+ );
374
+ }
375
+ renderWarn(
376
+ `${ALERT_QUEUE_DIR}/ holds undelivered events this run will not send: ${parts.join('; ')}. `
377
+ + 'They are kept, not dropped. Delivering them to the endpoint configured now is the '
378
+ + 'wrong-endpoint delivery this version exists to prevent, so re-send them deliberately '
379
+ + 'or remove them once you have looked.',
380
+ );
381
+ }
382
+
136
383
  /**
137
384
  * @param {string} url
138
385
  * @param {import('./envelope.js').FlectoEnvelope} envelope
@@ -170,7 +417,7 @@ export async function postWebhook(url, envelope, options = {}) {
170
417
  signal: controller.signal,
171
418
  });
172
419
  if (!response.ok) {
173
- renderWarn(`Webhook returned HTTP ${response.status}: ${url}`);
420
+ renderWarn(`Webhook returned HTTP ${response.status}: ${redactWebhookUrl(url)}`);
174
421
  if (response.status >= 500 && attempt < retries) {
175
422
  const backoff = Math.min(2_000, 200 * Math.pow(2, attempt));
176
423
  await sleep(backoff + Math.floor(Math.random() * 150));
@@ -182,7 +429,7 @@ export async function postWebhook(url, envelope, options = {}) {
182
429
  } catch (err) {
183
430
  const msg = err?.name === 'AbortError' ? `Webhook timed out after ${timeoutMs}ms` : 'Webhook failed';
184
431
  if (attempt >= retries) {
185
- renderWarn(`${msg}: ${url} (${err.message})`);
432
+ renderWarn(`${msg}: ${redactWebhookUrl(url)} (${redactedFailureDetail(err, url)})`);
186
433
  return false;
187
434
  }
188
435
  const backoff = Math.min(2_000, 200 * Math.pow(2, attempt));
@@ -251,7 +498,8 @@ export async function fireAlerts(options, envelope) {
251
498
 
252
499
  if (options.webhook) {
253
500
  if (options.deliveryMode === 'at-least-once') {
254
- await flushPersistentQueue((queued) => deliverWebhook(options, queued));
501
+ warnAboutStrandedQueue(queueDirFor(options));
502
+ await flushPersistentQueue(options, (queued) => deliverWebhook(options, queued));
255
503
  }
256
504
 
257
505
  let webhookOk = await deliverWebhook(options, envelope);
@@ -259,7 +507,7 @@ export async function fireAlerts(options, envelope) {
259
507
  webhookOk = await deliverWebhook({ ...options, webhookRetries: 5 }, envelope);
260
508
  }
261
509
  if (!webhookOk && options.deliveryMode === 'at-least-once') {
262
- enqueuePersistent(envelope);
510
+ enqueuePersistent(options, envelope);
263
511
  }
264
512
  ok = ok && webhookOk;
265
513
  }