@hasna/hooks 0.4.1 → 0.5.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 CHANGED
@@ -67,10 +67,21 @@ hooks install session-start prompt-guard pre-bash worktree-guard stop-sync knowl
67
67
  ```
68
68
 
69
69
  The scoped destructive-operation guard does not block every cleanup command. It
70
- blocks resolved shell/file-tool targets that threaten `~/.hasna`, configured
71
- workspace roots, Hasna division/scope roots, or active repo/worktree roots,
72
- including recursive `rm`, `rsync --delete`, destructive `find`, and destructive
73
- `git clean` / `git reset --hard` forms.
70
+ blocks resolved shell/file-tool targets that threaten `/` or a system root
71
+ (`/usr`, `/etc`, `/bin`, `/lib`, `/var`, `/boot`, `/home`, `/Users`, and the
72
+ other FHS and macOS equivalents), `~/.hasna`, configured workspace roots, Hasna
73
+ division/scope roots, or active repo/worktree roots, including recursive `rm`,
74
+ `rsync --delete`, destructive `find`, and destructive `git clean` / `git reset
75
+ --hard` forms.
76
+
77
+ It also blocks by *shape*: a destructive target containing a command
78
+ substitution or variable expansion immediately followed by `/` is checked as the
79
+ shell would render it if that expansion returned empty, so
80
+ `rm -rf "$(anything)"/*` and `rm -rf "$VAR"/*` are refused whatever the
81
+ expansion is. Wrapped forms (`bash -c`, `su -c`, `eval`, `ssh host '…'`) are
82
+ unwrapped first. See [`hooks/pre-bash/README.md`](hooks/pre-bash/README.md) for
83
+ the full rules, the deliberate exemptions (`${VAR:?}`, bare `"$(cmd)"` with no
84
+ trailing separator), and the recommended safe form.
74
85
 
75
86
  Apply that fragment through `open-configs` or the managed config renderer. A
76
87
  direct write path exists only for explicit local/test use:
package/bin/index.js CHANGED
@@ -6270,6 +6270,7 @@ var init_http = () => {};
6270
6270
 
6271
6271
  // node_modules/@hasna/events/dist/commander.js
6272
6272
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
6273
+ import { Buffer as Buffer2 } from "buffer";
6273
6274
  import { existsSync } from "fs";
6274
6275
  import { homedir } from "os";
6275
6276
  import { join } from "path";
@@ -6374,6 +6375,9 @@ function channelMatchesEvent(channel, event) {
6374
6375
  }
6375
6376
  var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
6376
6377
  var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
6378
+ var LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:";
6379
+ var DEFAULT_EVENT_PAGE_LIMIT = 100;
6380
+ var MAX_EVENT_PAGE_LIMIT = 1000;
6377
6381
  function getEventsDataDir(override) {
6378
6382
  return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
6379
6383
  }
@@ -6387,11 +6391,13 @@ function getActiveEventsDirEnv() {
6387
6391
 
6388
6392
  class JsonEventsStore {
6389
6393
  dataDir;
6394
+ runtime;
6390
6395
  channelsPath;
6391
6396
  eventsPath;
6392
6397
  deliveriesPath;
6393
6398
  constructor(dataDir = getEventsDataDir()) {
6394
6399
  this.dataDir = dataDir;
6400
+ this.runtime = localJsonRuntime(dataDir);
6395
6401
  this.channelsPath = join(dataDir, "channels.json");
6396
6402
  this.eventsPath = join(dataDir, "events.json");
6397
6403
  this.deliveriesPath = join(dataDir, "deliveries.json");
@@ -6439,13 +6445,58 @@ class JsonEventsStore {
6439
6445
  await this.writeJson(this.eventsPath, events);
6440
6446
  return event;
6441
6447
  }
6442
- async listEvents() {
6448
+ async appendEventOnce(event, options = {}) {
6443
6449
  await this.init();
6444
- return this.readJson(this.eventsPath, []);
6450
+ const events = await this.readJson(this.eventsPath, []);
6451
+ const dedupe = options.dedupe !== false;
6452
+ if (dedupe) {
6453
+ const existing = findEventByIdentity(events, { id: event.id, dedupeKey: event.dedupeKey });
6454
+ if (existing) {
6455
+ return {
6456
+ event: existing,
6457
+ stored: false,
6458
+ deduped: true,
6459
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
6460
+ };
6461
+ }
6462
+ }
6463
+ events.push(event);
6464
+ await this.writeJson(this.eventsPath, events);
6465
+ return {
6466
+ event,
6467
+ stored: true,
6468
+ deduped: false,
6469
+ identity: { id: event.id, dedupeKey: event.dedupeKey }
6470
+ };
6471
+ }
6472
+ async listEvents(options = {}) {
6473
+ await this.init();
6474
+ const events = await this.readJson(this.eventsPath, []);
6475
+ return queryEvents(events, options);
6476
+ }
6477
+ async listEventsPage(options = {}) {
6478
+ await this.init();
6479
+ const events = await this.readJson(this.eventsPath, []);
6480
+ const queried = queryEvents(events, {
6481
+ eventId: options.eventId,
6482
+ source: options.source,
6483
+ type: options.type
6484
+ });
6485
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
6486
+ const limit = normalizeEventPageLimit(options.limit);
6487
+ const pageEvents = queried.slice(offset, offset + limit);
6488
+ const nextOffset = offset + pageEvents.length;
6489
+ const hasMore = nextOffset < queried.length;
6490
+ return {
6491
+ events: pageEvents,
6492
+ cursor: options.cursor,
6493
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
6494
+ hasMore
6495
+ };
6445
6496
  }
6446
6497
  async findEventByIdentity(identity) {
6447
6498
  const events = await this.listEvents();
6448
- return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
6499
+ return findEventByIdentity(events, identity);
6449
6500
  }
6450
6501
  async appendDelivery(result) {
6451
6502
  await this.init();
@@ -6496,6 +6547,83 @@ class JsonEventsStore {
6496
6547
  });
6497
6548
  }
6498
6549
  }
6550
+ function localJsonRuntime(dataDir = getEventsDataDir()) {
6551
+ return {
6552
+ mode: "local-files",
6553
+ name: "json-events-store",
6554
+ remote: false,
6555
+ localFiles: true,
6556
+ localSqlite: false,
6557
+ postgres: false,
6558
+ s3: false,
6559
+ aws: false,
6560
+ durable: true,
6561
+ idempotency: "best-effort-local",
6562
+ replayCursors: true,
6563
+ description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
6564
+ };
6565
+ }
6566
+ function encodeLocalJsonEventCursor(offset, options = {}) {
6567
+ if (!Number.isInteger(offset) || offset < 0)
6568
+ throw new Error(`Invalid event cursor offset: ${offset}`);
6569
+ const payload = {
6570
+ offset,
6571
+ eventId: options.eventId,
6572
+ source: options.source,
6573
+ type: options.type
6574
+ };
6575
+ return `${LOCAL_JSON_EVENT_CURSOR_PREFIX}${Buffer2.from(JSON.stringify(payload), "utf-8").toString("base64url")}`;
6576
+ }
6577
+ function decodeLocalJsonEventCursor(cursor, options = {}) {
6578
+ if (!cursor)
6579
+ return 0;
6580
+ if (!cursor.startsWith(LOCAL_JSON_EVENT_CURSOR_PREFIX))
6581
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
6582
+ const rawPayload = cursor.slice(LOCAL_JSON_EVENT_CURSOR_PREFIX.length);
6583
+ let payload;
6584
+ try {
6585
+ payload = JSON.parse(Buffer2.from(rawPayload, "base64url").toString("utf-8"));
6586
+ } catch {
6587
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
6588
+ }
6589
+ const offset = payload.offset;
6590
+ if (!Number.isInteger(offset) || offset < 0)
6591
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
6592
+ assertCursorFilter("eventId", payload.eventId, options.eventId);
6593
+ assertCursorFilter("source", payload.source, options.source);
6594
+ assertCursorFilter("type", payload.type, options.type);
6595
+ return offset;
6596
+ }
6597
+ function normalizeEventPageLimit(limit) {
6598
+ if (limit === undefined)
6599
+ return DEFAULT_EVENT_PAGE_LIMIT;
6600
+ if (!Number.isInteger(limit) || limit < 1)
6601
+ throw new Error(`Event page limit must be a positive integer, got ${limit}`);
6602
+ return Math.min(limit, MAX_EVENT_PAGE_LIMIT);
6603
+ }
6604
+ function queryEvents(events, options) {
6605
+ let rows = events;
6606
+ if (options.eventId)
6607
+ rows = rows.filter((event) => event.id === options.eventId);
6608
+ if (options.source)
6609
+ rows = rows.filter((event) => event.source === options.source);
6610
+ if (options.type)
6611
+ rows = rows.filter((event) => event.type === options.type);
6612
+ if (options.cursor) {
6613
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
6614
+ rows = rows.slice(offset);
6615
+ }
6616
+ if (options.limit !== undefined)
6617
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
6618
+ return rows;
6619
+ }
6620
+ function assertCursorFilter(name, cursorValue, optionValue) {
6621
+ if (cursorValue !== optionValue)
6622
+ throw new Error(`Local JSON event cursor ${name} filter mismatch`);
6623
+ }
6624
+ function findEventByIdentity(events, identity) {
6625
+ return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
6626
+ }
6499
6627
  async function getEventsStatus(dataDir) {
6500
6628
  const store = new JsonEventsStore(dataDir);
6501
6629
  await store.init();
@@ -6512,6 +6640,7 @@ async function getEventsStatus(dataDir) {
6512
6640
  service: "events",
6513
6641
  schemaVersion: "1.0",
6514
6642
  dataDir: store.dataDir,
6643
+ storage: store.runtime,
6515
6644
  env: {
6516
6645
  primary: HASNA_EVENTS_DIR_ENV,
6517
6646
  fallback: HASNA_EVENTS_HOME_ENV,
@@ -6698,6 +6827,51 @@ function createDeliveryResult(event, channel, attempts) {
6698
6827
  completedAt: attempts.at(-1)?.completedAt ?? now()
6699
6828
  };
6700
6829
  }
6830
+
6831
+ class EventValidationError extends Error {
6832
+ eventType;
6833
+ issues;
6834
+ constructor(eventType, issues) {
6835
+ const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
6836
+ super(`Event validation failed for type "${eventType}": ${detail}`);
6837
+ this.name = "EventValidationError";
6838
+ this.eventType = eventType;
6839
+ this.issues = issues;
6840
+ }
6841
+ }
6842
+
6843
+ class EventTypeCatalog {
6844
+ definitions = new Map;
6845
+ register(definition) {
6846
+ this.definitions.set(definition.type, definition);
6847
+ return this;
6848
+ }
6849
+ unregister(type) {
6850
+ return this.definitions.delete(type);
6851
+ }
6852
+ has(type) {
6853
+ return this.definitions.has(type);
6854
+ }
6855
+ get(type) {
6856
+ return this.definitions.get(type);
6857
+ }
6858
+ list() {
6859
+ return [...this.definitions.values()];
6860
+ }
6861
+ validateEvent(event) {
6862
+ const definition = this.definitions.get(event.type);
6863
+ if (!definition)
6864
+ return { ok: true };
6865
+ return definition.validate(event.data, event);
6866
+ }
6867
+ assertEventValid(event) {
6868
+ const result = this.validateEvent(event);
6869
+ if (!result.ok) {
6870
+ throw new EventValidationError(event.type, result.issues);
6871
+ }
6872
+ }
6873
+ }
6874
+ var defaultEventTypeCatalog = new EventTypeCatalog;
6701
6875
  function createEvent(input) {
6702
6876
  return {
6703
6877
  id: input.id ?? randomUUID2(),
@@ -6718,10 +6892,14 @@ class EventsClient {
6718
6892
  store;
6719
6893
  redactors;
6720
6894
  transportOptions;
6895
+ catalog;
6896
+ validateCatalogTypes;
6721
6897
  constructor(options = {}) {
6722
6898
  this.store = options.store ?? new JsonEventsStore(options.dataDir);
6723
6899
  this.redactors = options.redactors ?? [];
6724
6900
  this.transportOptions = { fetchImpl: options.fetchImpl };
6901
+ this.catalog = options.catalog ?? defaultEventTypeCatalog;
6902
+ this.validateCatalogTypes = options.validateCatalogTypes ?? false;
6725
6903
  }
6726
6904
  async addChannel(input) {
6727
6905
  const timestamp = new Date().toISOString();
@@ -6739,18 +6917,40 @@ class EventsClient {
6739
6917
  }
6740
6918
  async emit(input, options = {}) {
6741
6919
  const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
6742
- if (options.dedupe !== false) {
6743
- const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
6744
- if (existing) {
6745
- return { event: existing, deliveries: [], deduped: true };
6746
- }
6747
- }
6748
- await this.store.appendEvent(event);
6749
- const deliveries = options.deliver === false ? [] : await this.deliver(event);
6750
- return { event, deliveries, deduped: false };
6751
- }
6752
- async listEvents() {
6753
- return this.store.listEvents();
6920
+ if (options.validate ?? this.validateCatalogTypes) {
6921
+ this.catalog.assertEventValid(event);
6922
+ }
6923
+ const append = await this.appendEvent(event, { dedupe: options.dedupe !== false });
6924
+ if (append.deduped) {
6925
+ return { event: append.event, deliveries: [], deduped: true };
6926
+ }
6927
+ const deliveries = options.deliver === false ? [] : await this.deliver(append.event);
6928
+ return { event: append.event, deliveries, deduped: false };
6929
+ }
6930
+ async listEvents(options = {}) {
6931
+ if (Object.keys(options).length === 0)
6932
+ return this.store.listEvents();
6933
+ return queryClientEvents(await this.store.listEvents(), options);
6934
+ }
6935
+ async listEventsPage(options = {}) {
6936
+ if (this.store.listEventsPage)
6937
+ return this.store.listEventsPage(options);
6938
+ const events = queryClientEvents(await this.store.listEvents(), {
6939
+ eventId: options.eventId,
6940
+ source: options.source,
6941
+ type: options.type
6942
+ });
6943
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
6944
+ const limit = normalizeEventPageLimit(options.limit);
6945
+ const pageEvents = events.slice(offset, offset + limit);
6946
+ const nextOffset = offset + pageEvents.length;
6947
+ const hasMore = nextOffset < events.length;
6948
+ return {
6949
+ events: pageEvents,
6950
+ cursor: options.cursor,
6951
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
6952
+ hasMore
6953
+ };
6754
6954
  }
6755
6955
  async listDeliveries() {
6756
6956
  return this.store.listDeliveries();
@@ -6818,22 +7018,37 @@ class EventsClient {
6818
7018
  return result;
6819
7019
  }
6820
7020
  async replay(options = {}) {
6821
- const events = (await this.store.listEvents()).filter((event) => {
6822
- if (options.eventId && event.id !== options.eventId)
6823
- return false;
6824
- if (options.source && event.source !== options.source)
6825
- return false;
6826
- if (options.type && event.type !== options.type)
6827
- return false;
6828
- return true;
6829
- });
7021
+ const page = options.cursor || options.limit !== undefined ? await this.listEventsPage(options) : { events: await this.listEvents(options), hasMore: false };
6830
7022
  if (options.dryRun)
6831
- return { events, deliveries: [] };
7023
+ return { events: page.events, deliveries: [], cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
6832
7024
  const deliveries = [];
6833
- for (const event of events) {
7025
+ for (const event of page.events) {
6834
7026
  deliveries.push(...await this.deliver(event));
6835
7027
  }
6836
- return { events, deliveries };
7028
+ return { events: page.events, deliveries, cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
7029
+ }
7030
+ async appendEvent(event, options) {
7031
+ if (this.store.appendEventOnce) {
7032
+ return this.store.appendEventOnce(event, { dedupe: options.dedupe });
7033
+ }
7034
+ if (options.dedupe) {
7035
+ const existing = await this.store.findEventByIdentity({ id: event.id, dedupeKey: event.dedupeKey });
7036
+ if (existing) {
7037
+ return {
7038
+ event: existing,
7039
+ stored: false,
7040
+ deduped: true,
7041
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
7042
+ };
7043
+ }
7044
+ }
7045
+ const stored = await this.store.appendEvent(event);
7046
+ return {
7047
+ event: stored,
7048
+ stored: true,
7049
+ deduped: false,
7050
+ identity: { id: stored.id, dedupeKey: stored.dedupeKey }
7051
+ };
6837
7052
  }
6838
7053
  async applyRedaction(event, channel) {
6839
7054
  let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
@@ -6910,6 +7125,20 @@ function setPath(input, path, replacement) {
6910
7125
  if (last && last in cursor)
6911
7126
  cursor[last] = replacement;
6912
7127
  }
7128
+ function queryClientEvents(events, options) {
7129
+ let rows = events;
7130
+ if (options.eventId)
7131
+ rows = rows.filter((event) => event.id === options.eventId);
7132
+ if (options.source)
7133
+ rows = rows.filter((event) => event.source === options.source);
7134
+ if (options.type)
7135
+ rows = rows.filter((event) => event.type === options.type);
7136
+ if (options.cursor)
7137
+ rows = rows.slice(decodeLocalJsonEventCursor(options.cursor, options));
7138
+ if (options.limit !== undefined)
7139
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
7140
+ return rows;
7141
+ }
6913
7142
  function normalizeTime(value) {
6914
7143
  if (!value)
6915
7144
  return new Date().toISOString();
@@ -6992,6 +7221,7 @@ function parseMatcherExpression(value, label) {
6992
7221
  negated: false
6993
7222
  };
6994
7223
  }
7224
+ var DEFAULT_EVENT_LIST_LIMIT = 100;
6995
7225
  function parseJsonObject(value, fallback) {
6996
7226
  if (!value)
6997
7227
  return fallback;
@@ -7024,6 +7254,14 @@ function print(value, json, text) {
7024
7254
  else
7025
7255
  console.log(text);
7026
7256
  }
7257
+ function fail(error, json) {
7258
+ const message = error instanceof Error ? error.message : String(error);
7259
+ if (json)
7260
+ console.log(JSON.stringify({ error: message }, null, 2));
7261
+ else
7262
+ console.error(message);
7263
+ process.exitCode = 1;
7264
+ }
7027
7265
  function hasJsonOption(options) {
7028
7266
  return Boolean(options?.json || options?.opts?.().json || options?.optsWithGlobals?.().json || options?.parent?.opts?.().json || options?.parent?.optsWithGlobals?.().json);
7029
7267
  }
@@ -7078,26 +7316,36 @@ function registerChannelCommands(program, options) {
7078
7316
  print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
7079
7317
  });
7080
7318
  channels.command("test").description("Send a test event to one channel").argument("<id>", "Channel identifier").option("--source <source>", "Event source override").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("--honor-filters", "Skip delivery when the sample event does not match channel filters", false).option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
7081
- const result = await createClient(options).testChannel(id, {
7082
- source: actionOptions.source ?? options.source,
7083
- type: actionOptions.type,
7084
- subject: actionOptions.subject ?? id,
7085
- message: actionOptions.message,
7086
- data: parseJsonObject(actionOptions.data, { test: true }),
7087
- metadata: parseJsonObject(actionOptions.metadata, {})
7088
- }, { honorFilters: actionOptions.honorFilters });
7089
- print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
7319
+ const json = wantsJson(actionOptions, command);
7320
+ try {
7321
+ const result = await createClient(options).testChannel(id, {
7322
+ source: actionOptions.source ?? options.source,
7323
+ type: actionOptions.type,
7324
+ subject: actionOptions.subject ?? id,
7325
+ message: actionOptions.message,
7326
+ data: parseJsonObject(actionOptions.data, { test: true }),
7327
+ metadata: parseJsonObject(actionOptions.metadata, {})
7328
+ }, { honorFilters: actionOptions.honorFilters });
7329
+ print(result, json, `${result.status}: ${result.channelId}`);
7330
+ } catch (error) {
7331
+ fail(error, json);
7332
+ }
7090
7333
  });
7091
7334
  channels.command("match").description("Check whether a sample event matches one channel without delivering").argument("<id>", "Channel identifier").option("--source <source>", "Event source override").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events match preview").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
7092
- const result = await createClient(options).matchChannel(id, {
7093
- source: actionOptions.source ?? options.source,
7094
- type: actionOptions.type,
7095
- subject: actionOptions.subject ?? id,
7096
- message: actionOptions.message,
7097
- data: parseJsonObject(actionOptions.data, { test: true }),
7098
- metadata: parseJsonObject(actionOptions.metadata, {})
7099
- });
7100
- print(result, wantsJson(actionOptions, command), `${result.matched ? "matched" : "skipped"}: ${result.channelId}`);
7335
+ const json = wantsJson(actionOptions, command);
7336
+ try {
7337
+ const result = await createClient(options).matchChannel(id, {
7338
+ source: actionOptions.source ?? options.source,
7339
+ type: actionOptions.type,
7340
+ subject: actionOptions.subject ?? id,
7341
+ message: actionOptions.message,
7342
+ data: parseJsonObject(actionOptions.data, { test: true }),
7343
+ metadata: parseJsonObject(actionOptions.metadata, {})
7344
+ });
7345
+ print(result, json, `${result.matched ? "matched" : "skipped"}: ${result.channelId}`);
7346
+ } catch (error) {
7347
+ fail(error, json);
7348
+ }
7101
7349
  });
7102
7350
  return channels;
7103
7351
  }
@@ -7116,7 +7364,8 @@ function registerEventCommands(program, options) {
7116
7364
  }, { deliver: actionOptions.deliver, dedupe: actionOptions.dedupe });
7117
7365
  print(result, wantsJson(actionOptions, command), `${result.deduped ? "Deduped" : "Emitted"} ${result.event.id} to ${result.deliveries.length} channel(s)`);
7118
7366
  });
7119
- events.command("list").description("List recorded events").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--limit <n>", "Limit results", parseNumber).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
7367
+ const defaultListLimit = options.defaultEventListLimit ?? DEFAULT_EVENT_LIST_LIMIT;
7368
+ events.command("list").description("List recorded events").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--limit <n>", `Limit to the most recent <n> events (default ${defaultListLimit}; use 0 for all)`, parseNumber, defaultListLimit).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
7120
7369
  let rows = await createClient(options).listEvents();
7121
7370
  if (actionOptions.source)
7122
7371
  rows = rows.filter((event) => event.source === actionOptions.source);
@@ -7135,14 +7384,16 @@ function registerEventCommands(program, options) {
7135
7384
  for (const event of rows)
7136
7385
  console.log(`${event.time} ${event.id} ${event.source} ${event.type} ${event.severity}`);
7137
7386
  });
7138
- events.command("replay").description("Replay recorded events").option("--id <id>", "Replay one event id").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--dry-run", "Preview without delivery", false).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
7387
+ events.command("replay").description("Replay recorded events").option("--id <id>", "Replay one event id").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--cursor <cursor>", "Opaque replay cursor from a previous page").option("--limit <n>", "Maximum events to replay", parseNumber).option("--dry-run", "Preview without delivery", false).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
7139
7388
  const result = await createClient(options).replay({
7140
7389
  eventId: actionOptions.id,
7141
7390
  source: actionOptions.source,
7142
7391
  type: actionOptions.type,
7392
+ cursor: actionOptions.cursor,
7393
+ limit: actionOptions.limit,
7143
7394
  dryRun: actionOptions.dryRun
7144
7395
  });
7145
- print(result, wantsJson(actionOptions, command), `Replayed ${result.events.length} event(s), ${result.deliveries.length} delivery result(s)`);
7396
+ print(result, wantsJson(actionOptions, command), replaySummary(result.events.length, result.deliveries.length, result.nextCursor));
7146
7397
  });
7147
7398
  return events;
7148
7399
  }
@@ -7160,6 +7411,10 @@ function collectValues(value, previous) {
7160
7411
  previous.push(value);
7161
7412
  return previous;
7162
7413
  }
7414
+ function replaySummary(events, deliveries, nextCursor) {
7415
+ const suffix = nextCursor ? `, next cursor: ${nextCursor}` : "";
7416
+ return `Replayed ${events} event(s), ${deliveries} delivery result(s)${suffix}`;
7417
+ }
7163
7418
 
7164
7419
  // src/cli/index.tsx
7165
7420
  import { render } from "ink";