@crewhaus/cloud-adapter-flyio 0.1.4 → 0.1.5

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.
@@ -0,0 +1,90 @@
1
+ import { type TargetShape } from "@crewhaus/docker-images";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+ /**
4
+ * Section 44 — `@crewhaus/cloud-adapter-flyio`
5
+ *
6
+ * One-click deploy adapter for [Fly.io](https://fly.io). Three surfaces:
7
+ *
8
+ * - `flyTomlFor({target, appName, region, …})` — emits a deterministic
9
+ * `fly.toml` config: HTTP service block for web shapes, process
10
+ * groups for batch workers, VM sizing, env vars, healthchecks.
11
+ *
12
+ * - `flyDockerfileFor({target, baseImage})` — wraps the §32 docker-images
13
+ * base with a Fly-tuned `EXPOSE` + healthcheck against the internal
14
+ * port (default 8080). Fly's load balancer probes via `[http_service.checks]`
15
+ * so a container HEALTHCHECK is optional, but we add one for shapes
16
+ * that already define `/healthz` to match the §44 contract.
17
+ *
18
+ * - `deployToFly({apiToken, appName, …})` — creates the app + first
19
+ * machine via the Machines API (`https://api.machines.dev/v1`).
20
+ * Live calls gated on `FLY_API_TOKEN` env (or explicit `apiToken`)
21
+ * plus injectable `fetchImpl` test seam.
22
+ *
23
+ * SECURITY (T8 credential-leak guard): `scrubApiToken()` runs every
24
+ * error path before surfacing to the caller — same approach as the
25
+ * §37 `exporter-datadog` `scrubApiKey` helper, mirrored from
26
+ * §44 `cloud-adapter-render`.
27
+ *
28
+ * Targets: daemon shapes only (channel, managed, batch, voice, browser).
29
+ * One-shot shapes throw `FlyAdapterError`.
30
+ */
31
+ export declare class FlyAdapterError extends CrewhausError {
32
+ readonly name = "FlyAdapterError";
33
+ constructor(message: string, cause?: unknown);
34
+ }
35
+ export declare const FLY_TARGET_SHAPES: readonly ["channel", "managed", "batch", "voice", "browser"];
36
+ export type FlyTargetShape = (typeof FLY_TARGET_SHAPES)[number];
37
+ export declare function isFlyTargetShape(value: unknown): value is FlyTargetShape;
38
+ /**
39
+ * Subset of Fly's primary regions. Fly publishes the full list at
40
+ * https://fly.io/docs/reference/regions/ — we accept any 3-letter code,
41
+ * but the named list here makes typos cheaper for the common cases.
42
+ */
43
+ export declare const FLY_REGIONS: readonly ["iad", "ord", "lax", "sjc", "lhr", "fra", "ams", "sin", "syd", "nrt", "gru"];
44
+ export type FlyRegion = (typeof FLY_REGIONS)[number];
45
+ export type FlyVmPreset = "shared-1x" | "shared-2x" | "performance-1x" | "performance-2x";
46
+ export type FlyTomlOptions = {
47
+ readonly target: TargetShape;
48
+ readonly appName: string;
49
+ readonly primaryRegion?: FlyRegion | string;
50
+ readonly internalPort?: number;
51
+ readonly vm?: FlyVmPreset;
52
+ /** Plain-text env vars merged into `[env]`. Secret values must come from `fly secrets set`. */
53
+ readonly envVars?: Readonly<Record<string, string>>;
54
+ /** Path to the Dockerfile inside the build context. Defaults to `Dockerfile.fly`. */
55
+ readonly dockerfilePath?: string;
56
+ };
57
+ export declare function flyTomlFor(opts: FlyTomlOptions): string;
58
+ export type FlyDockerfileOptions = {
59
+ readonly target: TargetShape;
60
+ readonly baseImage: string;
61
+ /** Internal port the container listens on (matches `flyTomlFor.internalPort`). */
62
+ readonly internalPort?: number;
63
+ };
64
+ export declare function flyDockerfileFor(opts: FlyDockerfileOptions): string;
65
+ export type FlyDeployRecord = {
66
+ readonly appName: string;
67
+ readonly machineId: string;
68
+ readonly status: string;
69
+ readonly region: string;
70
+ };
71
+ export type DeployToFlyOptions = {
72
+ readonly appName: string;
73
+ readonly imageRef: string;
74
+ readonly target: TargetShape;
75
+ readonly region?: FlyRegion | string;
76
+ readonly apiToken?: string;
77
+ readonly apiBaseUrl?: string;
78
+ /** Org slug the app belongs to. Defaults to "personal". */
79
+ readonly orgSlug?: string;
80
+ readonly fetchImpl?: typeof fetch;
81
+ };
82
+ export declare const FLY_DEFAULT_API_BASE = "https://api.machines.dev/v1";
83
+ export declare function scrubApiToken(message: string, apiToken: string | undefined): string;
84
+ /**
85
+ * Two-step deploy: (1) create app (idempotent — Fly returns 409 if it
86
+ * exists, which we treat as success), (2) launch first machine running
87
+ * the image. Returns a `FlyDeployRecord`. All errors run through
88
+ * `scrubApiToken()` before surfacing.
89
+ */
90
+ export declare function deployToFly(opts: DeployToFlyOptions): Promise<FlyDeployRecord>;
package/dist/index.js ADDED
@@ -0,0 +1,241 @@
1
+ import { isTargetShape } from "@crewhaus/docker-images";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+ /**
4
+ * Section 44 — `@crewhaus/cloud-adapter-flyio`
5
+ *
6
+ * One-click deploy adapter for [Fly.io](https://fly.io). Three surfaces:
7
+ *
8
+ * - `flyTomlFor({target, appName, region, …})` — emits a deterministic
9
+ * `fly.toml` config: HTTP service block for web shapes, process
10
+ * groups for batch workers, VM sizing, env vars, healthchecks.
11
+ *
12
+ * - `flyDockerfileFor({target, baseImage})` — wraps the §32 docker-images
13
+ * base with a Fly-tuned `EXPOSE` + healthcheck against the internal
14
+ * port (default 8080). Fly's load balancer probes via `[http_service.checks]`
15
+ * so a container HEALTHCHECK is optional, but we add one for shapes
16
+ * that already define `/healthz` to match the §44 contract.
17
+ *
18
+ * - `deployToFly({apiToken, appName, …})` — creates the app + first
19
+ * machine via the Machines API (`https://api.machines.dev/v1`).
20
+ * Live calls gated on `FLY_API_TOKEN` env (or explicit `apiToken`)
21
+ * plus injectable `fetchImpl` test seam.
22
+ *
23
+ * SECURITY (T8 credential-leak guard): `scrubApiToken()` runs every
24
+ * error path before surfacing to the caller — same approach as the
25
+ * §37 `exporter-datadog` `scrubApiKey` helper, mirrored from
26
+ * §44 `cloud-adapter-render`.
27
+ *
28
+ * Targets: daemon shapes only (channel, managed, batch, voice, browser).
29
+ * One-shot shapes throw `FlyAdapterError`.
30
+ */
31
+ export class FlyAdapterError extends CrewhausError {
32
+ name = "FlyAdapterError";
33
+ constructor(message, cause) {
34
+ super("config", message, cause);
35
+ }
36
+ }
37
+ export const FLY_TARGET_SHAPES = [
38
+ "channel",
39
+ "managed",
40
+ "batch",
41
+ "voice",
42
+ "browser",
43
+ ];
44
+ export function isFlyTargetShape(value) {
45
+ return typeof value === "string" && FLY_TARGET_SHAPES.includes(value);
46
+ }
47
+ /**
48
+ * Subset of Fly's primary regions. Fly publishes the full list at
49
+ * https://fly.io/docs/reference/regions/ — we accept any 3-letter code,
50
+ * but the named list here makes typos cheaper for the common cases.
51
+ */
52
+ export const FLY_REGIONS = [
53
+ "iad",
54
+ "ord",
55
+ "lax",
56
+ "sjc",
57
+ "lhr",
58
+ "fra",
59
+ "ams",
60
+ "sin",
61
+ "syd",
62
+ "nrt",
63
+ "gru",
64
+ ];
65
+ const VM_SIZING = {
66
+ "shared-1x": { cpuKind: "shared", cpus: 1, memoryMb: 256 },
67
+ "shared-2x": { cpuKind: "shared", cpus: 2, memoryMb: 512 },
68
+ "performance-1x": { cpuKind: "performance", cpus: 1, memoryMb: 2048 },
69
+ "performance-2x": { cpuKind: "performance", cpus: 2, memoryMb: 4096 },
70
+ };
71
+ const SERVICE_TYPE_FOR_TARGET = {
72
+ channel: "web",
73
+ managed: "web",
74
+ voice: "web",
75
+ browser: "web",
76
+ batch: "worker",
77
+ };
78
+ const APP_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/;
79
+ function assertFlyTarget(target) {
80
+ if (!isFlyTargetShape(target)) {
81
+ throw new FlyAdapterError(`target shape "${target}" is not supported by Fly.io. Supported daemon shapes: ${FLY_TARGET_SHAPES.join(", ")}.`);
82
+ }
83
+ }
84
+ function assertAppName(name) {
85
+ if (!APP_NAME_PATTERN.test(name)) {
86
+ throw new FlyAdapterError(`app name "${name}" is invalid. Must be lowercase alphanumerics + hyphens, 1-30 chars, no leading/trailing hyphen.`);
87
+ }
88
+ }
89
+ function tomlEscape(value) {
90
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
91
+ }
92
+ const TOML_HEADER = "# Generated by @crewhaus/cloud-adapter-flyio — do not edit by hand";
93
+ export function flyTomlFor(opts) {
94
+ if (!isTargetShape(opts.target)) {
95
+ throw new FlyAdapterError(`unknown target shape "${opts.target}"`);
96
+ }
97
+ assertFlyTarget(opts.target);
98
+ assertAppName(opts.appName);
99
+ const serviceType = SERVICE_TYPE_FOR_TARGET[opts.target];
100
+ const region = opts.primaryRegion ?? "iad";
101
+ const port = opts.internalPort ?? 8080;
102
+ const vm = VM_SIZING[opts.vm ?? "shared-1x"];
103
+ const dockerfilePath = opts.dockerfilePath ?? "Dockerfile.fly";
104
+ const lines = [
105
+ TOML_HEADER,
106
+ `app = "${tomlEscape(opts.appName)}"`,
107
+ `primary_region = "${tomlEscape(region)}"`,
108
+ "",
109
+ "[build]",
110
+ ` dockerfile = "${tomlEscape(dockerfilePath)}"`,
111
+ ];
112
+ if (opts.envVars && Object.keys(opts.envVars).length > 0) {
113
+ lines.push("", "[env]");
114
+ for (const key of Object.keys(opts.envVars).sort()) {
115
+ const value = opts.envVars[key];
116
+ if (value === undefined)
117
+ continue;
118
+ lines.push(` ${key} = "${tomlEscape(value)}"`);
119
+ }
120
+ }
121
+ if (serviceType === "web") {
122
+ lines.push("", "[http_service]", ` internal_port = ${port}`, " force_https = true", " auto_stop_machines = true", " auto_start_machines = true", " min_machines_running = 0", "", " [[http_service.checks]]", ' interval = "30s"', ' timeout = "5s"', ' grace_period = "10s"', ' method = "GET"', ' path = "/healthz"');
123
+ }
124
+ else {
125
+ lines.push("", "[processes]", ` worker = "node /app/dist/agent.js"`);
126
+ }
127
+ lines.push("", "[[vm]]", ` cpu_kind = "${vm.cpuKind}"`, ` cpus = ${vm.cpus}`, ` memory_mb = ${vm.memoryMb}`);
128
+ return `${lines.join("\n")}\n`;
129
+ }
130
+ export function flyDockerfileFor(opts) {
131
+ if (!isTargetShape(opts.target)) {
132
+ throw new FlyAdapterError(`unknown target shape "${opts.target}"`);
133
+ }
134
+ assertFlyTarget(opts.target);
135
+ const isWeb = SERVICE_TYPE_FOR_TARGET[opts.target] === "web";
136
+ const port = opts.internalPort ?? 8080;
137
+ const lines = [
138
+ `# Fly.io-tuned wrapper around ${opts.baseImage}`,
139
+ `FROM ${opts.baseImage}`,
140
+ `ENV PORT=${port}`,
141
+ ];
142
+ if (isWeb) {
143
+ lines.push(`EXPOSE ${port}`);
144
+ }
145
+ return `${lines.join("\n")}\n`;
146
+ }
147
+ export const FLY_DEFAULT_API_BASE = "https://api.machines.dev/v1";
148
+ function resolveApiToken(provided) {
149
+ const key = provided ?? process.env["FLY_API_TOKEN"];
150
+ if (key === undefined || key.length === 0) {
151
+ throw new FlyAdapterError("FLY_API_TOKEN is required. Pass `apiToken` explicitly or set the env var.");
152
+ }
153
+ return key;
154
+ }
155
+ export function scrubApiToken(message, apiToken) {
156
+ if (apiToken === undefined || apiToken.length < 8)
157
+ return message;
158
+ return message.split(apiToken).join("[REDACTED:FLY_API_TOKEN]");
159
+ }
160
+ /**
161
+ * Two-step deploy: (1) create app (idempotent — Fly returns 409 if it
162
+ * exists, which we treat as success), (2) launch first machine running
163
+ * the image. Returns a `FlyDeployRecord`. All errors run through
164
+ * `scrubApiToken()` before surfacing.
165
+ */
166
+ export async function deployToFly(opts) {
167
+ if (!isTargetShape(opts.target)) {
168
+ throw new FlyAdapterError(`unknown target shape "${opts.target}"`);
169
+ }
170
+ assertFlyTarget(opts.target);
171
+ assertAppName(opts.appName);
172
+ const apiToken = resolveApiToken(opts.apiToken);
173
+ const baseUrl = opts.apiBaseUrl ?? FLY_DEFAULT_API_BASE;
174
+ const region = opts.region ?? "iad";
175
+ const fetchFn = opts.fetchImpl ?? fetch;
176
+ const orgSlug = opts.orgSlug ?? "personal";
177
+ const headers = {
178
+ Authorization: `Bearer ${apiToken}`,
179
+ "Content-Type": "application/json",
180
+ Accept: "application/json",
181
+ };
182
+ // Step 1: create app (treat 409 conflict as already-exists, which is fine).
183
+ let createResponse;
184
+ try {
185
+ createResponse = await fetchFn(`${baseUrl}/apps`, {
186
+ method: "POST",
187
+ headers,
188
+ body: JSON.stringify({ app_name: opts.appName, org_slug: orgSlug }),
189
+ });
190
+ }
191
+ catch (err) {
192
+ const msg = err instanceof Error ? err.message : String(err);
193
+ throw new FlyAdapterError(`Fly Machines API request failed: ${scrubApiToken(msg, apiToken)}`, err);
194
+ }
195
+ if (!createResponse.ok && createResponse.status !== 409) {
196
+ const body = scrubApiToken(await createResponse.text(), apiToken);
197
+ throw new FlyAdapterError(`Fly app create returned ${createResponse.status} ${createResponse.statusText}: ${body}`);
198
+ }
199
+ // Step 2: launch the first machine.
200
+ let launchResponse;
201
+ try {
202
+ launchResponse = await fetchFn(`${baseUrl}/apps/${opts.appName}/machines`, {
203
+ method: "POST",
204
+ headers,
205
+ body: JSON.stringify({
206
+ region,
207
+ config: {
208
+ image: opts.imageRef,
209
+ auto_destroy: false,
210
+ restart: { policy: "always" },
211
+ },
212
+ }),
213
+ });
214
+ }
215
+ catch (err) {
216
+ const msg = err instanceof Error ? err.message : String(err);
217
+ throw new FlyAdapterError(`Fly Machines API request failed: ${scrubApiToken(msg, apiToken)}`, err);
218
+ }
219
+ const launchText = await launchResponse.text();
220
+ const scrubbed = scrubApiToken(launchText, apiToken);
221
+ if (!launchResponse.ok) {
222
+ throw new FlyAdapterError(`Fly machine launch returned ${launchResponse.status} ${launchResponse.statusText}: ${scrubbed}`);
223
+ }
224
+ let parsed;
225
+ try {
226
+ parsed = JSON.parse(launchText);
227
+ }
228
+ catch (err) {
229
+ throw new FlyAdapterError(`Fly Machines API returned non-JSON body: ${scrubbed}`, err);
230
+ }
231
+ const machineId = parsed.id;
232
+ if (typeof machineId !== "string") {
233
+ throw new FlyAdapterError(`Fly Machines API response missing machine id: ${scrubbed}`);
234
+ }
235
+ return {
236
+ appName: opts.appName,
237
+ machineId,
238
+ status: parsed.state ?? "created",
239
+ region,
240
+ };
241
+ }
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@crewhaus/cloud-adapter-flyio",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
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",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/docker-images": "0.1.4",
16
- "@crewhaus/errors": "0.1.4"
18
+ "@crewhaus/docker-images": "0.1.5",
19
+ "@crewhaus/errors": "0.1.5"
17
20
  },
18
21
  "license": "Apache-2.0",
19
22
  "author": {
@@ -33,5 +36,5 @@
33
36
  "publishConfig": {
34
37
  "access": "public"
35
38
  },
36
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
39
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
37
40
  }
package/src/index.test.ts DELETED
@@ -1,635 +0,0 @@
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
-
330
- test('falls back to status "created" when launch body omits state', async () => {
331
- const fetchImpl = (async (url: string | URL) => {
332
- const u = String(url);
333
- if (u.endsWith("/apps")) return new Response("{}", { status: 201 });
334
- // No `state` field — exercises the `?? "created"` fallback.
335
- return new Response(JSON.stringify({ id: "m-nostate" }), { status: 200 });
336
- }) as unknown as typeof fetch;
337
- const record = await deployToFly({
338
- target: "channel",
339
- appName: "my-bot",
340
- imageRef: "img",
341
- apiToken: "fo_testtoken_abc",
342
- fetchImpl,
343
- });
344
- expect(record.machineId).toBe("m-nostate");
345
- expect(record.status).toBe("created");
346
- });
347
-
348
- test("throws when launch returns 2xx with a non-JSON body", async () => {
349
- const fetchImpl = (async (url: string | URL) => {
350
- const u = String(url);
351
- if (u.endsWith("/apps")) return new Response("{}", { status: 201 });
352
- return new Response("not json at all", { status: 200 });
353
- }) as unknown as typeof fetch;
354
- let caught: Error | undefined;
355
- try {
356
- await deployToFly({
357
- target: "channel",
358
- appName: "my-bot",
359
- imageRef: "img",
360
- apiToken: "fo_testtoken_abc",
361
- fetchImpl,
362
- });
363
- } catch (err) {
364
- caught = err as Error;
365
- }
366
- expect(caught).toBeInstanceOf(FlyAdapterError);
367
- expect(caught?.message).toContain("non-JSON body");
368
- });
369
-
370
- test("throws when launch body is valid JSON but missing machine id", async () => {
371
- const fetchImpl = (async (url: string | URL) => {
372
- const u = String(url);
373
- if (u.endsWith("/apps")) return new Response("{}", { status: 201 });
374
- // Valid JSON, but `id` is absent (or non-string).
375
- return new Response(JSON.stringify({ state: "started" }), { status: 200 });
376
- }) as unknown as typeof fetch;
377
- let caught: Error | undefined;
378
- try {
379
- await deployToFly({
380
- target: "channel",
381
- appName: "my-bot",
382
- imageRef: "img",
383
- apiToken: "fo_testtoken_abc",
384
- fetchImpl,
385
- });
386
- } catch (err) {
387
- caught = err as Error;
388
- }
389
- expect(caught).toBeInstanceOf(FlyAdapterError);
390
- expect(caught?.message).toContain("missing machine id");
391
- });
392
-
393
- test("wraps a thrown fetch on app-create and scrubs the token", async () => {
394
- const fetchImpl = (async () => {
395
- throw new Error("network down for fo_leakingtoken_full retry");
396
- }) as unknown as typeof fetch;
397
- let caught: Error | undefined;
398
- try {
399
- await deployToFly({
400
- target: "channel",
401
- appName: "my-bot",
402
- imageRef: "img",
403
- apiToken: "fo_leakingtoken_full",
404
- fetchImpl,
405
- });
406
- } catch (err) {
407
- caught = err as Error;
408
- }
409
- expect(caught).toBeInstanceOf(FlyAdapterError);
410
- expect(caught?.message).toContain("Fly Machines API request failed");
411
- expect(caught?.message).not.toContain("fo_leakingtoken_full");
412
- expect(caught?.message).toContain("[REDACTED:FLY_API_TOKEN]");
413
- expect((caught as FlyAdapterError).cause).toBeInstanceOf(Error);
414
- });
415
-
416
- test("wraps a non-Error thrown fetch on machine launch", async () => {
417
- const fetchImpl = (async (url: string | URL) => {
418
- const u = String(url);
419
- if (u.endsWith("/apps")) return new Response("{}", { status: 201 });
420
- // Throw a non-Error so the `String(err)` branch is taken.
421
- throw "boom-string";
422
- }) as unknown as typeof fetch;
423
- let caught: Error | undefined;
424
- try {
425
- await deployToFly({
426
- target: "channel",
427
- appName: "my-bot",
428
- imageRef: "img",
429
- apiToken: "fo_testtoken_abc",
430
- fetchImpl,
431
- });
432
- } catch (err) {
433
- caught = err as Error;
434
- }
435
- expect(caught).toBeInstanceOf(FlyAdapterError);
436
- expect(caught?.message).toContain("Fly Machines API request failed: boom-string");
437
- });
438
-
439
- test("rejects unsupported (one-shot) target shapes before any network call", async () => {
440
- let called = false;
441
- const fetchImpl = (async () => {
442
- called = true;
443
- return new Response("", { status: 200 });
444
- }) as unknown as typeof fetch;
445
- let caught: Error | undefined;
446
- try {
447
- await deployToFly({
448
- target: "eval",
449
- appName: "my-bot",
450
- imageRef: "img",
451
- apiToken: "fo_testtoken_abc",
452
- fetchImpl,
453
- });
454
- } catch (err) {
455
- caught = err as Error;
456
- }
457
- expect(caught).toBeInstanceOf(FlyAdapterError);
458
- expect(called).toBe(false);
459
- });
460
-
461
- test("rejects unknown target shapes before any network call", async () => {
462
- let called = false;
463
- const fetchImpl = (async () => {
464
- called = true;
465
- return new Response("", { status: 200 });
466
- }) as unknown as typeof fetch;
467
- let caught: Error | undefined;
468
- try {
469
- await deployToFly({
470
- target: "mystery" as never,
471
- appName: "my-bot",
472
- imageRef: "img",
473
- apiToken: "fo_testtoken_abc",
474
- fetchImpl,
475
- });
476
- } catch (err) {
477
- caught = err as Error;
478
- }
479
- expect(caught).toBeInstanceOf(FlyAdapterError);
480
- expect(caught?.message).toContain("unknown target shape");
481
- expect(called).toBe(false);
482
- });
483
-
484
- test("treats an empty-string apiToken as unconfigured", async () => {
485
- delete process.env[API_TOKEN_ENV];
486
- let caught: Error | undefined;
487
- try {
488
- await deployToFly({
489
- target: "channel",
490
- appName: "my-bot",
491
- imageRef: "img",
492
- apiToken: "",
493
- });
494
- } catch (err) {
495
- caught = err as Error;
496
- }
497
- expect(caught).toBeInstanceOf(FlyAdapterError);
498
- expect(caught?.message).toContain("FLY_API_TOKEN is required");
499
- });
500
-
501
- test("honors an explicit org slug + region in the request payload", async () => {
502
- let createBody: string | undefined;
503
- let launchBody: string | undefined;
504
- const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
505
- const u = String(url);
506
- if (u.endsWith("/apps")) {
507
- createBody = init?.body as string;
508
- return new Response("{}", { status: 201 });
509
- }
510
- launchBody = init?.body as string;
511
- return new Response(JSON.stringify({ id: "m-1", state: "started" }), { status: 200 });
512
- }) as unknown as typeof fetch;
513
- const record = await deployToFly({
514
- target: "channel",
515
- appName: "my-bot",
516
- imageRef: "img",
517
- apiToken: "fo_testtoken_abc",
518
- orgSlug: "acme-co",
519
- region: "fra",
520
- fetchImpl,
521
- });
522
- expect(record.region).toBe("fra");
523
- expect(JSON.parse(createBody as string).org_slug).toBe("acme-co");
524
- expect(JSON.parse(launchBody as string).region).toBe("fra");
525
- });
526
-
527
- test("surfaces a scrubbed app-create failure for non-409 statuses", async () => {
528
- const fetchImpl = (async () =>
529
- new Response("boom fo_leakingtoken_full boom", {
530
- status: 500,
531
- statusText: "Internal Server Error",
532
- })) as unknown as typeof fetch;
533
- let caught: Error | undefined;
534
- try {
535
- await deployToFly({
536
- target: "channel",
537
- appName: "my-bot",
538
- imageRef: "img",
539
- apiToken: "fo_leakingtoken_full",
540
- fetchImpl,
541
- });
542
- } catch (err) {
543
- caught = err as Error;
544
- }
545
- expect(caught).toBeInstanceOf(FlyAdapterError);
546
- expect(caught?.message).toContain("Fly app create returned 500");
547
- expect(caught?.message).not.toContain("fo_leakingtoken_full");
548
- });
549
- });
550
-
551
- describe("security: app name guards the Machines API URL path", () => {
552
- // Regression: appName is interpolated into `${baseUrl}/apps/${appName}/machines`.
553
- // assertAppName must reject path-traversal / SSRF payloads BEFORE any fetch runs.
554
- const PAYLOADS = [
555
- "../../etc/passwd",
556
- "app/../../admin",
557
- "app%2F..%2Fadmin",
558
- "app name",
559
- "app\nmachines",
560
- "evil.example.com",
561
- "UPPER",
562
- ];
563
- for (const payload of PAYLOADS) {
564
- test(`rejects "${payload}" without calling fetch`, async () => {
565
- let called = false;
566
- const fetchImpl = (async () => {
567
- called = true;
568
- return new Response("{}", { status: 200 });
569
- }) as unknown as typeof fetch;
570
- let caught: Error | undefined;
571
- try {
572
- await deployToFly({
573
- target: "channel",
574
- appName: payload,
575
- imageRef: "img",
576
- apiToken: "fo_testtoken_abc",
577
- fetchImpl,
578
- });
579
- } catch (err) {
580
- caught = err as Error;
581
- }
582
- expect(caught).toBeInstanceOf(FlyAdapterError);
583
- expect(called).toBe(false);
584
- });
585
- }
586
- });
587
-
588
- describe("flyTomlFor: unknown vs unsupported shape branches", () => {
589
- test("rejects an entirely unknown shape (isTargetShape=false)", () => {
590
- expect(() => flyTomlFor({ target: "mystery" as never, appName: "svc" })).toThrow(
591
- /unknown target shape/,
592
- );
593
- });
594
-
595
- test("skips env entries whose value is undefined", () => {
596
- const toml = flyTomlFor({
597
- target: "channel",
598
- appName: "svc",
599
- // `MISSING` is explicitly undefined; it must not emit a line.
600
- envVars: { KEEP: "1", MISSING: undefined } as unknown as Record<string, string>,
601
- });
602
- expect(toml).toContain('KEEP = "1"');
603
- expect(toml).not.toContain("MISSING");
604
- });
605
-
606
- test("omits the [env] block entirely when envVars is empty", () => {
607
- const toml = flyTomlFor({ target: "channel", appName: "svc", envVars: {} });
608
- expect(toml).not.toContain("[env]");
609
- });
610
-
611
- test("escapes backslashes in env values", () => {
612
- const toml = flyTomlFor({
613
- target: "channel",
614
- appName: "svc",
615
- envVars: { WIN: "C:\\path" },
616
- });
617
- expect(toml).toContain('WIN = "C:\\\\path"');
618
- });
619
-
620
- test("uses a custom dockerfile path", () => {
621
- const toml = flyTomlFor({
622
- target: "channel",
623
- appName: "svc",
624
- dockerfilePath: "ops/Dockerfile.custom",
625
- });
626
- expect(toml).toContain('dockerfile = "ops/Dockerfile.custom"');
627
- });
628
-
629
- test("supports every web shape and the lone worker shape", () => {
630
- for (const shape of ["channel", "managed", "voice", "browser"] as const) {
631
- expect(flyTomlFor({ target: shape, appName: "svc" })).toContain("[http_service]");
632
- }
633
- expect(flyTomlFor({ target: "batch", appName: "svc" })).toContain("[processes]");
634
- });
635
- });
package/src/index.ts DELETED
@@ -1,345 +0,0 @@
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
- }