@fuzdev/fuz_gitops 0.71.0 → 0.72.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.
@@ -15,6 +15,16 @@ import {
15
15
  GITOPS_NPM_WAIT_TIMEOUT_DEFAULT,
16
16
  } from './gitops_constants.js';
17
17
  import {install_with_cache_healing} from './npm_install_helpers.js';
18
+ import {
19
+ type PublishingEvent,
20
+ type PublishingRunSummary,
21
+ summarize_events,
22
+ } from './publishing_event.js';
23
+ import {
24
+ type PublishingEventHandler,
25
+ capture_handler,
26
+ multi_handler,
27
+ } from './publishing_event_handler.js';
18
28
 
19
29
  export interface PublishingOptions {
20
30
  wetrun: boolean;
@@ -25,6 +35,8 @@ export interface PublishingOptions {
25
35
  skip_install?: boolean;
26
36
  log?: Logger;
27
37
  ops?: GitopsOperations;
38
+ /** Structured event sink; defaults to capture-only (events surface on the result). */
39
+ events?: PublishingEventHandler;
28
40
  }
29
41
 
30
42
  export interface PublishedVersion {
@@ -42,6 +54,10 @@ export interface PublishingResult {
42
54
  published: Array<PublishedVersion>;
43
55
  failed: Array<{name: string; error: Error}>;
44
56
  duration: number;
57
+ /** The structured event stream for this run, in emission order. */
58
+ events: Array<PublishingEvent>;
59
+ /** Tallied outcome, derived from `events`. */
60
+ summary: PublishingRunSummary;
45
61
  }
46
62
 
47
63
  export const publish_repos = async (
@@ -51,6 +67,13 @@ export const publish_repos = async (
51
67
  const start_time = Date.now();
52
68
  const {wetrun, update_deps, log, ops = default_gitops_operations} = options;
53
69
 
70
+ // Capture every event for the result; also forward to the caller's sink if provided.
71
+ const capture = capture_handler();
72
+ const events_handler = options.events ? multi_handler([options.events, capture]) : capture;
73
+ const emit = (event: PublishingEvent): void => {
74
+ events_handler.emit(event);
75
+ };
76
+
54
77
  // Preflight checks (skip for dry runs since we're not actually publishing)
55
78
  if (wetrun) {
56
79
  const preflight_options: PreflightOptions = {
@@ -82,9 +105,12 @@ export const publish_repos = async (
82
105
  log_order: true,
83
106
  });
84
107
 
108
+ emit({event: 'run_started', wetrun, total: order.length});
109
+
85
110
  const published: Map<string, PublishedVersion> = new Map();
86
111
  const failed: Map<string, Error> = new Map();
87
112
  const changed_repos: Set<string> = new Set(); // Track repos with any changes for selective deployment
113
+ const skipped_packages: Set<string> = new Set(); // dedupe the package_skipped event across iterations
88
114
 
89
115
  // Fixed-point iteration: keep publishing until no new changesets are created
90
116
  // This handles transitive dependency updates (auto-generated changesets)
@@ -93,8 +119,12 @@ export const publish_repos = async (
93
119
 
94
120
  while (!converged && iteration < GITOPS_MAX_ITERATIONS_DEFAULT) {
95
121
  iteration++;
122
+ emit({event: 'iteration_started', iteration, max: GITOPS_MAX_ITERATIONS_DEFAULT});
96
123
  log?.info(
97
- st('cyan', `\nšŸš€ Publishing iteration ${iteration}/${GITOPS_MAX_ITERATIONS_DEFAULT}...\n`),
124
+ st(
125
+ 'cyan',
126
+ `\nšŸš€ ${wetrun ? 'Publishing' : 'Dry run'} iteration ${iteration}/${GITOPS_MAX_ITERATIONS_DEFAULT}...\n`,
127
+ ),
98
128
  );
99
129
 
100
130
  // Track if any packages were published in this iteration
@@ -121,6 +151,7 @@ export const publish_repos = async (
121
151
  // Failed to check changesets
122
152
  const err = new Error(`Failed to check changesets: ${has_result.message}`);
123
153
  failed.set(pkg_name, err);
154
+ emit({event: 'package_failed', name: pkg_name, error: err.message, code: 'dependency'});
124
155
  log?.error(st('red', ` āŒ ${err.message}`));
125
156
  break;
126
157
  }
@@ -129,6 +160,11 @@ export const publish_repos = async (
129
160
  // Skip packages without changesets
130
161
  // In real publish: They might get auto-changesets during dependency updates
131
162
  // In dry run: We can't simulate auto-changesets, so just skip
163
+ // Emit once per package — the loop revisits no-changeset packages each iteration
164
+ if (!skipped_packages.has(pkg_name)) {
165
+ skipped_packages.add(pkg_name);
166
+ emit({event: 'package_skipped', name: pkg_name, reason: 'no changesets'});
167
+ }
132
168
  if (!wetrun) {
133
169
  // Silent skip in dry run - plan shows which packages get auto-changesets
134
170
  continue;
@@ -140,7 +176,12 @@ export const publish_repos = async (
140
176
 
141
177
  try {
142
178
  // 1. Publish this package
143
- log?.info(st('dim', ` [${i + 1}/${order.length}] Publishing ${pkg_name}...`));
179
+ log?.info(
180
+ st(
181
+ 'dim',
182
+ ` [${i + 1}/${order.length}] ${wetrun ? 'Publishing' : 'Would publish'} ${pkg_name}...`,
183
+ ),
184
+ );
144
185
  const version = await publish_single_repo(repo, options, ops);
145
186
  published.set(pkg_name, version);
146
187
  changed_repos.add(pkg_name); // Mark as changed for deployment
@@ -148,7 +189,21 @@ export const publish_repos = async (
148
189
  // (their dependencies didn't change, only their version)
149
190
  published_in_iteration = true;
150
191
  published_count++;
151
- log?.info(st('green', ` āœ… Published ${pkg_name}@${version.new_version}`));
192
+ emit({
193
+ event: 'package_completed',
194
+ name: pkg_name,
195
+ old_version: version.old_version,
196
+ new_version: version.new_version,
197
+ bump_type: version.bump_type,
198
+ breaking: version.breaking,
199
+ commit: version.commit,
200
+ tag: version.tag,
201
+ });
202
+ log?.info(
203
+ wetrun
204
+ ? st('green', ` āœ… Published ${pkg_name}@${version.new_version}`)
205
+ : st('cyan', ` ā—‡ Would publish ${pkg_name}@${version.new_version}`),
206
+ );
152
207
 
153
208
  if (wetrun) {
154
209
  // 2. Wait for this package to be available on NPM
@@ -166,9 +221,16 @@ export const publish_repos = async (
166
221
  });
167
222
 
168
223
  if (!wait_result.ok) {
169
- throw new Error(
224
+ // Handle inline (don't throw into the generic catch): the npm-wait failure
225
+ // carries a typed `timeout` signal, so we know this is a network failure
226
+ // without sniffing the message.
227
+ const err = new Error(
170
228
  `Failed to wait for package: ${wait_result.message}${wait_result.timeout ? ' (timeout)' : ''}`,
171
229
  );
230
+ failed.set(pkg_name, err);
231
+ emit({event: 'package_failed', name: pkg_name, error: err.message, code: 'network'});
232
+ log?.error(st('red', ` āŒ Failed to publish ${pkg_name}: ${err.message}`));
233
+ break; // fail fast
172
234
  }
173
235
 
174
236
  // 3. Update all repos that have prod/peer deps on this package
@@ -197,6 +259,12 @@ export const publish_repos = async (
197
259
  log?.info(
198
260
  ` Updating ${dependent_repo.library.name}'s dependency on ${pkg_name}`,
199
261
  );
262
+ emit({
263
+ event: 'dependency_updated',
264
+ dependent: dependent_repo.library.name,
265
+ dependency: pkg_name,
266
+ version: version.new_version,
267
+ });
200
268
  changed_repos.add(dependent_repo.library.name); // Mark as changed for deployment
201
269
  changed_in_iteration.add(dependent_repo.library.name); // Track for batch install
202
270
  await update_package_json(dependent_repo, updates, {
@@ -212,6 +280,15 @@ export const publish_repos = async (
212
280
  } catch (error) {
213
281
  const err = error instanceof Error ? error : new Error(String(error));
214
282
  failed.set(pkg_name, err);
283
+ emit({
284
+ event: 'package_failed',
285
+ name: pkg_name,
286
+ error: err.message,
287
+ // TODO: emit a precise code once the npm/process ops return typed errors —
288
+ // today a publish-step cause lives in unstructured stderr, so use the honest
289
+ // coarse bucket rather than guessing 'auth'/'network'/'build' from the message.
290
+ code: 'publish',
291
+ });
215
292
  log?.error(st('red', ` āŒ Failed to publish ${pkg_name}: ${err.message}`));
216
293
  break; // Always fail fast on error
217
294
  }
@@ -221,33 +298,39 @@ export const publish_repos = async (
221
298
  // This ensures workspace stays consistent before next iteration
222
299
  if (wetrun && !options.skip_install && changed_in_iteration.size > 0) {
223
300
  log?.info(st('cyan', '\nšŸ“¦ Installing dependencies for updated repos...\n'));
224
-
225
- for (const pkg_name of changed_in_iteration) {
226
- const repo = repos.find((r) => r.library.name === pkg_name);
227
- if (!repo) continue;
228
-
229
- try {
230
- log?.info(` Installing ${pkg_name}...`);
231
- await install_with_cache_healing(repo, ops, log);
232
- log?.info(st('green', ` āœ… Installed ${pkg_name}`));
233
- } catch (error) {
234
- const err = error instanceof Error ? error : new Error(String(error));
235
- failed.set(pkg_name, err);
236
- log?.error(st('red', ` āŒ Failed to install ${pkg_name}: ${err.message}`));
237
- // Continue with other installs instead of breaking
238
- }
301
+ for (const [name, err] of await install_repos(changed_in_iteration, repos, ops, emit, log)) {
302
+ failed.set(name, err);
239
303
  }
240
304
  }
241
305
 
242
306
  // Log iteration summary
243
307
  if (published_count > 0) {
244
- log?.info(st('dim', `\nIteration ${iteration}: ${published_count} package(s) published\n`));
308
+ log?.info(
309
+ st(
310
+ 'dim',
311
+ `\nIteration ${iteration}: ${published_count} package(s) ${wetrun ? 'published' : 'would be published'}\n`,
312
+ ),
313
+ );
245
314
  }
246
315
 
316
+ emit({
317
+ event: 'iteration_finished',
318
+ iteration,
319
+ published_count,
320
+ converged: !published_in_iteration,
321
+ });
322
+
247
323
  // Check for convergence: no packages published in this iteration
248
324
  if (!published_in_iteration) {
249
325
  converged = true;
250
- log?.info(st('green', `\nāœ“ Converged after ${iteration} iteration(s) - no new changesets\n`));
326
+ log?.info(
327
+ st(
328
+ 'green',
329
+ wetrun
330
+ ? `\nāœ“ Converged after ${iteration} iteration(s) - no new changesets\n`
331
+ : `\nāœ“ Dry run complete after ${iteration} iteration(s)\n`,
332
+ ),
333
+ );
251
334
  } else if (iteration === GITOPS_MAX_ITERATIONS_DEFAULT) {
252
335
  // Count packages that still have changesets (not yet published)
253
336
  const pending_count = order.length - published.size;
@@ -285,6 +368,14 @@ export const publish_repos = async (
285
368
 
286
369
  if (dev_updates.size > 0) {
287
370
  log?.info(` Updating ${dev_updates.size} dev dependencies in ${repo.library.name}`);
371
+ for (const [dep_name, dep_version] of dev_updates) {
372
+ emit({
373
+ event: 'dependency_updated',
374
+ dependent: repo.library.name,
375
+ dependency: dep_name,
376
+ version: dep_version,
377
+ });
378
+ }
288
379
  changed_repos.add(repo.library.name); // Mark as changed for deployment
289
380
  dev_updated_repos.add(repo.library.name); // Track for batch install
290
381
  await update_package_json(repo, dev_updates, {
@@ -300,21 +391,8 @@ export const publish_repos = async (
300
391
  // Phase 2b: Install dev dependencies for repos with dev dep updates
301
392
  if (wetrun && !options.skip_install && dev_updated_repos.size > 0) {
302
393
  log?.info(st('cyan', '\nšŸ“¦ Installing dev dependencies for updated repos...\n'));
303
-
304
- for (const pkg_name of dev_updated_repos) {
305
- const repo = repos.find((r) => r.library.name === pkg_name);
306
- if (!repo) continue;
307
-
308
- try {
309
- log?.info(` Installing ${pkg_name}...`);
310
- await install_with_cache_healing(repo, ops, log);
311
- log?.info(st('green', ` āœ… Installed ${pkg_name}`));
312
- } catch (error) {
313
- const err = error instanceof Error ? error : new Error(String(error));
314
- failed.set(pkg_name, err);
315
- log?.error(st('red', ` āŒ Failed to install ${pkg_name}: ${err.message}`));
316
- // Continue with other installs instead of breaking
317
- }
394
+ for (const [name, err] of await install_repos(dev_updated_repos, repos, ops, emit, log)) {
395
+ failed.set(name, err);
318
396
  }
319
397
  }
320
398
 
@@ -331,6 +409,7 @@ export const publish_repos = async (
331
409
 
332
410
  for (const repo of repos_to_deploy) {
333
411
  try {
412
+ emit({event: 'deploy_started', name: repo.library.name});
334
413
  log?.info(` Deploying ${repo.library.name}...`);
335
414
  const deploy_result = await ops.process.spawn({
336
415
  cmd: 'gro',
@@ -339,12 +418,16 @@ export const publish_repos = async (
339
418
  });
340
419
 
341
420
  if (deploy_result.ok) {
421
+ emit({event: 'deploy_completed', name: repo.library.name});
342
422
  log?.info(st('green', ` āœ… Deployed ${repo.library.name}`));
343
423
  } else {
424
+ emit({event: 'deploy_failed', name: repo.library.name, error: deploy_result.message});
344
425
  log?.warn(st('yellow', ` āš ļø Failed to deploy ${repo.library.name}`));
345
426
  }
346
427
  } catch (error) {
347
- log?.error(st('red', ` āŒ Error deploying ${repo.library.name}: ${error}`));
428
+ const err = error instanceof Error ? error : new Error(String(error));
429
+ emit({event: 'deploy_failed', name: repo.library.name, error: err.message});
430
+ log?.error(st('red', ` āŒ Error deploying ${repo.library.name}: ${err.message}`));
348
431
  }
349
432
  }
350
433
  }
@@ -352,25 +435,44 @@ export const publish_repos = async (
352
435
  // Summary
353
436
  const duration = Date.now() - start_time;
354
437
  const ok = failed.size === 0;
438
+ const summary = summarize_events(capture.events, duration);
355
439
 
356
- log?.info(st('cyan', '\nšŸ“‹ Publishing Summary\n'));
440
+ log?.info(st('cyan', `\nšŸ“‹ ${wetrun ? 'Publishing' : 'Dry Run'} Summary\n`));
357
441
  log?.info(` Duration: ${(duration / 1000).toFixed(1)}s`);
358
- log?.info(` Published: ${published.size} packages`);
442
+ log?.info(` ${wetrun ? 'Published' : 'Would publish'}: ${published.size} packages`);
359
443
  if (failed.size > 0) {
360
444
  log?.info(` Failed: ${failed.size} packages`);
361
445
  }
362
446
 
363
447
  if (ok) {
364
- log?.info(st('green', '\n✨ All packages published successfully!\n'));
448
+ log?.info(
449
+ st(
450
+ 'green',
451
+ wetrun
452
+ ? '\n✨ All packages published successfully!\n'
453
+ : `\n✨ Dry run complete — ${published.size} package(s) would be published. Re-run with --wetrun to publish.\n`,
454
+ ),
455
+ );
365
456
  } else {
366
- log?.error(st('red', '\nāŒ Some packages failed to publish\n'));
457
+ log?.error(
458
+ st(
459
+ 'red',
460
+ wetrun
461
+ ? '\nāŒ Some packages failed to publish\n'
462
+ : '\nāŒ Some packages failed during dry run\n',
463
+ ),
464
+ );
367
465
  }
368
466
 
467
+ emit({event: 'run_finished', summary});
468
+
369
469
  return {
370
470
  ok,
371
471
  published: Array.from(published.values()),
372
472
  failed: Array.from(failed.entries()).map(([name, error]) => ({name, error})),
373
473
  duration,
474
+ events: capture.events,
475
+ summary,
374
476
  };
375
477
  };
376
478
 
@@ -465,3 +567,36 @@ const publish_single_repo = async (
465
567
  tag: `v${new_version}`,
466
568
  };
467
569
  };
570
+
571
+ /**
572
+ * Installs dependencies for each named repo (with cache healing), emitting install
573
+ * events and logging progress. A failed install doesn't stop the batch — failures are
574
+ * collected and returned so the caller can fold them into the run's failures.
575
+ */
576
+ const install_repos = async (
577
+ names: Iterable<string>,
578
+ repos: Array<LocalRepo>,
579
+ ops: GitopsOperations,
580
+ emit: (event: PublishingEvent) => void,
581
+ log?: Logger,
582
+ ): Promise<Map<string, Error>> => {
583
+ const failures: Map<string, Error> = new Map();
584
+ for (const name of names) {
585
+ const repo = repos.find((r) => r.library.name === name);
586
+ if (!repo) continue;
587
+ try {
588
+ emit({event: 'install_started', name});
589
+ log?.info(` Installing ${name}...`);
590
+ await install_with_cache_healing(repo, ops, log);
591
+ emit({event: 'install_completed', name});
592
+ log?.info(st('green', ` āœ… Installed ${name}`));
593
+ } catch (error) {
594
+ const err = error instanceof Error ? error : new Error(String(error));
595
+ failures.set(name, err);
596
+ emit({event: 'install_failed', name, error: err.message});
597
+ log?.error(st('red', ` āŒ Failed to install ${name}: ${err.message}`));
598
+ // continue with other installs instead of breaking
599
+ }
600
+ }
601
+ return failures;
602
+ };
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Structured events for multi-repo publishing.
3
+ *
4
+ * Publishing emits a stream of tagged events alongside its human-readable logging,
5
+ * so machine consumers (CI, dashboards) can follow a run step by step. Every run
6
+ * opens with a `run_started` event carrying `wetrun`: when `false`, the run is a dry
7
+ * run and every `package_completed` is a prediction (its `commit` is `'simulated'`)
8
+ * rather than an applied change. A run's `run_finished` summary is derived from the
9
+ * same event list via `summarize_events`, so the stream and the summary never drift.
10
+ *
11
+ * Events are consumed through a `PublishingEventHandler` sink (see
12
+ * `publishing_event_handler.ts`).
13
+ *
14
+ * @module
15
+ */
16
+
17
+ import {z} from 'zod';
18
+
19
+ /**
20
+ * Coarse triage classification for a failed package. Lets consumers branch on
21
+ * failure kind without parsing the message.
22
+ */
23
+ export const PublishingErrorCode = z.enum([
24
+ 'publish',
25
+ 'network',
26
+ 'auth',
27
+ 'dependency',
28
+ 'build',
29
+ 'other',
30
+ ]);
31
+ export type PublishingErrorCode = z.infer<typeof PublishingErrorCode>;
32
+
33
+ /** Tallied outcome of a publishing run, derived from its events via `summarize_events`. */
34
+ export const PublishingRunSummary = z.strictObject({
35
+ total: z.number().meta({description: 'packages in the publishing order (the candidate set)'}),
36
+ published: z.number().meta({description: 'packages published (or, in a dry run, predicted)'}),
37
+ failed: z.number(),
38
+ skipped: z.number(),
39
+ duration: z.number().meta({description: 'wall-clock duration in milliseconds'}),
40
+ });
41
+ export type PublishingRunSummary = z.infer<typeof PublishingRunSummary>;
42
+
43
+ /**
44
+ * A single structured event emitted during a publishing run. Tagged on `event` so the
45
+ * union serializes as one self-describing JSON object per event (JSON-lines on the wire).
46
+ */
47
+ export const PublishingEvent = z.discriminatedUnion('event', [
48
+ z.strictObject({
49
+ event: z.literal('run_started'),
50
+ wetrun: z.boolean().meta({description: 'false means every package_completed is a prediction'}),
51
+ total: z.number(),
52
+ }),
53
+ z.strictObject({
54
+ event: z.literal('iteration_started'),
55
+ iteration: z.number(),
56
+ max: z.number(),
57
+ }),
58
+ z.strictObject({
59
+ event: z.literal('iteration_finished'),
60
+ iteration: z.number(),
61
+ published_count: z.number(),
62
+ converged: z.boolean(),
63
+ }),
64
+ z.strictObject({
65
+ event: z.literal('package_skipped'),
66
+ name: z.string(),
67
+ reason: z.string(),
68
+ }),
69
+ z.strictObject({
70
+ event: z.literal('package_completed'),
71
+ name: z.string(),
72
+ old_version: z.string(),
73
+ new_version: z.string(),
74
+ // mirrors `BumpType` from `semver.ts`; inline so the event schema is self-contained
75
+ bump_type: z.enum(['major', 'minor', 'patch']),
76
+ breaking: z.boolean(),
77
+ commit: z.string().meta({description: "'simulated' in a dry run, otherwise the commit hash"}),
78
+ tag: z.string(),
79
+ }),
80
+ z.strictObject({
81
+ event: z.literal('package_failed'),
82
+ name: z.string(),
83
+ error: z.string(),
84
+ code: PublishingErrorCode,
85
+ }),
86
+ z.strictObject({
87
+ event: z.literal('dependency_updated'),
88
+ dependent: z.string(),
89
+ dependency: z.string(),
90
+ version: z.string(),
91
+ }),
92
+ z.strictObject({
93
+ event: z.literal('install_started'),
94
+ name: z.string(),
95
+ }),
96
+ z.strictObject({
97
+ event: z.literal('install_completed'),
98
+ name: z.string(),
99
+ }),
100
+ z.strictObject({
101
+ event: z.literal('install_failed'),
102
+ name: z.string(),
103
+ error: z.string(),
104
+ }),
105
+ z.strictObject({
106
+ event: z.literal('deploy_started'),
107
+ name: z.string(),
108
+ }),
109
+ z.strictObject({
110
+ event: z.literal('deploy_completed'),
111
+ name: z.string(),
112
+ }),
113
+ z.strictObject({
114
+ event: z.literal('deploy_failed'),
115
+ name: z.string(),
116
+ error: z.string(),
117
+ }),
118
+ z.strictObject({
119
+ event: z.literal('run_finished'),
120
+ summary: PublishingRunSummary,
121
+ }),
122
+ ]);
123
+ export type PublishingEvent = z.infer<typeof PublishingEvent>;
124
+
125
+ /**
126
+ * Derives a run summary from the captured event list — the single canonical path from
127
+ * events to summary, so the `run_finished` summary always agrees with the stream.
128
+ * Call before emitting `run_finished` (which is not itself counted).
129
+ *
130
+ * @param events - the events captured so far this run
131
+ * @param duration - wall-clock duration in milliseconds
132
+ */
133
+ export const summarize_events = (
134
+ events: Array<PublishingEvent>,
135
+ duration: number,
136
+ ): PublishingRunSummary => {
137
+ let total = 0;
138
+ let published = 0;
139
+ let failed = 0;
140
+ let skipped = 0;
141
+ for (const event of events) {
142
+ switch (event.event) {
143
+ case 'run_started':
144
+ total = event.total;
145
+ break;
146
+ case 'package_completed':
147
+ published++;
148
+ break;
149
+ case 'package_failed':
150
+ failed++;
151
+ break;
152
+ case 'package_skipped':
153
+ skipped++;
154
+ break;
155
+ default:
156
+ break;
157
+ }
158
+ }
159
+ return {total, published, failed, skipped, duration};
160
+ };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Composable sinks for the publishing event stream.
3
+ *
4
+ * A `PublishingEventHandler` is anything that can receive a `PublishingEvent`. Handlers
5
+ * compose: `multi_handler` fans out, `masking_handler` redacts secrets then forwards.
6
+ * Emission is best-effort and synchronous — an observability sink must never fail or
7
+ * slow a run. The default sink is `null_handler` (drops everything).
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import type {PublishingEvent} from './publishing_event.js';
13
+
14
+ /** A sink for publishing events. */
15
+ export interface PublishingEventHandler {
16
+ emit: (event: PublishingEvent) => void;
17
+ }
18
+
19
+ /** A `capture_handler` also exposes the events it has collected. */
20
+ export interface CapturingEventHandler extends PublishingEventHandler {
21
+ readonly events: Array<PublishingEvent>;
22
+ }
23
+
24
+ /** Drops every event. The default when no handler is supplied. */
25
+ export const null_handler = (): PublishingEventHandler => ({
26
+ emit: () => {},
27
+ });
28
+
29
+ /** Collects events in memory. Used to build the run report and in tests. */
30
+ export const capture_handler = (): CapturingEventHandler => {
31
+ const events: Array<PublishingEvent> = [];
32
+ return {
33
+ events,
34
+ emit: (event) => {
35
+ events.push(event);
36
+ },
37
+ };
38
+ };
39
+
40
+ /**
41
+ * Writes each event as one JSON object per line (JSON-lines) to `process.stdout`.
42
+ * Write failures are swallowed — the stream is observability, not control flow.
43
+ */
44
+ export const stdout_handler = (): PublishingEventHandler => ({
45
+ emit: (event) => {
46
+ try {
47
+ process.stdout.write(JSON.stringify(event) + '\n');
48
+ } catch {
49
+ // best-effort: a logging sink must never fail a run
50
+ }
51
+ },
52
+ });
53
+
54
+ /** Fans an event out to every handler in order. */
55
+ export const multi_handler = (handlers: Array<PublishingEventHandler>): PublishingEventHandler => ({
56
+ emit: (event) => {
57
+ for (const handler of handlers) {
58
+ handler.emit(event);
59
+ }
60
+ },
61
+ });
62
+
63
+ /**
64
+ * Wraps a handler, masking secrets in each event's string fields before forwarding.
65
+ *
66
+ * @param inner - the handler to forward masked events to
67
+ * @param mask - the masking function, defaults to `mask_secrets`
68
+ */
69
+ export const masking_handler = (
70
+ inner: PublishingEventHandler,
71
+ mask: (event: PublishingEvent) => PublishingEvent = mask_secrets,
72
+ ): PublishingEventHandler => ({
73
+ emit: (event) => {
74
+ inner.emit(mask(event));
75
+ },
76
+ });
77
+
78
+ // Minimal redaction rules: npm auth tokens (bare or registry-scoped), `SECRET_*`
79
+ // env-style assignments, and `npm_`-prefixed tokens. Deliberately lean — error
80
+ // strings can carry npm/git output; a fuller secret catalog is deferred.
81
+ const SECRET_RULES: Array<readonly [RegExp, string]> = [
82
+ [/((?:\/\/[^\s:]+:)?_authToken\s*=\s*)\S+/gi, '$1[redacted]'],
83
+ [/(SECRET_[A-Z0-9_]+\s*[=:]\s*)\S+/g, '$1[redacted]'],
84
+ [/(npm_[A-Za-z0-9]{4})[A-Za-z0-9]{12,}/g, '$1[redacted]'],
85
+ ];
86
+
87
+ /** Redacts known secret shapes from a string. */
88
+ export const redact_secrets = (text: string): string =>
89
+ SECRET_RULES.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), text);
90
+
91
+ /** Returns a copy of the event with secrets redacted from its string-valued fields. */
92
+ export const mask_secrets = (event: PublishingEvent): PublishingEvent => {
93
+ const masked: Record<string, unknown> = {};
94
+ for (const [key, value] of Object.entries(event)) {
95
+ masked[key] = typeof value === 'string' ? redact_secrets(value) : value;
96
+ }
97
+ return masked as PublishingEvent;
98
+ };
@@ -83,7 +83,7 @@ export interface RepoPath {
83
83
  * Get repo paths from gitops config without full git sync.
84
84
  * Lighter weight than `get_gitops_ready()` - just resolves paths.
85
85
  *
86
- * @param config_path - path to `gitops.config.ts` (defaults to `./gitops.config.ts`)
86
+ * @param config_path - path to the gitops config file (defaults to `gitops.config.ts`)
87
87
  * @returns array of repo info with name, path, and url
88
88
  */
89
89
  export const get_repo_paths = async (config_path?: string): Promise<Array<RepoPath>> => {