@homeflare/alchemy 0.33.0 → 0.34.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 (50) hide show
  1. package/dist/grafana/alerting-provenance.d.ts +80 -0
  2. package/dist/grafana/alerting-provenance.d.ts.map +1 -0
  3. package/dist/grafana/contact-point-fixtures.d.ts +28 -0
  4. package/dist/grafana/contact-point-fixtures.d.ts.map +1 -0
  5. package/dist/grafana/contact-point.d.ts +99 -0
  6. package/dist/grafana/contact-point.d.ts.map +1 -0
  7. package/dist/grafana/dashboard-model.d.ts +8 -38
  8. package/dist/grafana/dashboard-model.d.ts.map +1 -1
  9. package/dist/grafana/datasource.d.ts +2 -9
  10. package/dist/grafana/datasource.d.ts.map +1 -1
  11. package/dist/grafana/index.d.ts +10 -4
  12. package/dist/grafana/index.d.ts.map +1 -1
  13. package/dist/grafana/index.js +198 -41
  14. package/dist/grafana/index.js.map +12 -6
  15. package/dist/grafana/message-template.d.ts +70 -0
  16. package/dist/grafana/message-template.d.ts.map +1 -0
  17. package/dist/grafana/mute-timing.d.ts +112 -0
  18. package/dist/grafana/mute-timing.d.ts.map +1 -0
  19. package/dist/grafana/providers.d.ts +1 -1
  20. package/dist/grafana/providers.d.ts.map +1 -1
  21. package/dist/grafana/secret-refs.d.ts +19 -0
  22. package/dist/grafana/secret-refs.d.ts.map +1 -0
  23. package/dist/grafana/subset-match.d.ts +2 -0
  24. package/dist/grafana/subset-match.d.ts.map +1 -0
  25. package/dist/linux/index.d.ts +1 -1
  26. package/dist/linux/index.d.ts.map +1 -1
  27. package/dist/linux/index.js +3 -1
  28. package/dist/linux/index.js.map +1 -1
  29. package/docs/grafana-alerting.md +145 -0
  30. package/docs/grafana.md +4 -3
  31. package/docs/upstream-conformance.md +26 -4
  32. package/package.json +1 -1
  33. package/src/grafana/alerting-provenance.ts +109 -0
  34. package/src/grafana/contact-point-fixtures.ts +25 -0
  35. package/src/grafana/contact-point-provenance.test.ts +113 -0
  36. package/src/grafana/contact-point-secrets.test.ts +117 -0
  37. package/src/grafana/contact-point.test.ts +123 -0
  38. package/src/grafana/contact-point.ts +179 -0
  39. package/src/grafana/dashboard-model.ts +8 -57
  40. package/src/grafana/datasource.ts +5 -27
  41. package/src/grafana/index.ts +24 -4
  42. package/src/grafana/message-template.test.ts +212 -0
  43. package/src/grafana/message-template.ts +114 -0
  44. package/src/grafana/mute-timing.test.ts +227 -0
  45. package/src/grafana/mute-timing.ts +160 -0
  46. package/src/grafana/providers.ts +6 -0
  47. package/src/grafana/secret-refs.ts +48 -0
  48. package/src/grafana/subset-match.ts +71 -0
  49. package/src/linux/index-export.test.ts +25 -0
  50. package/src/linux/index.ts +15 -1
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `Grafana.MuteTiming` — one Grafana alerting mute timing, keyed by `name` (there is no separate
3
+ * `uid` — `routeGetMuteTiming`/`routePutMuteTiming`/`routeDeleteMuteTiming` are all name-keyed on
4
+ * the wire, and `name` is the field a notification policy's own `mute_time_intervals` references).
5
+ *
6
+ * ★ `provenance`/`version` ARE READ THROUGH A WIDENED TYPE, NOT THE SDK's OWN `MuteTimeInterval` —
7
+ * MEASURED, and a genuinely surprising result worth recording in full. The SDK's generated
8
+ * `MuteTimeInterval` interface (`packages/distilled-grafana/src/services/grafana.ts`) declares
9
+ * only `name`/`time_intervals` — no `provenance`, no `version` — unlike `EmbeddedContactPoint`/
10
+ * `NotificationTemplate`/`ProvisionedAlertRule`/`Route`, which all carry `provenance`. A first
11
+ * pass at this file assumed that meant the data was gone: decoding a raw payload containing both
12
+ * fields through `S.decodeUnknownSync(MuteTimeInterval)` directly DOES strip them. But that is
13
+ * the wrong code path — it is not what `routeGetMuteTiming` itself does. Proven empirically by
14
+ * calling the REAL operation (through `fake-grafana.ts`'s harness, the exact path this file's own
15
+ * `fetchLive` uses) with a response body containing `provenance`/`version`: both come through on
16
+ * the decoded value intact. The mechanism, read from `@distilled.cloud/core@1.0.0-rc.12`'s
17
+ * `protocol-rest.ts`/`protocol-http.ts` (the runtime this SDK's every operation shares): response
18
+ * decoding is `JSON.parse` + `mapKeys` (renames only the field names a schema DOES declare) +
19
+ * `wrapSensitive` (wraps only fields a schema marks sensitive in `Redacted`) — never a strict
20
+ * `Schema.decode`. `protocol-http.ts`'s `mapKeys`, object branch: every response key NOT among a
21
+ * type's declared `props` is copied to the output verbatim in a second pass
22
+ * (`for (const [k, v] of Object.entries(value)) { if (consumed.has(k)) continue; out[k] = v; }`,
23
+ * line ~297-311) — the doc comment on the sibling `wrapSensitive` states the same rule plainly:
24
+ * "Keys the schema doesn't model pass through verbatim." So the TS type is simply narrower than
25
+ * the runtime value; `MuteTimeIntervalWire` below is that same runtime value, typed honestly.
26
+ * This is a real, worth-fixing SDK type-generation gap (tracked in `docs/upstream-conformance.md`,
27
+ * same S22 route as the other named gaps) but NOT a data-loss gap — unlike, say, a field the wire
28
+ * protocol itself never sent, this one is fully recoverable without touching vendored `src/`.
29
+ *
30
+ * The same test proved the outbound direction too: an object with extra keys (`weekdays`, `times`
31
+ * — the real Alertmanager time-interval fields Grafana's OpenAPI's own `TimeInterval` schema
32
+ * apparently fails to model either, for the same reason) passed to `routePutMuteTiming` reaches
33
+ * the wire body unchanged — `mapKeys`'s encode direction is the same verbatim-passthrough rule.
34
+ * `MuteTimingProps.timeIntervals` is therefore typed as the opaque `Record<string, unknown>`
35
+ * shape actually needed to declare a real mute timing (weekdays/times/months/etc.), not the
36
+ * SDK's own too-narrow `TimeInterval`, and cast at the two call sites below.
37
+ *
38
+ * ⚠️ THE EVIDENCE ABOVE IS A HAND-AUTHORED FIXTURE, NOT A CAPTURED REAL RESPONSE — flagged by an
39
+ * adversarial review of this PR. The estate has zero live mute timings to observe (grafana.md's
40
+ * census), so nothing confirms Grafana always includes `provenance` on this route in practice,
41
+ * or that some other SDK/version edge case never drops it. That is exactly why `attributes()`
42
+ * below never coerces a missing `live.provenance` to `''` — see alerting-provenance.ts's "fails
43
+ * open" note — an absent field refuses a write rather than being trusted as `ProvenanceNone`.
44
+ *
45
+ * ⛔ A FOREIGN-PROVENANCE MUTE TIMING REFUSES EVERY UPDATE AND DESTROY — `alerting-provenance.ts`,
46
+ * shared with `Grafana.ContactPoint`/`MessageTemplate`/`AlertRuleGroup`/`NotificationPolicy`; see
47
+ * that file's header. `create` is never at risk: it only runs when `fetchLive` already found
48
+ * nothing with this `name`.
49
+ *
50
+ * ⚠️ `version` IS SENT ON DESTROY, NOT ON UPDATE — MEASURED: `RouteDeleteMuteTimingRequest.version`
51
+ * ("Version of mute timing to use for optimistic concurrency. Leave empty to disable validation")
52
+ * exists; `RoutePutMuteTimingRequest` has no `version` field at all, so an update has nothing to
53
+ * send for it — a real Grafana API asymmetry between the two write routes, not an omission here.
54
+ *
55
+ * ⚠️ `X-Disable-Provenance` IS NEVER SET — same reasoning as `contact-point.ts`'s file header.
56
+ */
57
+ import { Resource } from 'alchemy';
58
+ import * as Provider from 'alchemy/Provider';
59
+ import * as grafana from '@distilled.cloud/grafana';
60
+ import * as Effect from 'effect/Effect';
61
+ import {
62
+ type GrafanaProvisionedObjectError,
63
+ isForeignProvenance,
64
+ refuseIfForeignProvenance,
65
+ } from './alerting-provenance.ts';
66
+ import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
67
+ import { declaredContentMatches } from './subset-match.ts';
68
+
69
+ export interface MuteTimingProps {
70
+ name: string;
71
+ /** Grafana's Alertmanager-style time-interval entries (`weekdays`/`times`/`months`/…), passed
72
+ * through opaquely — see the file header for why this is not the SDK's own `TimeInterval`. */
73
+ timeIntervals: ReadonlyArray<Record<string, unknown>>;
74
+ }
75
+
76
+ export interface MuteTimingAttributes {
77
+ name: string;
78
+ timeIntervals: ReadonlyArray<Record<string, unknown>>;
79
+ /** `""`/`"api"` (this family may write); anything else, INCLUDING `undefined` (Grafana didn't
80
+ * report the field, or the widened-type passthrough this file depends on turns out not to hold
81
+ * for some response shape not yet observed), refuses — see alerting-provenance.ts's "fails
82
+ * open" note. This is the field where that default matters most: unlike the other resources in
83
+ * this family, `provenance` here is not even in the SDK's declared type. */
84
+ provenance: string | undefined;
85
+ }
86
+
87
+ export interface GrafanaMuteTiming extends Resource<
88
+ 'Grafana.MuteTiming',
89
+ MuteTimingProps,
90
+ MuteTimingAttributes,
91
+ never
92
+ > {}
93
+
94
+ export const GrafanaMuteTiming = Resource<GrafanaMuteTiming>('Grafana.MuteTiming');
95
+
96
+ /** The real runtime shape of a `GET`/list response — see the file header's "measured" note. */
97
+ type MuteTimeIntervalWire = grafana.MuteTimeInterval & {
98
+ provenance?: string;
99
+ version?: string;
100
+ };
101
+
102
+ export const spec: GrafanaSpec<
103
+ MuteTimingProps,
104
+ MuteTimeIntervalWire,
105
+ MuteTimingAttributes,
106
+ | grafana.RouteGetMuteTimingError
107
+ | grafana.RoutePostMuteTimingError
108
+ | grafana.RoutePutMuteTimingError
109
+ | grafana.RouteDeleteMuteTimingError
110
+ | GrafanaProvisionedObjectError
111
+ > = {
112
+ attributes: (live) => ({
113
+ name: live.name ?? '',
114
+ // ⛔ NEVER `?? ''` — see alerting-provenance.ts's "fails open" note, and this file's own
115
+ // Attributes doc comment on why it matters most here.
116
+ provenance: live.provenance,
117
+ timeIntervals: (live.time_intervals ?? []) as unknown as ReadonlyArray<Record<string, unknown>>,
118
+ }),
119
+ create: (props) =>
120
+ grafana.routePostMuteTiming({
121
+ name: props.name,
122
+ time_intervals:
123
+ props.timeIntervals as unknown as grafana.RoutePostMuteTimingRequestTimeIntervalsList,
124
+ }),
125
+ destroy: (props, live) =>
126
+ Effect.gen(function* () {
127
+ if (isForeignProvenance(live.provenance)) {
128
+ return yield* refuseIfForeignProvenance('Grafana.MuteTiming', props.name, live.provenance);
129
+ }
130
+ yield* grafana.routeDeleteMuteTiming({
131
+ name: props.name,
132
+ ...(live.version === undefined ? {} : { version: live.version }),
133
+ });
134
+ }),
135
+ fetchLive: (props) =>
136
+ grafana.routeGetMuteTiming({ name: props.name }).pipe(
137
+ Effect.map((live) => live as MuteTimeIntervalWire),
138
+ Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
139
+ ),
140
+ /** Subset match (subset-match.ts), not plain equality — tolerates a default Grafana might
141
+ * inject into a time-interval entry the declaration never set. */
142
+ matches: (attributes, props) =>
143
+ declaredContentMatches(props.timeIntervals, attributes.timeIntervals),
144
+ update: (props, live) =>
145
+ Effect.gen(function* () {
146
+ if (isForeignProvenance(live.provenance)) {
147
+ return yield* refuseIfForeignProvenance('Grafana.MuteTiming', props.name, live.provenance);
148
+ }
149
+ yield* grafana.routePutMuteTiming({
150
+ name: props.name,
151
+ time_intervals:
152
+ props.timeIntervals as unknown as grafana.RoutePutMuteTimingRequestTimeIntervalsList,
153
+ });
154
+ }),
155
+ };
156
+
157
+ export const handlers = grafanaHandlers(spec);
158
+
159
+ export const GrafanaMuteTimingProvider = () =>
160
+ Provider.effect(GrafanaMuteTiming, Effect.succeed(GrafanaMuteTiming.Provider.of(handlers)));
@@ -12,14 +12,20 @@
12
12
  * deploys anything over HTTP already has one.
13
13
  */
14
14
  import * as Layer from 'effect/Layer';
15
+ import { GrafanaContactPointProvider } from './contact-point.ts';
15
16
  import { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
16
17
  import { GrafanaDashboardProvider } from './dashboard.ts';
17
18
  import { GrafanaDatasourceProvider } from './datasource.ts';
18
19
  import { GrafanaFolderProvider } from './folder.ts';
20
+ import { GrafanaMessageTemplateProvider } from './message-template.ts';
21
+ import { GrafanaMuteTimingProvider } from './mute-timing.ts';
19
22
 
20
23
  export const grafanaProviders = (target: GrafanaTarget) =>
21
24
  Layer.mergeAll(
22
25
  GrafanaDatasourceProvider(),
23
26
  GrafanaFolderProvider(),
24
27
  GrafanaDashboardProvider(),
28
+ GrafanaContactPointProvider(),
29
+ GrafanaMuteTimingProvider(),
30
+ GrafanaMessageTemplateProvider(),
25
31
  ).pipe(Layer.provide(grafanaCredentials(target)));
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared "secret stays in env, never in state" resolution (S25) for this family —
3
+ * `Grafana.Datasource`'s `secureJsonDataRefs` and `Grafana.ContactPoint`'s `secureSettingsRefs`
4
+ * are the identical shape: a map of field name to an env var NAME, resolved fresh from
5
+ * `process.env` inside the caller's own create/update effect (S24), never at plan time and never
6
+ * returned in `attributes`. Extracted here once a second family needed the exact same loop
7
+ * `datasource.ts` already had — see that file's original note for why this exists at all:
8
+ * Grafana's own read APIs never return a secret value, only whether one is set
9
+ * (`DataSource.secureJsonFields`'s booleans; a contact point's `settings` comes back with secure
10
+ * keys replaced by a literal redaction placeholder instead — see contact-point.ts).
11
+ */
12
+ import * as Data from 'effect/Data';
13
+ import * as Effect from 'effect/Effect';
14
+
15
+ /** A named secure-field env var is unset — a domain refusal, not a distilled error. */
16
+ export class GrafanaSecretRefUnsetError extends Data.TaggedError('GrafanaSecretRefUnsetError')<{
17
+ readonly message: string;
18
+ }> {}
19
+
20
+ /**
21
+ * Reads every named env var fresh, inside the caller's effect (S24) — never at plan time.
22
+ * `describeField` builds the refusal message's own field-specific phrase — each caller names its
23
+ * declaration path differently (`secureJsonData.<field>` for a datasource,
24
+ * `settings.<field>` for a contact point) — so the message stays accurate to what the caller
25
+ * itself declares.
26
+ */
27
+ export const resolveSecretRefs = (
28
+ refs: Record<string, string> | undefined,
29
+ describeField: (field: string) => string,
30
+ ): Effect.Effect<Record<string, string> | undefined, GrafanaSecretRefUnsetError> =>
31
+ Effect.gen(function* () {
32
+ if (refs === undefined) return undefined;
33
+ const out: Record<string, string> = {};
34
+ for (const [field, envVar] of Object.entries(refs)) {
35
+ const raw = process.env[envVar];
36
+ if (raw === undefined || raw.trim() === '') {
37
+ return yield* Effect.fail(
38
+ new GrafanaSecretRefUnsetError({
39
+ message:
40
+ `${envVar} is unset. Export the value of ${describeField(field)} there — ` +
41
+ 'never as an Alchemy prop.',
42
+ }),
43
+ );
44
+ }
45
+ out[field] = raw.trim();
46
+ }
47
+ return out;
48
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `declaredContentMatches` — "is the declaration a subset of what is live", not plain equality.
3
+ * Extracted out of `dashboard-model.ts` (where it was introduced, after an adversarial review of
4
+ * that PR found plain structural equality made an ordinary, never-edited dashboard show `update`
5
+ * FOREVER) once a second shape needed the identical rule: `Grafana.ContactPoint`'s `settings` is
6
+ * the same story — Grafana can inject default fields into a contact point's settings that the
7
+ * declaration never mentioned, and `Grafana.AlertRuleGroup`'s per-rule `data`/`annotations`/
8
+ * `labels` maps carry the same risk. Nothing about the reasoning below is dashboard-specific;
9
+ * `dashboard-model.ts` now just re-exports this.
10
+ *
11
+ * ★ `declared` NEED ONLY BE A SUBSET OF `live`. Grafana's own save/decode path can decorate an
12
+ * object with fields the declaration never set (a dashboard panel's auto-resolved `datasource`
13
+ * reference is the documented example dashboard-model.ts found — Grafana's schema migration
14
+ * decorates any panel/target whose `datasource` is absent with an explicit `{type, uid}`
15
+ * reference on save). This function instead asks only "does every field the DECLARATION
16
+ * mentions, at any depth, still match what is live" — `live` may carry additional fields, at any
17
+ * depth, that `declared` never mentioned, without being compared at all. A field `declared` DOES
18
+ * mention that is missing, or different, in `live` still fails the match — real content drift is
19
+ * still caught exactly as before; only fields the declaration is silent about are now tolerated.
20
+ *
21
+ * ⚠️ THE TRADE-OFF THIS ACCEPTS, DELIBERATELY: removing a field from the declaration (rather than
22
+ * changing its value) can no longer force Grafana to drop it. If a previous declaration set a
23
+ * field and a later one deletes that key entirely, `live` keeps whatever the last write set — the
24
+ * declaration is silent about that key now, so nothing compares it, so no `update` ever fires to
25
+ * change it. This is the same "undeclared means don't care, never means force-to-default"
26
+ * convention already used everywhere else in this kit for a plain optional prop; it now also
27
+ * applies inside a nested JSON value, not just at the top-level props. Declare an explicit
28
+ * replacement value to change a field; omission never clears one that was set by a previous
29
+ * declaration or by Grafana itself.
30
+ *
31
+ * ⚠️ ARRAYS COMPARE BY LENGTH, THEN ELEMENT-WISE SUBSET — never a looser "declared has fewer
32
+ * items" rule. A caller whose array IS order-significant (dashboard `panels`/`targets`) or
33
+ * position-keyed (an alert rule group's `rules`) gets that for free; a caller with a genuinely
34
+ * unordered array would need its own normalization first (this file has no `sortedSet`, and
35
+ * nothing in this kit's Grafana family needs one — see dashboard-model.ts's own note).
36
+ *
37
+ * ⛔ NOT RE-MEASURED FOR EVERY CALLER — flagged by an adversarial review of this PR. The exact
38
+ * length rule is confirmed for dashboards (the original, documented case), but whether Grafana
39
+ * ever PADS or APPENDS a default entry to an array the way it decorates an object field (the
40
+ * `datasource` auto-decoration above) is unverified for `Grafana.MuteTiming`'s `time_intervals`
41
+ * specifically — no live mute timing exists in this house to observe (grafana.md's census), and
42
+ * `mute-timing.test.ts`'s own fixtures are hand-authored, not captured. If a future live
43
+ * observation finds Grafana appending an entry (e.g. a normalized default time range) to a
44
+ * declared `time_intervals` array, the length check here would show a permanent false `update` —
45
+ * the exact regression this whole file exists to prevent for OBJECT fields already. `mute-timing.
46
+ * test.ts`'s "array length is exact, not tolerant" test pins today's behavior so that fix has an
47
+ * obvious, single place to change (relax this rule, or give the mute-timing caller its own
48
+ * length-tolerant wrapper) rather than being rediscovered from scratch.
49
+ * ⚠️ `null` ON EITHER SIDE OF A LEAF NEVER MISMATCHES (mirrors `alchemy/Diff`'s `deepEqual` with
50
+ * `stripNullish: true`, which this function replaces wherever a caller needs subset semantics).
51
+ */
52
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
53
+ typeof value === 'object' && value !== null && !Array.isArray(value);
54
+
55
+ export const declaredContentMatches = (declared: unknown, live: unknown): boolean => {
56
+ if (declared == null) return true;
57
+ if (Array.isArray(declared)) {
58
+ return (
59
+ Array.isArray(live) &&
60
+ declared.length === live.length &&
61
+ declared.every((item, index) => declaredContentMatches(item, live[index]))
62
+ );
63
+ }
64
+ if (isPlainObject(declared)) {
65
+ return (
66
+ isPlainObject(live) &&
67
+ Object.entries(declared).every(([key, value]) => declaredContentMatches(value, live[key]))
68
+ );
69
+ }
70
+ return declared === live;
71
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `renderContainerFile`/`containerPathFor` are reachable from the package entry — the same pure
3
+ * `.container` renderer `container-fixture.test.ts` (container-form.ts) already proves internally,
4
+ * now callable by a CONSUMER without a deep import.
5
+ *
6
+ * ★ WHY THIS WAS MISSING. `index.ts`'s barrel forwarded `QUADLET_DEFAULT_DIRECTORY`/
7
+ * `QUADLET_SEARCH_DIRECTORIES` (constants) but not the renderer or the path rule, unlike
8
+ * `Systemd.Unit`'s own pure helpers (`renderUnit`/`DEFAULT_UNIT_DIRECTORY`), which ARE both on
9
+ * the barrel. A consumer (homeflare-ct100 PR #1) writing an equivalence test against a live
10
+ * host — the same kind of test this package's own fixture tests are — had no import path to
11
+ * the real renderer and had to reimplement the render contract by hand to write one at all.
12
+ */
13
+ import { expect, test } from 'bun:test';
14
+ import { containerPathFor, renderContainerFile } from './index.ts';
15
+
16
+ test('renderContainerFile and containerPathFor are reachable from the package entry', () => {
17
+ const rendered = renderContainerFile({
18
+ container: { image: 'docker.io/library/busybox:1', network: 'host' },
19
+ name: 'smoke',
20
+ });
21
+ expect(rendered).toContain('[Container]');
22
+ expect(rendered).toContain('Image=docker.io/library/busybox:1');
23
+ expect(rendered).toContain('Network=host');
24
+ expect(containerPathFor({ name: 'smoke' })).toBe('/etc/containers/systemd/smoke.container');
25
+ });
@@ -38,5 +38,19 @@ export type {
38
38
  PodmanContainerProps,
39
39
  PodmanUnitLine,
40
40
  } from './container.ts';
41
- export { QUADLET_DEFAULT_DIRECTORY, QUADLET_SEARCH_DIRECTORIES } from './container-form.ts';
41
+ // ★ `renderContainerFile`/`containerPathFor` are the pure `.container` file renderer and its path
42
+ // rule — the same kind of pure helper `renderUnit`/`DEFAULT_UNIT_DIRECTORY` above already are
43
+ // for `Systemd.Unit`. A consumer needs these to prove ITS OWN declared props render into the
44
+ // directive set it expects (an equivalence/fixture test against a live host), the same way this
45
+ // package's own container-fixture.test.ts does internally — without them, a consuming repo has
46
+ // no import path to the real renderer and has to reimplement it to write that test at all
47
+ // (found: homeflare-ct100 PR #1). `containerProblems`/`refuseContainer`/`serviceNameFor` stay
48
+ // internal — no consumer need for those was raised, and validation/naming internals are a wider
49
+ // surface to commit to than one pure render function.
50
+ export {
51
+ containerPathFor,
52
+ QUADLET_DEFAULT_DIRECTORY,
53
+ QUADLET_SEARCH_DIRECTORIES,
54
+ renderContainerFile,
55
+ } from './container-form.ts';
42
56
  export { PodmanContainer, PodmanContainerProvider } from './container.ts';