@crewhaus/cloud-adapter-heroku 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-heroku",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "One-click deploy adapter for Heroku — generates a heroku.yml + app.json + Dockerfile and wraps the Heroku Platform 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-heroku"
28
+ },
29
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/cloud-adapter-heroku#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,260 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ HEROKU_DEFAULT_API_BASE,
4
+ HEROKU_TARGET_SHAPES,
5
+ HerokuAdapterError,
6
+ appJsonFor,
7
+ deployToHeroku,
8
+ herokuDockerfileFor,
9
+ herokuYmlFor,
10
+ isHerokuTargetShape,
11
+ scrubApiKey,
12
+ } from "./index";
13
+
14
+ const API_KEY_ENV = "HEROKU_API_KEY";
15
+
16
+ describe("isHerokuTargetShape", () => {
17
+ test("accepts daemon shapes", () => {
18
+ for (const shape of HEROKU_TARGET_SHAPES) {
19
+ expect(isHerokuTargetShape(shape)).toBe(true);
20
+ }
21
+ });
22
+ test("rejects one-shot shapes", () => {
23
+ for (const shape of ["cli", "workflow", "eval", "graph", "pipeline", "crew", "research"]) {
24
+ expect(isHerokuTargetShape(shape)).toBe(false);
25
+ }
26
+ });
27
+ });
28
+
29
+ describe("herokuYmlFor", () => {
30
+ test("web shape gets web process", () => {
31
+ const yml = herokuYmlFor({ target: "channel" });
32
+ expect(yml).toContain("build:");
33
+ expect(yml).toContain("docker:");
34
+ expect(yml).toContain("web: Dockerfile.heroku");
35
+ expect(yml).toContain("run:");
36
+ expect(yml).toContain("web: bun run start");
37
+ });
38
+
39
+ test("batch shape gets worker process", () => {
40
+ const yml = herokuYmlFor({ target: "batch" });
41
+ expect(yml).toContain("worker: Dockerfile.heroku");
42
+ expect(yml).toContain("worker: bun run start");
43
+ expect(yml).not.toContain("web:");
44
+ });
45
+
46
+ test("respects dockerfile path override", () => {
47
+ const yml = herokuYmlFor({ target: "channel", dockerfilePath: "Dockerfile.prod" });
48
+ expect(yml).toContain("web: Dockerfile.prod");
49
+ });
50
+
51
+ test("rejects unsupported shapes", () => {
52
+ expect(() => herokuYmlFor({ target: "cli" })).toThrow(HerokuAdapterError);
53
+ });
54
+ });
55
+
56
+ describe("appJsonFor", () => {
57
+ test("emits container stack with formation", () => {
58
+ const parsed = JSON.parse(appJsonFor({ target: "channel", name: "my-bot" }));
59
+ expect(parsed.stack).toBe("container");
60
+ expect(parsed.name).toBe("my-bot");
61
+ expect(parsed.formation.web).toEqual({ quantity: 1, size: "basic" });
62
+ });
63
+
64
+ test("worker shape gets worker formation", () => {
65
+ const parsed = JSON.parse(appJsonFor({ target: "batch", name: "my-worker" }));
66
+ expect(parsed.formation.worker).toEqual({ quantity: 1, size: "basic" });
67
+ expect(parsed.formation.web).toBeUndefined();
68
+ });
69
+
70
+ test("env vars are sorted deterministically", () => {
71
+ const a = appJsonFor({
72
+ target: "channel",
73
+ name: "my-bot",
74
+ envVars: {
75
+ ZETA: { value: "1" },
76
+ ALPHA: { value: "2" },
77
+ },
78
+ });
79
+ const b = appJsonFor({
80
+ target: "channel",
81
+ name: "my-bot",
82
+ envVars: {
83
+ ALPHA: { value: "2" },
84
+ ZETA: { value: "1" },
85
+ },
86
+ });
87
+ expect(a).toBe(b);
88
+ expect(a.indexOf('"ALPHA"')).toBeLessThan(a.indexOf('"ZETA"'));
89
+ });
90
+
91
+ test("respects dyno size and quantity", () => {
92
+ const parsed = JSON.parse(
93
+ appJsonFor({
94
+ target: "channel",
95
+ name: "my-bot",
96
+ dynoSize: "performance-m",
97
+ quantity: 3,
98
+ }),
99
+ );
100
+ expect(parsed.formation.web.size).toBe("performance-m");
101
+ expect(parsed.formation.web.quantity).toBe(3);
102
+ });
103
+
104
+ test("rejects invalid app names", () => {
105
+ expect(() => appJsonFor({ target: "channel", name: "MyBot" })).toThrow(HerokuAdapterError);
106
+ expect(() => appJsonFor({ target: "channel", name: "ab" })).toThrow(HerokuAdapterError);
107
+ expect(() => appJsonFor({ target: "channel", name: "1starts-with-digit" })).toThrow(
108
+ HerokuAdapterError,
109
+ );
110
+ expect(() => appJsonFor({ target: "channel", name: "trailing-" })).toThrow(HerokuAdapterError);
111
+ });
112
+
113
+ test("rejects unsupported shapes", () => {
114
+ expect(() => appJsonFor({ target: "cli", name: "my-bot" })).toThrow(HerokuAdapterError);
115
+ });
116
+ });
117
+
118
+ describe("herokuDockerfileFor", () => {
119
+ test("binds to runtime PORT", () => {
120
+ const df = herokuDockerfileFor({ target: "channel", baseImage: "crewhaus/channel:0.1.0" });
121
+ expect(df).toContain("FROM crewhaus/channel:0.1.0");
122
+ expect(df).toContain("ENV PORT=${PORT:-8080}");
123
+ expect(df).not.toContain("EXPOSE");
124
+ });
125
+
126
+ test("rejects unsupported shapes", () => {
127
+ expect(() => herokuDockerfileFor({ target: "cli", baseImage: "x" })).toThrow(
128
+ HerokuAdapterError,
129
+ );
130
+ });
131
+ });
132
+
133
+ describe("scrubApiKey", () => {
134
+ test("redacts the key", () => {
135
+ expect(scrubApiKey("auth hrk_abcdefghij12345 denied", "hrk_abcdefghij12345")).toBe(
136
+ "auth [REDACTED:HEROKU_API_KEY] denied",
137
+ );
138
+ });
139
+ test("ignores short keys", () => {
140
+ expect(scrubApiKey("untouched", "abc")).toBe("untouched");
141
+ });
142
+ });
143
+
144
+ describe("deployToHeroku", () => {
145
+ const ORIGINAL_KEY = process.env[API_KEY_ENV];
146
+ afterEach(() => {
147
+ if (ORIGINAL_KEY === undefined) delete process.env[API_KEY_ENV];
148
+ else process.env[API_KEY_ENV] = ORIGINAL_KEY;
149
+ });
150
+
151
+ test("creates an app with container stack", async () => {
152
+ let observed: { url: string; init: RequestInit } | undefined;
153
+ const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
154
+ observed = { url: String(url), init: init ?? {} };
155
+ return new Response(
156
+ JSON.stringify({
157
+ id: "app-uuid-123",
158
+ name: "my-bot",
159
+ web_url: "https://my-bot.herokuapp.com/",
160
+ }),
161
+ { status: 201 },
162
+ );
163
+ }) as unknown as typeof fetch;
164
+ const record = await deployToHeroku({
165
+ appName: "my-bot",
166
+ apiKey: "hrk_testkey_abcdef",
167
+ fetchImpl,
168
+ });
169
+ expect(observed?.url).toBe(`${HEROKU_DEFAULT_API_BASE}/apps`);
170
+ const body = JSON.parse(String(observed?.init.body));
171
+ expect(body.stack).toBe("container");
172
+ expect(body.region).toBe("us");
173
+ expect((observed?.init.headers as Record<string, string>)["Accept"]).toBe(
174
+ "application/vnd.heroku+json; version=3",
175
+ );
176
+ expect(record.appId).toBe("app-uuid-123");
177
+ expect(record.appName).toBe("my-bot");
178
+ expect(record.webUrl).toBe("https://my-bot.herokuapp.com/");
179
+ });
180
+
181
+ test("respects region override", async () => {
182
+ let body: Record<string, unknown> = {};
183
+ const fetchImpl = (async (_url: string | URL, init?: RequestInit) => {
184
+ body = JSON.parse(String(init?.body));
185
+ return new Response(JSON.stringify({ id: "x", name: "my-bot" }), { status: 201 });
186
+ }) as unknown as typeof fetch;
187
+ await deployToHeroku({
188
+ appName: "my-bot",
189
+ region: "eu",
190
+ apiKey: "hrk_testkey_abcdef",
191
+ fetchImpl,
192
+ });
193
+ expect(body["region"]).toBe("eu");
194
+ });
195
+
196
+ test("scrubs API key from error bodies", async () => {
197
+ const fetchImpl = (async () =>
198
+ new Response("unauthorized — key hrk_leakingkey_full denied", {
199
+ status: 401,
200
+ statusText: "Unauthorized",
201
+ })) as unknown as typeof fetch;
202
+ let caught: Error | undefined;
203
+ try {
204
+ await deployToHeroku({
205
+ appName: "my-bot",
206
+ apiKey: "hrk_leakingkey_full",
207
+ fetchImpl,
208
+ });
209
+ } catch (err) {
210
+ caught = err as Error;
211
+ }
212
+ expect(caught?.message).not.toContain("hrk_leakingkey_full");
213
+ expect(caught?.message).toContain("[REDACTED:HEROKU_API_KEY]");
214
+ });
215
+
216
+ test("throws clearly when no API key configured", async () => {
217
+ delete process.env[API_KEY_ENV];
218
+ let caught: Error | undefined;
219
+ try {
220
+ await deployToHeroku({ appName: "my-bot" });
221
+ } catch (err) {
222
+ caught = err as Error;
223
+ }
224
+ expect(caught).toBeInstanceOf(HerokuAdapterError);
225
+ expect(caught?.message).toContain("HEROKU_API_KEY is required");
226
+ });
227
+
228
+ test("validates app name before network call", async () => {
229
+ let called = false;
230
+ const fetchImpl = (async () => {
231
+ called = true;
232
+ return new Response("", { status: 201 });
233
+ }) as unknown as typeof fetch;
234
+ let caught: Error | undefined;
235
+ try {
236
+ await deployToHeroku({
237
+ appName: "BadCaps",
238
+ apiKey: "hrk_testkey_abcdef",
239
+ fetchImpl,
240
+ });
241
+ } catch (err) {
242
+ caught = err as Error;
243
+ }
244
+ expect(caught).toBeInstanceOf(HerokuAdapterError);
245
+ expect(called).toBe(false);
246
+ });
247
+
248
+ test("derives web_url when API omits it", async () => {
249
+ const fetchImpl = (async () =>
250
+ new Response(JSON.stringify({ id: "x", name: "my-bot" }), {
251
+ status: 201,
252
+ })) as unknown as typeof fetch;
253
+ const record = await deployToHeroku({
254
+ appName: "my-bot",
255
+ apiKey: "hrk_testkey_abcdef",
256
+ fetchImpl,
257
+ });
258
+ expect(record.webUrl).toBe("https://my-bot.herokuapp.com/");
259
+ });
260
+ });
package/src/index.ts ADDED
@@ -0,0 +1,258 @@
1
+ import { type TargetShape, isTargetShape } from "@crewhaus/docker-images";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+
4
+ /**
5
+ * Section 44 — `@crewhaus/cloud-adapter-heroku`
6
+ *
7
+ * One-click deploy adapter for [Heroku](https://heroku.com). Four surfaces:
8
+ *
9
+ * - `herokuYmlFor({target, …})` — emits a deterministic `heroku.yml`
10
+ * manifest pointing at the Container stack with a Docker build for
11
+ * each process type (web for daemon shapes, worker for batch).
12
+ *
13
+ * - `appJsonFor({target, name, …})` — emits a `app.json` Heroku Setup
14
+ * manifest (`stack: container`, env vars, formation).
15
+ *
16
+ * - `herokuDockerfileFor({target, baseImage})` — wraps the §32 base
17
+ * image with a Heroku-tuned `ENV PORT=$PORT` and shell-form CMD —
18
+ * Heroku injects `$PORT` at runtime and expects the container to
19
+ * bind to it. No fixed `EXPOSE`.
20
+ *
21
+ * - `deployToHeroku({apiKey, appName, …})` — creates an app via the
22
+ * Heroku Platform API and sets the stack to `container`. Live
23
+ * calls gated on `HEROKU_API_KEY` (or explicit `apiKey`) + an
24
+ * injectable `fetchImpl`.
25
+ *
26
+ * SECURITY (T8 credential-leak guard): `scrubApiKey()` wraps every
27
+ * error path. Mirrors the §37 exporter-datadog approach.
28
+ *
29
+ * Targets: daemon shapes (channel, managed, batch, voice, browser).
30
+ */
31
+
32
+ export class HerokuAdapterError extends CrewhausError {
33
+ override readonly name = "HerokuAdapterError";
34
+ constructor(message: string, cause?: unknown) {
35
+ super("config", message, cause);
36
+ }
37
+ }
38
+
39
+ export const HEROKU_TARGET_SHAPES = [
40
+ "channel",
41
+ "managed",
42
+ "batch",
43
+ "voice",
44
+ "browser",
45
+ ] as const satisfies readonly TargetShape[];
46
+
47
+ export type HerokuTargetShape = (typeof HEROKU_TARGET_SHAPES)[number];
48
+
49
+ export function isHerokuTargetShape(value: unknown): value is HerokuTargetShape {
50
+ return typeof value === "string" && (HEROKU_TARGET_SHAPES as readonly string[]).includes(value);
51
+ }
52
+
53
+ export type HerokuDynoSize = "basic" | "standard-1x" | "standard-2x" | "performance-m";
54
+
55
+ const PROCESS_FOR_TARGET: Readonly<Record<HerokuTargetShape, "web" | "worker">> = {
56
+ channel: "web",
57
+ managed: "web",
58
+ voice: "web",
59
+ browser: "web",
60
+ batch: "worker",
61
+ };
62
+
63
+ const APP_NAME_PATTERN = /^[a-z][a-z0-9-]{2,28}[a-z0-9]$/;
64
+
65
+ function assertHerokuTarget(target: TargetShape): asserts target is HerokuTargetShape {
66
+ if (!isHerokuTargetShape(target)) {
67
+ throw new HerokuAdapterError(
68
+ `target shape "${target}" is not supported by Heroku. Supported daemon shapes: ${HEROKU_TARGET_SHAPES.join(", ")}.`,
69
+ );
70
+ }
71
+ }
72
+
73
+ function assertAppName(name: string): void {
74
+ if (!APP_NAME_PATTERN.test(name)) {
75
+ throw new HerokuAdapterError(
76
+ `app name "${name}" is invalid. Must be lowercase letters/digits/hyphens, 4-30 chars, starts with a letter, no trailing hyphen.`,
77
+ );
78
+ }
79
+ }
80
+
81
+ export type HerokuYmlOptions = {
82
+ readonly target: TargetShape;
83
+ readonly dockerfilePath?: string;
84
+ };
85
+
86
+ /**
87
+ * Heroku.yml is YAML but small + deterministic enough to template
88
+ * inline. Three top-level sections: build, run, release (release omitted
89
+ * for now — no DB migrations in the shipped scope).
90
+ */
91
+ export function herokuYmlFor(opts: HerokuYmlOptions): string {
92
+ if (!isTargetShape(opts.target)) {
93
+ throw new HerokuAdapterError(`unknown target shape "${opts.target}"`);
94
+ }
95
+ assertHerokuTarget(opts.target);
96
+ const dockerfilePath = opts.dockerfilePath ?? "Dockerfile.heroku";
97
+ const processName = PROCESS_FOR_TARGET[opts.target];
98
+ const lines = [
99
+ "# Generated by @crewhaus/cloud-adapter-heroku — do not edit by hand",
100
+ "build:",
101
+ " docker:",
102
+ ` ${processName}: ${dockerfilePath}`,
103
+ "run:",
104
+ ` ${processName}: bun run start`,
105
+ ];
106
+ return `${lines.join("\n")}\n`;
107
+ }
108
+
109
+ export type AppJsonOptions = {
110
+ readonly target: TargetShape;
111
+ readonly name: string;
112
+ readonly description?: string;
113
+ readonly envVars?: Readonly<Record<string, { value?: string; required?: boolean }>>;
114
+ readonly dynoSize?: HerokuDynoSize;
115
+ readonly quantity?: number;
116
+ };
117
+
118
+ /**
119
+ * `app.json` Heroku Setup manifest. Deterministic key order, sorted
120
+ * env-var keys.
121
+ */
122
+ export function appJsonFor(opts: AppJsonOptions): string {
123
+ if (!isTargetShape(opts.target)) {
124
+ throw new HerokuAdapterError(`unknown target shape "${opts.target}"`);
125
+ }
126
+ assertHerokuTarget(opts.target);
127
+ assertAppName(opts.name);
128
+ const processName = PROCESS_FOR_TARGET[opts.target];
129
+ const formation = {
130
+ [processName]: {
131
+ quantity: opts.quantity ?? 1,
132
+ size: opts.dynoSize ?? "basic",
133
+ },
134
+ };
135
+ const env: Record<string, { value?: string; required?: boolean }> = {};
136
+ if (opts.envVars) {
137
+ for (const key of Object.keys(opts.envVars).sort()) {
138
+ const value = opts.envVars[key];
139
+ if (value === undefined) continue;
140
+ env[key] = value;
141
+ }
142
+ }
143
+ const manifest: Record<string, unknown> = {
144
+ name: opts.name,
145
+ stack: "container",
146
+ formation,
147
+ };
148
+ if (opts.description !== undefined) {
149
+ manifest["description"] = opts.description;
150
+ }
151
+ if (Object.keys(env).length > 0) {
152
+ manifest["env"] = env;
153
+ }
154
+ return `${JSON.stringify(manifest, null, 2)}\n`;
155
+ }
156
+
157
+ export type HerokuDockerfileOptions = {
158
+ readonly target: TargetShape;
159
+ readonly baseImage: string;
160
+ };
161
+
162
+ export function herokuDockerfileFor(opts: HerokuDockerfileOptions): string {
163
+ if (!isTargetShape(opts.target)) {
164
+ throw new HerokuAdapterError(`unknown target shape "${opts.target}"`);
165
+ }
166
+ assertHerokuTarget(opts.target);
167
+ // Heroku injects $PORT at runtime; container must bind to it. No EXPOSE.
168
+ const lines = [
169
+ `# Heroku-tuned wrapper around ${opts.baseImage}`,
170
+ `FROM ${opts.baseImage}`,
171
+ "ENV PORT=${PORT:-8080}",
172
+ ];
173
+ return `${lines.join("\n")}\n`;
174
+ }
175
+
176
+ export type HerokuDeployRecord = {
177
+ readonly appId: string;
178
+ readonly appName: string;
179
+ readonly webUrl: string;
180
+ };
181
+
182
+ export type DeployToHerokuOptions = {
183
+ readonly appName: string;
184
+ readonly region?: "us" | "eu";
185
+ readonly apiKey?: string;
186
+ readonly apiBaseUrl?: string;
187
+ readonly fetchImpl?: typeof fetch;
188
+ };
189
+
190
+ export const HEROKU_DEFAULT_API_BASE = "https://api.heroku.com";
191
+
192
+ function resolveApiKey(provided: string | undefined): string {
193
+ const key = provided ?? process.env["HEROKU_API_KEY"];
194
+ if (key === undefined || key.length === 0) {
195
+ throw new HerokuAdapterError(
196
+ "HEROKU_API_KEY is required. Pass `apiKey` explicitly or set the env var.",
197
+ );
198
+ }
199
+ return key;
200
+ }
201
+
202
+ export function scrubApiKey(message: string, apiKey: string | undefined): string {
203
+ if (apiKey === undefined || apiKey.length < 8) return message;
204
+ return message.split(apiKey).join("[REDACTED:HEROKU_API_KEY]");
205
+ }
206
+
207
+ /**
208
+ * Create an app via Heroku Platform API. Stack is pinned to `container`
209
+ * so the heroku.yml + Dockerfile build path is used. All errors run
210
+ * through `scrubApiKey()`.
211
+ */
212
+ export async function deployToHeroku(opts: DeployToHerokuOptions): Promise<HerokuDeployRecord> {
213
+ assertAppName(opts.appName);
214
+ const apiKey = resolveApiKey(opts.apiKey);
215
+ const baseUrl = opts.apiBaseUrl ?? HEROKU_DEFAULT_API_BASE;
216
+ const fetchFn = opts.fetchImpl ?? fetch;
217
+ const region = opts.region ?? "us";
218
+
219
+ let response: Response;
220
+ try {
221
+ response = await fetchFn(`${baseUrl}/apps`, {
222
+ method: "POST",
223
+ headers: {
224
+ Authorization: `Bearer ${apiKey}`,
225
+ Accept: "application/vnd.heroku+json; version=3",
226
+ "Content-Type": "application/json",
227
+ },
228
+ body: JSON.stringify({ name: opts.appName, stack: "container", region }),
229
+ });
230
+ } catch (err) {
231
+ const msg = err instanceof Error ? err.message : String(err);
232
+ throw new HerokuAdapterError(
233
+ `Heroku Platform API request failed: ${scrubApiKey(msg, apiKey)}`,
234
+ err,
235
+ );
236
+ }
237
+ const text = await response.text();
238
+ const scrubbed = scrubApiKey(text, apiKey);
239
+ if (!response.ok) {
240
+ throw new HerokuAdapterError(
241
+ `Heroku Platform API returned ${response.status} ${response.statusText}: ${scrubbed}`,
242
+ );
243
+ }
244
+ let parsed: { id?: string; name?: string; web_url?: string };
245
+ try {
246
+ parsed = JSON.parse(text);
247
+ } catch (err) {
248
+ throw new HerokuAdapterError(`Heroku Platform API returned non-JSON body: ${scrubbed}`, err);
249
+ }
250
+ if (typeof parsed.id !== "string" || typeof parsed.name !== "string") {
251
+ throw new HerokuAdapterError(`Heroku Platform API response missing id/name: ${scrubbed}`);
252
+ }
253
+ return {
254
+ appId: parsed.id,
255
+ appName: parsed.name,
256
+ webUrl: parsed.web_url ?? `https://${parsed.name}.herokuapp.com/`,
257
+ };
258
+ }