@homeflare/alchemy 0.30.0 → 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.
- package/README.md +22 -0
- package/dist/discord/application-command.d.ts +52 -0
- package/dist/discord/application-command.d.ts.map +1 -0
- package/dist/discord/command-form.d.ts +62 -0
- package/dist/discord/command-form.d.ts.map +1 -0
- package/dist/discord/credentials.d.ts +35 -0
- package/dist/discord/credentials.d.ts.map +1 -0
- package/dist/discord/fake-discord.d.ts +44 -0
- package/dist/discord/fake-discord.d.ts.map +1 -0
- package/dist/discord/guild-application-command.d.ts +53 -0
- package/dist/discord/guild-application-command.d.ts.map +1 -0
- package/dist/discord/index.d.ts +12 -0
- package/dist/discord/index.d.ts.map +1 -0
- package/dist/discord/index.js +193 -0
- package/dist/discord/index.js.map +15 -0
- package/dist/discord/providers.d.ts +15 -0
- package/dist/discord/providers.d.ts.map +1 -0
- package/dist/discord/resource.d.ts +75 -0
- package/dist/discord/resource.d.ts.map +1 -0
- package/docs/credentials.md +9 -0
- package/docs/discord.md +129 -0
- package/docs/upstream-conformance.md +17 -1
- package/package.json +5 -1
- package/src/discord/application-command.test.ts +103 -0
- package/src/discord/application-command.ts +109 -0
- package/src/discord/command-form.test.ts +144 -0
- package/src/discord/command-form.ts +133 -0
- package/src/discord/credentials.ts +36 -0
- package/src/discord/fake-discord.ts +70 -0
- package/src/discord/guild-application-command.test.ts +215 -0
- package/src/discord/guild-application-command.ts +129 -0
- package/src/discord/index.ts +31 -0
- package/src/discord/providers.ts +18 -0
- package/src/discord/resource.ts +164 -0
- package/src/discord/retry.test.ts +74 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Input } from 'alchemy/Input';
|
|
2
|
+
import type { DiscordOpContext } from '@distilled.cloud/discord/Protocol';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
5
|
+
/**
|
|
6
|
+
* What every stack must still provide after `discordHandlers` bakes in `CredentialsFromEnv`
|
|
7
|
+
* (below): the transport. `DiscordOpContext` is `Credentials | HttpClient.HttpClient`, so once
|
|
8
|
+
* `Credentials` is supplied, `HttpClient.HttpClient` is what remains — mirroring
|
|
9
|
+
* `../netbox/resource.ts#NetboxRequirements`.
|
|
10
|
+
*/
|
|
11
|
+
export type DiscordRequirements = HttpClient.HttpClient;
|
|
12
|
+
/** Every Discord command resource's identity key, regardless of scope. */
|
|
13
|
+
export interface DiscordCommandIdentity {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* One Discord command object's distilled calls. `Live` is whatever the SDK decodes
|
|
18
|
+
* (`ApplicationCommandResponse`); `E` is left to each resource file to declare, so a call site's
|
|
19
|
+
* precise error union (in practice always `DiscordOpError` — see `protocol.ts` — because every
|
|
20
|
+
* generated Discord operation shares one error channel) flows straight through.
|
|
21
|
+
*/
|
|
22
|
+
export type DiscordSpec<Props extends DiscordCommandIdentity, Live, Attributes extends object, E> = {
|
|
23
|
+
/** Locate the live command by its declared name within this spec's scope (app, or app+guild). */
|
|
24
|
+
readonly fetchByName: (props: Props) => Effect.Effect<Live | undefined, E, DiscordOpContext>;
|
|
25
|
+
readonly attributes: (live: Live) => Attributes;
|
|
26
|
+
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
27
|
+
readonly upsertBody: (props: Props) => Record<string, unknown>;
|
|
28
|
+
/** POST create-or-overwrite — see the upsert note above. */
|
|
29
|
+
readonly upsert: (props: Props, body: Record<string, unknown>) => Effect.Effect<Live, E, DiscordOpContext>;
|
|
30
|
+
readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, DiscordOpContext>;
|
|
31
|
+
/** For error messages and test descriptions. */
|
|
32
|
+
readonly describe: (props: Props) => string;
|
|
33
|
+
};
|
|
34
|
+
export declare const discordOperations: <Props extends DiscordCommandIdentity, Live, Attributes extends object, E>(spec: DiscordSpec<Props, Live, Attributes, E>) => {
|
|
35
|
+
read: ({ olds, output }: {
|
|
36
|
+
olds: Props;
|
|
37
|
+
output: Attributes | undefined;
|
|
38
|
+
}) => Effect.Effect<Attributes | undefined, E, DiscordOpContext>;
|
|
39
|
+
diff: (news: Input<Props>, output: Attributes | undefined) => Effect.Effect<{
|
|
40
|
+
readonly action: 'update';
|
|
41
|
+
} | {
|
|
42
|
+
readonly action: 'noop';
|
|
43
|
+
} | undefined, E, DiscordOpContext>;
|
|
44
|
+
reconcile: (news: Props) => Effect.Effect<Attributes, E, DiscordOpContext>;
|
|
45
|
+
destroy: (olds: Props) => Effect.Effect<void, E, DiscordOpContext>;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The four provider handlers for a spec'd Discord command, wired once.
|
|
49
|
+
*
|
|
50
|
+
* ★ CREDENTIALS ARE PROVIDED HERE, ONCE. `CredentialsFromEnv` resolves `DISCORD_BOT_TOKEN` /
|
|
51
|
+
* `DISCORD_TOKEN` on the calling fiber per request (credentials.ts), so this closure keeps that
|
|
52
|
+
* laziness — nothing is captured at module load.
|
|
53
|
+
*/
|
|
54
|
+
export declare const discordHandlers: <Props extends DiscordCommandIdentity, Live, Attributes extends object, E>(spec: DiscordSpec<Props, Live, Attributes, E>) => {
|
|
55
|
+
list: () => Effect.Effect<never[], never, never>;
|
|
56
|
+
read: ({ olds, output }: {
|
|
57
|
+
olds: Props;
|
|
58
|
+
output: Attributes | undefined;
|
|
59
|
+
}) => Effect.Effect<Attributes | undefined, E, HttpClient.HttpClient>;
|
|
60
|
+
diff: ({ news, output }: {
|
|
61
|
+
news: Input<Props>;
|
|
62
|
+
output: Attributes | undefined;
|
|
63
|
+
}) => Effect.Effect<{
|
|
64
|
+
readonly action: 'update';
|
|
65
|
+
} | {
|
|
66
|
+
readonly action: 'noop';
|
|
67
|
+
} | undefined, E, HttpClient.HttpClient>;
|
|
68
|
+
reconcile: ({ news }: {
|
|
69
|
+
news: Props;
|
|
70
|
+
}) => Effect.Effect<Attributes, E, HttpClient.HttpClient>;
|
|
71
|
+
delete: ({ olds }: {
|
|
72
|
+
olds: Props;
|
|
73
|
+
}) => Effect.Effect<void, E, HttpClient.HttpClient>;
|
|
74
|
+
};
|
|
75
|
+
//# sourceMappingURL=resource.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../../src/discord/resource.ts"],"names":[],"mappings":"AAmCA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAC1E,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAGnE;;;;;GAKG;AACH,MAAM,MAAM,mBAAmB,GAAG,UAAU,CAAC,UAAU,CAAC;AAExD,0EAA0E;AAC1E,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED;;;;;GAKG;AACH,MAAM,MAAM,WAAW,CACrB,KAAK,SAAS,sBAAsB,EACpC,IAAI,EACJ,UAAU,SAAS,MAAM,EACzB,CAAC,IACC;IACF,iGAAiG;IACjG,QAAQ,CAAC,WAAW,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,GAAG,SAAS,EAAE,CAAC,EAAE,gBAAgB,CAAC,CAAC;IAC7F,QAAQ,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,UAAU,CAAC;IAChD,QAAQ,CAAC,OAAO,EAAE,CAAC,UAAU,EAAE,UAAU,EAAE,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;IACpE,QAAQ,CAAC,UAAU,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/D,4DAA4D;IAC5D,QAAQ,CAAC,MAAM,EAAE,CACf,KAAK,EAAE,KAAK,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC1B,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,EAAE,gBAAgB,CAAC,CAAC;IAC9C,QAAQ,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,KAAK,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,EAAE,gBAAgB,CAAC,CAAC;IAC5F,gDAAgD;IAChD,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,MAAM,CAAC;CAC7C,CAAC;AAEF,eAAO,MAAM,iBAAiB,GAC5B,KAAK,SAAS,sBAAsB,EACpC,IAAI,EACJ,UAAU,SAAS,MAAM,EACzB,CAAC,QAEK,WAAW,CAAC,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC;IAE7C,IAAI,qBAAqB;QAAE,IAAI,EAAE,KAAK,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAA;KAAE;IAYxE,IAAI,SAAS,KAAK,CAAC,KAAK,CAAC,UAAU,UAAU,GAAG,SAAS;yBAIZ,QAAQ;;yBAElC,MAAM;;IAIzB,SAAS,SAAS,KAAK;IAiBvB,OAAO,SAAS,KAAK;CAMrB,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,GAC1B,KAAK,SAAS,sBAAsB,EACpC,IAAI,EACJ,UAAU,SAAS,MAAM,EACzB,CAAC,QAEK,WAAW,CAAC,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC;IAM3C,IAAI;IACJ,IAAI,qBAAqB;QAAE,IAAI,EAAE,KAAK,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAA;KAAE;IAExE,IAAI,qBAAqB;QAAE,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAA;KAAE;yBArDpC,QAAQ;;yBAElC,MAAM;;IAqDvB,SAAS,aAAa;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE;IACrC,MAAM,aAAa;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE;CAErC,CAAC"}
|
package/docs/credentials.md
CHANGED
|
@@ -46,6 +46,15 @@ CLI client's variable names, not a house choice), still resolved on the calling
|
|
|
46
46
|
never captured at module scope. `Authorization: Bearer <value>` is built by the package's protocol
|
|
47
47
|
layer, not by this package. ⛔ The key is never a prop, for the same reason as above.
|
|
48
48
|
|
|
49
|
+
The Discord subpath (2026-09-24, new — `@distilled.cloud/discord`, no prior hand-rolled client)
|
|
50
|
+
resolves credentials through the SDK's own `CredentialsFromEnv` layer, which reads
|
|
51
|
+
`DISCORD_BOT_TOKEN` (falling back to `DISCORD_TOKEN`), resolved on the calling fiber per request,
|
|
52
|
+
never captured at module scope. `Authorization: Bot <value>` is built by the package's protocol
|
|
53
|
+
layer. On CT100, `hf-discord-halibut.service` holds its own bot token at
|
|
54
|
+
`/opt/homeflare/env/discord-halibut.env`; this package never reads that file. ⛔ The token is
|
|
55
|
+
never a prop, for the same reason as above. See [discord.md](./discord.md) for the live census
|
|
56
|
+
this credential would let a deploy act against.
|
|
57
|
+
|
|
49
58
|
The Google Workspace subpath (new 2026-09-24, on `@distilled.cloud/google-workspace`) resolves
|
|
50
59
|
credentials through the SDK's own `CredentialsFromEnv`, which reads `GOOGLE_ACCESS_TOKEN` — but
|
|
51
60
|
unlike NetBox/Forgejo/Paperless/LiteLLM's API-token schemes, this variable holds an OAuth2 bearer
|
package/docs/discord.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Discord — `@homeflare/alchemy/discord`
|
|
2
|
+
|
|
3
|
+
Declares Discord application commands (global and guild-scoped) against
|
|
4
|
+
`@distilled.cloud/discord@1.0.0-rc.12`. Built 2026-09-24, walked against the live target,
|
|
5
|
+
`hf-discord-halibut.service` on CT100 — read-only; nothing in this walk deployed anything or
|
|
6
|
+
touched a real token. See [`upstream-conformance.md`](./upstream-conformance.md#discord) for the
|
|
7
|
+
rule-by-rule accounting.
|
|
8
|
+
|
|
9
|
+
## Resource set, and why it stops there
|
|
10
|
+
|
|
11
|
+
Two resources: `Discord.ApplicationCommand` (global) and `Discord.GuildApplicationCommand`
|
|
12
|
+
(guild-scoped). The SDK's `discord.ts` service also generates guild role, guild channel and
|
|
13
|
+
webhook operations (`createGuildRole`, `createGuildChannel`, `createWebhook`, …), but
|
|
14
|
+
`hf-discord-halibut.service` — the only live Discord workload the estate runs — touches none of
|
|
15
|
+
them: it registers slash commands and nothing else (measured on CT100, see below). "Start with
|
|
16
|
+
what Halibut actually uses" (the task this shipped under) means roles, channels and webhooks stay
|
|
17
|
+
unbuilt until a real stack needs to declare one; the engine in `resource.ts` is generic enough
|
|
18
|
+
that adding `Discord.GuildRole` later is a new spec file, not a new engine.
|
|
19
|
+
|
|
20
|
+
## Halibut, measured on CT100 (read-only, 2026-09-24)
|
|
21
|
+
|
|
22
|
+
- **Application/bot**: `hf-discord-halibut.service`, `/opt/homeflare/discord-bot/halibut`,
|
|
23
|
+
Sapphire on discord.js. Token in `/opt/homeflare/env/discord-halibut.env`
|
|
24
|
+
(`DISCORD_TOKEN`, `0640 root:homeflare`), loaded by systemd's `EnvironmentFile=`. Never read by
|
|
25
|
+
this package.
|
|
26
|
+
- **Guild(s)**: `DISCORD_GUILD_ID`, a CSV of two snowflakes (commented `HomeFlare,schenanigans`
|
|
27
|
+
in `register.ts`) — guild-scoped registration only; `DISCORD_GUILD_ID` is always set in
|
|
28
|
+
production, so `guildIdsForRegister()` never falls through to global.
|
|
29
|
+
- **Commands** (`CURRENT_SLASH_NAMES`, `src/discord/register.ts`): `ping`, `whoami`, `session`,
|
|
30
|
+
`status`, `llmstats`, `run`, `sch`, `pr`, `voice` — nine guild-scoped slash commands.
|
|
31
|
+
- **Registration timing**: `applySapphireGuildRegister()` runs once **before login**, setting
|
|
32
|
+
Sapphire's `RegisterBehavior.BulkOverwrite` and its default guild ids. On every connect,
|
|
33
|
+
Sapphire then `application.commands.set()`s the exact `CURRENT_SLASH_NAMES` set — a PUT that
|
|
34
|
+
wipes anything else registered on those guilds ("Stale guild cmds … have no handler → Discord
|
|
35
|
+
'interaction failed'", the file's own comment, measured 2026-09-04).
|
|
36
|
+
- **Webhooks, roles, channels**: none found. `grep -rli webhook` over `dist/` matched nothing;
|
|
37
|
+
the only role/channel-shaped env var is an unrelated `DISCORD_FORUM_TAG_MAP`.
|
|
38
|
+
|
|
39
|
+
## 🔴 Ownership conflict: do not deploy `GuildApplicationCommand` against Halibut's guilds yet
|
|
40
|
+
|
|
41
|
+
Halibut's `BulkOverwrite` on every restart and this resource's `reconcile` (an upsert on every
|
|
42
|
+
apply where `matches` fails) are two independent writers converging the same object on two
|
|
43
|
+
different schedules. Declaring `Discord.GuildApplicationCommand` for `ping`/`whoami`/… on
|
|
44
|
+
Halibut's guilds today would not replace Halibut's registration — it would race it. Whichever ran
|
|
45
|
+
last after each of the bot's restarts and each `alchemy deploy` would win until the other ran
|
|
46
|
+
again, and `alchemy plan` would report drift the bot itself caused.
|
|
47
|
+
|
|
48
|
+
**The handover, when someone runs it (none of this is done by this change):**
|
|
49
|
+
|
|
50
|
+
1. Freeze `CURRENT_SLASH_NAMES` — it is already the single source of truth for the set.
|
|
51
|
+
2. Declare each command as a `guildApplicationCommand(...)` (the `adopt(true)` constructor) with
|
|
52
|
+
props matching what is live exactly. A verified apply should be a no-op — `hf-adopt-verify`
|
|
53
|
+
proves that without writing (H6).
|
|
54
|
+
3. **In the same deploy window**, remove the `applySapphireGuildRegister()` call from Halibut's
|
|
55
|
+
`src/discord/register.ts` and redeploy the bot. Step 2 without step 3 leaves both writers
|
|
56
|
+
active; step 3 without step 2 leaves the commands with no owner at all until the next deploy.
|
|
57
|
+
|
|
58
|
+
Steps 1–3 are bot-runtime changes and out of scope for this family, which is declarative only.
|
|
59
|
+
|
|
60
|
+
## Credentials
|
|
61
|
+
|
|
62
|
+
`DISCORD_BOT_TOKEN` (falling back to `DISCORD_TOKEN`), read **at call time**, never a prop —
|
|
63
|
+
`@distilled.cloud/discord/Credentials`' own `CredentialsFromEnv`, re-exported from
|
|
64
|
+
`credentials.ts` rather than re-implemented. No OpenBao mint yet (H8's target,
|
|
65
|
+
`Alchemy.Stack({ secrets })`, is unreleased); today a deploy process exports the variable itself.
|
|
66
|
+
|
|
67
|
+
## Adopt is `adopt(true)`, not silent
|
|
68
|
+
|
|
69
|
+
Unlike NetBox (H1's documented gap), this family follows upstream's `Snippet.ts` reference for a
|
|
70
|
+
marker-less API exactly: a cold `read` (no persisted `output`) that finds a live command by name
|
|
71
|
+
returns `Unowned(attrs)`, so a first deploy against an existing command fails
|
|
72
|
+
`OwnedBySomeoneElse` **unless** `adopt(true)` is set. Both convenience constructors
|
|
73
|
+
(`applicationCommand`, `guildApplicationCommand`) pipe `adopt(true)` on by default (H5), matching
|
|
74
|
+
`cloudflare/website.ts`'s `astroWebsite`/`viteWebsite`.
|
|
75
|
+
|
|
76
|
+
## Reconcile is one upsert call
|
|
77
|
+
|
|
78
|
+
Discord's own docs for both Create Global/Guild Application Command say a command with the same
|
|
79
|
+
name (and type) as an existing one overwrites it — the create endpoint is a true upsert, the same
|
|
80
|
+
shape `Snippet.ts`'s `putSnippet` uses. `reconcile` therefore has no separate PATCH path: it reads
|
|
81
|
+
the live command, and calls `create` only when `commandMatches` says the declaration and the live
|
|
82
|
+
object disagree. ⚠️ REASONED NOT MEASURED — this task had no token and made no real Discord API
|
|
83
|
+
call; it is documented Discord behaviour, unchanged across API v10, not something exercised here.
|
|
84
|
+
|
|
85
|
+
## Rate limits: the SDK's default bound, not a house retry
|
|
86
|
+
|
|
87
|
+
Every generated operation threads a `Retry` context tag (`retry: Retry.Retry` in
|
|
88
|
+
`protocol.ts`/`api.ts`). With no policy installed, `@distilled.cloud/core`'s `makeDefault` applies:
|
|
89
|
+
capped exponential backoff (250ms × 2, capped at 5s), **`Schedule.recurs(8)`** (bounded — S26 asks
|
|
90
|
+
for 8–10 attempts), and a `429`'s `Retry-After` honored with precedence, itself capped at 60s
|
|
91
|
+
(`DEFAULT_SERVER_RETRY_HINT_CAP_MS`). This family adds nothing on top — no `Discord.Retry.policy`
|
|
92
|
+
call anywhere in `resource.ts` — because adding one could only make it either redundant with, or
|
|
93
|
+
looser than, the bound the SDK already guarantees. `retry.test.ts` proves both directions against
|
|
94
|
+
the fake: a rate-limited call is retried and eventually succeeds, AND a call rate-limited forever
|
|
95
|
+
still stops — `fake.seen.length` is asserted `<= 9` (1 attempt + `Schedule.recurs(8)`), not just
|
|
96
|
+
"eventually rejects", so the bound itself is what the test checks.
|
|
97
|
+
|
|
98
|
+
## SDK gaps
|
|
99
|
+
|
|
100
|
+
- 🔴 **MEASURED: every generated operation's typed error union is narrower than the protocol's own
|
|
101
|
+
`HTTP_STATUS_MAP`.** `tsc` is what surfaced this — see `guild-application-command.test.ts`'s
|
|
102
|
+
401 test and its own comment. `DiscordOpError` (every operation's declared error type) resolves
|
|
103
|
+
to `DefaultErrors | ConfigError | HttpClientError`, and core's `DefaultErrors` is built only from
|
|
104
|
+
`DEFAULT_ERROR_STATUSES` — 401, 429, 500, 502, 503, 504. `Forbidden` (403), `NotFound` (404),
|
|
105
|
+
`BadRequest` (400) and `Conflict` (409) are in `HTTP_STATUS_MAP` and so ARE constructed by the
|
|
106
|
+
protocol at runtime on those statuses, but they are absent from the exported error TYPE, so
|
|
107
|
+
`catchTag('Forbidden', …)` does not type-check anywhere in this family without an unsafe cast.
|
|
108
|
+
Nothing in `resource.ts` currently needs to catch any of those four (locate is list-based and
|
|
109
|
+
never 404s, the same reasoning `netbox/prefix.ts` documents), but a future resource that does —
|
|
110
|
+
or `destroy`'s own TOCTOU race, where the command could vanish between `fetchByName` and the
|
|
111
|
+
delete call and legitimately 404 — cannot `catchTag('NotFound', …)` today. That gap is real and
|
|
112
|
+
unhandled in `resource.ts#destroy`, documented rather than papered over with a cast.
|
|
113
|
+
- **`options` is passed through opaquely**, not re-modeled. The generated option union is ~40
|
|
114
|
+
nested schema types; `command-form.ts` types it as `readonly Record<string, unknown>[]` and
|
|
115
|
+
neither validates nor transforms it. A caller supplies Discord's own wire shape.
|
|
116
|
+
- **No vendor constraint table**, unlike NetBox/Paperless. Distilled's schema carries Discord's
|
|
117
|
+
field limits (name length, description length, max 25 options, …) but this change did not walk
|
|
118
|
+
`vendor-schema` for Discord; a bad declaration fails at the API call, not at plan time.
|
|
119
|
+
- **`default_member_permissions` type asymmetry**: the generated request types show
|
|
120
|
+
`number | null`, the response shows `string | null`. Discord's real wire contract is a
|
|
121
|
+
stringified bitfield on both sides; `command-form.ts` types the prop as `string` (matching the
|
|
122
|
+
response and Discord's docs) and casts through `unknown` at the SDK call boundary, the same
|
|
123
|
+
pattern `netbox/prefix.ts` uses for its own generated-type mismatches.
|
|
124
|
+
- **Global command propagation** (up to an hour, Discord's own docs) is not polled — S26 bounds
|
|
125
|
+
waits at ~60s, which cannot cover an hour. A `diff` run inside that window may report transient
|
|
126
|
+
drift. Documented in `application-command.ts`, not solved.
|
|
127
|
+
- **No live lifecycle test** (H12): tests run against `fake-discord.ts`, a loopback fake, not a
|
|
128
|
+
real Discord application. This task could not hold a token to run one even if the house allowed
|
|
129
|
+
agent-run live writes, which it currently does not.
|
|
@@ -224,8 +224,24 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
|
|
|
224
224
|
the house's glyph rationale into `//` comments.
|
|
225
225
|
13. **Tests never use `alchemy/Test/Bun`.** Every lifecycle is proven against loopback
|
|
226
226
|
fakes (S28, H12). Live suites need a place to run, and that is a maintainer decision.
|
|
227
|
+
14. **`discord/*` is new (2026-09-24, task-authorized, kit PR TBD) and built directly on
|
|
228
|
+
`@distilled.cloud/discord` — no hand-rolled `client.ts` ever existed to retire.**
|
|
229
|
+
`Discord.ApplicationCommand` and `Discord.GuildApplicationCommand` call the SDK's
|
|
230
|
+
typed operations, `catchTag`'d through the shared `DiscordOpError` union (S21), with no
|
|
231
|
+
status sniffing anywhere in `resource.ts`. It follows upstream's `Snippet.ts` reference
|
|
232
|
+
exactly for a marker-less API (S7, S8): a cold `read` returns `Unowned(attrs)`, and both
|
|
233
|
+
convenience constructors pipe `adopt(true)` (H5) — a stricter posture than `netbox/*`'s
|
|
234
|
+
documented H1 gap, not a repeat of it. Rate-limit handling is the SDK's own default
|
|
235
|
+
`Retry` policy (bounded: `Schedule.recurs(8)`, S26) — this family adds nothing on top.
|
|
236
|
+
Full detail, the live Halibut census and its ownership-handover sequence, and every SDK
|
|
237
|
+
gap: [`discord.md`](./discord.md). ⛔ **Diverges on S24/S25 the same way every distilled
|
|
238
|
+
family in this ledger does:** credentials are read at call time through the SDK's own
|
|
239
|
+
`CredentialsFromEnv`, not a house `alchemy/Auth` provider — unchanged from `netbox/*`
|
|
240
|
+
and `litellm/*`'s entries above. **Gap, not yet fixed:** no vendor constraint table
|
|
241
|
+
(unlike NetBox/Paperless); `options` passed through opaquely rather than modeled from
|
|
242
|
+
the schema (`docs/discord.md#sdk-gaps`).
|
|
227
243
|
|
|
228
|
-
|
|
244
|
+
15. **`grafana/*` (added 2026-09-24) ships only `Datasource` — a real SDK gap, not scope-trimming.**
|
|
229
245
|
Measured against the published `@distilled.cloud/grafana@1.0.0-rc.12` tarball's
|
|
230
246
|
`lib/services/grafana.d.ts` (4,938 lines): the package has no create/read/update/delete
|
|
231
247
|
operations for folders (only `updateFolderPermissions` exists), plain dashboards (only
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
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",
|
|
@@ -96,6 +96,10 @@
|
|
|
96
96
|
"types": "./dist/verify/index.d.ts",
|
|
97
97
|
"default": "./dist/verify/index.js"
|
|
98
98
|
},
|
|
99
|
+
"./discord": {
|
|
100
|
+
"types": "./dist/discord/index.d.ts",
|
|
101
|
+
"default": "./dist/discord/index.js"
|
|
102
|
+
},
|
|
99
103
|
"./package.json": "./package.json"
|
|
100
104
|
},
|
|
101
105
|
"publishConfig": {
|
|
@@ -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
|
+
});
|