@celilo/cli 0.3.30 → 0.4.0-alpha.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 (155) hide show
  1. package/drizzle/0005_module_operations.sql +12 -0
  2. package/drizzle/0006_base_module_aspects.sql +15 -0
  3. package/drizzle/0007_module_systems.sql +17 -0
  4. package/drizzle/meta/_journal.json +21 -0
  5. package/package.json +5 -4
  6. package/schemas/system_config.json +14 -28
  7. package/src/ansible/inventory.test.ts +46 -62
  8. package/src/ansible/inventory.ts +48 -25
  9. package/src/capabilities/registration.ts +25 -7
  10. package/src/capabilities/validation.test.ts +30 -0
  11. package/src/capabilities/validation.ts +8 -0
  12. package/src/cli/backup-rename.test.ts +95 -0
  13. package/src/cli/cli.test.ts +17 -23
  14. package/src/cli/command-registry.ts +199 -0
  15. package/src/cli/commands/backup-list.ts +1 -1
  16. package/src/cli/commands/events.ts +96 -0
  17. package/src/cli/commands/machine-add.ts +103 -59
  18. package/src/cli/commands/module-import.ts +153 -4
  19. package/src/cli/commands/module-remove.ts +86 -17
  20. package/src/cli/commands/module-status.ts +6 -2
  21. package/src/cli/commands/publish/alpha.test.ts +185 -0
  22. package/src/cli/commands/publish/alpha.ts +226 -0
  23. package/src/cli/commands/publish/changesets.test.ts +89 -0
  24. package/src/cli/commands/publish/changesets.ts +144 -0
  25. package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
  26. package/src/cli/commands/publish/consumer-pins.ts +149 -0
  27. package/src/cli/commands/publish/execute.ts +131 -0
  28. package/src/cli/commands/publish/global-install.test.ts +154 -0
  29. package/src/cli/commands/publish/global-install.ts +171 -0
  30. package/src/cli/commands/publish/helpers.ts +227 -0
  31. package/src/cli/commands/publish/index.ts +365 -0
  32. package/src/cli/commands/publish/module-registry.test.ts +40 -0
  33. package/src/cli/commands/publish/module-registry.ts +64 -0
  34. package/src/cli/commands/publish/plan.ts +107 -0
  35. package/src/cli/commands/publish/preflight.ts +238 -0
  36. package/src/cli/commands/publish/types.ts +264 -0
  37. package/src/cli/commands/publish/workspace.test.ts +323 -0
  38. package/src/cli/commands/publish/workspace.ts +596 -0
  39. package/src/cli/commands/restore.ts +126 -0
  40. package/src/cli/commands/storage-add-local.ts +1 -1
  41. package/src/cli/commands/storage-add-s3.ts +1 -1
  42. package/src/cli/commands/subscribers-add.ts +68 -0
  43. package/src/cli/commands/subscribers-list.ts +48 -0
  44. package/src/cli/commands/subscribers-remove.ts +38 -0
  45. package/src/cli/commands/subscribers-serve.ts +77 -0
  46. package/src/cli/commands/subscribers-status.ts +33 -0
  47. package/src/cli/commands/subscribers-test.ts +71 -0
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
  49. package/src/cli/commands/system-apply-config.test.ts +70 -0
  50. package/src/cli/commands/system-apply-config.ts +130 -0
  51. package/src/cli/commands/system-audit.ts +2 -1
  52. package/src/cli/commands/system-init-deprecation.test.ts +90 -0
  53. package/src/cli/commands/system-init.ts +36 -70
  54. package/src/cli/commands/system-update.ts +3 -2
  55. package/src/cli/completion.ts +22 -1
  56. package/src/cli/index.ts +214 -6
  57. package/src/cli/interactive-config.test.ts +19 -0
  58. package/src/cli/restore-command.test.ts +131 -0
  59. package/src/db/client.ts +42 -0
  60. package/src/db/schema.test.ts +13 -16
  61. package/src/db/schema.ts +161 -9
  62. package/src/hooks/capability-loader-firewall.test.ts +6 -15
  63. package/src/hooks/capability-loader.test.ts +2 -3
  64. package/src/hooks/capability-loader.ts +36 -2
  65. package/src/hooks/define-hook.test.ts +4 -0
  66. package/src/hooks/executor.test.ts +18 -0
  67. package/src/hooks/executor.ts +21 -2
  68. package/src/hooks/load-hook-config.test.ts +26 -24
  69. package/src/hooks/load-hook-config.ts +11 -2
  70. package/src/hooks/run-named-hook.ts +16 -0
  71. package/src/hooks/types.ts +9 -1
  72. package/src/manifest/contracts/v1.ts +70 -0
  73. package/src/manifest/schema.ts +262 -16
  74. package/src/manifest/validate-privileged.test.ts +84 -0
  75. package/src/manifest/validate.test.ts +156 -0
  76. package/src/manifest/validate.ts +69 -0
  77. package/src/module/import.ts +12 -0
  78. package/src/services/aspect-approvals.test.ts +231 -0
  79. package/src/services/aspect-approvals.ts +120 -0
  80. package/src/services/aspect-runner.test.ts +493 -0
  81. package/src/services/aspect-runner.ts +438 -0
  82. package/src/services/aspect-template-resolver.test.ts +101 -0
  83. package/src/services/aspect-template-resolver.ts +122 -0
  84. package/src/services/backup-create.ts +104 -25
  85. package/src/services/backup-envelope-roundtrip.test.ts +199 -0
  86. package/src/services/backup-in-flight-refusal.test.ts +163 -0
  87. package/src/services/backup-manifest.test.ts +115 -0
  88. package/src/services/backup-manifest.ts +163 -0
  89. package/src/services/backup-restore.ts +154 -19
  90. package/src/services/build-bus/delivery-events.ts +92 -0
  91. package/src/services/build-bus/event-factory.ts +54 -0
  92. package/src/services/build-bus/fan-out.test.ts +279 -0
  93. package/src/services/build-bus/fan-out.ts +161 -0
  94. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
  95. package/src/services/build-bus/hook-dispatch.test.ts +207 -0
  96. package/src/services/build-bus/hook-dispatch.ts +198 -0
  97. package/src/services/build-bus/hook-dispatcher.ts +115 -0
  98. package/src/services/build-bus/index.ts +41 -0
  99. package/src/services/build-bus/receiver-server.test.ts +179 -0
  100. package/src/services/build-bus/receiver-server.ts +159 -0
  101. package/src/services/build-bus/status.test.ts +212 -0
  102. package/src/services/build-bus/status.ts +213 -0
  103. package/src/services/build-bus/subscriber-store.ts +113 -0
  104. package/src/services/celilo-events.test.ts +70 -0
  105. package/src/services/celilo-events.ts +92 -0
  106. package/src/services/celilo-mgmt-hooks.test.ts +296 -0
  107. package/src/services/config-interview.ts +13 -95
  108. package/src/services/cross-module-data-manager.ts +2 -31
  109. package/src/services/cross-module-read.test.ts +250 -0
  110. package/src/services/cross-module-read.ts +232 -0
  111. package/src/services/deploy-validation.ts +7 -0
  112. package/src/services/deployed-systems.test.ts +235 -0
  113. package/src/services/deployed-systems.ts +308 -0
  114. package/src/services/dns-provider-backfill.ts +75 -0
  115. package/src/services/health-runner.ts +19 -3
  116. package/src/services/infrastructure-variable-resolver.test.ts +6 -32
  117. package/src/services/infrastructure-variable-resolver.ts +3 -13
  118. package/src/services/machine-detector.ts +104 -48
  119. package/src/services/machine-pool.ts +145 -2
  120. package/src/services/module-config.ts +78 -120
  121. package/src/services/module-deploy.ts +113 -40
  122. package/src/services/module-operations.test.ts +154 -0
  123. package/src/services/module-operations.ts +154 -0
  124. package/src/services/module-subscriptions.test.ts +58 -0
  125. package/src/services/module-subscriptions.ts +24 -1
  126. package/src/services/module-types-generator.test.ts +3 -3
  127. package/src/services/module-types-generator.ts +7 -2
  128. package/src/services/proxmox-reconcile.test.ts +333 -0
  129. package/src/services/proxmox-reconcile.ts +156 -0
  130. package/src/services/proxmox-state-recovery.ts +3 -24
  131. package/src/services/restore-from-file.test.ts +177 -0
  132. package/src/services/restore-from-file.ts +355 -0
  133. package/src/services/restore-preflight.test.ts +127 -0
  134. package/src/services/restore-preflight.ts +118 -0
  135. package/src/services/storage-providers/s3.ts +10 -2
  136. package/src/services/system-identity.ts +30 -0
  137. package/src/services/system-init.test.ts +64 -21
  138. package/src/services/system-init.ts +28 -26
  139. package/src/templates/generator.test.ts +7 -16
  140. package/src/templates/generator.ts +28 -115
  141. package/src/test-utils/integration.ts +5 -2
  142. package/src/types/infrastructure.ts +8 -0
  143. package/src/variables/computed/computed-integration.test.ts +191 -0
  144. package/src/variables/computed/computed.test.ts +177 -0
  145. package/src/variables/computed/evaluate.ts +271 -0
  146. package/src/variables/computed/marker.ts +53 -0
  147. package/src/variables/computed/parse.ts +262 -0
  148. package/src/variables/computed/provider-lookup.ts +130 -0
  149. package/src/variables/context.test.ts +89 -28
  150. package/src/variables/context.ts +196 -191
  151. package/src/variables/parser.ts +3 -3
  152. package/src/variables/resolver.test.ts +61 -0
  153. package/src/variables/resolver.ts +81 -0
  154. package/src/variables/types.ts +23 -1
  155. package/src/services/dns-auto-register.ts +0 -211
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Receiver-server integration tests. Spins up the real Bun.serve
3
+ * receiver against a temp SQLite bus, fires signed envelopes at it,
4
+ * asserts the local bus actually received the emit.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
8
+ import { mkdtempSync, rmSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { defineEvents, openBus } from '@celilo/event-bus';
12
+ import type { PublishEvent } from '@celilo/event-bus/build-bus';
13
+ import { signEvent } from '@celilo/event-bus/build-bus';
14
+ import { type ReceiverServer, startReceiverServer } from './receiver-server';
15
+
16
+ const NO_SCHEMAS = defineEvents({});
17
+
18
+ function buildEvent(overrides: Partial<PublishEvent> = {}): PublishEvent {
19
+ return {
20
+ eventId: `evt-${Math.random().toString(36).slice(2)}`,
21
+ timestamp: new Date().toISOString(),
22
+ registry: 'npm',
23
+ tag: 'latest',
24
+ package: { name: '@celilo/cli', version: '0.4.0' },
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ describe('receiver-server', () => {
30
+ let tmpDir: string;
31
+ let busPath: string;
32
+ let server: ReceiverServer;
33
+ const SECRET = 'shared-receiver-secret';
34
+
35
+ beforeEach(async () => {
36
+ tmpDir = mkdtempSync(join(tmpdir(), 'celilo-build-bus-receiver-'));
37
+ busPath = join(tmpDir, 'bus.db');
38
+ server = startReceiverServer({ port: 0, secret: SECRET, busPath });
39
+ });
40
+
41
+ afterEach(async () => {
42
+ await server.stop();
43
+ rmSync(tmpDir, { recursive: true, force: true });
44
+ });
45
+
46
+ test('GET /health returns 200 ok', async () => {
47
+ const r = await fetch(`${server.url}health`);
48
+ expect(r.status).toBe(200);
49
+ expect(await r.text()).toBe('ok');
50
+ });
51
+
52
+ test('GET / returns 405 method-not-allowed', async () => {
53
+ const r = await fetch(server.url);
54
+ expect(r.status).toBe(405);
55
+ });
56
+
57
+ test('POST to unknown path returns 404', async () => {
58
+ const r = await fetch(`${server.url}other`, { method: 'POST', body: '{}' });
59
+ expect(r.status).toBe(404);
60
+ });
61
+
62
+ test('properly signed envelope: 200 + event emitted on local bus', async () => {
63
+ const event = buildEvent();
64
+ const envelope = { event, signature: signEvent(event, SECRET) };
65
+
66
+ const r = await fetch(server.url, {
67
+ method: 'POST',
68
+ headers: { 'content-type': 'application/json' },
69
+ body: JSON.stringify(envelope),
70
+ });
71
+ expect(r.status).toBe(200);
72
+ const body = (await r.json()) as { ok: boolean; eventId: string };
73
+ expect(body.ok).toBe(true);
74
+ expect(body.eventId).toBe(event.eventId);
75
+
76
+ // Open the same bus and confirm the event landed.
77
+ const bus = openBus({ dbPath: busPath, events: NO_SCHEMAS });
78
+ try {
79
+ const recent = bus.recentEvents({ limit: 10, type: 'build-bus.publish' });
80
+ expect(recent).toHaveLength(1);
81
+ const payload = recent[0].payload as PublishEvent;
82
+ expect(payload.eventId).toBe(event.eventId);
83
+ expect(payload.package.name).toBe('@celilo/cli');
84
+ } finally {
85
+ bus.close();
86
+ }
87
+ });
88
+
89
+ test('wrong-secret signature: 401 + nothing emitted', async () => {
90
+ const event = buildEvent();
91
+ const envelope = { event, signature: signEvent(event, 'attacker-secret') };
92
+
93
+ const r = await fetch(server.url, {
94
+ method: 'POST',
95
+ headers: { 'content-type': 'application/json' },
96
+ body: JSON.stringify(envelope),
97
+ });
98
+ expect(r.status).toBe(401);
99
+
100
+ const bus = openBus({ dbPath: busPath, events: NO_SCHEMAS });
101
+ try {
102
+ const recent = bus.recentEvents({ limit: 10, type: 'build-bus.publish' });
103
+ expect(recent).toHaveLength(0);
104
+ } finally {
105
+ bus.close();
106
+ }
107
+ });
108
+
109
+ test('stale timestamp: 401', async () => {
110
+ const event = buildEvent({ timestamp: '2020-01-01T00:00:00.000Z' });
111
+ const envelope = { event, signature: signEvent(event, SECRET) };
112
+
113
+ const r = await fetch(server.url, {
114
+ method: 'POST',
115
+ headers: { 'content-type': 'application/json' },
116
+ body: JSON.stringify(envelope),
117
+ });
118
+ expect(r.status).toBe(401);
119
+ });
120
+
121
+ test('malformed JSON body: 400', async () => {
122
+ const r = await fetch(server.url, {
123
+ method: 'POST',
124
+ headers: { 'content-type': 'application/json' },
125
+ body: 'not json at all',
126
+ });
127
+ expect(r.status).toBe(400);
128
+ });
129
+
130
+ test('duplicate eventId dedupes via bus dedupKey (publisher retry safety)', async () => {
131
+ const event = buildEvent();
132
+ const envelope = { event, signature: signEvent(event, SECRET) };
133
+
134
+ const r1 = await fetch(server.url, {
135
+ method: 'POST',
136
+ headers: { 'content-type': 'application/json' },
137
+ body: JSON.stringify(envelope),
138
+ });
139
+ expect(r1.status).toBe(200);
140
+ const r2 = await fetch(server.url, {
141
+ method: 'POST',
142
+ headers: { 'content-type': 'application/json' },
143
+ body: JSON.stringify(envelope),
144
+ });
145
+ expect(r2.status).toBe(200); // still ok — publisher's idempotent retry
146
+
147
+ // Only one event on the bus.
148
+ const bus = openBus({ dbPath: busPath, events: NO_SCHEMAS });
149
+ try {
150
+ const recent = bus.recentEvents({ limit: 10, type: 'build-bus.publish' });
151
+ expect(recent).toHaveLength(1);
152
+ } finally {
153
+ bus.close();
154
+ }
155
+ });
156
+
157
+ test('onEvent hook fires after a verified emit', async () => {
158
+ const seen: string[] = [];
159
+ await server.stop();
160
+ server = startReceiverServer({
161
+ port: 0,
162
+ secret: SECRET,
163
+ busPath,
164
+ onEvent: (envelope) => {
165
+ seen.push(envelope.event.eventId);
166
+ },
167
+ });
168
+
169
+ const event = buildEvent();
170
+ const envelope = { event, signature: signEvent(event, SECRET) };
171
+ await fetch(server.url, {
172
+ method: 'POST',
173
+ headers: { 'content-type': 'application/json' },
174
+ body: JSON.stringify(envelope),
175
+ });
176
+
177
+ expect(seen).toEqual([event.eventId]);
178
+ });
179
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * HTTP receiver daemon for the build bus
3
+ * ([[v2/BUILD_BUS.md]] Phase 3).
4
+ *
5
+ * Verifies signed webhook envelopes against a per-receiver shared
6
+ * secret, then re-emits the contained PublishEvent onto the local
7
+ * SQLite event bus. From there, the local-bus dispatcher (or this
8
+ * process's own hook dispatcher — see hook-dispatcher.ts) can react.
9
+ *
10
+ * Local-bus event shape:
11
+ *
12
+ * type: 'build-bus.publish' (constant; filters happen on payload)
13
+ * payload: the verified PublishEvent
14
+ * dedupKey: event.eventId (the bus silently dedupes
15
+ * duplicate inserts — no extra
16
+ * table needed)
17
+ *
18
+ * Endpoint: POST / accepts the WebhookEnvelope JSON. Any other path
19
+ * returns 404. Any other method returns 405. GET /health returns
20
+ * 200 for liveness probes.
21
+ */
22
+
23
+ import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
24
+ import type { WebhookEnvelope } from '@celilo/event-bus/build-bus';
25
+ import { verifyEnvelope } from '@celilo/event-bus/build-bus';
26
+ import { getEventBusPath } from '../../config/paths';
27
+
28
+ const NO_SCHEMAS = defineEvents({});
29
+
30
+ export interface ReceiverServerOptions {
31
+ /** Port to listen on. Default: 8123. */
32
+ port?: number;
33
+ /** Shared HMAC secret. Publishers signing webhooks at this endpoint must use the same string. */
34
+ secret: string;
35
+ /**
36
+ * SQLite event-bus path override (mostly for tests). Production
37
+ * uses `getEventBusPath()` so the receiver emits into the same
38
+ * bus the rest of celilo reads.
39
+ */
40
+ busPath?: string;
41
+ /**
42
+ * Optional hook fired after every successful emit. Used by the
43
+ * combined daemon to trigger the hook dispatcher in-process
44
+ * without an extra polling layer.
45
+ */
46
+ onEvent?: (envelope: WebhookEnvelope) => void | Promise<void>;
47
+ /** Override for tests; default is the global fetch. */
48
+ now?: () => number;
49
+ }
50
+
51
+ export interface ReceiverServer {
52
+ port: number;
53
+ /** URL clients should POST to. */
54
+ url: string;
55
+ stop(): Promise<void>;
56
+ }
57
+
58
+ /**
59
+ * Start the HTTP receiver. Resolves once the listener is bound.
60
+ * Caller is responsible for keeping the server alive — typically
61
+ * the CLI command's `await new Promise(() => {})` pattern.
62
+ */
63
+ export function startReceiverServer(opts: ReceiverServerOptions): ReceiverServer {
64
+ const port = opts.port ?? 8123;
65
+ const busPath = opts.busPath ?? getEventBusPath();
66
+ // Empty registry — we emit to a known type ('build-bus.publish')
67
+ // without going through any registered schema. Use emitRaw.
68
+ const bus: Bus = openBus({ dbPath: busPath, events: NO_SCHEMAS });
69
+
70
+ const server = Bun.serve({
71
+ port,
72
+ fetch: async (req) => {
73
+ const url = new URL(req.url);
74
+
75
+ if (req.method === 'GET' && url.pathname === '/health') {
76
+ return new Response('ok', { status: 200 });
77
+ }
78
+
79
+ if (url.pathname !== '/') {
80
+ return new Response('not found', { status: 404 });
81
+ }
82
+
83
+ if (req.method !== 'POST') {
84
+ return new Response('method not allowed', { status: 405 });
85
+ }
86
+
87
+ let envelope: WebhookEnvelope;
88
+ try {
89
+ envelope = (await req.json()) as WebhookEnvelope;
90
+ } catch (err) {
91
+ return new Response(
92
+ JSON.stringify({
93
+ ok: false,
94
+ reason: 'malformed',
95
+ detail: err instanceof Error ? err.message : String(err),
96
+ }),
97
+ { status: 400, headers: { 'content-type': 'application/json' } },
98
+ );
99
+ }
100
+
101
+ const outcome = verifyEnvelope(envelope, {
102
+ secret: opts.secret,
103
+ now: opts.now?.(),
104
+ });
105
+ if (!outcome.ok) {
106
+ return new Response(JSON.stringify(outcome), {
107
+ status: outcome.reason === 'malformed' ? 400 : 401,
108
+ headers: { 'content-type': 'application/json' },
109
+ });
110
+ }
111
+
112
+ try {
113
+ bus.emitRaw('build-bus.publish', envelope.event, {
114
+ dedupKey: envelope.event.eventId,
115
+ emittedBy: 'build-bus.receiver',
116
+ });
117
+ } catch (err) {
118
+ // emit failures (DB locked, schema mismatch) get a 500 so the
119
+ // publisher retries. We don't want to drop on the floor.
120
+ return new Response(
121
+ JSON.stringify({
122
+ ok: false,
123
+ reason: 'emit-failed',
124
+ detail: err instanceof Error ? err.message : String(err),
125
+ }),
126
+ { status: 500, headers: { 'content-type': 'application/json' } },
127
+ );
128
+ }
129
+
130
+ if (opts.onEvent) {
131
+ // Best-effort: hook dispatch failures shouldn't 5xx the
132
+ // webhook (the publisher already won). Surface via the
133
+ // process's own logging.
134
+ try {
135
+ await opts.onEvent(envelope);
136
+ } catch (err) {
137
+ console.warn(
138
+ `[build-bus] onEvent handler failed: ${err instanceof Error ? err.message : String(err)}`,
139
+ );
140
+ }
141
+ }
142
+
143
+ return new Response(JSON.stringify({ ok: true, eventId: outcome.eventId }), {
144
+ status: 200,
145
+ headers: { 'content-type': 'application/json' },
146
+ });
147
+ },
148
+ });
149
+
150
+ const actualPort = server.port ?? port;
151
+ return {
152
+ port: actualPort,
153
+ url: `http://localhost:${actualPort}/`,
154
+ async stop() {
155
+ server.stop(true);
156
+ bus.close();
157
+ },
158
+ };
159
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Status-aggregator tests — Phase 6 of [[v2/BUILD_BUS.md]].
3
+ *
4
+ * The aggregator is pure: takes subscribers + delivery records,
5
+ * returns per-subscriber summaries. The formatter is pure too.
6
+ * Both tested directly here without spinning up a real bus or
7
+ * publishing anything.
8
+ */
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import type { Subscriber } from '@celilo/event-bus/build-bus';
12
+ import {
13
+ WEBHOOK_DELIVERED_EVENT,
14
+ WEBHOOK_FAILED_EVENT,
15
+ type WebhookDeliveryPayload,
16
+ } from './delivery-events';
17
+ import {
18
+ type DeliveryRecord,
19
+ aggregateSubscriberStatus,
20
+ describeAgo,
21
+ formatStatus,
22
+ } from './status';
23
+
24
+ function sub(overrides: Partial<Subscriber>): Subscriber {
25
+ return {
26
+ url: 'https://example.test/webhook',
27
+ secret: 'shared',
28
+ match: {},
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ function record(
34
+ type: typeof WEBHOOK_DELIVERED_EVENT | typeof WEBHOOK_FAILED_EVENT,
35
+ payloadOverrides: Partial<WebhookDeliveryPayload>,
36
+ emittedAt: number,
37
+ ): DeliveryRecord {
38
+ return {
39
+ type,
40
+ emittedAt,
41
+ payload: {
42
+ subscriberLabel: 'test-sub',
43
+ subscriberUrl: 'https://example.test/webhook',
44
+ eventId: 'evt-0',
45
+ packageName: '@celilo/cli',
46
+ packageVersion: '0.4.0',
47
+ tag: 'latest',
48
+ attempts: 1,
49
+ durationMs: 42,
50
+ ...payloadOverrides,
51
+ },
52
+ };
53
+ }
54
+
55
+ describe('aggregateSubscriberStatus', () => {
56
+ test('subscriber with no deliveries → totals 0, successRate null, no lastDelivery', () => {
57
+ const [s] = aggregateSubscriberStatus([sub({ name: 'a' })], []);
58
+ expect(s.totals).toEqual({ delivered: 0, failed: 0 });
59
+ expect(s.successRatePct).toBeNull();
60
+ expect(s.lastDelivery).toBeUndefined();
61
+ expect(s.recentFailures).toEqual([]);
62
+ });
63
+
64
+ test('all deliveries succeed → 100% success', () => {
65
+ const subscribers = [sub({ name: 'a' })];
66
+ const records = [
67
+ record(WEBHOOK_DELIVERED_EVENT, {}, 1000),
68
+ record(WEBHOOK_DELIVERED_EVENT, {}, 2000),
69
+ record(WEBHOOK_DELIVERED_EVENT, {}, 3000),
70
+ ];
71
+ const [s] = aggregateSubscriberStatus(subscribers, records);
72
+ expect(s.totals).toEqual({ delivered: 3, failed: 0 });
73
+ expect(s.successRatePct).toBe(100);
74
+ });
75
+
76
+ test('mixed success/failure → integer success rate', () => {
77
+ const subscribers = [sub({ name: 'a' })];
78
+ const records = [
79
+ record(WEBHOOK_DELIVERED_EVENT, {}, 1000),
80
+ record(WEBHOOK_FAILED_EVENT, { error: 'boom' }, 2000),
81
+ record(WEBHOOK_DELIVERED_EVENT, {}, 3000),
82
+ ];
83
+ const [s] = aggregateSubscriberStatus(subscribers, records);
84
+ expect(s.totals).toEqual({ delivered: 2, failed: 1 });
85
+ // 2/3 = 66.66… → rounded to 67%
86
+ expect(s.successRatePct).toBe(67);
87
+ });
88
+
89
+ test('lastDelivery is the newest by emittedAt (success or failure)', () => {
90
+ const subscribers = [sub({ name: 'a' })];
91
+ const records = [
92
+ record(WEBHOOK_DELIVERED_EVENT, { eventId: 'old' }, 1000),
93
+ record(WEBHOOK_FAILED_EVENT, { eventId: 'newest', error: 'x' }, 9000),
94
+ record(WEBHOOK_DELIVERED_EVENT, { eventId: 'middle' }, 5000),
95
+ ];
96
+ const [s] = aggregateSubscriberStatus(subscribers, records);
97
+ expect(s.lastDelivery?.eventId).toBe('newest');
98
+ expect(s.lastDelivery?.ok).toBe(false);
99
+ });
100
+
101
+ test('recentFailures are newest-first and capped at 5', () => {
102
+ const subscribers = [sub({ name: 'a' })];
103
+ const records: DeliveryRecord[] = [];
104
+ for (let i = 0; i < 8; i++) {
105
+ records.push(record(WEBHOOK_FAILED_EVENT, { eventId: `f-${i}`, error: `e-${i}` }, i * 1000));
106
+ }
107
+ const [s] = aggregateSubscriberStatus(subscribers, records);
108
+ expect(s.recentFailures).toHaveLength(5);
109
+ // Newest first.
110
+ expect(s.recentFailures[0].eventId).toBe('f-7');
111
+ expect(s.recentFailures[4].eventId).toBe('f-3');
112
+ });
113
+
114
+ test('error strings are truncated to keep the status output readable', () => {
115
+ const longError = 'X'.repeat(500);
116
+ const subscribers = [sub({ name: 'a' })];
117
+ const records = [record(WEBHOOK_FAILED_EVENT, { error: longError }, 1000)];
118
+ const [s] = aggregateSubscriberStatus(subscribers, records);
119
+ expect(s.recentFailures[0].error.length).toBeLessThanOrEqual(200);
120
+ });
121
+
122
+ test('groups by subscriberUrl (subscribers whose URLs do not match get no records)', () => {
123
+ const subscribers = [
124
+ sub({ name: 'a', url: 'https://a.test/' }),
125
+ sub({ name: 'b', url: 'https://b.test/' }),
126
+ ];
127
+ const records = [
128
+ record(WEBHOOK_DELIVERED_EVENT, { subscriberUrl: 'https://a.test/' }, 1000),
129
+ record(WEBHOOK_DELIVERED_EVENT, { subscriberUrl: 'https://a.test/' }, 2000),
130
+ record(WEBHOOK_FAILED_EVENT, { subscriberUrl: 'https://b.test/', error: 'oops' }, 1500),
131
+ ];
132
+ const [a, b] = aggregateSubscriberStatus(subscribers, records);
133
+ expect(a.totals).toEqual({ delivered: 2, failed: 0 });
134
+ expect(b.totals).toEqual({ delivered: 0, failed: 1 });
135
+ });
136
+
137
+ test('preserves subscriber input order', () => {
138
+ const subscribers = [
139
+ sub({ name: 'z', url: 'https://z.test/' }),
140
+ sub({ name: 'a', url: 'https://a.test/' }),
141
+ sub({ name: 'm', url: 'https://m.test/' }),
142
+ ];
143
+ const result = aggregateSubscriberStatus(subscribers, []);
144
+ expect(result.map((s) => s.label)).toEqual(['z', 'a', 'm']);
145
+ });
146
+ });
147
+
148
+ describe('formatStatus', () => {
149
+ const now = 10_000_000;
150
+ test('renders a no-subscribers message', () => {
151
+ expect(formatStatus([], now)).toBe('No subscribers configured.');
152
+ });
153
+
154
+ test('renders no-deliveries-yet for an unused subscriber', () => {
155
+ const aggregated = aggregateSubscriberStatus([sub({ name: 'fresh' })], []);
156
+ const out = formatStatus(aggregated, now);
157
+ expect(out).toContain('deliveries: none yet');
158
+ expect(out).not.toContain('last delivery');
159
+ });
160
+
161
+ test('renders success rate + last delivery for an active subscriber', () => {
162
+ const aggregated = aggregateSubscriberStatus(
163
+ [sub({ name: 'active' })],
164
+ [
165
+ record(
166
+ WEBHOOK_DELIVERED_EVENT,
167
+ { packageName: '@celilo/cli', packageVersion: '0.4.0' },
168
+ now - 60_000,
169
+ ),
170
+ record(WEBHOOK_DELIVERED_EVENT, {}, now - 300_000),
171
+ ],
172
+ );
173
+ const out = formatStatus(aggregated, now);
174
+ expect(out).toContain('deliveries: 2/2 ok (100%)');
175
+ expect(out).toContain('last delivery: ✓ @celilo/cli@0.4.0 (1m ago)');
176
+ });
177
+
178
+ test('renders recent-failures block with truncated errors', () => {
179
+ const aggregated = aggregateSubscriberStatus(
180
+ [sub({ name: 'flaky' })],
181
+ [record(WEBHOOK_FAILED_EVENT, { error: 'HTTP 500: backend wedged' }, now - 30_000)],
182
+ );
183
+ const out = formatStatus(aggregated, now);
184
+ expect(out).toContain('recent failures (1):');
185
+ expect(out).toContain('HTTP 500: backend wedged');
186
+ expect(out).toContain('30s ago');
187
+ });
188
+ });
189
+
190
+ describe('describeAgo', () => {
191
+ test('seconds', () => {
192
+ expect(describeAgo(0)).toBe('0s ago');
193
+ expect(describeAgo(15_000)).toBe('15s ago');
194
+ });
195
+
196
+ test('minutes', () => {
197
+ expect(describeAgo(90 * 1000)).toBe('1m ago');
198
+ expect(describeAgo(45 * 60 * 1000)).toBe('45m ago');
199
+ });
200
+
201
+ test('hours', () => {
202
+ expect(describeAgo(2 * 60 * 60 * 1000)).toBe('2h ago');
203
+ });
204
+
205
+ test('days', () => {
206
+ expect(describeAgo(3 * 24 * 60 * 60 * 1000)).toBe('3d ago');
207
+ });
208
+
209
+ test('future deltas (clock skew) → "in the future"', () => {
210
+ expect(describeAgo(-500)).toBe('in the future');
211
+ });
212
+ });