@celilo/cli 1.2.0 → 1.4.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.
@@ -25,6 +25,7 @@ import {
25
25
  reserveVMID,
26
26
  unreserveIP,
27
27
  unreserveVMID,
28
+ updateReservationReason,
28
29
  } from './allocator';
29
30
 
30
31
  describe('IPAM Allocator', () => {
@@ -295,6 +296,43 @@ describe('IPAM Allocator', () => {
295
296
  const reservations = await listReservations(db);
296
297
  expect(reservations).toHaveLength(2);
297
298
  });
299
+
300
+ // Editing in place, rather than include-then-exclude: that dance drops the
301
+ // row for a moment and can race an allocation into the held address.
302
+ test('should edit a reservation reason in place', async () => {
303
+ await reserveIP('10.0.10.50', 'dmz', 'LB', null, db);
304
+
305
+ const updated = await updateReservationReason('10.0.10.50', 'dmz', 'Retired LB', db);
306
+
307
+ expect(updated).toBe(true);
308
+ const reservations = await db.select().from(ipReservations).all();
309
+ expect(reservations).toHaveLength(1);
310
+ expect(reservations[0].reason).toBe('Retired LB');
311
+ });
312
+
313
+ test('should leave the address held while editing', async () => {
314
+ await reserveIP('10.0.10.50', 'dmz', 'LB', null, db);
315
+
316
+ await updateReservationReason('10.0.10.50', 'dmz', 'Retired LB', db);
317
+
318
+ expect(await isIPAvailable('10.0.10.50', 'dmz', db)).toBe(false);
319
+ });
320
+
321
+ test('should edit only the addressed row', async () => {
322
+ await reserveIP('10.0.10.50', 'dmz', 'LB', null, db);
323
+ await reserveIP('10.0.10.51', 'dmz', 'LB', null, db);
324
+
325
+ await updateReservationReason('10.0.10.50', 'dmz', 'Retired LB', db);
326
+
327
+ const reasons = (await listReservations(db)).map((r) => r.reason);
328
+ expect(reasons.sort()).toEqual(['LB', 'Retired LB']);
329
+ });
330
+
331
+ test('should report when no reservation exists to edit', async () => {
332
+ const updated = await updateReservationReason('10.0.10.99', 'dmz', 'Nothing there', db);
333
+
334
+ expect(updated).toBe(false);
335
+ });
298
336
  });
299
337
 
300
338
  describe('getAllocatedIPsInSubnet', () => {
@@ -4,7 +4,7 @@
4
4
  * Prevents conflicts and tracks allocations
5
5
  */
6
6
 
7
- import { and, eq } from 'drizzle-orm';
7
+ import { and, eq, like, or } from 'drizzle-orm';
8
8
  import type { BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite';
9
9
  import type { DbClient } from '../db/client';
10
10
  import { ipAllocations, ipReservations, systemConfig, vmidReservations } from '../db/schema';
@@ -246,6 +246,68 @@ export async function unreserveIP(
246
246
  .where(and(eq(ipReservations.ipStart, ip), eq(ipReservations.zone, zone)));
247
247
  }
248
248
 
249
+ /**
250
+ * Release every ingress-IP reservation a module holds (celilo#892).
251
+ *
252
+ * `ensureIngressIps` reserves an `internal`-subnet address at generate time and
253
+ * nothing released it at removal, so each install/remove cycle permanently
254
+ * burned one address from the static range — with no error and no way to tell
255
+ * the dead row from the live one, since both carry the same reason string.
256
+ *
257
+ * TWO reason formats exist in live databases and both must go: the current
258
+ * `ingress:<module>:<variable>` (since celilo#879) and the pre-879
259
+ * `dns-ingress:<module>`, which celilo-mgr still holds. Matching only the
260
+ * current one would leave the installed base leaking.
261
+ *
262
+ * Deletion is by REASON, not by the stored config value: a module can hold an
263
+ * ingress reservation with no `ip_allocations` row (it deploys onto a machine
264
+ * rather than a container), and the config rows are about to be cascade-deleted
265
+ * anyway. The reason string is the only thing that names the owner.
266
+ *
267
+ * @returns The addresses released, for reporting.
268
+ */
269
+ export async function releaseIngressReservations(
270
+ moduleId: string,
271
+ db: DbOrTransaction,
272
+ ): Promise<string[]> {
273
+ // Module IDs are validated kebab-case, so they carry no LIKE wildcards.
274
+ const ownedByModule = or(
275
+ like(ipReservations.reason, `ingress:${moduleId}:%`),
276
+ eq(ipReservations.reason, `dns-ingress:${moduleId}`),
277
+ );
278
+
279
+ const held = await db.select().from(ipReservations).where(ownedByModule).all();
280
+ if (held.length === 0) return [];
281
+
282
+ await db.delete(ipReservations).where(ownedByModule);
283
+
284
+ return held.map((r: typeof ipReservations.$inferSelect) => r.ipStart);
285
+ }
286
+
287
+ /**
288
+ * Change an existing reservation's reason in place.
289
+ *
290
+ * The alternative — include then re-exclude — drops the row for a moment and
291
+ * can race an allocation into the address it was holding.
292
+ *
293
+ * @returns False when no reservation exists for that IP in that zone.
294
+ */
295
+ export async function updateReservationReason(
296
+ ipStart: string,
297
+ zone: IpamZone,
298
+ reason: string,
299
+ db: DbOrTransaction,
300
+ ): Promise<boolean> {
301
+ const ip = stripCIDR(ipStart);
302
+ const match = and(eq(ipReservations.ipStart, ip), eq(ipReservations.zone, zone));
303
+
304
+ const existing = await db.select().from(ipReservations).where(match).all();
305
+ if (existing.length === 0) return false;
306
+
307
+ await db.update(ipReservations).set({ reason }).where(match);
308
+ return true;
309
+ }
310
+
249
311
  /**
250
312
  * List all IP reservations
251
313
  */
@@ -10,6 +10,7 @@
10
10
  import { eq } from 'drizzle-orm';
11
11
  import type { DbClient } from '../db/client';
12
12
  import { ipAllocations } from '../db/schema';
13
+ import { releaseIngressReservations } from './allocator';
13
14
 
14
15
  export interface IpamAllocation {
15
16
  moduleId: string;
@@ -258,6 +259,12 @@ export async function allocateForModule(
258
259
  * @returns True if allocation was removed, false if none existed
259
260
  */
260
261
  export async function deallocateForModule(moduleId: string, db: DbClient): Promise<boolean> {
262
+ // Ingress reservations are released FIRST and unconditionally (celilo#892).
263
+ // A module can hold one with no `ip_allocations` row at all — it deploys onto
264
+ // a machine rather than a celilo-provisioned container — so releasing it
265
+ // after the early return below would skip exactly the modules that leak.
266
+ await releaseIngressReservations(moduleId, db);
267
+
261
268
  // Check if allocation exists before deleting
262
269
  const existing = getAllocation(moduleId, db);
263
270
  if (!existing) {
@@ -261,6 +261,41 @@ export const V1_HOOKS: ContractHooks = {
261
261
  inputs: {},
262
262
  outputs: {},
263
263
  },
264
+ /**
265
+ * Report what a tunnel/interface is ACTUALLY carrying, diffed against what
266
+ * celilo's config says it should carry.
267
+ *
268
+ * Deliberately a read of the live thing rather than of celilo's own record.
269
+ * celilo#928 was exactly a divergence between the two — a device present in
270
+ * config, rendered as active by a UI, and absent from the interface — and
271
+ * diagnosing it needed a screenshot and a root SSH because nothing celilo
272
+ * offered could tell the two apart. A hook that reported the record would
273
+ * have reproduced the misleading signal rather than exposing it.
274
+ *
275
+ * No structured outputs: it reports through the logger, so an operator and an
276
+ * agent read the same thing.
277
+ */
278
+ list_peers: {
279
+ inputs: {},
280
+ outputs: {},
281
+ },
282
+ /**
283
+ * Re-assert a module's desired peer set onto the thing carrying it, on a
284
+ * timer, without an operator.
285
+ *
286
+ * The counterpart to applying on change: apply-on-change bounds latency, this
287
+ * bounds how long ANY divergence can persist — including drift nobody
288
+ * predicted (a hand-edit on the box, an apply that failed, a record written
289
+ * before the applying code existed). celilo#934, where the module could
290
+ * already SEE the drift in `health_check` and had nowhere to act on it.
291
+ *
292
+ * A converge that changes nothing must be silent, so the log stays a record
293
+ * of things happening.
294
+ */
295
+ reconcile_peers: {
296
+ inputs: {},
297
+ outputs: {},
298
+ },
264
299
  /**
265
300
  * Build-bus upstream publish hook. The executor passes the
266
301
  * PublishEvent fields as env vars (CELILO_EVENT_PAYLOAD,
@@ -364,6 +364,8 @@ const LIFECYCLE_HOOK_SCHEMAS = {
364
364
  refresh_registrations: LifecycleHookSchema.optional(),
365
365
  reassert_dhcp_dns: LifecycleHookSchema.optional(),
366
366
  reconcile_clients: LifecycleHookSchema.optional(),
367
+ list_peers: LifecycleHookSchema.optional(),
368
+ reconcile_peers: LifecycleHookSchema.optional(),
367
369
  } satisfies Record<HookName, z.ZodTypeAny>;
368
370
 
369
371
  /**
@@ -11,6 +11,19 @@
11
11
  * refer to that document's sections 1-4 — read the row before changing a line
12
12
  * here.
13
13
  *
14
+ * **Every debt entry names the issue tracking its removal.** The audit is a
15
+ * ruling; the issues are the work. If you are here because the gate went red,
16
+ * the issue in the entry's `why` is where the fix belongs:
17
+ *
18
+ * #937 the hardcoded well-known registry + its duplicate zone table
19
+ * #938 the loader's capName branches, CAPABILITY_MODULE_MAP, isFirewallModule
20
+ * #939 the four provider-domain tables
21
+ * #940 the Caddyfile generator + interface classification in the shared package
22
+ * #941 core shelling iptables-save
23
+ * #943 the three capability-named CLI verbs
24
+ * #944 core shelling wg pubkey
25
+ * #945 three core primitives gated on one capability (generalise, don't move)
26
+ *
14
27
  * Two kinds of entry live here and they must not be confused:
15
28
  *
16
29
  * - **Debt** — the default. It is here because it is wrong and not yet fixed.
@@ -25,6 +38,9 @@
25
38
  * each belongs to. Scan A asserts the tagged set EQUALS this map — not that it
26
39
  * is empty. These four exist today (audit T1, T2, T3, T6) and Phase 1 migrates
27
40
  * nothing; an assertion that core holds none would be red the day it landed.
41
+ *
42
+ * Tracked by #939. Migrating them keeps the ownership CLAIM in core — dropping
43
+ * it is the mistake that made `dns_registration_consumers` necessary (#626).
28
44
  */
29
45
  export const CAPABILITY_OWNED_TABLES: Readonly<Record<string, string>> = {
30
46
  web_routes: 'public_web',
@@ -68,25 +84,25 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
68
84
  file: 'apps/celilo/src/capabilities/well-known.ts',
69
85
  capability: 'dhcp_server',
70
86
  count: 1,
71
- why: 'X1 — hardcoded canonical hostnames, required zones and literal ports; Phase 2',
87
+ why: 'X1 — hardcoded canonical hostnames, required zones and literal ports; Phase 2 (#937)',
72
88
  },
73
89
  {
74
90
  file: 'apps/celilo/src/capabilities/well-known.ts',
75
91
  capability: 'dns_internal',
76
92
  count: 1,
77
- why: 'X1 — hardcoded canonical hostnames, required zones and literal ports; Phase 2',
93
+ why: 'X1 — hardcoded canonical hostnames, required zones and literal ports; Phase 2 (#937)',
78
94
  },
79
95
  {
80
96
  file: 'apps/celilo/src/capabilities/well-known.ts',
81
97
  capability: 'dns_registrar',
82
98
  count: 1,
83
- why: 'X1 — hardcoded canonical hostnames, required zones and literal ports; Phase 2',
99
+ why: 'X1 — hardcoded canonical hostnames, required zones and literal ports; Phase 2 (#937)',
84
100
  },
85
101
  {
86
102
  file: 'apps/celilo/src/capabilities/well-known.ts',
87
103
  capability: 'public_web',
88
104
  count: 1,
89
- why: 'X1 — hardcoded canonical hostnames, required zones and literal ports; Phase 2',
105
+ why: 'X1 — hardcoded canonical hostnames, required zones and literal ports; Phase 2 (#937)',
90
106
  },
91
107
  {
92
108
  file: 'apps/celilo/src/cli/commands/notify-config.ts',
@@ -98,91 +114,91 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
98
114
  file: 'apps/celilo/src/cli/commands/token.ts',
99
115
  capability: 'idp',
100
116
  count: 1,
101
- why: 'X7 — a top-level CLI verb backed by the idp capability; Phase 5',
117
+ why: 'X7 — a top-level CLI verb backed by the idp capability; Phase 5 (#943)',
102
118
  },
103
119
  {
104
120
  file: 'apps/celilo/src/cli/completion.ts',
105
121
  capability: 'firewall',
106
122
  count: 3,
107
- why: 'X6 — tab completion for the capability-named CLI verbs; falls out with X6',
123
+ why: 'X6 — tab completion for the capability-named CLI verbs; falls out with X6 (#943)',
108
124
  },
109
125
  {
110
126
  file: 'apps/celilo/src/cli/index.ts',
111
127
  capability: 'firewall',
112
128
  count: 2,
113
- why: 'X5/X6/X7 — the command tree wiring the capability-named CLI verbs; Phase 5',
129
+ why: 'X5/X6/X7 — the command tree wiring the capability-named CLI verbs; Phase 5 (#943)',
114
130
  },
115
131
  {
116
132
  file: 'apps/celilo/src/hooks/capability-loader.ts',
117
133
  capability: 'control_plane_vpn',
118
134
  count: 1,
119
- why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3',
135
+ why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3 (#938)',
120
136
  },
121
137
  {
122
138
  file: 'apps/celilo/src/hooks/capability-loader.ts',
123
139
  capability: 'dhcp_server',
124
140
  count: 1,
125
- why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3',
141
+ why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3 (#938)',
126
142
  },
127
143
  {
128
144
  file: 'apps/celilo/src/hooks/capability-loader.ts',
129
145
  capability: 'dns_internal',
130
146
  count: 3,
131
- why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3',
147
+ why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3 (#938)',
132
148
  },
133
149
  {
134
150
  file: 'apps/celilo/src/hooks/capability-loader.ts',
135
151
  capability: 'dns_registrar',
136
152
  count: 4,
137
- why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3',
153
+ why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3 (#938)',
138
154
  },
139
155
  {
140
156
  file: 'apps/celilo/src/hooks/capability-loader.ts',
141
157
  capability: 'external_web',
142
158
  count: 1,
143
- why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3',
159
+ why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3 (#938)',
144
160
  },
145
161
  {
146
162
  file: 'apps/celilo/src/hooks/capability-loader.ts',
147
163
  capability: 'firewall',
148
164
  count: 12,
149
- why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3',
165
+ why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3 (#938)',
150
166
  },
151
167
  {
152
168
  file: 'apps/celilo/src/hooks/capability-loader.ts',
153
169
  capability: 'idp',
154
170
  count: 1,
155
- why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3',
171
+ why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3 (#938)',
156
172
  },
157
173
  {
158
174
  file: 'apps/celilo/src/hooks/capability-loader.ts',
159
175
  capability: 'notification',
160
176
  count: 1,
161
- why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3',
177
+ why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3 (#938)',
162
178
  },
163
179
  {
164
180
  file: 'apps/celilo/src/hooks/capability-loader.ts',
165
181
  capability: 'private_web',
166
182
  count: 1,
167
- why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3',
183
+ why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3 (#938)',
168
184
  },
169
185
  {
170
186
  file: 'apps/celilo/src/hooks/capability-loader.ts',
171
187
  capability: 'public_web',
172
188
  count: 8,
173
- why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3',
189
+ why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3 (#938)',
174
190
  },
175
191
  {
176
192
  file: 'apps/celilo/src/hooks/capability-loader.ts',
177
193
  capability: 'registry_publish',
178
194
  count: 1,
179
- why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3',
195
+ why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3 (#938)',
180
196
  },
181
197
  {
182
198
  file: 'apps/celilo/src/hooks/capability-loader.ts',
183
199
  capability: 'source_forge',
184
200
  count: 1,
185
- why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3',
201
+ why: 'L1-L12 — core branches on the capability NAME; becomes a provider declaration in Phase 3 (#938)',
186
202
  },
187
203
  {
188
204
  file: 'apps/celilo/src/manifest/validate.ts',
@@ -212,49 +228,49 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
212
228
  file: 'apps/celilo/src/services/dns-provider-backfill.ts',
213
229
  capability: 'dns_internal',
214
230
  count: 2,
215
- why: 'S3 — the generic event-replay half is core; the dns_internal gate is not',
231
+ why: 'S3 — the generic event-replay half is core; the dns_internal gate is not (#945)',
216
232
  },
217
233
  {
218
234
  file: 'apps/celilo/src/services/firewall-reach.ts',
219
235
  capability: 'firewall',
220
236
  count: 1,
221
- why: 'S8 — core shells iptables-save at a remote box and parses the output',
237
+ why: 'S8 — core shells iptables-save at a remote box and parses the output (#941)',
222
238
  },
223
239
  {
224
240
  file: 'apps/celilo/src/services/infrastructure-selector.ts',
225
241
  capability: 'firewall',
226
242
  count: 1,
227
- why: 'S15 — isFirewallModule hardcodes a placement rule the manifest should declare',
243
+ why: 'S15 — isFirewallModule hardcodes a placement rule the manifest should declare (#938)',
228
244
  },
229
245
  {
230
246
  file: 'apps/celilo/src/services/module-deploy.ts',
231
247
  capability: 'firewall',
232
248
  count: 2,
233
- why: "S16 — two more copies of S15's decision; fixing S15 removes all three",
249
+ why: "S16 — two more copies of S15's decision; fixing S15 removes all three (#938)",
234
250
  },
235
251
  {
236
252
  file: 'apps/celilo/src/services/public-web-republish.ts',
237
253
  capability: 'public_web',
238
254
  count: 1,
239
- why: "S2 — encodes caddy's redeploy behaviour; generalise to a provider-declared re-assert signal",
255
+ why: "S2 — encodes caddy's redeploy behaviour; generalise to a provider-declared re-assert signal (#945)",
240
256
  },
241
257
  {
242
258
  file: 'apps/celilo/src/services/zone-policy.ts',
243
259
  capability: 'public_web',
244
260
  count: 1,
245
- why: 'S13 — ZONE_REQUIREMENTS, a second hand-maintained copy of well-known.ts; Phase 2',
261
+ why: 'S13 — ZONE_REQUIREMENTS, a second hand-maintained copy of well-known.ts; Phase 2 (#937)',
246
262
  },
247
263
  {
248
264
  file: 'apps/celilo/src/templates/generator.ts',
249
265
  capability: 'dns_internal',
250
266
  count: 1,
251
- why: 'X4 — declaration-driven and the model for the rest; only the error string names the capability',
267
+ why: 'X4 — declaration-driven and the model for the rest; only the error string names the capability (#945)',
252
268
  },
253
269
  {
254
270
  file: 'apps/celilo/src/variables/context.ts',
255
271
  capability: 'dns_internal',
256
272
  count: 1,
257
- why: 'X3 — reaches capabilitiesMap.dns_internal by name; should read a declared field (X4 is the model)',
273
+ why: 'X3 — reaches capabilitiesMap.dns_internal by name; should read a declared field (X4 is the model) (#945)',
258
274
  },
259
275
  {
260
276
  file: 'packages/capabilities/src/capability-contract.ts',
@@ -344,13 +360,13 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
344
360
  file: 'packages/capabilities/src/public-web.ts',
345
361
  capability: 'public_web',
346
362
  count: 1,
347
- why: "X8 — generateCaddyfile renders caddy's complete config from the shared package; Phase 5",
363
+ why: "X8 — generateCaddyfile renders caddy's complete config from the shared package; Phase 5 (#940)",
348
364
  },
349
365
  {
350
366
  file: 'packages/capabilities/src/utils.ts',
351
367
  capability: 'public_web',
352
368
  count: 3,
353
- why: 'X8 — shared helpers that name the capability they serve; moves with X8 in Phase 5',
369
+ why: 'X8 — shared helpers that name the capability they serve; moves with X8 in Phase 5 (#940)',
354
370
  },
355
371
  ];
356
372
 
@@ -368,17 +384,17 @@ export const SERVICE_FILENAME_BASELINE: readonly ServiceFilenameRow[] = [
368
384
  {
369
385
  file: 'apps/celilo/src/services/dns-internal-records.ts',
370
386
  capability: 'dns_internal',
371
- why: 'S5 — the store for T6; moves with the table in Phase 4',
387
+ why: 'S5 — the store for T6; moves with the table in Phase 4 (#939)',
372
388
  },
373
389
  {
374
390
  file: 'apps/celilo/src/services/firewall-reach.ts',
375
391
  capability: 'firewall',
376
- why: 'S8 — core reaching into one provider implementation',
392
+ why: 'S8 — core reaching into one provider implementation (#941)',
377
393
  },
378
394
  {
379
395
  file: 'apps/celilo/src/services/public-web-republish.ts',
380
396
  capability: 'public_web',
381
- why: "S2 — named for one provider's redeploy behaviour",
397
+ why: "S2 — named for one provider's redeploy behaviour (#945)",
382
398
  },
383
399
  ];
384
400
 
@@ -387,18 +403,18 @@ export const PROVIDER_LITERAL_BASELINE: readonly ProviderLiteralRow[] = [
387
403
  file: 'apps/celilo/src/secrets/generators.ts',
388
404
  literal: 'wg pubkey',
389
405
  count: 1,
390
- why: 'X11 — core shells `wg pubkey` to derive a key, and tells the operator to brew install wireguard-tools',
406
+ why: 'X11 — core shells `wg pubkey` to derive a key, and tells the operator to brew install wireguard-tools (#944)',
391
407
  },
392
408
  {
393
409
  file: 'apps/celilo/src/services/firewall-reach.ts',
394
410
  literal: 'iptables-save',
395
411
  count: 1,
396
- why: "S8 — core parses a firewall's live ruleset; implementation-specific, not merely capability-specific",
412
+ why: "S8 — core parses a firewall's live ruleset; implementation-specific, not merely capability-specific (#941)",
397
413
  },
398
414
  {
399
415
  file: 'packages/capabilities/src/public-web.ts',
400
416
  literal: '/srv/www',
401
417
  count: 4,
402
- why: "X8 — the Caddyfile generator knows caddy's on-disk asset layout",
418
+ why: "X8 — the Caddyfile generator knows caddy's on-disk asset layout (#940)",
403
419
  },
404
420
  ];