@crewhaus/cloud-adapter-flyio 0.1.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/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@crewhaus/cloud-adapter-flyio",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "One-click deploy adapter for Fly.io — generates a fly.toml + a Fly-tuned Dockerfile and wraps the Machines API for daemon target shapes (Section 44)",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/docker-images": "0.0.0",
16
+ "@crewhaus/errors": "0.0.0"
17
+ },
18
+ "license": "Apache-2.0",
19
+ "author": {
20
+ "name": "Max Meier",
21
+ "email": "max@studiomax.io",
22
+ "url": "https://studiomax.io"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/crewhaus/factory.git",
27
+ "directory": "packages/cloud-adapter-flyio"
28
+ },
29
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/cloud-adapter-flyio#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/crewhaus/factory/issues"
32
+ },
33
+ "publishConfig": {
34
+ "access": "restricted"
35
+ },
36
+ "files": [
37
+ "src",
38
+ "README.md",
39
+ "LICENSE",
40
+ "NOTICE"
41
+ ]
42
+ }
@@ -0,0 +1,329 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ FLY_DEFAULT_API_BASE,
4
+ FLY_TARGET_SHAPES,
5
+ FlyAdapterError,
6
+ deployToFly,
7
+ flyDockerfileFor,
8
+ flyTomlFor,
9
+ isFlyTargetShape,
10
+ scrubApiToken,
11
+ } from "./index";
12
+
13
+ const API_TOKEN_ENV = "FLY_API_TOKEN";
14
+
15
+ describe("isFlyTargetShape", () => {
16
+ test("accepts daemon shapes", () => {
17
+ for (const shape of FLY_TARGET_SHAPES) {
18
+ expect(isFlyTargetShape(shape)).toBe(true);
19
+ }
20
+ });
21
+ test("rejects one-shot shapes", () => {
22
+ for (const shape of ["cli", "workflow", "eval", "graph", "pipeline", "crew", "research"]) {
23
+ expect(isFlyTargetShape(shape)).toBe(false);
24
+ }
25
+ });
26
+ });
27
+
28
+ describe("flyTomlFor", () => {
29
+ test("web shape emits http_service block + healthcheck", () => {
30
+ const toml = flyTomlFor({ target: "channel", appName: "my-bot" });
31
+ expect(toml).toContain('app = "my-bot"');
32
+ expect(toml).toContain('primary_region = "iad"');
33
+ expect(toml).toContain("[build]");
34
+ expect(toml).toContain('dockerfile = "Dockerfile.fly"');
35
+ expect(toml).toContain("[http_service]");
36
+ expect(toml).toContain("internal_port = 8080");
37
+ expect(toml).toContain("force_https = true");
38
+ expect(toml).toContain('path = "/healthz"');
39
+ expect(toml).toContain("[[vm]]");
40
+ expect(toml).toContain('cpu_kind = "shared"');
41
+ });
42
+
43
+ test("batch shape emits process group (no http_service)", () => {
44
+ const toml = flyTomlFor({ target: "batch", appName: "my-worker" });
45
+ expect(toml).not.toContain("[http_service]");
46
+ expect(toml).toContain("[processes]");
47
+ expect(toml).toContain("worker =");
48
+ });
49
+
50
+ test("env vars are TOML-encoded + sorted", () => {
51
+ const a = flyTomlFor({
52
+ target: "channel",
53
+ appName: "svc",
54
+ envVars: { ZETA: "1", ALPHA: "2" },
55
+ });
56
+ const b = flyTomlFor({
57
+ target: "channel",
58
+ appName: "svc",
59
+ envVars: { ALPHA: "2", ZETA: "1" },
60
+ });
61
+ expect(a).toBe(b);
62
+ const alpha = a.indexOf("ALPHA");
63
+ const zeta = a.indexOf("ZETA");
64
+ expect(alpha).toBeLessThan(zeta);
65
+ });
66
+
67
+ test("env var values with quotes are escaped", () => {
68
+ const toml = flyTomlFor({
69
+ target: "channel",
70
+ appName: "svc",
71
+ envVars: { TRICKY: 'has "quote" inside' },
72
+ });
73
+ expect(toml).toContain('TRICKY = "has \\"quote\\" inside"');
74
+ });
75
+
76
+ test("respects vm preset", () => {
77
+ const toml = flyTomlFor({ target: "channel", appName: "svc", vm: "performance-2x" });
78
+ expect(toml).toContain('cpu_kind = "performance"');
79
+ expect(toml).toContain("cpus = 2");
80
+ expect(toml).toContain("memory_mb = 4096");
81
+ });
82
+
83
+ test("respects internal port + region overrides", () => {
84
+ const toml = flyTomlFor({
85
+ target: "channel",
86
+ appName: "svc",
87
+ internalPort: 3000,
88
+ primaryRegion: "fra",
89
+ });
90
+ expect(toml).toContain("internal_port = 3000");
91
+ expect(toml).toContain('primary_region = "fra"');
92
+ });
93
+
94
+ test("rejects invalid app names", () => {
95
+ expect(() => flyTomlFor({ target: "channel", appName: "MyApp" })).toThrow(FlyAdapterError);
96
+ expect(() => flyTomlFor({ target: "channel", appName: "-leading" })).toThrow(FlyAdapterError);
97
+ expect(() =>
98
+ flyTomlFor({ target: "channel", appName: "this-app-name-is-far-too-long-for-fly" }),
99
+ ).toThrow(FlyAdapterError);
100
+ });
101
+
102
+ test("rejects unsupported target shapes", () => {
103
+ expect(() => flyTomlFor({ target: "cli", appName: "svc" })).toThrow(FlyAdapterError);
104
+ expect(() => flyTomlFor({ target: "eval", appName: "svc" })).toThrow(FlyAdapterError);
105
+ });
106
+ });
107
+
108
+ describe("flyDockerfileFor", () => {
109
+ test("web shape gets EXPOSE on the internal port", () => {
110
+ const df = flyDockerfileFor({ target: "channel", baseImage: "crewhaus/channel:0.1.0" });
111
+ expect(df).toContain("FROM crewhaus/channel:0.1.0");
112
+ expect(df).toContain("ENV PORT=8080");
113
+ expect(df).toContain("EXPOSE 8080");
114
+ });
115
+
116
+ test("worker shape skips EXPOSE", () => {
117
+ const df = flyDockerfileFor({ target: "batch", baseImage: "crewhaus/batch:0.1.0" });
118
+ expect(df).not.toContain("EXPOSE");
119
+ });
120
+
121
+ test("respects internal port override", () => {
122
+ const df = flyDockerfileFor({
123
+ target: "channel",
124
+ baseImage: "crewhaus/channel:0.1.0",
125
+ internalPort: 3000,
126
+ });
127
+ expect(df).toContain("EXPOSE 3000");
128
+ expect(df).toContain("ENV PORT=3000");
129
+ });
130
+
131
+ test("rejects one-shot shapes", () => {
132
+ expect(() => flyDockerfileFor({ target: "cli", baseImage: "x" })).toThrow(FlyAdapterError);
133
+ });
134
+
135
+ test("rejects unknown shapes", () => {
136
+ expect(() => flyDockerfileFor({ target: "mystery" as never, baseImage: "x" })).toThrow(
137
+ FlyAdapterError,
138
+ );
139
+ });
140
+ });
141
+
142
+ describe("scrubApiToken", () => {
143
+ test("redacts the token", () => {
144
+ expect(scrubApiToken("token fo_abcdefghij123 leaked", "fo_abcdefghij123")).toBe(
145
+ "token [REDACTED:FLY_API_TOKEN] leaked",
146
+ );
147
+ });
148
+ test("ignores short tokens", () => {
149
+ expect(scrubApiToken("untouched", "abc")).toBe("untouched");
150
+ });
151
+ test("ignores undefined tokens", () => {
152
+ expect(scrubApiToken("untouched", undefined)).toBe("untouched");
153
+ });
154
+ });
155
+
156
+ describe("deployToFly", () => {
157
+ const ORIGINAL_TOKEN = process.env[API_TOKEN_ENV];
158
+ afterEach(() => {
159
+ if (ORIGINAL_TOKEN === undefined) delete process.env[API_TOKEN_ENV];
160
+ else process.env[API_TOKEN_ENV] = ORIGINAL_TOKEN;
161
+ });
162
+
163
+ function okFetchImpl(machineId = "m-default", state = "started"): typeof fetch {
164
+ return (async (url: string | URL) => {
165
+ const u = String(url);
166
+ if (u.endsWith("/apps")) {
167
+ return new Response("{}", { status: 201 });
168
+ }
169
+ if (u.includes("/machines")) {
170
+ return new Response(JSON.stringify({ id: machineId, state }), { status: 200 });
171
+ }
172
+ return new Response("not matched", { status: 500 });
173
+ }) as unknown as typeof fetch;
174
+ }
175
+
176
+ test("creates app + launches first machine", async () => {
177
+ const record = await deployToFly({
178
+ target: "channel",
179
+ appName: "my-bot",
180
+ imageRef: "registry.fly.io/my-bot:latest",
181
+ apiToken: "fo_testtoken_abc",
182
+ fetchImpl: okFetchImpl("m-12345", "started"),
183
+ });
184
+ expect(record.appName).toBe("my-bot");
185
+ expect(record.machineId).toBe("m-12345");
186
+ expect(record.status).toBe("started");
187
+ expect(record.region).toBe("iad");
188
+ });
189
+
190
+ test("treats 409 app-create conflict as success (already exists)", async () => {
191
+ const fetchImpl = (async (url: string | URL) => {
192
+ const u = String(url);
193
+ if (u.endsWith("/apps")) {
194
+ return new Response('{"error":"app exists"}', { status: 409 });
195
+ }
196
+ return new Response(JSON.stringify({ id: "m-x", state: "started" }), { status: 200 });
197
+ }) as unknown as typeof fetch;
198
+ const record = await deployToFly({
199
+ target: "channel",
200
+ appName: "my-bot",
201
+ imageRef: "img",
202
+ apiToken: "fo_testtoken_abc",
203
+ fetchImpl,
204
+ });
205
+ expect(record.machineId).toBe("m-x");
206
+ });
207
+
208
+ test("scrubs the token from machine-launch error bodies", async () => {
209
+ const fetchImpl = (async (url: string | URL) => {
210
+ const u = String(url);
211
+ if (u.endsWith("/apps")) return new Response("{}", { status: 201 });
212
+ return new Response("unauthorized — token fo_leakingtoken_full denied", {
213
+ status: 401,
214
+ statusText: "Unauthorized",
215
+ });
216
+ }) as unknown as typeof fetch;
217
+ let caught: Error | undefined;
218
+ try {
219
+ await deployToFly({
220
+ target: "channel",
221
+ appName: "my-bot",
222
+ imageRef: "img",
223
+ apiToken: "fo_leakingtoken_full",
224
+ fetchImpl,
225
+ });
226
+ } catch (err) {
227
+ caught = err as Error;
228
+ }
229
+ expect(caught).toBeInstanceOf(FlyAdapterError);
230
+ expect(caught?.message).not.toContain("fo_leakingtoken_full");
231
+ expect(caught?.message).toContain("[REDACTED:FLY_API_TOKEN]");
232
+ });
233
+
234
+ test("scrubs the token from app-create error bodies", async () => {
235
+ const fetchImpl = (async () =>
236
+ new Response("forbidden — token fo_leakingtoken_full denied", {
237
+ status: 403,
238
+ statusText: "Forbidden",
239
+ })) as unknown as typeof fetch;
240
+ let caught: Error | undefined;
241
+ try {
242
+ await deployToFly({
243
+ target: "channel",
244
+ appName: "my-bot",
245
+ imageRef: "img",
246
+ apiToken: "fo_leakingtoken_full",
247
+ fetchImpl,
248
+ });
249
+ } catch (err) {
250
+ caught = err as Error;
251
+ }
252
+ expect(caught).toBeInstanceOf(FlyAdapterError);
253
+ expect(caught?.message).not.toContain("fo_leakingtoken_full");
254
+ });
255
+
256
+ test("throws clearly when no token is configured", async () => {
257
+ delete process.env[API_TOKEN_ENV];
258
+ let caught: Error | undefined;
259
+ try {
260
+ await deployToFly({
261
+ target: "channel",
262
+ appName: "my-bot",
263
+ imageRef: "img",
264
+ });
265
+ } catch (err) {
266
+ caught = err as Error;
267
+ }
268
+ expect(caught).toBeInstanceOf(FlyAdapterError);
269
+ expect(caught?.message).toContain("FLY_API_TOKEN is required");
270
+ });
271
+
272
+ test("resolves token from env when apiToken omitted", async () => {
273
+ process.env[API_TOKEN_ENV] = "fo_env_provided_token";
274
+ let seenAuth: string | undefined;
275
+ const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
276
+ seenAuth = (init?.headers as Record<string, string>)["Authorization"];
277
+ const u = String(url);
278
+ if (u.endsWith("/apps")) return new Response("{}", { status: 201 });
279
+ return new Response(JSON.stringify({ id: "m-1", state: "started" }), { status: 200 });
280
+ }) as unknown as typeof fetch;
281
+ await deployToFly({
282
+ target: "channel",
283
+ appName: "my-bot",
284
+ imageRef: "img",
285
+ fetchImpl,
286
+ });
287
+ expect(seenAuth).toBe("Bearer fo_env_provided_token");
288
+ });
289
+
290
+ test("rejects invalid app name before any network call", async () => {
291
+ let called = false;
292
+ const fetchImpl = (async () => {
293
+ called = true;
294
+ return new Response("", { status: 200 });
295
+ }) as unknown as typeof fetch;
296
+ let caught: Error | undefined;
297
+ try {
298
+ await deployToFly({
299
+ target: "channel",
300
+ appName: "BadCaps",
301
+ imageRef: "img",
302
+ apiToken: "fo_testtoken_abc",
303
+ fetchImpl,
304
+ });
305
+ } catch (err) {
306
+ caught = err as Error;
307
+ }
308
+ expect(caught).toBeInstanceOf(FlyAdapterError);
309
+ expect(called).toBe(false);
310
+ });
311
+
312
+ test("uses the configured base url", async () => {
313
+ let seenUrl: string | undefined;
314
+ const fetchImpl = (async (url: string | URL) => {
315
+ seenUrl = seenUrl ?? String(url);
316
+ const u = String(url);
317
+ if (u.endsWith("/apps")) return new Response("{}", { status: 201 });
318
+ return new Response(JSON.stringify({ id: "m-1", state: "started" }), { status: 200 });
319
+ }) as unknown as typeof fetch;
320
+ await deployToFly({
321
+ target: "channel",
322
+ appName: "my-bot",
323
+ imageRef: "img",
324
+ apiToken: "fo_testtoken_abc",
325
+ fetchImpl,
326
+ });
327
+ expect(seenUrl).toBe(`${FLY_DEFAULT_API_BASE}/apps`);
328
+ });
329
+ });
package/src/index.ts ADDED
@@ -0,0 +1,345 @@
1
+ import { type TargetShape, isTargetShape } from "@crewhaus/docker-images";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+
4
+ /**
5
+ * Section 44 — `@crewhaus/cloud-adapter-flyio`
6
+ *
7
+ * One-click deploy adapter for [Fly.io](https://fly.io). Three surfaces:
8
+ *
9
+ * - `flyTomlFor({target, appName, region, …})` — emits a deterministic
10
+ * `fly.toml` config: HTTP service block for web shapes, process
11
+ * groups for batch workers, VM sizing, env vars, healthchecks.
12
+ *
13
+ * - `flyDockerfileFor({target, baseImage})` — wraps the §32 docker-images
14
+ * base with a Fly-tuned `EXPOSE` + healthcheck against the internal
15
+ * port (default 8080). Fly's load balancer probes via `[http_service.checks]`
16
+ * so a container HEALTHCHECK is optional, but we add one for shapes
17
+ * that already define `/healthz` to match the §44 contract.
18
+ *
19
+ * - `deployToFly({apiToken, appName, …})` — creates the app + first
20
+ * machine via the Machines API (`https://api.machines.dev/v1`).
21
+ * Live calls gated on `FLY_API_TOKEN` env (or explicit `apiToken`)
22
+ * plus injectable `fetchImpl` test seam.
23
+ *
24
+ * SECURITY (T8 credential-leak guard): `scrubApiToken()` runs every
25
+ * error path before surfacing to the caller — same approach as the
26
+ * §37 `exporter-datadog` `scrubApiKey` helper, mirrored from
27
+ * §44 `cloud-adapter-render`.
28
+ *
29
+ * Targets: daemon shapes only (channel, managed, batch, voice, browser).
30
+ * One-shot shapes throw `FlyAdapterError`.
31
+ */
32
+
33
+ export class FlyAdapterError extends CrewhausError {
34
+ override readonly name = "FlyAdapterError";
35
+ constructor(message: string, cause?: unknown) {
36
+ super("config", message, cause);
37
+ }
38
+ }
39
+
40
+ export const FLY_TARGET_SHAPES = [
41
+ "channel",
42
+ "managed",
43
+ "batch",
44
+ "voice",
45
+ "browser",
46
+ ] as const satisfies readonly TargetShape[];
47
+
48
+ export type FlyTargetShape = (typeof FLY_TARGET_SHAPES)[number];
49
+
50
+ export function isFlyTargetShape(value: unknown): value is FlyTargetShape {
51
+ return typeof value === "string" && (FLY_TARGET_SHAPES as readonly string[]).includes(value);
52
+ }
53
+
54
+ /**
55
+ * Subset of Fly's primary regions. Fly publishes the full list at
56
+ * https://fly.io/docs/reference/regions/ — we accept any 3-letter code,
57
+ * but the named list here makes typos cheaper for the common cases.
58
+ */
59
+ export const FLY_REGIONS = [
60
+ "iad",
61
+ "ord",
62
+ "lax",
63
+ "sjc",
64
+ "lhr",
65
+ "fra",
66
+ "ams",
67
+ "sin",
68
+ "syd",
69
+ "nrt",
70
+ "gru",
71
+ ] as const;
72
+ export type FlyRegion = (typeof FLY_REGIONS)[number];
73
+
74
+ export type FlyVmPreset = "shared-1x" | "shared-2x" | "performance-1x" | "performance-2x";
75
+
76
+ const VM_SIZING: Readonly<
77
+ Record<FlyVmPreset, { cpuKind: string; cpus: number; memoryMb: number }>
78
+ > = {
79
+ "shared-1x": { cpuKind: "shared", cpus: 1, memoryMb: 256 },
80
+ "shared-2x": { cpuKind: "shared", cpus: 2, memoryMb: 512 },
81
+ "performance-1x": { cpuKind: "performance", cpus: 1, memoryMb: 2048 },
82
+ "performance-2x": { cpuKind: "performance", cpus: 2, memoryMb: 4096 },
83
+ };
84
+
85
+ const SERVICE_TYPE_FOR_TARGET: Readonly<Record<FlyTargetShape, "web" | "worker">> = {
86
+ channel: "web",
87
+ managed: "web",
88
+ voice: "web",
89
+ browser: "web",
90
+ batch: "worker",
91
+ };
92
+
93
+ const APP_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/;
94
+
95
+ function assertFlyTarget(target: TargetShape): asserts target is FlyTargetShape {
96
+ if (!isFlyTargetShape(target)) {
97
+ throw new FlyAdapterError(
98
+ `target shape "${target}" is not supported by Fly.io. Supported daemon shapes: ${FLY_TARGET_SHAPES.join(", ")}.`,
99
+ );
100
+ }
101
+ }
102
+
103
+ function assertAppName(name: string): void {
104
+ if (!APP_NAME_PATTERN.test(name)) {
105
+ throw new FlyAdapterError(
106
+ `app name "${name}" is invalid. Must be lowercase alphanumerics + hyphens, 1-30 chars, no leading/trailing hyphen.`,
107
+ );
108
+ }
109
+ }
110
+
111
+ function tomlEscape(value: string): string {
112
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
113
+ }
114
+
115
+ export type FlyTomlOptions = {
116
+ readonly target: TargetShape;
117
+ readonly appName: string;
118
+ readonly primaryRegion?: FlyRegion | string;
119
+ readonly internalPort?: number;
120
+ readonly vm?: FlyVmPreset;
121
+ /** Plain-text env vars merged into `[env]`. Secret values must come from `fly secrets set`. */
122
+ readonly envVars?: Readonly<Record<string, string>>;
123
+ /** Path to the Dockerfile inside the build context. Defaults to `Dockerfile.fly`. */
124
+ readonly dockerfilePath?: string;
125
+ };
126
+
127
+ const TOML_HEADER = "# Generated by @crewhaus/cloud-adapter-flyio — do not edit by hand";
128
+
129
+ export function flyTomlFor(opts: FlyTomlOptions): string {
130
+ if (!isTargetShape(opts.target)) {
131
+ throw new FlyAdapterError(`unknown target shape "${opts.target}"`);
132
+ }
133
+ assertFlyTarget(opts.target);
134
+ assertAppName(opts.appName);
135
+
136
+ const serviceType = SERVICE_TYPE_FOR_TARGET[opts.target];
137
+ const region = opts.primaryRegion ?? "iad";
138
+ const port = opts.internalPort ?? 8080;
139
+ const vm = VM_SIZING[opts.vm ?? "shared-1x"];
140
+ const dockerfilePath = opts.dockerfilePath ?? "Dockerfile.fly";
141
+
142
+ const lines: string[] = [
143
+ TOML_HEADER,
144
+ `app = "${tomlEscape(opts.appName)}"`,
145
+ `primary_region = "${tomlEscape(region)}"`,
146
+ "",
147
+ "[build]",
148
+ ` dockerfile = "${tomlEscape(dockerfilePath)}"`,
149
+ ];
150
+
151
+ if (opts.envVars && Object.keys(opts.envVars).length > 0) {
152
+ lines.push("", "[env]");
153
+ for (const key of Object.keys(opts.envVars).sort()) {
154
+ const value = opts.envVars[key];
155
+ if (value === undefined) continue;
156
+ lines.push(` ${key} = "${tomlEscape(value)}"`);
157
+ }
158
+ }
159
+
160
+ if (serviceType === "web") {
161
+ lines.push(
162
+ "",
163
+ "[http_service]",
164
+ ` internal_port = ${port}`,
165
+ " force_https = true",
166
+ " auto_stop_machines = true",
167
+ " auto_start_machines = true",
168
+ " min_machines_running = 0",
169
+ "",
170
+ " [[http_service.checks]]",
171
+ ' interval = "30s"',
172
+ ' timeout = "5s"',
173
+ ' grace_period = "10s"',
174
+ ' method = "GET"',
175
+ ' path = "/healthz"',
176
+ );
177
+ } else {
178
+ lines.push("", "[processes]", ` worker = "node /app/dist/agent.js"`);
179
+ }
180
+
181
+ lines.push(
182
+ "",
183
+ "[[vm]]",
184
+ ` cpu_kind = "${vm.cpuKind}"`,
185
+ ` cpus = ${vm.cpus}`,
186
+ ` memory_mb = ${vm.memoryMb}`,
187
+ );
188
+
189
+ return `${lines.join("\n")}\n`;
190
+ }
191
+
192
+ export type FlyDockerfileOptions = {
193
+ readonly target: TargetShape;
194
+ readonly baseImage: string;
195
+ /** Internal port the container listens on (matches `flyTomlFor.internalPort`). */
196
+ readonly internalPort?: number;
197
+ };
198
+
199
+ export function flyDockerfileFor(opts: FlyDockerfileOptions): string {
200
+ if (!isTargetShape(opts.target)) {
201
+ throw new FlyAdapterError(`unknown target shape "${opts.target}"`);
202
+ }
203
+ assertFlyTarget(opts.target);
204
+ const isWeb = SERVICE_TYPE_FOR_TARGET[opts.target] === "web";
205
+ const port = opts.internalPort ?? 8080;
206
+ const lines = [
207
+ `# Fly.io-tuned wrapper around ${opts.baseImage}`,
208
+ `FROM ${opts.baseImage}`,
209
+ `ENV PORT=${port}`,
210
+ ];
211
+ if (isWeb) {
212
+ lines.push(`EXPOSE ${port}`);
213
+ }
214
+ return `${lines.join("\n")}\n`;
215
+ }
216
+
217
+ export type FlyDeployRecord = {
218
+ readonly appName: string;
219
+ readonly machineId: string;
220
+ readonly status: string;
221
+ readonly region: string;
222
+ };
223
+
224
+ export type DeployToFlyOptions = {
225
+ readonly appName: string;
226
+ readonly imageRef: string;
227
+ readonly target: TargetShape;
228
+ readonly region?: FlyRegion | string;
229
+ readonly apiToken?: string;
230
+ readonly apiBaseUrl?: string;
231
+ /** Org slug the app belongs to. Defaults to "personal". */
232
+ readonly orgSlug?: string;
233
+ readonly fetchImpl?: typeof fetch;
234
+ };
235
+
236
+ export const FLY_DEFAULT_API_BASE = "https://api.machines.dev/v1";
237
+
238
+ function resolveApiToken(provided: string | undefined): string {
239
+ const key = provided ?? process.env["FLY_API_TOKEN"];
240
+ if (key === undefined || key.length === 0) {
241
+ throw new FlyAdapterError(
242
+ "FLY_API_TOKEN is required. Pass `apiToken` explicitly or set the env var.",
243
+ );
244
+ }
245
+ return key;
246
+ }
247
+
248
+ export function scrubApiToken(message: string, apiToken: string | undefined): string {
249
+ if (apiToken === undefined || apiToken.length < 8) return message;
250
+ return message.split(apiToken).join("[REDACTED:FLY_API_TOKEN]");
251
+ }
252
+
253
+ /**
254
+ * Two-step deploy: (1) create app (idempotent — Fly returns 409 if it
255
+ * exists, which we treat as success), (2) launch first machine running
256
+ * the image. Returns a `FlyDeployRecord`. All errors run through
257
+ * `scrubApiToken()` before surfacing.
258
+ */
259
+ export async function deployToFly(opts: DeployToFlyOptions): Promise<FlyDeployRecord> {
260
+ if (!isTargetShape(opts.target)) {
261
+ throw new FlyAdapterError(`unknown target shape "${opts.target}"`);
262
+ }
263
+ assertFlyTarget(opts.target);
264
+ assertAppName(opts.appName);
265
+ const apiToken = resolveApiToken(opts.apiToken);
266
+ const baseUrl = opts.apiBaseUrl ?? FLY_DEFAULT_API_BASE;
267
+ const region = opts.region ?? "iad";
268
+ const fetchFn = opts.fetchImpl ?? fetch;
269
+ const orgSlug = opts.orgSlug ?? "personal";
270
+
271
+ const headers = {
272
+ Authorization: `Bearer ${apiToken}`,
273
+ "Content-Type": "application/json",
274
+ Accept: "application/json",
275
+ };
276
+
277
+ // Step 1: create app (treat 409 conflict as already-exists, which is fine).
278
+ let createResponse: Response;
279
+ try {
280
+ createResponse = await fetchFn(`${baseUrl}/apps`, {
281
+ method: "POST",
282
+ headers,
283
+ body: JSON.stringify({ app_name: opts.appName, org_slug: orgSlug }),
284
+ });
285
+ } catch (err) {
286
+ const msg = err instanceof Error ? err.message : String(err);
287
+ throw new FlyAdapterError(
288
+ `Fly Machines API request failed: ${scrubApiToken(msg, apiToken)}`,
289
+ err,
290
+ );
291
+ }
292
+ if (!createResponse.ok && createResponse.status !== 409) {
293
+ const body = scrubApiToken(await createResponse.text(), apiToken);
294
+ throw new FlyAdapterError(
295
+ `Fly app create returned ${createResponse.status} ${createResponse.statusText}: ${body}`,
296
+ );
297
+ }
298
+
299
+ // Step 2: launch the first machine.
300
+ let launchResponse: Response;
301
+ try {
302
+ launchResponse = await fetchFn(`${baseUrl}/apps/${opts.appName}/machines`, {
303
+ method: "POST",
304
+ headers,
305
+ body: JSON.stringify({
306
+ region,
307
+ config: {
308
+ image: opts.imageRef,
309
+ auto_destroy: false,
310
+ restart: { policy: "always" },
311
+ },
312
+ }),
313
+ });
314
+ } catch (err) {
315
+ const msg = err instanceof Error ? err.message : String(err);
316
+ throw new FlyAdapterError(
317
+ `Fly Machines API request failed: ${scrubApiToken(msg, apiToken)}`,
318
+ err,
319
+ );
320
+ }
321
+ const launchText = await launchResponse.text();
322
+ const scrubbed = scrubApiToken(launchText, apiToken);
323
+ if (!launchResponse.ok) {
324
+ throw new FlyAdapterError(
325
+ `Fly machine launch returned ${launchResponse.status} ${launchResponse.statusText}: ${scrubbed}`,
326
+ );
327
+ }
328
+
329
+ let parsed: { id?: string; state?: string };
330
+ try {
331
+ parsed = JSON.parse(launchText);
332
+ } catch (err) {
333
+ throw new FlyAdapterError(`Fly Machines API returned non-JSON body: ${scrubbed}`, err);
334
+ }
335
+ const machineId = parsed.id;
336
+ if (typeof machineId !== "string") {
337
+ throw new FlyAdapterError(`Fly Machines API response missing machine id: ${scrubbed}`);
338
+ }
339
+ return {
340
+ appName: opts.appName,
341
+ machineId,
342
+ status: parsed.state ?? "created",
343
+ region,
344
+ };
345
+ }