@homeflare/alchemy 0.29.1 → 0.31.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 (118) hide show
  1. package/README.md +42 -0
  2. package/dist/caddy/config-reconcile.d.ts.map +1 -1
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/format-warnings.d.ts +29 -0
  5. package/dist/caddy/format-warnings.d.ts.map +1 -0
  6. package/dist/caddy/format.d.ts +50 -0
  7. package/dist/caddy/format.d.ts.map +1 -0
  8. package/dist/caddy/index.d.ts +1 -0
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +78 -10
  11. package/dist/caddy/index.js.map +7 -5
  12. package/dist/discord/application-command.d.ts +52 -0
  13. package/dist/discord/application-command.d.ts.map +1 -0
  14. package/dist/discord/command-form.d.ts +62 -0
  15. package/dist/discord/command-form.d.ts.map +1 -0
  16. package/dist/discord/credentials.d.ts +35 -0
  17. package/dist/discord/credentials.d.ts.map +1 -0
  18. package/dist/discord/fake-discord.d.ts +44 -0
  19. package/dist/discord/fake-discord.d.ts.map +1 -0
  20. package/dist/discord/guild-application-command.d.ts +53 -0
  21. package/dist/discord/guild-application-command.d.ts.map +1 -0
  22. package/dist/discord/index.d.ts +12 -0
  23. package/dist/discord/index.d.ts.map +1 -0
  24. package/dist/discord/index.js +193 -0
  25. package/dist/discord/index.js.map +15 -0
  26. package/dist/discord/providers.d.ts +15 -0
  27. package/dist/discord/providers.d.ts.map +1 -0
  28. package/dist/discord/resource.d.ts +75 -0
  29. package/dist/discord/resource.d.ts.map +1 -0
  30. package/dist/google-workspace/credentials.d.ts +54 -0
  31. package/dist/google-workspace/credentials.d.ts.map +1 -0
  32. package/dist/google-workspace/domain-alias.d.ts +74 -0
  33. package/dist/google-workspace/domain-alias.d.ts.map +1 -0
  34. package/dist/google-workspace/fake-google-workspace.d.ts +41 -0
  35. package/dist/google-workspace/fake-google-workspace.d.ts.map +1 -0
  36. package/dist/google-workspace/group-member.d.ts +73 -0
  37. package/dist/google-workspace/group-member.d.ts.map +1 -0
  38. package/dist/google-workspace/group.d.ts +87 -0
  39. package/dist/google-workspace/group.d.ts.map +1 -0
  40. package/dist/google-workspace/index.d.ts +19 -0
  41. package/dist/google-workspace/index.d.ts.map +1 -0
  42. package/dist/google-workspace/index.js +283 -0
  43. package/dist/google-workspace/index.js.map +17 -0
  44. package/dist/google-workspace/org-unit.d.ts +75 -0
  45. package/dist/google-workspace/org-unit.d.ts.map +1 -0
  46. package/dist/google-workspace/providers.d.ts +16 -0
  47. package/dist/google-workspace/providers.d.ts.map +1 -0
  48. package/dist/google-workspace/resource.d.ts +80 -0
  49. package/dist/google-workspace/resource.d.ts.map +1 -0
  50. package/dist/google-workspace/values.d.ts +28 -0
  51. package/dist/google-workspace/values.d.ts.map +1 -0
  52. package/dist/grafana/credentials.d.ts +38 -0
  53. package/dist/grafana/credentials.d.ts.map +1 -0
  54. package/dist/grafana/datasource.d.ts +90 -0
  55. package/dist/grafana/datasource.d.ts.map +1 -0
  56. package/dist/grafana/fake-grafana.d.ts +48 -0
  57. package/dist/grafana/fake-grafana.d.ts.map +1 -0
  58. package/dist/grafana/index.d.ts +17 -0
  59. package/dist/grafana/index.d.ts.map +1 -0
  60. package/dist/grafana/index.js +149 -0
  61. package/dist/grafana/index.js.map +13 -0
  62. package/dist/grafana/providers.d.ts +17 -0
  63. package/dist/grafana/providers.d.ts.map +1 -0
  64. package/dist/grafana/resource.d.ts +68 -0
  65. package/dist/grafana/resource.d.ts.map +1 -0
  66. package/docs/caddy-fmt.md +59 -0
  67. package/docs/caddy.md +7 -0
  68. package/docs/credentials.md +20 -0
  69. package/docs/discord.md +129 -0
  70. package/docs/google-workspace.md +105 -0
  71. package/docs/grafana.md +102 -0
  72. package/docs/peers.md +9 -0
  73. package/docs/upstream-conformance.md +53 -0
  74. package/package.json +13 -1
  75. package/src/caddy/config-format-warning.test.ts +91 -0
  76. package/src/caddy/config-reconcile.ts +6 -0
  77. package/src/caddy/config.ts +8 -1
  78. package/src/caddy/format-warnings.test.ts +50 -0
  79. package/src/caddy/format-warnings.ts +39 -0
  80. package/src/caddy/format.test.ts +105 -0
  81. package/src/caddy/format.ts +121 -0
  82. package/src/caddy/index.ts +7 -0
  83. package/src/caddy/providers.test.ts +8 -0
  84. package/src/discord/application-command.test.ts +103 -0
  85. package/src/discord/application-command.ts +109 -0
  86. package/src/discord/command-form.test.ts +144 -0
  87. package/src/discord/command-form.ts +133 -0
  88. package/src/discord/credentials.ts +36 -0
  89. package/src/discord/fake-discord.ts +70 -0
  90. package/src/discord/guild-application-command.test.ts +215 -0
  91. package/src/discord/guild-application-command.ts +129 -0
  92. package/src/discord/index.ts +31 -0
  93. package/src/discord/providers.ts +18 -0
  94. package/src/discord/resource.ts +164 -0
  95. package/src/discord/retry.test.ts +74 -0
  96. package/src/google-workspace/credentials.test.ts +40 -0
  97. package/src/google-workspace/credentials.ts +75 -0
  98. package/src/google-workspace/domain-alias.test.ts +69 -0
  99. package/src/google-workspace/domain-alias.ts +116 -0
  100. package/src/google-workspace/fake-google-workspace.ts +54 -0
  101. package/src/google-workspace/group-member.test.ts +91 -0
  102. package/src/google-workspace/group-member.ts +126 -0
  103. package/src/google-workspace/group.test.ts +93 -0
  104. package/src/google-workspace/group.ts +128 -0
  105. package/src/google-workspace/index.ts +54 -0
  106. package/src/google-workspace/org-unit.test.ts +81 -0
  107. package/src/google-workspace/org-unit.ts +143 -0
  108. package/src/google-workspace/providers.ts +26 -0
  109. package/src/google-workspace/resource.test.ts +186 -0
  110. package/src/google-workspace/resource.ts +162 -0
  111. package/src/google-workspace/values.ts +36 -0
  112. package/src/grafana/credentials.ts +65 -0
  113. package/src/grafana/datasource.test.ts +183 -0
  114. package/src/grafana/datasource.ts +171 -0
  115. package/src/grafana/fake-grafana.ts +64 -0
  116. package/src/grafana/index.ts +22 -0
  117. package/src/grafana/providers.ts +19 -0
  118. package/src/grafana/resource.ts +120 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * formatCaddyfile()'s Effect wiring, proven against a fake `caddy` — a tiny shell script that
3
+ * "formats" by stripping trailing whitespace per line — so stdin-in/stdout-out plumbing, the
4
+ * exit-code check and missing-binary detection are exercised without a real Caddy on the runner.
5
+ * The last block runs the REAL `caddy fmt` and is skipped, its name saying why, when there is none
6
+ * on PATH (S42: tests are Bun-native, so `Bun.which` decides, not a hand-rolled PATH search).
7
+ */
8
+ import { afterAll, describe, expect, test } from 'bun:test';
9
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { layer as BunServicesLayer } from '@effect/platform-bun/BunServices';
13
+ import * as Effect from 'effect/Effect';
14
+ import { CaddyFmtFailed, CaddyFmtNotFound, formatCaddyfile } from './format.ts';
15
+
16
+ /** Only understands `fmt -`: stdin in, "formatted" stdout out — `FAIL_MARKER` fakes a bad parse. */
17
+ const FAKE_SCRIPT = `#!/bin/sh
18
+ if [ "$1" != "fmt" ] || [ "$2" != "-" ]; then
19
+ echo "fake caddy: unexpected args: $*" >&2
20
+ exit 2
21
+ fi
22
+ input="$(cat)"
23
+ case "$input" in
24
+ *FAIL_MARKER*)
25
+ echo "fake caddy: syntax error" >&2
26
+ exit 1
27
+ ;;
28
+ esac
29
+ printf '%s' "$input" | sed -E 's/[[:space:]]+$//'
30
+ `;
31
+
32
+ const dir = mkdtempSync(join(tmpdir(), 'hf-caddy-fmt-'));
33
+ const fakeBinary = join(dir, 'hf-fake-caddy');
34
+ writeFileSync(fakeBinary, FAKE_SCRIPT, { mode: 0o755 });
35
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
36
+
37
+ const run = (text: string, options?: Parameters<typeof formatCaddyfile>[1]) =>
38
+ Effect.runPromise(formatCaddyfile(text, options).pipe(Effect.provide(BunServicesLayer)));
39
+
40
+ const runFailure = (text: string, options?: Parameters<typeof formatCaddyfile>[1]) =>
41
+ Effect.runPromise(
42
+ formatCaddyfile(text, options).pipe(Effect.provide(BunServicesLayer), Effect.flip),
43
+ );
44
+
45
+ describe('formatCaddyfile — Effect wiring against a fake binary', () => {
46
+ test('pipes text to stdin and returns the formatted stdout', async () => {
47
+ // ⚠️ The fake reads stdin through `$(cat)`, which — like real shell command substitution —
48
+ // drops a trailing newline; that is the fake's plumbing, not formatCaddyfile()'s.
49
+ const out = await run('foo \nbar\t\n', { binaryPath: fakeBinary });
50
+ expect(out).toBe('foo\nbar');
51
+ });
52
+
53
+ test('a non-zero exit becomes a typed CaddyFmtFailed, stderr included — never the input back', async () => {
54
+ const failure = await runFailure('site FAIL_MARKER', { binaryPath: fakeBinary });
55
+ expect(failure).toBeInstanceOf(CaddyFmtFailed);
56
+ expect((failure as CaddyFmtFailed).exitCode).toBe(1);
57
+ expect(failure.message).toContain('syntax error');
58
+ });
59
+
60
+ test('a missing binary fails with the typed CaddyFmtNotFound — never silently skipped', async () => {
61
+ const missing = join(dir, 'does-not-exist-caddy');
62
+ const failure = await runFailure('x', { binaryPath: missing });
63
+ expect(failure).toBeInstanceOf(CaddyFmtNotFound);
64
+ expect((failure as CaddyFmtNotFound).binaryPath).toBe(missing);
65
+ expect(failure.message).toContain(missing);
66
+ });
67
+
68
+ test('the default binary name resolves through PATH, not only an absolute path', async () => {
69
+ const pathDir = mkdtempSync(join(tmpdir(), 'hf-caddy-fmt-path-'));
70
+ writeFileSync(join(pathDir, 'caddy'), FAKE_SCRIPT, { mode: 0o755 });
71
+ const original = process.env['PATH'];
72
+ // ★ Prepended, so this fake wins even when a real `caddy` also sits on PATH.
73
+ process.env['PATH'] = `${pathDir}:${original ?? ''}`;
74
+ try {
75
+ expect(await run('baz \n')).toBe('baz');
76
+ } finally {
77
+ process.env['PATH'] = original;
78
+ rmSync(pathDir, { recursive: true, force: true });
79
+ }
80
+ });
81
+ });
82
+
83
+ const realCaddy = Bun.which('caddy');
84
+ // ⚠️ DELIBERATELY NOT `caddy fmt`'s own canonical style — no space before `{`, spaces instead of
85
+ // a tab, and the closing brace indented — every one of which `caddy fmt` actually rewrites, so
86
+ // `expect(formatted).not.toBe(UNFORMATTED)` below cannot pass by accident on already-formatted
87
+ // input. (Caught in review: a tab-indented, flush-brace fixture here IS canonical caddy fmt
88
+ // style, which would make that assertion fail on a correctly working formatCaddyfile().)
89
+ const UNFORMATTED = 'example.com{\n reverse_proxy 127.0.0.1:8080\n }\n';
90
+
91
+ describe('formatCaddyfile — real caddy binary', () => {
92
+ test.skipIf(realCaddy === null)(
93
+ realCaddy === null
94
+ ? 'formats a known-unformatted Caddyfile (skipped: no caddy on PATH)'
95
+ : 'formats a known-unformatted Caddyfile, and formatting twice is idempotent',
96
+ async () => {
97
+ const formatted = await run(UNFORMATTED);
98
+ expect(formatted).not.toBe(UNFORMATTED);
99
+ // ★ THE REAL PROOF IT IS FORMATTED: running caddy's own formatter over its own output changes
100
+ // nothing. (Never asserted against a fixed expected string — that would pin this test to
101
+ // one caddy build's exact formatting choices.)
102
+ expect(await run(formatted)).toBe(formatted);
103
+ },
104
+ );
105
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `formatCaddyfile(text)` — pipes a Caddyfile through the LOCAL `caddy fmt -` binary (stdin in,
3
+ * formatted text out), the same formatter the `caddy fmt` CLI command runs
4
+ * (caddyconfig/caddyfile Format in caddyserver/caddy).
5
+ *
6
+ * ⛔ THERE IS NO ADMIN API FOR THIS. `caddy fmt` is a CLI-only feature (cmd/commandfuncs.go); no
7
+ * `/adapt`, `/load` or other admin endpoint formats a Caddyfile, so this never belongs in
8
+ * `@distilled.cloud/caddy` — measured 2026-09-24. It shells out instead, the same pattern
9
+ * talos/talosctl.ts uses for a vendor CLI Alchemy does not wrap.
10
+ * ⛔ NEVER REIMPLEMENTED IN TS. The Caddyfile grammar's formatting rules live in the Go adapter;
11
+ * a hand-rolled formatter would drift from it silently. This module only ever runs the real
12
+ * binary.
13
+ * ⛔ A MISSING OR FAILING BINARY FAILS THE EFFECT — IT NEVER RETURNS THE INPUT UNCHANGED.
14
+ * `CaddyFmtNotFound` and `CaddyFmtFailed` are typed, catchTag-able failures (S21); there is no
15
+ * fallback path that silently skips formatting.
16
+ * ★ WHY THIS MATTERS: admin-calls.ts's module doc — Caddy's adapter warns "Caddyfile input is not
17
+ * formatted" on EVERY unformatted input (adapter.go FormattingDifference), which shares the
18
+ * `/load` response body with the refused-but-200 trap. format-warnings.ts surfaces that warning
19
+ * distinctly in plan/deploy output; this function is how a caller silences it — by formatting
20
+ * the Caddyfile before declaring it, not by ignoring the warning.
21
+ */
22
+ import * as Effect from 'effect/Effect';
23
+ import * as Stream from 'effect/Stream';
24
+ import * as ChildProcess from 'effect/unstable/process/ChildProcess';
25
+ import * as ChildProcessSpawner from 'effect/unstable/process/ChildProcessSpawner';
26
+ import type * as PlatformError from 'effect/PlatformError';
27
+
28
+ /** `caddy` resolved on `PATH`, unless the caller points at a specific build. */
29
+ export const DEFAULT_CADDY_BINARY = 'caddy';
30
+
31
+ /** The configured binary is not on `PATH` (or not executable) — install Caddy, or fix the path. */
32
+ export class CaddyFmtNotFound extends Error {
33
+ readonly _tag = 'CaddyFmtNotFound';
34
+ constructor(
35
+ readonly binaryPath: string,
36
+ cause: PlatformError.PlatformError,
37
+ ) {
38
+ super(
39
+ `formatCaddyfile: "${binaryPath}" was not found (${cause.message}). Install Caddy so it is ` +
40
+ 'on PATH, or pass { binaryPath } naming the exact build the target Caddy runs.',
41
+ );
42
+ this.name = 'CaddyFmtNotFound';
43
+ }
44
+ }
45
+
46
+ /** The binary ran but exited non-zero — a syntax error in `text`, most likely. */
47
+ export class CaddyFmtFailed extends Error {
48
+ readonly _tag = 'CaddyFmtFailed';
49
+ constructor(
50
+ readonly binaryPath: string,
51
+ readonly exitCode: number,
52
+ stderr: string,
53
+ ) {
54
+ super(
55
+ `formatCaddyfile: "${binaryPath} fmt -" exited ${String(exitCode)}: ${stderr.slice(0, 300)}`,
56
+ );
57
+ this.name = 'CaddyFmtFailed';
58
+ }
59
+ }
60
+
61
+ export type FormatCaddyfileOptions = {
62
+ /** The `caddy` binary to run `fmt -` against. @default DEFAULT_CADDY_BINARY, resolved on PATH. */
63
+ readonly binaryPath?: string;
64
+ };
65
+
66
+ /**
67
+ * Format a Caddyfile the way `caddy fmt` would, by actually running it — never a TS
68
+ * reimplementation. Requires `ChildProcessSpawner` (S19: no `node:child_process`, no
69
+ * `async`/`await`); provide `@effect/platform-node`'s or `@effect/platform-bun`'s layer.
70
+ */
71
+ export const formatCaddyfile = (
72
+ text: string,
73
+ options: FormatCaddyfileOptions = {},
74
+ ): Effect.Effect<
75
+ string,
76
+ // ⚠️ A raw PlatformError still escapes for anything that is not "missing binary" (a permission
77
+ // problem, a process the OS killed mid-run) — narrower than that would misname what happened.
78
+ CaddyFmtNotFound | CaddyFmtFailed | PlatformError.PlatformError,
79
+ ChildProcessSpawner.ChildProcessSpawner
80
+ > =>
81
+ Effect.gen(function* () {
82
+ const binaryPath = options.binaryPath ?? DEFAULT_CADDY_BINARY;
83
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
84
+ const input = new TextEncoder().encode(text);
85
+ const result = yield* ChildProcess.make(binaryPath, ['fmt', '-'], {
86
+ detached: false,
87
+ stderr: 'pipe',
88
+ stdin: Stream.make(input),
89
+ stdout: 'pipe',
90
+ }).pipe(
91
+ spawner.spawn,
92
+ Effect.flatMap((child) =>
93
+ Effect.all(
94
+ {
95
+ exitCode: child.exitCode,
96
+ stderr: child.stderr.pipe(
97
+ Stream.decodeText,
98
+ Stream.mkString,
99
+ Effect.map((out) => out.trim()),
100
+ ),
101
+ stdout: child.stdout.pipe(Stream.decodeText, Stream.mkString),
102
+ },
103
+ { concurrency: 'unbounded' },
104
+ ),
105
+ ),
106
+ Effect.scoped,
107
+ // ⛔ ENOENT (the binary is missing) becomes the typed, named failure above — every other
108
+ // PlatformError (a permission problem, a killed process) propagates as-is, still typed.
109
+ Effect.catchTag(
110
+ 'PlatformError',
111
+ (error): Effect.Effect<never, CaddyFmtNotFound | PlatformError.PlatformError> =>
112
+ error.reason._tag === 'NotFound'
113
+ ? Effect.fail(new CaddyFmtNotFound(binaryPath, error))
114
+ : Effect.fail(error),
115
+ ),
116
+ );
117
+ if (result.exitCode !== 0) {
118
+ return yield* Effect.fail(new CaddyFmtFailed(binaryPath, result.exitCode, result.stderr));
119
+ }
120
+ return result.stdout;
121
+ });
@@ -24,6 +24,13 @@ export { CaddyAdminService, caddyAdminLayer } from './admin.ts';
24
24
  export { isUnreachable } from './caddy-http-client.ts';
25
25
  export type { CaddyConfigAttributes, CaddyConfigProps } from './config.ts';
26
26
  export { CaddyConfig, CaddyConfigProvider } from './config.ts';
27
+ export {
28
+ CaddyFmtFailed,
29
+ CaddyFmtNotFound,
30
+ DEFAULT_CADDY_BINARY,
31
+ formatCaddyfile,
32
+ type FormatCaddyfileOptions,
33
+ } from './format.ts';
27
34
  export type { LocalCaddyAdminOptions } from './local-admin.ts';
28
35
  export { DEFAULT_ADMIN_ADDRESS, localCaddyAdmin } from './local-admin.ts';
29
36
  export { caddyProviders } from './providers.ts';
@@ -224,13 +224,21 @@ describe('the barrel', () => {
224
224
  'CaddyAdminService',
225
225
  'CaddyConfig',
226
226
  'CaddyConfigProvider',
227
+ // The two typed failures formatCaddyfile() can raise — see format.ts.
228
+ 'CaddyFmtFailed',
229
+ 'CaddyFmtNotFound',
227
230
  'DEFAULT_ADMIN_ADDRESS',
231
+ // `caddy` resolved on PATH, unless formatCaddyfile()'s caller points at a build — format.ts.
232
+ 'DEFAULT_CADDY_BINARY',
228
233
  // Caddyfile TEXT for an Access-protected route, not a resource — see
229
234
  // access-forward-auth.ts. It pairs with @homeflare/cloudflare/access-auth.
230
235
  'accessForwardAuth',
231
236
  'caddyAdminLayer',
232
237
  'caddyProviders',
233
238
  'caddyWithFile',
239
+ // `caddy fmt -`, piped through the local binary — see format.ts's module doc for why this
240
+ // is never a TS reimplementation and never belongs in @distilled.cloud/caddy.
241
+ 'formatCaddyfile',
234
242
  // The type guard config.ts's plan-time escape hatch narrows unreachable failures with —
235
243
  // exported so a consumer's own transport (an SSH forward, a remote runner) can reuse it.
236
244
  'isUnreachable',
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `Discord.ApplicationCommand`'s `spec` (global scope) against a fake Discord. Shares its engine
3
+ * (`discordOperations`) and its comparison functions (`command-form.ts`) with
4
+ * `guild-application-command.test.ts`, which covers those in depth — this file proves only what
5
+ * differs for global commands: the URL shape (no `guild_id`), and that `spec.attributes` never
6
+ * invents one.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import type * as command from '@distilled.cloud/discord/discord';
10
+ import * as Effect from 'effect/Effect';
11
+ import { spec } from './application-command.ts';
12
+ import type { ApplicationCommandProps } from './application-command.ts';
13
+ import { fakeDiscord, fakeDiscordLayer, fakeFailure } from './fake-discord.ts';
14
+ import { discordOperations } from './resource.ts';
15
+
16
+ const APP = '111111111111111111';
17
+ const CMD_PATH = `/applications/${APP}/commands`;
18
+ const PROPS: ApplicationCommandProps = {
19
+ applicationId: APP,
20
+ description: 'ping the bot',
21
+ name: 'ping',
22
+ };
23
+
24
+ /** Loosely typed on purpose — a fixture, cast to the real response shape at the boundary. */
25
+ const liveCommand = (overrides: Record<string, unknown> = {}) =>
26
+ ({
27
+ application_id: APP,
28
+ default_member_permissions: null,
29
+ description: 'ping the bot',
30
+ id: '999',
31
+ name: 'ping',
32
+ nsfw: false,
33
+ type: 1,
34
+ version: '1',
35
+ ...overrides,
36
+ }) as unknown as command.ApplicationCommandResponse;
37
+
38
+ describe('Discord.ApplicationCommand spec.fetchByName', () => {
39
+ test('lists the GLOBAL path, with no guild segment', async () => {
40
+ const fake = fakeDiscord((method, url) =>
41
+ method === 'GET' && url.pathname === CMD_PATH
42
+ ? Response.json([liveCommand()])
43
+ : fakeFailure(400, 0, 'unexpected request'),
44
+ );
45
+ const live = await Effect.runPromise(
46
+ spec.fetchByName(PROPS).pipe(Effect.provide(fakeDiscordLayer(fake.fetch))),
47
+ );
48
+ expect(live?.id).toBe('999');
49
+ expect(fake.seen[0]?.path).toBe(CMD_PATH);
50
+ });
51
+ });
52
+
53
+ describe('Discord.ApplicationCommand spec.attributes', () => {
54
+ test('has no guildId field at all — a global command is scopeless', () => {
55
+ const attrs = spec.attributes(liveCommand());
56
+ expect('guildId' in attrs).toBe(false);
57
+ expect(attrs.applicationId).toBe(APP);
58
+ });
59
+ });
60
+
61
+ describe('discordOperations(spec).reconcile', () => {
62
+ test('creates against the global path when absent', async () => {
63
+ const fake = fakeDiscord((method, url) => {
64
+ if (method === 'GET' && url.pathname === CMD_PATH) return Response.json([]);
65
+ if (method === 'POST' && url.pathname === CMD_PATH) return Response.json(liveCommand());
66
+ return fakeFailure(400, 0, 'unexpected request');
67
+ });
68
+ const ops = discordOperations(spec);
69
+ const attrs = await Effect.runPromise(
70
+ ops.reconcile(PROPS).pipe(Effect.provide(fakeDiscordLayer(fake.fetch))),
71
+ );
72
+ expect(attrs.commandId).toBe('999');
73
+ expect(fake.seen.some((s) => s.method === 'POST' && s.path === CMD_PATH)).toBe(true);
74
+ });
75
+
76
+ test('a matching live command makes zero write calls (S10)', async () => {
77
+ const fake = fakeDiscord((method, url) =>
78
+ method === 'GET' && url.pathname === CMD_PATH
79
+ ? Response.json([liveCommand()])
80
+ : fakeFailure(500, 0, 'unexpected write'),
81
+ );
82
+ const ops = discordOperations(spec);
83
+ await Effect.runPromise(
84
+ ops.reconcile(PROPS).pipe(Effect.provide(fakeDiscordLayer(fake.fetch))),
85
+ );
86
+ expect(fake.seen).toHaveLength(1);
87
+ expect(fake.seen[0]?.method).toBe('GET');
88
+ });
89
+ });
90
+
91
+ describe('discordOperations(spec).destroy', () => {
92
+ test('deletes against the global path by command id', async () => {
93
+ const fake = fakeDiscord((method, url) => {
94
+ if (method === 'GET' && url.pathname === CMD_PATH) return Response.json([liveCommand()]);
95
+ if (method === 'DELETE' && url.pathname === `${CMD_PATH}/999`)
96
+ return new Response(null, { status: 204 });
97
+ return fakeFailure(400, 0, 'unexpected request');
98
+ });
99
+ const ops = discordOperations(spec);
100
+ await Effect.runPromise(ops.destroy(PROPS).pipe(Effect.provide(fakeDiscordLayer(fake.fetch))));
101
+ expect(fake.seen.some((s) => s.method === 'DELETE')).toBe(true);
102
+ });
103
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `Discord.ApplicationCommand` — one GLOBAL slash/user/message command on a Discord application.
3
+ *
4
+ * ⛔ NOT WHAT `hf-discord-halibut.service` USES TODAY. Halibut registers guild-scoped commands
5
+ * only (`DISCORD_GUILD_ID` is always set in production — see `docs/discord.md#halibut`), so this
6
+ * resource has no live object to adopt yet. It exists because the distilled SDK, and Discord's
7
+ * own API, model global and guild commands as siblings (same shape, different scope), and a
8
+ * future bot or a future Halibut command that goes global needs it without a second family.
9
+ *
10
+ * ⚠️ GLOBAL COMMAND CHANGES TAKE UP TO AN HOUR TO PROPAGATE (Discord's own docs). A `diff` that
11
+ * ran right after a write could see stale results; this resource does not poll for propagation
12
+ * (S26 bounds waits at ~60s, an hour is not boundable), so a plan run inside that window may
13
+ * report drift that will resolve itself. Documented, not solved — see `docs/discord.md`.
14
+ */
15
+ import { adopt } from 'alchemy/AdoptPolicy';
16
+ import { Resource } from 'alchemy';
17
+ import * as Provider from 'alchemy/Provider';
18
+ import * as command from '@distilled.cloud/discord/discord';
19
+ import type { DiscordOpError } from '@distilled.cloud/discord/Protocol';
20
+ import * as Effect from 'effect/Effect';
21
+ import {
22
+ type CommandFields,
23
+ type CommandFieldsAttributes,
24
+ commandBody,
25
+ commandFieldsOf,
26
+ commandMatches,
27
+ } from './command-form.ts';
28
+ import type { DiscordRequirements, DiscordSpec } from './resource.ts';
29
+ import { discordHandlers } from './resource.ts';
30
+
31
+ export interface ApplicationCommandProps extends CommandFields {
32
+ /** The Discord application (bot) this command belongs to — a public snowflake, not a secret. */
33
+ applicationId: string;
34
+ }
35
+
36
+ export interface ApplicationCommandAttributes extends CommandFieldsAttributes {
37
+ commandId: string;
38
+ applicationId: string;
39
+ version: string;
40
+ }
41
+
42
+ export interface DiscordApplicationCommand extends Resource<
43
+ 'Discord.ApplicationCommand',
44
+ ApplicationCommandProps,
45
+ ApplicationCommandAttributes,
46
+ never,
47
+ DiscordRequirements
48
+ > {}
49
+
50
+ export const DiscordApplicationCommand = Resource<DiscordApplicationCommand>(
51
+ 'Discord.ApplicationCommand',
52
+ {
53
+ // Commands are the thing a user reads; nothing here can be edited into another object.
54
+ defaultRemovalPolicy: 'retain',
55
+ },
56
+ );
57
+
58
+ export const isDiscordApplicationCommand = (value: unknown): value is DiscordApplicationCommand =>
59
+ typeof value === 'object' &&
60
+ value !== null &&
61
+ (value as { Type?: unknown }).Type === 'Discord.ApplicationCommand';
62
+
63
+ const attributesOf = (live: command.ApplicationCommandResponse): ApplicationCommandAttributes => ({
64
+ applicationId: live.application_id,
65
+ commandId: live.id,
66
+ version: live.version,
67
+ ...commandFieldsOf(live),
68
+ });
69
+
70
+ /** Exported for direct testing against `fake-discord.ts` — the same seam `../netbox/prefix.ts` uses. */
71
+ export const spec: DiscordSpec<
72
+ ApplicationCommandProps,
73
+ command.ApplicationCommandResponse,
74
+ ApplicationCommandAttributes,
75
+ DiscordOpError
76
+ > = {
77
+ attributes: attributesOf,
78
+ describe: (props) => `applications/${props.applicationId}/commands ${props.name}`,
79
+ destroy: (props, live) =>
80
+ command.deleteApplicationCommand({ application_id: props.applicationId, command_id: live.id }),
81
+ fetchByName: (props) =>
82
+ command
83
+ .listApplicationCommands({ application_id: props.applicationId })
84
+ .pipe(Effect.map((rows) => rows.find((row) => row.name === props.name))),
85
+ matches: commandMatches,
86
+ upsert: (props, body) =>
87
+ command.createApplicationCommand({
88
+ application_id: props.applicationId,
89
+ ...(body as Omit<command.CreateApplicationCommandRequest, 'application_id'>),
90
+ }),
91
+ upsertBody: commandBody,
92
+ };
93
+
94
+ export const handlers = discordHandlers(spec);
95
+
96
+ export const DiscordApplicationCommandProvider = () =>
97
+ Provider.effect(
98
+ DiscordApplicationCommand,
99
+ Effect.succeed(DiscordApplicationCommand.Provider.of(handlers)),
100
+ );
101
+
102
+ /**
103
+ * `applicationCommand('help', { applicationId, name: 'help', description: '…' })` — `adopt(true)`
104
+ * piped on by default (H5), so a first deploy against a command that already exists silently
105
+ * takes it over instead of failing `OwnedBySomeoneElse`. Mirrors `cloudflare/website.ts`'s
106
+ * `astroWebsite`/`viteWebsite` convenience constructors.
107
+ */
108
+ export const applicationCommand = (id: string, props: ApplicationCommandProps) =>
109
+ DiscordApplicationCommand(id, props).pipe(adopt(true));
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `command-form.ts`'s pure functions — no server, no Effect runtime, the same shape
3
+ * `../netbox/prefix-form.test.ts` uses for `prefixBody`/`prefixMatches`.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import {
7
+ type CommandFields,
8
+ type CommandFieldsAttributes,
9
+ commandBody,
10
+ commandFieldsOf,
11
+ commandMatches,
12
+ } from './command-form.ts';
13
+
14
+ describe('commandBody', () => {
15
+ test('settles description/nsfw/type when undeclared, the way Discord itself would', () => {
16
+ expect(commandBody({ name: 'ping' })).toEqual({
17
+ description: '',
18
+ name: 'ping',
19
+ nsfw: false,
20
+ type: 1,
21
+ });
22
+ });
23
+
24
+ test('an omitted field is left OUT of the body, never sent empty/null/false', () => {
25
+ const body = commandBody({ name: 'ping' });
26
+ expect(Object.prototype.hasOwnProperty.call(body, 'options')).toBe(false);
27
+ expect(Object.prototype.hasOwnProperty.call(body, 'default_member_permissions')).toBe(false);
28
+ expect(Object.prototype.hasOwnProperty.call(body, 'dm_permission')).toBe(false);
29
+ expect(Object.prototype.hasOwnProperty.call(body, 'contexts')).toBe(false);
30
+ expect(Object.prototype.hasOwnProperty.call(body, 'integration_types')).toBe(false);
31
+ });
32
+
33
+ test('every declared field reaches the wire body under its wire name', () => {
34
+ const props: CommandFields = {
35
+ contexts: [0, 1],
36
+ defaultMemberPermissions: '2147483648',
37
+ description: 'ping the bot',
38
+ dmPermission: false,
39
+ integrationTypes: [0],
40
+ name: 'ping',
41
+ nsfw: true,
42
+ options: [{ name: 'loud', type: 5 }],
43
+ type: 1,
44
+ };
45
+ expect(commandBody(props)).toEqual({
46
+ contexts: [0, 1],
47
+ default_member_permissions: '2147483648',
48
+ description: 'ping the bot',
49
+ dm_permission: false,
50
+ integration_types: [0],
51
+ name: 'ping',
52
+ nsfw: true,
53
+ options: [{ name: 'loud', type: 5 }],
54
+ type: 1,
55
+ });
56
+ });
57
+ });
58
+
59
+ const attrsOf = (overrides: Partial<CommandFieldsAttributes> = {}): CommandFieldsAttributes => ({
60
+ contexts: undefined,
61
+ defaultMemberPermissions: undefined,
62
+ description: '',
63
+ dmPermission: undefined,
64
+ integrationTypes: undefined,
65
+ name: 'ping',
66
+ nsfw: false,
67
+ options: [],
68
+ type: 1,
69
+ ...overrides,
70
+ });
71
+
72
+ describe('commandMatches', () => {
73
+ test('an exact match reports no drift', () => {
74
+ expect(commandMatches(attrsOf(), { name: 'ping' })).toBe(true);
75
+ });
76
+
77
+ test('a description change is drift', () => {
78
+ expect(
79
+ commandMatches(attrsOf({ description: 'old' }), { description: 'new', name: 'ping' }),
80
+ ).toBe(false);
81
+ });
82
+
83
+ test('a name change is drift — name is part of identity but still compared', () => {
84
+ expect(commandMatches(attrsOf({ name: 'ping' }), { name: 'pong' })).toBe(false);
85
+ });
86
+
87
+ test('options are compared by deep equality, order included', () => {
88
+ const live = attrsOf({ options: [{ name: 'a' }, { name: 'b' }] });
89
+ expect(commandMatches(live, { name: 'ping', options: [{ name: 'a' }, { name: 'b' }] })).toBe(
90
+ true,
91
+ );
92
+ expect(commandMatches(live, { name: 'ping', options: [{ name: 'b' }, { name: 'a' }] })).toBe(
93
+ false,
94
+ );
95
+ });
96
+
97
+ test('an undeclared optional field is not compared — "not mine", both directions', () => {
98
+ const live = attrsOf({ defaultMemberPermissions: '8', dmPermission: true });
99
+ expect(commandMatches(live, { name: 'ping' })).toBe(true);
100
+ });
101
+
102
+ /**
103
+ * 🔴 THE CASE THAT SHIPPED WRONG ONCE (caught in review, before merge — not measured live).
104
+ * `options` must be gated exactly like every other optional field above: a declaration with
105
+ * no `options` at all reports no drift against a live command that has some, because
106
+ * `commandBody` already omits `options` when undeclared — comparing unconditionally would
107
+ * report drift no write could ever fix (an infinite "update"), and worse, the first real
108
+ * (non-dry-run) deploy would send a body with no `options` key, and Discord's real
109
+ * full-replace create would wipe the live command's arguments.
110
+ */
111
+ test('undeclared options against a live command that HAS options is not drift', () => {
112
+ const live = attrsOf({ options: [{ name: 'loud', type: 5 }] });
113
+ expect(commandMatches(live, { name: 'ping' })).toBe(true);
114
+ });
115
+
116
+ test('a declared optional field that disagrees with live is drift', () => {
117
+ const live = attrsOf({ defaultMemberPermissions: '8' });
118
+ expect(commandMatches(live, { defaultMemberPermissions: '16', name: 'ping' })).toBe(false);
119
+ });
120
+ });
121
+
122
+ describe('commandFieldsOf', () => {
123
+ test('reads every field off an ApplicationCommandResponse-shaped object, null coerced to undefined', () => {
124
+ expect(
125
+ commandFieldsOf({
126
+ contexts: null,
127
+ default_member_permissions: null,
128
+ description: 'ping the bot',
129
+ dm_permission: undefined,
130
+ integration_types: undefined,
131
+ name: 'ping',
132
+ nsfw: undefined,
133
+ options: null,
134
+ type: 1,
135
+ }),
136
+ ).toEqual(attrsOf({ description: 'ping the bot' }));
137
+ });
138
+
139
+ test('nsfw is strictly `=== true`, never truthy-coerced', () => {
140
+ expect(commandFieldsOf({ description: '', name: 'x', nsfw: undefined, type: 1 }).nsfw).toBe(
141
+ false,
142
+ );
143
+ });
144
+ });