@alchemy.run/boat 0.0.0 → 2.0.0-beta.87

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 (154) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +66 -4
  3. package/lib/AiProxy.d.ts +89 -0
  4. package/lib/AiProxy.d.ts.map +1 -0
  5. package/lib/AiProxy.js +125 -0
  6. package/lib/AiProxy.js.map +1 -0
  7. package/lib/ApiKey.d.ts +105 -0
  8. package/lib/ApiKey.d.ts.map +1 -0
  9. package/lib/ApiKey.js +134 -0
  10. package/lib/ApiKey.js.map +1 -0
  11. package/lib/AuthProvider.d.ts +71 -0
  12. package/lib/AuthProvider.d.ts.map +1 -0
  13. package/lib/AuthProvider.js +322 -0
  14. package/lib/AuthProvider.js.map +1 -0
  15. package/lib/BoatBindings.d.ts +52 -0
  16. package/lib/BoatBindings.d.ts.map +1 -0
  17. package/lib/BoatBindings.js +104 -0
  18. package/lib/BoatBindings.js.map +1 -0
  19. package/lib/Bootstrap.d.ts +8 -0
  20. package/lib/Bootstrap.d.ts.map +1 -0
  21. package/lib/Bootstrap.js +28 -0
  22. package/lib/Bootstrap.js.map +1 -0
  23. package/lib/ConnectSandbox.d.ts +56 -0
  24. package/lib/ConnectSandbox.d.ts.map +1 -0
  25. package/lib/ConnectSandbox.js +3 -0
  26. package/lib/ConnectSandbox.js.map +1 -0
  27. package/lib/ConnectSandboxHttp.d.ts +21 -0
  28. package/lib/ConnectSandboxHttp.d.ts.map +1 -0
  29. package/lib/ConnectSandboxHttp.js +46 -0
  30. package/lib/ConnectSandboxHttp.js.map +1 -0
  31. package/lib/Contributions.d.ts +7 -0
  32. package/lib/Contributions.d.ts.map +1 -0
  33. package/lib/Contributions.js +6 -0
  34. package/lib/Contributions.js.map +1 -0
  35. package/lib/CreateSandbox.d.ts +134 -0
  36. package/lib/CreateSandbox.d.ts.map +1 -0
  37. package/lib/CreateSandbox.js +3 -0
  38. package/lib/CreateSandbox.js.map +1 -0
  39. package/lib/CreateSandboxHttp.d.ts +21 -0
  40. package/lib/CreateSandboxHttp.d.ts.map +1 -0
  41. package/lib/CreateSandboxHttp.js +93 -0
  42. package/lib/CreateSandboxHttp.js.map +1 -0
  43. package/lib/CredentialProfile.d.ts +157 -0
  44. package/lib/CredentialProfile.d.ts.map +1 -0
  45. package/lib/CredentialProfile.js +245 -0
  46. package/lib/CredentialProfile.js.map +1 -0
  47. package/lib/Credentials.d.ts +15 -0
  48. package/lib/Credentials.d.ts.map +1 -0
  49. package/lib/Credentials.js +23 -0
  50. package/lib/Credentials.js.map +1 -0
  51. package/lib/Environment.d.ts +219 -0
  52. package/lib/Environment.d.ts.map +1 -0
  53. package/lib/Environment.js +273 -0
  54. package/lib/Environment.js.map +1 -0
  55. package/lib/Errors.d.ts +50 -0
  56. package/lib/Errors.d.ts.map +1 -0
  57. package/lib/Errors.js +17 -0
  58. package/lib/Errors.js.map +1 -0
  59. package/lib/Providers.d.ts +33 -0
  60. package/lib/Providers.d.ts.map +1 -0
  61. package/lib/Providers.js +47 -0
  62. package/lib/Providers.js.map +1 -0
  63. package/lib/Sandbox.d.ts +205 -0
  64. package/lib/Sandbox.d.ts.map +1 -0
  65. package/lib/Sandbox.js +368 -0
  66. package/lib/Sandbox.js.map +1 -0
  67. package/lib/SandboxClient.d.ts +159 -0
  68. package/lib/SandboxClient.d.ts.map +1 -0
  69. package/lib/SandboxClient.js +1 -0
  70. package/lib/SandboxClient.js.map +1 -0
  71. package/lib/SandboxEvents.d.ts +111 -0
  72. package/lib/SandboxEvents.d.ts.map +1 -0
  73. package/lib/SandboxEvents.js +60 -0
  74. package/lib/SandboxEvents.js.map +1 -0
  75. package/lib/SandboxEventsLive.d.ts +13 -0
  76. package/lib/SandboxEventsLive.d.ts.map +1 -0
  77. package/lib/SandboxEventsLive.js +63 -0
  78. package/lib/SandboxEventsLive.js.map +1 -0
  79. package/lib/Snapshot.d.ts +123 -0
  80. package/lib/Snapshot.d.ts.map +1 -0
  81. package/lib/Snapshot.js +173 -0
  82. package/lib/Snapshot.js.map +1 -0
  83. package/lib/Webhook.d.ts +56 -0
  84. package/lib/Webhook.d.ts.map +1 -0
  85. package/lib/Webhook.js +98 -0
  86. package/lib/Webhook.js.map +1 -0
  87. package/lib/index.d.ts +19 -0
  88. package/lib/index.d.ts.map +1 -0
  89. package/lib/index.js +19 -0
  90. package/lib/index.js.map +1 -0
  91. package/lib/internal/Declared.d.ts +34 -0
  92. package/lib/internal/Declared.d.ts.map +1 -0
  93. package/lib/internal/Declared.js +38 -0
  94. package/lib/internal/Declared.js.map +1 -0
  95. package/lib/internal/Org.d.ts +22 -0
  96. package/lib/internal/Org.d.ts.map +1 -0
  97. package/lib/internal/Org.js +23 -0
  98. package/lib/internal/Org.js.map +1 -0
  99. package/lib/internal/Ownership.d.ts +29 -0
  100. package/lib/internal/Ownership.d.ts.map +1 -0
  101. package/lib/internal/Ownership.js +50 -0
  102. package/lib/internal/Ownership.js.map +1 -0
  103. package/lib/internal/Program.d.ts +69 -0
  104. package/lib/internal/Program.d.ts.map +1 -0
  105. package/lib/internal/Program.js +178 -0
  106. package/lib/internal/Program.js.map +1 -0
  107. package/lib/internal/Ref.d.ts +14 -0
  108. package/lib/internal/Ref.d.ts.map +1 -0
  109. package/lib/internal/Ref.js +7 -0
  110. package/lib/internal/Ref.js.map +1 -0
  111. package/lib/internal/SandboxHandle.d.ts +17 -0
  112. package/lib/internal/SandboxHandle.d.ts.map +1 -0
  113. package/lib/internal/SandboxHandle.js +238 -0
  114. package/lib/internal/SandboxHandle.js.map +1 -0
  115. package/lib/internal/SavedSnapshot.d.ts +10 -0
  116. package/lib/internal/SavedSnapshot.d.ts.map +1 -0
  117. package/lib/internal/SavedSnapshot.js +22 -0
  118. package/lib/internal/SavedSnapshot.js.map +1 -0
  119. package/lib/internal/WebhookDelivery.d.ts +16 -0
  120. package/lib/internal/WebhookDelivery.d.ts.map +1 -0
  121. package/lib/internal/WebhookDelivery.js +109 -0
  122. package/lib/internal/WebhookDelivery.js.map +1 -0
  123. package/package.json +67 -3
  124. package/src/AiProxy.ts +171 -0
  125. package/src/ApiKey.ts +250 -0
  126. package/src/AuthProvider.ts +472 -0
  127. package/src/BoatBindings.ts +136 -0
  128. package/src/Bootstrap.ts +55 -0
  129. package/src/ConnectSandbox.ts +60 -0
  130. package/src/ConnectSandboxHttp.ts +59 -0
  131. package/src/Contributions.ts +9 -0
  132. package/src/CreateSandbox.ts +143 -0
  133. package/src/CreateSandboxHttp.ts +115 -0
  134. package/src/CredentialProfile.ts +350 -0
  135. package/src/Credentials.ts +33 -0
  136. package/src/Environment.ts +416 -0
  137. package/src/Errors.ts +34 -0
  138. package/src/Providers.ts +71 -0
  139. package/src/Sandbox.ts +647 -0
  140. package/src/SandboxClient.ts +200 -0
  141. package/src/SandboxEvents.ts +125 -0
  142. package/src/SandboxEventsLive.ts +88 -0
  143. package/src/Snapshot.ts +264 -0
  144. package/src/Webhook.ts +163 -0
  145. package/src/globals.d.ts +2 -0
  146. package/src/index.ts +18 -0
  147. package/src/internal/Declared.ts +87 -0
  148. package/src/internal/Org.ts +31 -0
  149. package/src/internal/Ownership.ts +75 -0
  150. package/src/internal/Program.ts +250 -0
  151. package/src/internal/Ref.ts +15 -0
  152. package/src/internal/SandboxHandle.ts +407 -0
  153. package/src/internal/SavedSnapshot.ts +34 -0
  154. package/src/internal/WebhookDelivery.ts +179 -0
@@ -0,0 +1,416 @@
1
+ import * as boat from "@distilled.cloud/boat-dev/boat";
2
+ import { Unowned } from "alchemy/AdoptPolicy";
3
+ import * as Provider from "alchemy/Provider";
4
+ import { Resource } from "alchemy/Resource";
5
+ import * as Effect from "effect/Effect";
6
+ import * as Redacted from "effect/Redacted";
7
+ import { BoatUnexpectedResponse, InvalidEnvironmentValue } from "./Errors.ts";
8
+ import { declarable } from "./internal/Declared.ts";
9
+ import { inOrg, sameOrg, stackOrg } from "./internal/Org.ts";
10
+ import { boatName, isGeneratedName } from "./internal/Ownership.ts";
11
+ import type { Providers } from "./Providers.ts";
12
+
13
+ /** A file written into every sandbox on the environment. */
14
+ export interface EnvironmentSecretFile {
15
+ /** Where it goes, under the home directory: `.npmrc`, `~/.npmrc` or `/home/user/.npmrc`. */
16
+ path: string;
17
+ contents: string | Redacted.Redacted<string>;
18
+ }
19
+
20
+ /** A credential profile, by id. Accepts a `Boat.CredentialProfile`. */
21
+ export interface EnvironmentCredentialProfileRef {
22
+ credentialProfileId: string;
23
+ }
24
+
25
+ /** An AI credentials proxy switch. Accepts a `Boat.AiProxy`. */
26
+ export interface EnvironmentAiProxyRef {
27
+ enabled: boolean;
28
+ }
29
+
30
+ export interface EnvironmentProps {
31
+ /**
32
+ * Environment name: letters, digits, `.`, `-` and `_`, up to 64. If
33
+ * omitted, a unique name is generated from the stack, stage and logical
34
+ * ID. Renamed in place.
35
+ */
36
+ name?: string;
37
+ /**
38
+ * Environment variables every sandbox on it gets. Updated in place;
39
+ * running sandboxes keep the version they started with. A value can't
40
+ * hold a newline, or both `'` and `"`.
41
+ */
42
+ env?: Record<string, string | Redacted.Redacted<string>>;
43
+ /** Files written into every sandbox on it. Updated in place. */
44
+ secretFiles?: EnvironmentSecretFile[];
45
+ /**
46
+ * Pass nothing to sandboxes: no repositories, secrets or credentials,
47
+ * whatever the flags below say. For sandboxes you give to third parties.
48
+ * @default false
49
+ */
50
+ safeForThirdParties?: boolean;
51
+ /**
52
+ * Attach `env` and `secretFiles`.
53
+ * @default true
54
+ */
55
+ passSecrets?: boolean;
56
+ /**
57
+ * Attach your GitHub token, the repositories selected on the dashboard,
58
+ * and their setup scripts. A sandbox with it can push as you.
59
+ * @default false
60
+ */
61
+ passGithub?: boolean;
62
+ /**
63
+ * Attach a Boat API key, so a sandbox can snapshot, stop and resume
64
+ * itself.
65
+ * @default false
66
+ */
67
+ passSandboxCredentials?: boolean;
68
+ /**
69
+ * Attach the keys and sign-ins of `credentialProfile`, or of the sandbox
70
+ * owner's default profile when there is none.
71
+ * @default true with a `credentialProfile`, else false
72
+ */
73
+ passAgentsCredentials?: boolean;
74
+ /**
75
+ * The `Boat.CredentialProfile` whose keys the sandboxes get (or
76
+ * `{ credentialProfileId }` of one). Omit for the sandbox owner's default
77
+ * profile.
78
+ */
79
+ credentialProfile?: EnvironmentCredentialProfileRef;
80
+ /**
81
+ * With `safeForThirdParties`, still give sandboxes the credential
82
+ * profile, as placeholders the AI credentials proxy swaps for the real
83
+ * keys. Real keys never enter them. Boat rejects it while the proxy is
84
+ * off for the profile's owner, so pass the `Boat.AiProxy` that turns it
85
+ * on: the environment then waits for it, and proxies only while it is on.
86
+ * @default false
87
+ */
88
+ proxyAiCredentials?: boolean | EnvironmentAiProxyRef;
89
+ /**
90
+ * Make it the account's default: sandboxes created without an
91
+ * `environment` use it.
92
+ * @default false
93
+ */
94
+ isDefault?: boolean;
95
+ }
96
+
97
+ export interface EnvironmentAttributes {
98
+ /** Boat's environment id. */
99
+ environmentId: string;
100
+ name: string;
101
+ /** Each change mints a version; new sandboxes start on the latest. */
102
+ latestVersionNumber: number;
103
+ isDefault: boolean;
104
+ /**
105
+ * The organization whose shared environment this is, as the stack's
106
+ * `org` named it; `undefined` for a personal environment.
107
+ */
108
+ org: string | undefined;
109
+ }
110
+
111
+ export interface Environment extends Resource<
112
+ "Boat.Environment",
113
+ EnvironmentProps,
114
+ EnvironmentAttributes,
115
+ never,
116
+ Providers,
117
+ { name: string }
118
+ > {}
119
+
120
+ /**
121
+ * A Boat environment: the environment variables, secret files and
122
+ * credential settings a sandbox starts with.
123
+ *
124
+ * Every change mints a new version. New sandboxes start on the latest
125
+ * version; running ones keep the version they started with until they are
126
+ * upgraded on the dashboard.
127
+ *
128
+ * An environment is personal, or shared by an organization. With an `org`
129
+ * on the stack (`Boat.providers({ org })`, or the profile's), it is that
130
+ * organization's: every member can use it, and owners and admins change
131
+ * it. Without one it is yours alone, even when your active wallet is an
132
+ * organization's. Changing the stack's `org` replaces it.
133
+ *
134
+ * An environment passes a sandbox its own `env` and `secretFiles`, and
135
+ * nothing of your account. Your GitHub token, a Boat API key and your AI
136
+ * credentials are each one flag, off until you turn it on.
137
+ *
138
+ * ### Creating an environment
139
+ * **Example:** Variables and a secret file
140
+ * ```typescript
141
+ * export const MyEnv = Boat.Environment("MyEnv", {
142
+ * env: { NODE_ENV: "production", API_TOKEN: Config.Redacted("API_TOKEN") },
143
+ * secretFiles: [{ path: ".npmrc", contents: Config.Redacted("NPMRC") }],
144
+ * });
145
+ *
146
+ * export const Box = Boat.Sandbox("Box", { environment: MyEnv });
147
+ * ```
148
+ *
149
+ * **Example:** An environment for sandboxes you hand to users
150
+ * ```typescript
151
+ * export const Tenants = Boat.Environment("Tenants", { safeForThirdParties: true });
152
+ * ```
153
+ *
154
+ * ### Passing account credentials
155
+ * **Example:** Let sandboxes push to GitHub as you, and manage themselves
156
+ * ```typescript
157
+ * export const Mine = Boat.Environment("Mine", {
158
+ * passGithub: true,
159
+ * passSandboxCredentials: true,
160
+ * });
161
+ * ```
162
+ *
163
+ * ### Giving sandboxes AI credentials
164
+ * **Example:** The keys of a credential profile
165
+ * ```typescript
166
+ * export const MyProfile = Boat.CredentialProfile("MyProfile", {
167
+ * anthropic: Config.Redacted("ANTHROPIC_API_KEY"),
168
+ * });
169
+ *
170
+ * export const MyEnv = Boat.Environment("MyEnv", { credentialProfile: MyProfile });
171
+ * ```
172
+ *
173
+ * **Example:** The sign-ins you made on the dashboard
174
+ * ```typescript
175
+ * export const MyEnv = Boat.Environment("MyEnv", {
176
+ * credentialProfile: Boat.CredentialProfile.lookup({ name: "default" }),
177
+ * });
178
+ * ```
179
+ *
180
+ * **Example:** Placeholders, for sandboxes you hand to users
181
+ * ```typescript
182
+ * export const MyProfile = Boat.CredentialProfile("MyProfile", {
183
+ * anthropic: Config.Redacted("ANTHROPIC_API_KEY"),
184
+ * });
185
+ *
186
+ * // Billed: $1 a day, plus $1 per 1,000 proxied requests.
187
+ * export const Proxy = Boat.AiProxy("Proxy", { enabled: true });
188
+ *
189
+ * export const Tenants = Boat.Environment("Tenants", {
190
+ * safeForThirdParties: true,
191
+ * credentialProfile: MyProfile,
192
+ * proxyAiCredentials: Proxy,
193
+ * });
194
+ * ```
195
+ *
196
+ * ### Sharing with an organization
197
+ * **Example:** An environment the whole team uses
198
+ * ```typescript
199
+ * // alchemy.run.ts
200
+ * export default Alchemy.Stack(
201
+ * "Team",
202
+ * { providers: Boat.providers({ org: "acme" }) },
203
+ * Effect.gen(function* () {
204
+ * const env = yield* Boat.Environment("MyEnv", { env: { MODE: "agent" } });
205
+ * return { environment: env.name };
206
+ * }),
207
+ * );
208
+ * ```
209
+ *
210
+ * ### Referencing an environment the stack doesn't manage
211
+ * **Example:** Look one up by name
212
+ * ```typescript
213
+ * export const Box = Boat.Sandbox("Box", {
214
+ * environment: Boat.Environment.lookup({ name: "base" }),
215
+ * });
216
+ * ```
217
+ *
218
+ * @resource
219
+ * @product Environment
220
+ */
221
+ export const Environment = declarable<Environment>()(Resource<Environment>("Boat.Environment"));
222
+
223
+ const reveal = (value: string | Redacted.Redacted<string>) =>
224
+ Redacted.isRedacted(value) ? Redacted.value(value) : value;
225
+
226
+ /**
227
+ * One `KEY=value` line as Boat reads it: single quotes keep a value
228
+ * verbatim; double quotes are stripped without escapes. A value holding a
229
+ * newline, or both kinds of quote, has no form Boat reads back intact.
230
+ */
231
+ const envLine = (key: string, value: string) =>
232
+ /^[A-Za-z0-9_./:@+,=%-]*$/.test(value)
233
+ ? Effect.succeed(`${key}=${value}`)
234
+ : value.includes("\n") || (value.includes("'") && value.includes('"'))
235
+ ? Effect.fail(
236
+ new InvalidEnvironmentValue({
237
+ key,
238
+ message: `Boat can't hold environment variable ${key}: its value has a newline, or both ' and "`,
239
+ }),
240
+ )
241
+ : Effect.succeed(value.includes("'") ? `${key}="${value}"` : `${key}='${value}'`);
242
+
243
+ const envContentsOf = (env: EnvironmentProps["env"]) =>
244
+ Effect.map(
245
+ Effect.forEach(Object.entries(env ?? {}), ([key, value]) => envLine(key, reveal(value))),
246
+ (lines) => lines.join("\n"),
247
+ );
248
+
249
+ /** Boat writes secret files relative to the home directory and doesn't expand `~`. */
250
+ const relativePath = (path: string) => path.replace(/^~\//, "").replace(/^\/home\/user\//, "");
251
+
252
+ const secretFilesOf = (files: EnvironmentProps["secretFiles"]) =>
253
+ (files ?? []).map((file) => ({
254
+ path: relativePath(file.path),
255
+ contents: reveal(file.contents),
256
+ }));
257
+
258
+ const listEnvironments = Effect.fn(function* (org: string | undefined) {
259
+ const { environments } = yield* boat.listEnvironments(inOrg(org));
260
+ return environments;
261
+ });
262
+
263
+ const findEnvironment = Effect.fn(function* (
264
+ org: string | undefined,
265
+ match: { environmentId?: string; name: string },
266
+ ) {
267
+ const environments = yield* listEnvironments(org);
268
+ return (
269
+ environments.find((environment) => environment.id === match.environmentId) ??
270
+ environments.find((environment) => environment.name === match.name)
271
+ );
272
+ });
273
+
274
+ const toAttributes = (
275
+ environment: boat.SandboxEnvironment,
276
+ org: string | undefined,
277
+ ): EnvironmentAttributes => ({
278
+ environmentId: environment.id,
279
+ name: environment.name,
280
+ latestVersionNumber: environment.latestVersionNumber ?? 0,
281
+ isDefault: environment.isDefault,
282
+ org,
283
+ });
284
+
285
+ /**
286
+ * What the props set, as the fields `updateEnvironment` takes. Nothing of
287
+ * the account is passed unless a prop asks for it: Boat's own defaults pass
288
+ * everything.
289
+ */
290
+ const desiredOf = (name: string, news: EnvironmentProps, envContents: string) => ({
291
+ name,
292
+ envContents,
293
+ secretFiles: secretFilesOf(news.secretFiles),
294
+ safeForThirdParties: news.safeForThirdParties ?? false,
295
+ passSecrets: news.passSecrets ?? true,
296
+ passGithub: news.passGithub ?? false,
297
+ passSandboxCredentials: news.passSandboxCredentials ?? false,
298
+ passAgentsCredentials: news.passAgentsCredentials ?? news.credentialProfile !== undefined,
299
+ proxyAiCredentials:
300
+ typeof news.proxyAiCredentials === "object"
301
+ ? news.proxyAiCredentials.enabled
302
+ : (news.proxyAiCredentials ?? false),
303
+ credentialProfileId: news.credentialProfile?.credentialProfileId ?? null,
304
+ });
305
+
306
+ const sortedFiles = (files: ReadonlyArray<{ path: string; contents: string }>) =>
307
+ JSON.stringify([...files].sort((a, b) => a.path.localeCompare(b.path)));
308
+
309
+ export const EnvironmentProvider = () =>
310
+ Provider.effect(
311
+ Environment,
312
+ Effect.gen(function* () {
313
+ return {
314
+ stables: ["environmentId", "org"],
315
+ // An environment belongs to one organization (or to you) for life.
316
+ diff: Effect.fn(function* ({ output }) {
317
+ if (output !== undefined && !sameOrg(output.org, yield* stackOrg)) {
318
+ return { action: "replace" } as const;
319
+ }
320
+ }),
321
+ read: Effect.fn(function* ({ id, olds, output }) {
322
+ const name = output?.name ?? (yield* boatName(id, olds?.name, 64));
323
+ const org = output ? output.org : yield* stackOrg;
324
+ const environment = yield* findEnvironment(org, {
325
+ environmentId: output?.environmentId,
326
+ name,
327
+ });
328
+ if (environment === undefined) return undefined;
329
+ const attrs = toAttributes(environment, org);
330
+ return output !== undefined || isGeneratedName(environment.name) ? attrs : Unowned(attrs);
331
+ }),
332
+ lookup: Effect.fn(function* ({ name }) {
333
+ const org = yield* stackOrg;
334
+ const environment = yield* findEnvironment(org, { name });
335
+ return environment === undefined ? undefined : toAttributes(environment, org);
336
+ }),
337
+ reconcile: Effect.fn(function* ({ id, news, output, session }) {
338
+ const name = yield* boatName(id, news.name ?? output?.name, 64);
339
+ // A different org replaces (see diff), so this is the org it lives in.
340
+ const org = yield* stackOrg;
341
+
342
+ // Observe — by id once we have one (it survives renames), else by name.
343
+ let environment = yield* findEnvironment(org, {
344
+ environmentId: output?.environmentId,
345
+ name,
346
+ });
347
+
348
+ // Ensure — a concurrent create of the same name is a race, not a failure.
349
+ if (environment === undefined) {
350
+ yield* session.note(`Creating environment ${name}...`);
351
+ const created = yield* boat
352
+ .createEnvironment({ name, ...inOrg(org) })
353
+ .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
354
+ environment =
355
+ created?.environments.find((candidate) => candidate.name === name) ??
356
+ (yield* findEnvironment(org, { name }));
357
+ if (environment === undefined) {
358
+ return yield* new BoatUnexpectedResponse({
359
+ message: `Boat environment ${name} vanished after create`,
360
+ });
361
+ }
362
+ }
363
+
364
+ // Sync — each update mints a version, so send only what differs.
365
+ const desired = desiredOf(name, news, yield* envContentsOf(news.env));
366
+ const delta: Partial<typeof desired> = {};
367
+ if (environment.name !== desired.name) delta.name = desired.name;
368
+ if (environment.envContents.trim() !== desired.envContents.trim()) {
369
+ delta.envContents = desired.envContents;
370
+ }
371
+ if (sortedFiles(environment.secretFiles) !== sortedFiles(desired.secretFiles)) {
372
+ delta.secretFiles = desired.secretFiles;
373
+ }
374
+ for (const flag of [
375
+ "safeForThirdParties",
376
+ "passGithub",
377
+ "passSecrets",
378
+ "passSandboxCredentials",
379
+ "passAgentsCredentials",
380
+ ] as const) {
381
+ if (environment[flag] !== desired[flag]) delta[flag] = desired[flag];
382
+ }
383
+ if ((environment.proxyAiCredentials ?? false) !== desired.proxyAiCredentials) {
384
+ delta.proxyAiCredentials = desired.proxyAiCredentials;
385
+ }
386
+ if ((environment.credentialProfileId ?? null) !== desired.credentialProfileId) {
387
+ delta.credentialProfileId = desired.credentialProfileId;
388
+ }
389
+ const makeDefault = news.isDefault === true && !environment.isDefault;
390
+ if (Object.keys(delta).length > 0 || makeDefault) {
391
+ yield* session.note(`Updating environment ${name}...`);
392
+ const updated = yield* boat.updateEnvironment({
393
+ environmentId: environment.id,
394
+ ...inOrg(org),
395
+ ...delta,
396
+ ...(makeDefault ? { isDefault: true } : {}),
397
+ });
398
+ environment = updated.environment ?? environment;
399
+ }
400
+
401
+ return toAttributes(environment, org);
402
+ }),
403
+ delete: Effect.fn(function* ({ output }) {
404
+ yield* boat
405
+ .deleteEnvironment({ environmentId: output.environmentId, ...inOrg(output.org) })
406
+ .pipe(Effect.catchTag("NotFound", () => Effect.void));
407
+ }),
408
+ list: Effect.fn(function* () {
409
+ const org = yield* stackOrg;
410
+ return (yield* listEnvironments(org))
411
+ .filter((environment) => isGeneratedName(environment.name))
412
+ .map((environment) => toAttributes(environment, org));
413
+ }),
414
+ };
415
+ }),
416
+ );
package/src/Errors.ts ADDED
@@ -0,0 +1,34 @@
1
+ import * as Data from "effect/Data";
2
+
3
+ /** Boat answered with something Alchemy can't use (a missing field, a vanished object). */
4
+ export class BoatUnexpectedResponse extends Data.TaggedError("Boat.UnexpectedResponse")<{
5
+ readonly message: string;
6
+ }> {}
7
+
8
+ /** A command ran past its `timeout` and Boat stopped it. */
9
+ export class ExecTimedOut extends Data.TaggedError("Boat.ExecTimedOut")<{
10
+ readonly message: string;
11
+ readonly stdout: string;
12
+ readonly stderr: string;
13
+ }> {}
14
+
15
+ /** A saved snapshot Boat could not save. */
16
+ export class SnapshotFailed extends Data.TaggedError("Boat.SnapshotFailed")<{
17
+ readonly message: string;
18
+ /** The snapshot's name, or the sandbox whose setup it waited on. */
19
+ readonly snapshot: string;
20
+ }> {}
21
+
22
+ /** An environment variable value Boat's `.env` format can't hold. */
23
+ export class InvalidEnvironmentValue extends Data.TaggedError("Boat.InvalidEnvironmentValue")<{
24
+ readonly message: string;
25
+ readonly key: string;
26
+ }> {}
27
+
28
+ /** A snapshot's setup command failed or ran past 10 minutes. */
29
+ export class SnapshotSetupFailed extends Data.TaggedError("Boat.SnapshotSetupFailed")<{
30
+ readonly message: string;
31
+ readonly command: string;
32
+ readonly exitCode: number;
33
+ readonly stderr: string;
34
+ }> {}
@@ -0,0 +1,71 @@
1
+ import { CredentialsStoreLive } from "alchemy/Auth/Credentials";
2
+ import { ProfileStoreLive } from "alchemy/Auth/Profile";
3
+ import * as Provider from "alchemy/Provider";
4
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
5
+ import * as Layer from "effect/Layer";
6
+ import { AiProxy, AiProxyProvider } from "./AiProxy.ts";
7
+ import { ApiKey, ApiKeyProvider } from "./ApiKey.ts";
8
+ import { BoatAuth } from "./AuthProvider.ts";
9
+ import { CredentialProfile, CredentialProfileProvider } from "./CredentialProfile.ts";
10
+ import * as Credentials from "./Credentials.ts";
11
+ import { Environment, EnvironmentProvider } from "./Environment.ts";
12
+ import { Sandbox, SandboxProvider } from "./Sandbox.ts";
13
+ import { Snapshot, SnapshotProvider } from "./Snapshot.ts";
14
+ import { Webhook, WebhookProvider } from "./Webhook.ts";
15
+
16
+ /**
17
+ * Service tag bundling all Boat providers + auth + credentials. Use
18
+ * `Boat.providers()` to materialize the full layer.
19
+ */
20
+ export class Providers extends Provider.ProviderCollection<Providers>()("Boat") {}
21
+
22
+ export type ProviderRequirements = Layer.Services<ReturnType<typeof providers>>;
23
+
24
+ /**
25
+ * Build the complete Boat providers Layer: every Boat resource, the Boat
26
+ * AuthProvider registration (so `alchemy profile edit` can configure it),
27
+ * and credential resolution.
28
+ *
29
+ * `org` picks the organization (wallet) the stack's resources bill and the
30
+ * one whose shared environments `Boat.Environment` manages: an org id
31
+ * (`team_…`), an org name, or `personal`. It overrides the profile's `org`
32
+ * (or `BOAT_ORG`); with neither, Boat uses the account's active wallet and
33
+ * environments are your personal ones.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * Alchemy.Stack("App", { providers: Boat.providers() }, ...)
38
+ * Alchemy.Stack("App", { providers: Boat.providers({ org: "team_abc" }) }, ...)
39
+ * ```
40
+ */
41
+ export const providers = (options?: { readonly org?: string }) =>
42
+ Layer.effect(
43
+ Providers,
44
+ Provider.collection([
45
+ AiProxy,
46
+ ApiKey,
47
+ CredentialProfile,
48
+ Environment,
49
+ Sandbox,
50
+ Snapshot,
51
+ Webhook,
52
+ ]),
53
+ ).pipe(
54
+ Layer.provide(
55
+ Layer.mergeAll(
56
+ AiProxyProvider(),
57
+ ApiKeyProvider(),
58
+ CredentialProfileProvider(),
59
+ EnvironmentProvider(),
60
+ SandboxProvider(),
61
+ SnapshotProvider(),
62
+ WebhookProvider(),
63
+ ),
64
+ ),
65
+ Layer.provideMerge(Credentials.fromAuthProvider(options)),
66
+ Layer.provideMerge(FetchHttpClient.layer),
67
+ Layer.provideMerge(BoatAuth),
68
+ Layer.provideMerge(ProfileStoreLive),
69
+ Layer.provideMerge(CredentialsStoreLive),
70
+ Layer.orDie,
71
+ );