@homeflare/alchemy 0.7.0 → 0.8.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 (137) hide show
  1. package/README.md +26 -37
  2. package/dist/caddy/config-lifecycle.d.ts +49 -3
  3. package/dist/caddy/config-lifecycle.d.ts.map +1 -1
  4. package/dist/caddy/config.d.ts +6 -2
  5. package/dist/caddy/config.d.ts.map +1 -1
  6. package/dist/caddy/fake-caddy.d.ts +11 -0
  7. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  8. package/dist/caddy/index.d.ts +2 -1
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +71 -13
  11. package/dist/caddy/index.js.map +9 -9
  12. package/dist/caddy/with-file.d.ts +7 -3
  13. package/dist/caddy/with-file.d.ts.map +1 -1
  14. package/dist/launchd/fake-sudo.d.ts +50 -0
  15. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  16. package/dist/launchd/host-effect.d.ts +18 -9
  17. package/dist/launchd/host-effect.d.ts.map +1 -1
  18. package/dist/launchd/host-file-form.d.ts +5 -0
  19. package/dist/launchd/host-file-form.d.ts.map +1 -1
  20. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  21. package/dist/launchd/index.d.ts +2 -0
  22. package/dist/launchd/index.d.ts.map +1 -1
  23. package/dist/launchd/index.js +342 -8
  24. package/dist/launchd/index.js.map +15 -11
  25. package/dist/launchd/job-preflight.d.ts.map +1 -1
  26. package/dist/launchd/job-validate.d.ts +2 -0
  27. package/dist/launchd/job-validate.d.ts.map +1 -1
  28. package/dist/launchd/local-runner.d.ts +2 -0
  29. package/dist/launchd/local-runner.d.ts.map +1 -1
  30. package/dist/launchd/providers.d.ts +2 -1
  31. package/dist/launchd/providers.d.ts.map +1 -1
  32. package/dist/launchd/runner.d.ts +7 -5
  33. package/dist/launchd/runner.d.ts.map +1 -1
  34. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  35. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  36. package/dist/launchd/sudo-guard.d.ts +56 -0
  37. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  38. package/dist/launchd/sudo-runner.d.ts +60 -0
  39. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  40. package/dist/launchd/sudo-stage.d.ts +7 -0
  41. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  42. package/dist/openbao/auth-role-form.d.ts +0 -9
  43. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  44. package/dist/openbao/auth-role.d.ts +3 -2
  45. package/dist/openbao/auth-role.d.ts.map +1 -1
  46. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  47. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  48. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  49. package/dist/openbao/fake-engines.d.ts +8 -2
  50. package/dist/openbao/fake-engines.d.ts.map +1 -1
  51. package/dist/openbao/fake-families.d.ts +49 -0
  52. package/dist/openbao/fake-families.d.ts.map +1 -0
  53. package/dist/openbao/host-approles.d.ts.map +1 -1
  54. package/dist/openbao/index.js +359 -280
  55. package/dist/openbao/index.js.map +21 -19
  56. package/dist/openbao/jwt-config.d.ts +5 -0
  57. package/dist/openbao/jwt-config.d.ts.map +1 -1
  58. package/dist/openbao/jwt-role.d.ts +2 -1
  59. package/dist/openbao/jwt-role.d.ts.map +1 -1
  60. package/dist/openbao/kubernetes-role.d.ts +2 -1
  61. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  62. package/dist/openbao/mfa-enforcement.d.ts +6 -3
  63. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  64. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  65. package/dist/openbao/pki-role.d.ts.map +1 -1
  66. package/dist/openbao/plugin.d.ts.map +1 -1
  67. package/dist/openbao/policy.d.ts.map +1 -1
  68. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  69. package/dist/openbao/rename-identity.d.ts +69 -0
  70. package/dist/openbao/rename-identity.d.ts.map +1 -0
  71. package/dist/openbao/rename.d.ts +13 -12
  72. package/dist/openbao/rename.d.ts.map +1 -1
  73. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  74. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  75. package/dist/openbao/ssh-role.d.ts.map +1 -1
  76. package/docs/caddy-admin.md +24 -0
  77. package/docs/caddy.md +73 -54
  78. package/docs/launchd-sudo.md +122 -0
  79. package/docs/launchd.md +7 -4
  80. package/docs/peers.md +39 -0
  81. package/package.json +1 -1
  82. package/src/caddy/adopt-scope.test.ts +82 -0
  83. package/src/caddy/config-adopt.test.ts +235 -0
  84. package/src/caddy/config-lifecycle.test.ts +31 -53
  85. package/src/caddy/config-lifecycle.ts +98 -7
  86. package/src/caddy/config.ts +48 -11
  87. package/src/caddy/fake-caddy.ts +19 -0
  88. package/src/caddy/index.ts +2 -1
  89. package/src/caddy/providers.test.ts +8 -23
  90. package/src/caddy/with-file.ts +7 -3
  91. package/src/launchd/fake-sudo.ts +136 -0
  92. package/src/launchd/host-effect.ts +33 -1
  93. package/src/launchd/host-file-form.ts +10 -2
  94. package/src/launchd/host-file-lifecycle.ts +2 -1
  95. package/src/launchd/index.ts +2 -0
  96. package/src/launchd/job-preflight.ts +3 -2
  97. package/src/launchd/job-validate.ts +1 -1
  98. package/src/launchd/local-runner.ts +6 -3
  99. package/src/launchd/providers.test.ts +3 -0
  100. package/src/launchd/providers.ts +2 -1
  101. package/src/launchd/runner.ts +7 -5
  102. package/src/launchd/sudo-allowlist.test.ts +191 -0
  103. package/src/launchd/sudo-allowlist.ts +213 -0
  104. package/src/launchd/sudo-guard.test.ts +100 -0
  105. package/src/launchd/sudo-guard.ts +151 -0
  106. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  107. package/src/launchd/sudo-runner-files.test.ts +240 -0
  108. package/src/launchd/sudo-runner.test.ts +126 -0
  109. package/src/launchd/sudo-runner.ts +235 -0
  110. package/src/launchd/sudo-stage.ts +28 -0
  111. package/src/openbao/README.md +8 -2
  112. package/src/openbao/REPLACE.md +65 -37
  113. package/src/openbao/auth-role-form.test.ts +2 -16
  114. package/src/openbao/auth-role-form.ts +0 -11
  115. package/src/openbao/auth-role.ts +35 -14
  116. package/src/openbao/cloudflare-role.ts +6 -14
  117. package/src/openbao/fake-engines-roles.ts +114 -0
  118. package/src/openbao/fake-engines.ts +18 -11
  119. package/src/openbao/fake-families.ts +182 -0
  120. package/src/openbao/host-approles.test.ts +15 -2
  121. package/src/openbao/host-approles.ts +19 -4
  122. package/src/openbao/jwt-config.ts +26 -5
  123. package/src/openbao/jwt-role.ts +24 -8
  124. package/src/openbao/kubernetes-role.ts +20 -8
  125. package/src/openbao/mfa-enforcement.ts +23 -8
  126. package/src/openbao/mfa-totp.ts +21 -8
  127. package/src/openbao/pki-role.ts +19 -7
  128. package/src/openbao/plugin.ts +41 -15
  129. package/src/openbao/policy.ts +7 -7
  130. package/src/openbao/proxmox-role.ts +7 -16
  131. package/src/openbao/rename-adoption.test.ts +82 -0
  132. package/src/openbao/rename-families.test.ts +122 -0
  133. package/src/openbao/rename-identity.test.ts +129 -0
  134. package/src/openbao/rename-identity.ts +117 -0
  135. package/src/openbao/rename.ts +19 -15
  136. package/src/openbao/ssh-role-erase.ts +50 -0
  137. package/src/openbao/ssh-role.ts +16 -54
package/docs/launchd.md CHANGED
@@ -112,8 +112,11 @@ The `system` domain, and another user's `gui/<uid>`, need root. The provider ref
112
112
  unless the runner is root (`effectiveUid() === 0`) or was deliberately built with
113
113
  `privileged: true`. `localRunner()` — the default — is never privileged and never calls `sudo`, so:
114
114
 
115
- - **system daemons:** start the whole deploy as root; or pass `launchdProviders(yourRunner)` where
116
- `yourRunner` is a `HostRunner` you wrote that already holds root (a root helper, say).
115
+ - **system daemons, deployed as yourself:** `launchdProviders(sudoRunner({ prefixes }))` sends only
116
+ the privileged calls through `sudo -n`, from a fixed allowlist, logging each. It never prompts.
117
+ See [launchd-sudo.md](./launchd-sudo.md).
118
+ - **system daemons, deployed as root:** start the whole deploy as root; or pass
119
+ `launchdProviders(yourRunner)` where `yourRunner` is a `HostRunner` that already holds root.
117
120
  - **your own agents** (`gui/<your uid>`): no root needed.
118
121
 
119
122
  Reading (`launchctl print`, `print-disabled`) needs no root. ⚠️ A `HostFile` whose mode hides it
@@ -163,5 +166,5 @@ Nix job is booted out first. Per job:
163
166
  `HostRunner` (see `src/launchd/runner.ts`) is `exec(argv)` (never through a shell), `readFile`,
164
167
  `stat` (lstat), `writeFileAtomic`, `removeFile`, `lookupUser`, `lookupGroup`, `sleep`, plus
165
168
  `privileged` and `effectiveUid()`. `localRunner()` implements it with `node:fs` and
166
- `node:child_process`; tests use an in-memory fake, so nothing in the test suite runs `launchctl` or
167
- writes outside a temp directory.
169
+ `node:child_process`; `sudoRunner()` wraps it and elevates the allowlist. Tests use an in-memory
170
+ fake, so nothing in the test suite runs `launchctl` or `sudo`, or writes outside a temp directory.
package/docs/peers.md ADDED
@@ -0,0 +1,39 @@
1
+ # Peers — and why each pin exists
2
+
3
+ The install line for `@homeflare/alchemy`, and why every peer on it and every entry in the
4
+ `overrides` block is required. The block itself, which every consumer copies, is in the
5
+ [README](../README.md#peers--and-one-override-you-need).
6
+
7
+ ```sh
8
+ bun add @homeflare/alchemy alchemy@2.0.0-beta.79 effect@4.0.0-rc.115 \
9
+ @effect/platform-node@4.0.0-rc.115 cloudflare@4.5.0 mime@4.1.0 \
10
+ @distilled.cloud/cloudflare@1.0.0-rc.12
11
+ ```
12
+
13
+ 🔴 **Why, measured 2026-09-16 on 0.1.0 and re-checked 2026-09-17 against Alchemy 78.**
14
+ Effect's `rc` line is not semver-compatible with itself. Alchemy 78's peer is
15
+ `effect >= 4.0.0-rc.115`; an unlocked `@effect/platform-node-shared` still floats to the
16
+ next rc and breaks at import. Pin the whole set.
17
+
18
+ | what resolves | what happens |
19
+ | ------------------------------------------------------------- | ------------------------------------------------------ |
20
+ | Alchemy 77 + `effect` → rc.115 (measured 2026-09-16) | `TypeError: Config.string is not a function` at import |
21
+ | `@effect/platform-node-shared` newer than the pinned `effect` | `Cannot find module 'effect/ByteSize'` |
22
+ | `rolldown` → 1.2.9 via vite's `~1.2.6` (measured 2026-09-16) | `GET …/rolldown-1.2.9.tgz - 404` at `bun add` |
23
+ | no `mime` (measured 2026-09-17 against Alchemy 78) | `Cannot find package 'mime'` from cloudflare-runtime |
24
+
25
+ Alchemy 78 adapted to rc.115 — that first row is why we used to pin 112, not a reason to
26
+ stay there. The override is still the only thing that holds the set together.
27
+
28
+ ⚠️ `@effect/platform-node` is **required, not optional**: Alchemy's module graph reaches
29
+ `Cloudflare/Workers/WorkerBridge → @effect/platform-node/NodeServices` even when you only
30
+ import the Proxmox subpath.
31
+
32
+ ⚠️ So is `mime`. Alchemy 78's `@alchemy.run/cloudflare-runtime` imported it without
33
+ declaring it (measured 2026-09-17). 79 declares it; the peer stays so a consumer
34
+ that followed the 78 README does not drop a required line.
35
+
36
+ ⚠️ So is `cloudflare`. It was marked optional in 0.1.1, which claimed the `/cloudflare`
37
+ subpath would degrade without it — measured 2026-09-16, the subpath does not load at all:
38
+ `Cannot find package 'cloudflare'`. An optional peer should mean a feature is absent, not
39
+ that an import fails.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A resource-scoped `adopt(…)` at apply — the create Alchemy never probed (caddyWithFile's first
3
+ * deploy). The planner resolves adoption as `resource.Adopt ?? --adopt` (alchemy beta.79 Plan.ts);
4
+ * the apply-time check must resolve it the same way, BOTH ways, or `--adopt` would take over a
5
+ * Caddy the stack declared `.pipe(adopt(false))`.
6
+ * ★ Registered through the REAL `Resource` (caddyWithFile under a Stack), not a hand-built map: the
7
+ * lookup reads what Alchemy records at registration, so an upgrade that stops recording `Adopt`
8
+ * in `Stack.resources` fails here (host-effect.ts adoptEnabled).
9
+ */
10
+ import { afterEach, expect, test } from 'bun:test';
11
+ import { push } from 'alchemy';
12
+ import { AdoptPolicy, adopt } from 'alchemy/AdoptPolicy';
13
+ import { Stack } from 'alchemy/Stack';
14
+ import * as Effect from 'effect/Effect';
15
+ import * as Layer from 'effect/Layer';
16
+ import { caddyAdminLayer } from './admin.ts';
17
+ import { CaddyConfig, CaddyConfigProvider } from './config.ts';
18
+ import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
19
+ import { caddyWithFile } from './with-file.ts';
20
+
21
+ const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
22
+ /** A config someone else loaded: it serves a site, and it is not what the stack declares. */
23
+ const FOREIGN = { apps: { http: { servers: { legacy: { listen: [':80'] } } } } };
24
+
25
+ let fake: FakeCaddy | undefined;
26
+ afterEach(() => {
27
+ fake?.stop();
28
+ fake = undefined;
29
+ });
30
+
31
+ type Scope = 'adopt(true)' | 'adopt(false)' | 'none';
32
+
33
+ /**
34
+ * Declare caddyWithFile with `scope` under a deploy whose `--adopt` is `flag` — the session provides
35
+ * AdoptPolicy to registration AND apply, as Alchemist does — then apply the CaddyConfig with no state.
36
+ */
37
+ const applyFirstDeploy = (scope: Scope, flag: boolean) => {
38
+ const made = fakeDefaultCaddy(FOREIGN);
39
+ fake = made.caddy;
40
+ const spec = { actions: {}, bindings: {}, name: 'test', resources: {}, stage: 'test' };
41
+ const declared = caddyWithFile('caddy', { caddyfile: SITE, path: '/usr/local/etc/Caddyfile' });
42
+ const scoped =
43
+ scope === 'none'
44
+ ? declared
45
+ : (declared.pipe(adopt(scope === 'adopt(true)')) as typeof declared);
46
+ const program = Effect.gen(function* () {
47
+ // ★ Under a namespace, so the FQN (`edge/caddy`) differs from the logical id (`caddy`).
48
+ yield* push('edge', scoped);
49
+ const provider = yield* CaddyConfig.Provider;
50
+ return yield* provider.reconcile({
51
+ bindings: [] as never,
52
+ fqn: 'edge/caddy',
53
+ id: 'caddy',
54
+ instanceId: 'i-1',
55
+ news: { caddyfile: SITE, sourceFile: '/usr/local/etc/Caddyfile' },
56
+ olds: undefined,
57
+ output: undefined,
58
+ session: undefined as never,
59
+ });
60
+ }).pipe(
61
+ Effect.provide(CaddyConfigProvider().pipe(Layer.provide(caddyAdminLayer(made.admin)))),
62
+ Effect.provideService(AdoptPolicy, flag),
63
+ Effect.provideService(Stack, spec as never),
64
+ );
65
+ return { applying: Effect.runPromise(program as Effect.Effect<unknown, unknown>), spec };
66
+ };
67
+
68
+ const loads = () => fake?.seen.filter((call) => call.path === '/load') ?? [];
69
+
70
+ test.each([
71
+ ['adopt(false) under --adopt', 'adopt(false)', true, false],
72
+ ['adopt(true) without --adopt', 'adopt(true)', false, true],
73
+ ['no scope under --adopt', 'none', true, true],
74
+ ['no scope without --adopt', 'none', false, false],
75
+ ] as const)('a first deploy with %s', async (_, scope, flag, loaded) => {
76
+ const { applying, spec } = applyFirstDeploy(scope, flag);
77
+ if (loaded) await applying;
78
+ else await expect(applying).rejects.toThrow(/which this stack did not load.*--adopt/);
79
+ // ★ The lookup key is the FQN the engine hands reconcile — the one registration used.
80
+ expect(Object.keys(spec.resources)).toContain('edge/caddy');
81
+ expect(loads()).toHaveLength(loaded ? 1 : 0);
82
+ });
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Ownership with no state — decision 2026-09-21: CaddyConfig never adopts silently. The adoption
3
+ * probe (`read` with no output) and the apply of a create the engine never probed must agree on
4
+ * what is claimable: the declared config itself, or a Caddy serving nothing. Anything else is
5
+ * `Unowned` at plan time and refused at apply, unless the deploy runs with `--adopt` — and state
6
+ * lends authority only over the Caddy it was applied to.
7
+ */
8
+ import { afterEach, describe, expect, test } from 'bun:test';
9
+ import { AdoptPolicy, Unowned } from 'alchemy/AdoptPolicy';
10
+ import { AlchemyContext } from 'alchemy/AlchemyContext';
11
+ import * as Effect from 'effect/Effect';
12
+ import * as Layer from 'effect/Layer';
13
+ import { type CaddyAdmin, CaddyUnreachableError, caddyAdminLayer } from './admin.ts';
14
+ import { CaddyConfig, CaddyConfigProvider } from './config.ts';
15
+ import { probeLive, reconcileConfig } from './config-lifecycle.ts';
16
+ import { configDigest } from './digest.ts';
17
+ import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
18
+
19
+ const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
20
+ /** A config someone else loaded: it serves a site, and it is not what the stack declares. */
21
+ const FOREIGN = { apps: { http: { servers: { legacy: { listen: [':80'] } } } } };
22
+ const ids = { fqn: 'stack/caddy', id: 'caddy', instanceId: 'i-1' };
23
+
24
+ let fake: FakeCaddy | undefined;
25
+ afterEach(() => {
26
+ fake?.stop();
27
+ fake = undefined;
28
+ });
29
+
30
+ const setup = (running?: unknown) => {
31
+ const made = fakeDefaultCaddy(running);
32
+ fake = made.caddy;
33
+ return made;
34
+ };
35
+
36
+ /** A Caddy already running the declared config — as one started from caddyWithFile's file is. */
37
+ const runningDeclared = () => {
38
+ const made = setup();
39
+ made.caddy.running = made.caddy.adapt(SITE);
40
+ return made;
41
+ };
42
+
43
+ const loads = (caddy: FakeCaddy) => caddy.seen.filter((call) => call.path === '/load');
44
+
45
+ describe('the probe: read with no state', () => {
46
+ test('a Caddy already running the declared config is ours — adopting it changes nothing', async () => {
47
+ const { admin, caddy } = runningDeclared();
48
+ const probe = await probeLive(admin, SITE);
49
+ expect(probe).toMatchObject({ attributes: { configSha256: configDigest(caddy.adapt(SITE)) } });
50
+ expect(probe?.ours).toBe(true);
51
+ expect(loads(caddy)).toHaveLength(0);
52
+ });
53
+
54
+ test('a Caddy running any other config is not ours', async () => {
55
+ const { admin } = setup(FOREIGN);
56
+ const probe = await probeLive(admin, SITE, '/usr/local/etc/Caddyfile');
57
+ expect(probe?.ours).toBe(false);
58
+ expect(probe?.unchecked).toBeUndefined();
59
+ expect(probe?.attributes).toEqual({
60
+ configSha256: configDigest(FOREIGN),
61
+ endpoint: fake?.address ?? '',
62
+ sourceFile: '/usr/local/etc/Caddyfile',
63
+ });
64
+ });
65
+
66
+ test.each([
67
+ ['no config at all', null],
68
+ ['only an admin block', { admin: { listen: 'localhost:2019' } }],
69
+ ])('a Caddy serving nothing (%s) reads as absent: a create', async (_, running) => {
70
+ const { admin } = setup(running);
71
+ expect(await probeLive(admin, SITE)).toBeUndefined();
72
+ });
73
+
74
+ test.each([
75
+ ['does not adapt', 'SYNTAX_ERROR', /Error during parsing/],
76
+ ['is not plain text (a stripped Output)', undefined, /not plain text/],
77
+ ])('a declaration that %s is not ours — and never fails the read', async (_, text, reason) => {
78
+ // ⛔ The engine replays this read with an interrupted create's props: a throw here would fail
79
+ // every later plan, the one carrying the fix included.
80
+ const { admin } = setup(FOREIGN);
81
+ const probe = await probeLive(admin, text);
82
+ expect(probe?.ours).toBe(false);
83
+ expect(probe?.unchecked).toMatch(reason);
84
+ });
85
+
86
+ test('Caddy vanishing mid-probe stays "no Caddy", never "not ours"', async () => {
87
+ const { admin } = setup(FOREIGN);
88
+ const flaky: CaddyAdmin = {
89
+ ...admin,
90
+ request: (call) =>
91
+ call.path === '/adapt'
92
+ ? Promise.reject(new CaddyUnreachableError('gone'))
93
+ : admin.request(call),
94
+ };
95
+ await expect(probeLive(flaky, SITE)).rejects.toThrow('gone');
96
+ });
97
+ });
98
+
99
+ /** A create the engine never probed, or state from another endpoint (config.ts decides which). */
100
+ describe('the apply without authority over the running config', () => {
101
+ test('without authority, a config the stack cannot claim is refused before any load', async () => {
102
+ const { admin, caddy } = setup(FOREIGN);
103
+ await expect(reconcileConfig(admin, { caddyfile: SITE }, { takeOver: false })).rejects.toThrow(
104
+ /which this stack did not load.*Deploy with --adopt/,
105
+ );
106
+ expect(loads(caddy)).toHaveLength(0);
107
+ expect(caddy.running).toEqual(FOREIGN);
108
+ });
109
+
110
+ test('the declared config already running is taken as it is — no load', async () => {
111
+ const { admin, caddy } = runningDeclared();
112
+ const applied = await reconcileConfig(admin, { caddyfile: SITE }, { takeOver: false });
113
+ expect(applied.loaded).toBe(false);
114
+ expect(loads(caddy)).toHaveLength(0);
115
+ });
116
+
117
+ test('a Caddy serving nothing is loaded — there is nothing to take over', async () => {
118
+ const { admin, caddy } = setup();
119
+ const applied = await reconcileConfig(admin, { caddyfile: SITE }, { takeOver: false });
120
+ expect(applied.loaded).toBe(true);
121
+ expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
122
+ });
123
+
124
+ test('the config the state last stored is known — the same Caddy at a new endpoint', async () => {
125
+ const { admin } = setup(FOREIGN);
126
+ const authority = { stored: configDigest(FOREIGN), takeOver: false };
127
+ expect((await reconcileConfig(admin, { caddyfile: SITE }, authority)).loaded).toBe(true);
128
+ });
129
+
130
+ test('with authority, the foreign config is replaced', async () => {
131
+ const { admin, caddy } = setup(FOREIGN);
132
+ const applied = await reconcileConfig(admin, { caddyfile: SITE }, { takeOver: true });
133
+ expect(applied.loaded).toBe(true);
134
+ expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
135
+ });
136
+ });
137
+
138
+ type Deploy = { readonly adopt?: boolean; readonly contextAdopt?: boolean };
139
+
140
+ /** The provider over `admin`, run under the services a deploy provides (the CLI's `--adopt`). */
141
+ const withProvider = <A>(
142
+ admin: CaddyAdmin,
143
+ deploy: Deploy,
144
+ use: (p: Effect.Success<typeof CaddyConfig.Provider>) => Effect.Effect<A, unknown>,
145
+ ) => {
146
+ let program = Effect.gen(function* () {
147
+ return yield* use(yield* CaddyConfig.Provider);
148
+ }).pipe(Effect.provide(CaddyConfigProvider().pipe(Layer.provide(caddyAdminLayer(admin)))));
149
+ if (deploy.adopt !== undefined) {
150
+ program = program.pipe(Effect.provideService(AdoptPolicy, deploy.adopt));
151
+ }
152
+ if (deploy.contextAdopt !== undefined) {
153
+ const context = { adopt: deploy.contextAdopt, dev: false, dotAlchemy: '.alchemy' };
154
+ program = program.pipe(Effect.provideService(AlchemyContext, context));
155
+ }
156
+ return Effect.runPromise(program);
157
+ };
158
+
159
+ const read = (admin: CaddyAdmin, caddyfile: unknown, output?: object) =>
160
+ withProvider(admin, {}, (provider) => {
161
+ if (provider.read === undefined) throw new Error('provider has no read handler');
162
+ return provider.read({ ...ids, olds: { caddyfile } as never, output: output as never });
163
+ });
164
+
165
+ /** Apply, then check that exactly the expected loads reached Caddy — or none, and why. */
166
+ const expectApply = async (applying: Promise<unknown>, caddy: FakeCaddy, loaded: boolean) => {
167
+ if (loaded) await applying;
168
+ else await expect(applying).rejects.toThrow(/Deploy with --adopt/);
169
+ expect(loads(caddy)).toHaveLength(loaded ? 1 : 0);
170
+ };
171
+
172
+ const reconcile = (admin: CaddyAdmin, deploy: Deploy, output?: object) =>
173
+ withProvider(admin, deploy, (provider) =>
174
+ provider.reconcile({
175
+ ...ids,
176
+ bindings: [] as never,
177
+ news: { caddyfile: SITE },
178
+ olds: undefined,
179
+ output: output as never,
180
+ session: undefined as never,
181
+ }),
182
+ );
183
+
184
+ describe('CaddyConfigProvider', () => {
185
+ test('read: Unowned with no state, plain with state, plain when it is the declared config', async () => {
186
+ const { admin, caddy } = setup(FOREIGN);
187
+ const probe = await read(admin, SITE);
188
+ expect(Unowned.is(probe)).toBe(true);
189
+ expect(probe).toMatchObject({ configSha256: configDigest(FOREIGN) });
190
+ const owned = await read(admin, SITE, { configSha256: 'stored', endpoint: caddy.address });
191
+ expect(owned).toBeDefined();
192
+ expect(Unowned.is(owned)).toBe(false);
193
+ caddy.running = caddy.adapt(SITE);
194
+ const same = await read(admin, SITE);
195
+ expect(same).toBeDefined();
196
+ expect(Unowned.is(same)).toBe(false);
197
+ });
198
+
199
+ test('read with no state: nothing served is a create; an unreadable declaration is Unowned', async () => {
200
+ expect(await read(setup().admin, SITE)).toBeUndefined();
201
+ fake?.stop();
202
+ const { admin } = setup(FOREIGN);
203
+ expect(Unowned.is(await read(admin, 'SYNTAX_ERROR'))).toBe(true);
204
+ expect(Unowned.is(await read(admin, {}))).toBe(true);
205
+ });
206
+
207
+ test.each([
208
+ ['no adopt setting at all', {}, false],
209
+ ['AdoptPolicy off', { adopt: false }, false],
210
+ ['--adopt (AdoptPolicy)', { adopt: true }, true],
211
+ ['--adopt reaching only AlchemyContext', { contextAdopt: true }, true],
212
+ ['AdoptPolicy off over AlchemyContext on', { adopt: false, contextAdopt: true }, false],
213
+ ] as const)('reconcile with no state, %s', async (_, deploy, loaded) => {
214
+ const { admin, caddy } = setup(FOREIGN);
215
+ await expectApply(reconcile(admin, deploy), caddy, loaded);
216
+ });
217
+
218
+ test('reconcile with state for this Caddy loads over anything, without --adopt', async () => {
219
+ // ★ An update correcting drift (a hand edit since the stored digest), or an adopted create.
220
+ const { admin, caddy } = setup(FOREIGN);
221
+ const output = { configSha256: 'before-a-hand-edit', endpoint: caddy.address };
222
+ await expectApply(reconcile(admin, {}, output), caddy, true);
223
+ });
224
+
225
+ test.each([
226
+ ['a config it never stored, without --adopt', 'stored-elsewhere', {}, false],
227
+ ['a config it never stored, with --adopt', 'stored-elsewhere', { adopt: true }, true],
228
+ ['the config it last stored (the same Caddy, renamed)', configDigest(FOREIGN), {}, true],
229
+ ] as const)('reconcile with state from another endpoint: %s', async (_, sha, deploy, loaded) => {
230
+ // ⛔ State vouches for the Caddy it was applied to — not for whatever the transport now reaches.
231
+ const { admin, caddy } = setup(FOREIGN);
232
+ const output = { configSha256: sha, endpoint: 'unix:///run/elsewhere.sock' };
233
+ await expectApply(reconcile(admin, deploy, output), caddy, loaded);
234
+ });
235
+ });
@@ -4,10 +4,9 @@
4
4
  */
5
5
  import { afterEach, describe, expect, test } from 'bun:test';
6
6
  import type { CaddyAdmin } from './admin.ts';
7
- import { type FakeCaddy, fakeCaddy } from './fake-caddy.ts';
7
+ import { type FakeCaddy, fakeDefaultCaddy } from './fake-caddy.ts';
8
8
  import { configDigest } from './digest.ts';
9
9
  import { diffConfig, readLive, reconcileConfig } from './config-lifecycle.ts';
10
- import { localCaddyAdmin } from './local-admin.ts';
11
10
 
12
11
  const SITE = 'app.example.com reverse_proxy 127.0.0.1:8080';
13
12
  const OTHER = 'www.example.com respond <hello> & bye';
@@ -18,27 +17,22 @@ afterEach(() => {
18
17
  fake = undefined;
19
18
  });
20
19
 
21
- /**
22
- * ★ Addressed as a Caddy on its DEFAULT :2019 behind a forward (`hostHeader`), so a Caddyfile with
23
- * no `admin` line — the common case — passes the guard. A Caddy reached on any other port needs
24
- * `admin <address>` declared, or the first load would move it (admin-guard.ts).
25
- */
20
+ /** A Caddy on its default :2019 (fake-caddy.ts fakeDefaultCaddy), stopped after each test. */
26
21
  const setup = (running?: unknown) => {
27
- fake = fakeCaddy({ listenPort: 2019, ...(running === undefined ? {} : { running }) });
28
- const admin = localCaddyAdmin({
29
- address: fake.address,
30
- hostHeader: '127.0.0.1:2019',
31
- retries: 0,
32
- });
33
- return { admin, caddy: fake };
22
+ const made = fakeDefaultCaddy(running);
23
+ fake = made.caddy;
24
+ return made;
34
25
  };
35
26
 
27
+ /** ★ These are the resource's own applies (it has state); config-adopt.test.ts covers a create. */
28
+ const OWNED = { takeOver: true } as const;
29
+
36
30
  const loads = (caddy: FakeCaddy) => caddy.seen.filter((call) => call.path === '/load');
37
31
 
38
32
  describe('apply', () => {
39
33
  test('loads the Caddyfile, reads it back, and stores the digest Caddy reports', async () => {
40
34
  const { admin, caddy } = setup();
41
- const applied = await reconcileConfig(admin, { caddyfile: OTHER });
35
+ const applied = await reconcileConfig(admin, { caddyfile: OTHER }, OWNED);
42
36
  expect(applied.loaded).toBe(true);
43
37
  // ★ The fake's /config/ is sorted and escaped differently from /adapt; the digests still agree.
44
38
  expect(applied.attributes.configSha256).toBe(configDigest(caddy.adapt(OTHER)));
@@ -50,8 +44,8 @@ describe('apply', () => {
50
44
 
51
45
  test('a Caddy already running this config is not reloaded', async () => {
52
46
  const { admin, caddy } = setup();
53
- await reconcileConfig(admin, { caddyfile: SITE });
54
- const again = await reconcileConfig(admin, { caddyfile: SITE });
47
+ await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
48
+ const again = await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
55
49
  expect(again.loaded).toBe(false);
56
50
  expect(loads(caddy)).toHaveLength(1);
57
51
  });
@@ -59,7 +53,7 @@ describe('apply', () => {
59
53
  test('sourceFile rides as the headers `caddy reload` sends, and lands in the attributes', async () => {
60
54
  const { admin, caddy } = setup();
61
55
  const path = '/usr/local/etc/Caddyfile';
62
- const applied = await reconcileConfig(admin, { caddyfile: SITE, sourceFile: path });
56
+ const applied = await reconcileConfig(admin, { caddyfile: SITE, sourceFile: path }, OWNED);
63
57
  expect(loads(caddy)[0]?.headers.get('caddy-config-source-file')).toBe(path);
64
58
  expect(loads(caddy)[0]?.headers.get('caddy-config-source-adapter')).toBe('caddyfile');
65
59
  expect(applied.attributes.sourceFile).toBe(path);
@@ -67,7 +61,7 @@ describe('apply', () => {
67
61
 
68
62
  test('adapter warnings are returned, not refused', async () => {
69
63
  const { admin } = setup();
70
- const applied = await reconcileConfig(admin, { caddyfile: `${SITE}\t` });
64
+ const applied = await reconcileConfig(admin, { caddyfile: `${SITE}\t` }, OWNED);
71
65
  expect(applied.warnings).toEqual(['Caddyfile:1: Caddyfile input is not formatted']);
72
66
  });
73
67
  });
@@ -75,33 +69,33 @@ describe('apply', () => {
75
69
  describe('a config Caddy refuses', () => {
76
70
  test('surfaces Caddy’s reason and confirms the previous config is still running', async () => {
77
71
  const { admin, caddy } = setup();
78
- await reconcileConfig(admin, { caddyfile: SITE });
72
+ await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
79
73
  const before = caddy.running;
80
- await expect(reconcileConfig(admin, { caddyfile: `${SITE}\nPROVISION_ERROR` })).rejects.toThrow(
81
- /address already in use.*kept the previous config/,
82
- );
74
+ await expect(
75
+ reconcileConfig(admin, { caddyfile: `${SITE}\nPROVISION_ERROR` }, OWNED),
76
+ ).rejects.toThrow(/address already in use.*kept the previous config/);
83
77
  expect(caddy.running).toEqual(before);
84
78
  });
85
79
 
86
80
  test('a refusal that came back as a 200 (warnings first) is still a refusal', async () => {
87
81
  const { admin, caddy } = setup();
88
- await reconcileConfig(admin, { caddyfile: SITE });
82
+ await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
89
83
  await expect(
90
- reconcileConfig(admin, { caddyfile: `${SITE}\t\nPROVISION_ERROR` }),
84
+ reconcileConfig(admin, { caddyfile: `${SITE}\t\nPROVISION_ERROR` }, OWNED),
91
85
  ).rejects.toThrow(/→ 200: loading config.*error in a 200 body.*kept the previous/);
92
86
  expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
93
87
  });
94
88
 
95
89
  test('a load answered 200 that Caddy did not apply fails the deploy (the read-back)', async () => {
96
90
  const { admin, caddy } = setup();
97
- await reconcileConfig(admin, { caddyfile: SITE });
91
+ await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
98
92
  // ⚠️ A concurrent writer, or a 200 that carried no change: state must not record OTHER.
99
93
  const swallowing: CaddyAdmin = {
100
94
  ...admin,
101
95
  request: (call) =>
102
96
  call.path === '/load' ? Promise.resolve({ body: '', status: 200 }) : admin.request(call),
103
97
  };
104
- await expect(reconcileConfig(swallowing, { caddyfile: OTHER })).rejects.toThrow(
98
+ await expect(reconcileConfig(swallowing, { caddyfile: OTHER }, OWNED)).rejects.toThrow(
105
99
  /after the load Caddy runs config \w+, not the \w+ this Caddyfile adapts to/,
106
100
  );
107
101
  expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
@@ -109,7 +103,7 @@ describe('a config Caddy refuses', () => {
109
103
 
110
104
  test('a Caddyfile that does not adapt is refused before any load', async () => {
111
105
  const { admin, caddy } = setup();
112
- await expect(reconcileConfig(admin, { caddyfile: 'SYNTAX_ERROR' })).rejects.toThrow(
106
+ await expect(reconcileConfig(admin, { caddyfile: 'SYNTAX_ERROR' }, OWNED)).rejects.toThrow(
113
107
  /\/adapt → 400: Caddyfile:1 - Error during parsing/,
114
108
  );
115
109
  expect(loads(caddy)).toHaveLength(0);
@@ -122,7 +116,9 @@ describe('refused before anything is sent', () => {
122
116
  ['an empty Caddyfile', ' \n'],
123
117
  ])('%s', async (_, caddyfile) => {
124
118
  const { admin, caddy } = setup();
125
- await expect(reconcileConfig(admin, { caddyfile })).rejects.toThrow(/Caddy\.Config at http/);
119
+ await expect(reconcileConfig(admin, { caddyfile }, OWNED)).rejects.toThrow(
120
+ /Caddy\.Config at http/,
121
+ );
126
122
  expect(caddy.seen).toHaveLength(0);
127
123
  });
128
124
 
@@ -131,15 +127,15 @@ describe('refused before anything is sent', () => {
131
127
  ['only global options', 'admin localhost:2019'],
132
128
  ])('a Caddyfile of %s adapts to no apps, and is never loaded', async (_, caddyfile) => {
133
129
  const { admin, caddy } = setup();
134
- await expect(reconcileConfig(admin, { caddyfile })).rejects.toThrow(/adapts to no apps/);
130
+ await expect(reconcileConfig(admin, { caddyfile }, OWNED)).rejects.toThrow(/adapts to no apps/);
135
131
  expect(loads(caddy)).toHaveLength(0);
136
132
  });
137
133
 
138
134
  test('a Caddyfile that would turn the admin API off is never loaded', async () => {
139
135
  const { admin, caddy } = setup();
140
- await expect(reconcileConfig(admin, { caddyfile: `admin off\n${SITE}` })).rejects.toThrow(
141
- /admin off/,
142
- );
136
+ await expect(
137
+ reconcileConfig(admin, { caddyfile: `admin off\n${SITE}` }, OWNED),
138
+ ).rejects.toThrow(/admin off/);
143
139
  expect(loads(caddy)).toHaveLength(0);
144
140
  });
145
141
  });
@@ -147,7 +143,7 @@ describe('refused before anything is sent', () => {
147
143
  describe('drift', () => {
148
144
  const applied = async () => {
149
145
  const { admin, caddy } = setup();
150
- const { attributes } = await reconcileConfig(admin, { caddyfile: SITE });
146
+ const { attributes } = await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
151
147
  return { admin, attributes, caddy };
152
148
  };
153
149
 
@@ -165,7 +161,7 @@ describe('drift', () => {
165
161
  const { admin, attributes, caddy } = await applied();
166
162
  caddy.running = { apps: { http: { servers: {} } } };
167
163
  expect(await diffConfig(admin, { caddyfile: SITE }, attributes)).toEqual({ action: 'update' });
168
- const again = await reconcileConfig(admin, { caddyfile: SITE });
164
+ const again = await reconcileConfig(admin, { caddyfile: SITE }, OWNED);
169
165
  expect(again.attributes.configSha256).toBe(attributes.configSha256);
170
166
  });
171
167
 
@@ -198,21 +194,3 @@ describe('drift', () => {
198
194
  expect(diff).toEqual({ action: 'update' });
199
195
  });
200
196
  });
201
-
202
- describe('adopt', () => {
203
- test('a running Caddy reads as its live config; the first apply replaces it', async () => {
204
- const { admin, caddy } = setup({
205
- apps: { http: { servers: { legacy: { listen: [':80'] } } } },
206
- });
207
- const adopted = await readLive(admin);
208
- expect(adopted.configSha256).toBe(configDigest(caddy.running));
209
- expect(await diffConfig(admin, { caddyfile: SITE }, adopted)).toEqual({ action: 'update' });
210
- await reconcileConfig(admin, { caddyfile: SITE });
211
- expect(configDigest(caddy.running)).toBe(configDigest(caddy.adapt(SITE)));
212
- });
213
-
214
- test('an empty Caddy (config null) is adoptable too', async () => {
215
- const { admin } = setup();
216
- expect((await readLive(admin)).configSha256).toBe(configDigest(null));
217
- });
218
- });