@crewhaus/cloud-adapter-railway 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-railway",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "One-click deploy adapter for Railway — generates a railway.json + Dockerfile and wraps the Railway GraphQL 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-railway"
28
+ },
29
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/cloud-adapter-railway#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,209 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ RAILWAY_DEFAULT_API_BASE,
4
+ RAILWAY_TARGET_SHAPES,
5
+ RailwayAdapterError,
6
+ deployToRailway,
7
+ isRailwayTargetShape,
8
+ railwayConfigFor,
9
+ railwayDockerfileFor,
10
+ scrubApiToken,
11
+ } from "./index";
12
+
13
+ const API_TOKEN_ENV = "RAILWAY_API_TOKEN";
14
+
15
+ describe("isRailwayTargetShape", () => {
16
+ test("accepts daemon shapes", () => {
17
+ for (const shape of RAILWAY_TARGET_SHAPES) {
18
+ expect(isRailwayTargetShape(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(isRailwayTargetShape(shape)).toBe(false);
24
+ }
25
+ });
26
+ });
27
+
28
+ describe("railwayConfigFor", () => {
29
+ test("web shape gets healthcheck path", () => {
30
+ const json = railwayConfigFor({ target: "channel" });
31
+ const parsed = JSON.parse(json);
32
+ expect(parsed.build.builder).toBe("DOCKERFILE");
33
+ expect(parsed.build.dockerfilePath).toBe("Dockerfile.railway");
34
+ expect(parsed.deploy.healthcheckPath).toBe("/healthz");
35
+ expect(parsed.deploy.healthcheckTimeout).toBe(300);
36
+ expect(parsed.deploy.restartPolicyType).toBe("ON_FAILURE");
37
+ });
38
+
39
+ test("worker shape omits healthcheck", () => {
40
+ const json = railwayConfigFor({ target: "batch" });
41
+ const parsed = JSON.parse(json);
42
+ expect(parsed.deploy.healthcheckPath).toBeUndefined();
43
+ expect(parsed.deploy.healthcheckTimeout).toBeUndefined();
44
+ });
45
+
46
+ test("is deterministic for the same input", () => {
47
+ const a = railwayConfigFor({ target: "channel" });
48
+ const b = railwayConfigFor({ target: "channel" });
49
+ expect(a).toBe(b);
50
+ });
51
+
52
+ test("respects restart policy + dockerfile path overrides", () => {
53
+ const parsed = JSON.parse(
54
+ railwayConfigFor({
55
+ target: "managed",
56
+ restartPolicy: "ALWAYS",
57
+ restartPolicyMaxRetries: 25,
58
+ dockerfilePath: "Dockerfile.prod",
59
+ }),
60
+ );
61
+ expect(parsed.deploy.restartPolicyType).toBe("ALWAYS");
62
+ expect(parsed.deploy.restartPolicyMaxRetries).toBe(25);
63
+ expect(parsed.build.dockerfilePath).toBe("Dockerfile.prod");
64
+ });
65
+
66
+ test("rejects unsupported shapes", () => {
67
+ expect(() => railwayConfigFor({ target: "cli" })).toThrow(RailwayAdapterError);
68
+ expect(() => railwayConfigFor({ target: "eval" })).toThrow(RailwayAdapterError);
69
+ });
70
+
71
+ test("rejects unknown shapes", () => {
72
+ expect(() => railwayConfigFor({ target: "mystery" as never })).toThrow(RailwayAdapterError);
73
+ });
74
+ });
75
+
76
+ describe("railwayDockerfileFor", () => {
77
+ test("web shape EXPOSEs 8080", () => {
78
+ const df = railwayDockerfileFor({ target: "channel", baseImage: "crewhaus/channel:0.1.0" });
79
+ expect(df).toContain("FROM crewhaus/channel:0.1.0");
80
+ expect(df).toContain("EXPOSE 8080");
81
+ });
82
+
83
+ test("worker shape does not EXPOSE", () => {
84
+ const df = railwayDockerfileFor({ target: "batch", baseImage: "crewhaus/batch:0.1.0" });
85
+ expect(df).not.toContain("EXPOSE");
86
+ });
87
+
88
+ test("rejects unsupported shapes", () => {
89
+ expect(() => railwayDockerfileFor({ target: "cli", baseImage: "x" })).toThrow(
90
+ RailwayAdapterError,
91
+ );
92
+ });
93
+ });
94
+
95
+ describe("scrubApiToken", () => {
96
+ test("redacts", () => {
97
+ expect(scrubApiToken("token rw_abcdef1234567890 leaked", "rw_abcdef1234567890")).toBe(
98
+ "token [REDACTED:RAILWAY_API_TOKEN] leaked",
99
+ );
100
+ });
101
+ test("ignores short tokens", () => {
102
+ expect(scrubApiToken("untouched", "abc")).toBe("untouched");
103
+ });
104
+ });
105
+
106
+ describe("deployToRailway", () => {
107
+ const ORIGINAL_TOKEN = process.env[API_TOKEN_ENV];
108
+ afterEach(() => {
109
+ if (ORIGINAL_TOKEN === undefined) delete process.env[API_TOKEN_ENV];
110
+ else process.env[API_TOKEN_ENV] = ORIGINAL_TOKEN;
111
+ });
112
+
113
+ test("posts a serviceCreate mutation", async () => {
114
+ let observed: { url: string; body: string } | undefined;
115
+ const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
116
+ observed = { url: String(url), body: String(init?.body ?? "") };
117
+ return new Response(
118
+ JSON.stringify({ data: { serviceCreate: { id: "svc-123", name: "my-bot" } } }),
119
+ { status: 200 },
120
+ );
121
+ }) as unknown as typeof fetch;
122
+ const record = await deployToRailway({
123
+ projectId: "p-456",
124
+ serviceName: "my-bot",
125
+ apiToken: "rw_testtoken_abc",
126
+ fetchImpl,
127
+ });
128
+ expect(observed?.url).toBe(RAILWAY_DEFAULT_API_BASE);
129
+ expect(observed?.body).toContain("serviceCreate");
130
+ expect(observed?.body).toContain("p-456");
131
+ expect(record.serviceId).toBe("svc-123");
132
+ expect(record.serviceName).toBe("my-bot");
133
+ expect(record.projectId).toBe("p-456");
134
+ });
135
+
136
+ test("surfaces GraphQL errors", async () => {
137
+ const fetchImpl = (async () =>
138
+ new Response(JSON.stringify({ errors: [{ message: "project not found" }] }), {
139
+ status: 200,
140
+ })) as unknown as typeof fetch;
141
+ let caught: Error | undefined;
142
+ try {
143
+ await deployToRailway({
144
+ projectId: "missing",
145
+ serviceName: "my-bot",
146
+ apiToken: "rw_testtoken_abc",
147
+ fetchImpl,
148
+ });
149
+ } catch (err) {
150
+ caught = err as Error;
151
+ }
152
+ expect(caught).toBeInstanceOf(RailwayAdapterError);
153
+ expect(caught?.message).toContain("project not found");
154
+ });
155
+
156
+ test("scrubs token from HTTP error bodies", async () => {
157
+ const fetchImpl = (async () =>
158
+ new Response("unauthorized — token rw_leakingtoken_full denied", {
159
+ status: 401,
160
+ statusText: "Unauthorized",
161
+ })) as unknown as typeof fetch;
162
+ let caught: Error | undefined;
163
+ try {
164
+ await deployToRailway({
165
+ projectId: "p",
166
+ serviceName: "my-bot",
167
+ apiToken: "rw_leakingtoken_full",
168
+ fetchImpl,
169
+ });
170
+ } catch (err) {
171
+ caught = err as Error;
172
+ }
173
+ expect(caught?.message).not.toContain("rw_leakingtoken_full");
174
+ expect(caught?.message).toContain("[REDACTED:RAILWAY_API_TOKEN]");
175
+ });
176
+
177
+ test("throws clearly when no token configured", async () => {
178
+ delete process.env[API_TOKEN_ENV];
179
+ let caught: Error | undefined;
180
+ try {
181
+ await deployToRailway({ projectId: "p", serviceName: "my-bot" });
182
+ } catch (err) {
183
+ caught = err as Error;
184
+ }
185
+ expect(caught).toBeInstanceOf(RailwayAdapterError);
186
+ expect(caught?.message).toContain("RAILWAY_API_TOKEN is required");
187
+ });
188
+
189
+ test("validates service name before network", async () => {
190
+ let called = false;
191
+ const fetchImpl = (async () => {
192
+ called = true;
193
+ return new Response("", { status: 200 });
194
+ }) as unknown as typeof fetch;
195
+ let caught: Error | undefined;
196
+ try {
197
+ await deployToRailway({
198
+ projectId: "p",
199
+ serviceName: "BadCaps",
200
+ apiToken: "rw_testtoken_abc",
201
+ fetchImpl,
202
+ });
203
+ } catch (err) {
204
+ caught = err as Error;
205
+ }
206
+ expect(caught).toBeInstanceOf(RailwayAdapterError);
207
+ expect(called).toBe(false);
208
+ });
209
+ });
package/src/index.ts ADDED
@@ -0,0 +1,247 @@
1
+ import { type TargetShape, isTargetShape } from "@crewhaus/docker-images";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+
4
+ /**
5
+ * Section 44 — `@crewhaus/cloud-adapter-railway`
6
+ *
7
+ * One-click deploy adapter for [Railway](https://railway.com). Three surfaces:
8
+ *
9
+ * - `railwayConfigFor({target, …})` — emits a deterministic
10
+ * `railway.json` config: builder = DOCKERFILE, healthcheckPath
11
+ * for web shapes, restart policy. Railway reads this at repo root.
12
+ *
13
+ * - `railwayDockerfileFor({target, baseImage})` — wraps the §32
14
+ * docker-images base with a Railway-tuned final stage. Railway
15
+ * injects `$PORT` at runtime; web shapes EXPOSE 8080 for clarity.
16
+ *
17
+ * - `deployToRailway({apiToken, …})` — sends a GraphQL `serviceCreate`
18
+ * mutation against `https://backboard.railway.com/graphql/v2`.
19
+ * Live calls gated on `RAILWAY_API_TOKEN` env (or explicit
20
+ * `apiToken`) plus injectable `fetchImpl` test seam.
21
+ *
22
+ * SECURITY (T8 credential-leak guard): `scrubApiToken()` runs every
23
+ * error path. Same approach as `cloud-adapter-render` / `-flyio`.
24
+ *
25
+ * Targets: daemon shapes (channel, managed, batch, voice, browser).
26
+ */
27
+
28
+ export class RailwayAdapterError extends CrewhausError {
29
+ override readonly name = "RailwayAdapterError";
30
+ constructor(message: string, cause?: unknown) {
31
+ super("config", message, cause);
32
+ }
33
+ }
34
+
35
+ export const RAILWAY_TARGET_SHAPES = [
36
+ "channel",
37
+ "managed",
38
+ "batch",
39
+ "voice",
40
+ "browser",
41
+ ] as const satisfies readonly TargetShape[];
42
+
43
+ export type RailwayTargetShape = (typeof RAILWAY_TARGET_SHAPES)[number];
44
+
45
+ export function isRailwayTargetShape(value: unknown): value is RailwayTargetShape {
46
+ return typeof value === "string" && (RAILWAY_TARGET_SHAPES as readonly string[]).includes(value);
47
+ }
48
+
49
+ export type RailwayRestartPolicy = "NEVER" | "ON_FAILURE" | "ALWAYS";
50
+
51
+ const SERVICE_TYPE_FOR_TARGET: Readonly<Record<RailwayTargetShape, "web" | "worker">> = {
52
+ channel: "web",
53
+ managed: "web",
54
+ voice: "web",
55
+ browser: "web",
56
+ batch: "worker",
57
+ };
58
+
59
+ const SERVICE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
60
+
61
+ function assertRailwayTarget(target: TargetShape): asserts target is RailwayTargetShape {
62
+ if (!isRailwayTargetShape(target)) {
63
+ throw new RailwayAdapterError(
64
+ `target shape "${target}" is not supported by Railway. Supported daemon shapes: ${RAILWAY_TARGET_SHAPES.join(", ")}.`,
65
+ );
66
+ }
67
+ }
68
+
69
+ function assertServiceName(name: string): void {
70
+ if (!SERVICE_NAME_PATTERN.test(name)) {
71
+ throw new RailwayAdapterError(
72
+ `service name "${name}" is invalid. Must be lowercase alphanumerics + hyphens, 1-63 chars, no leading/trailing hyphen.`,
73
+ );
74
+ }
75
+ }
76
+
77
+ export type RailwayConfigOptions = {
78
+ readonly target: TargetShape;
79
+ readonly dockerfilePath?: string;
80
+ readonly healthcheckPath?: string;
81
+ readonly healthcheckTimeoutSec?: number;
82
+ readonly restartPolicy?: RailwayRestartPolicy;
83
+ readonly restartPolicyMaxRetries?: number;
84
+ };
85
+
86
+ /**
87
+ * Stable JSON for the railway.json file. Keys are emitted in a fixed
88
+ * order so two equivalent inputs produce byte-identical output.
89
+ */
90
+ export function railwayConfigFor(opts: RailwayConfigOptions): string {
91
+ if (!isTargetShape(opts.target)) {
92
+ throw new RailwayAdapterError(`unknown target shape "${opts.target}"`);
93
+ }
94
+ assertRailwayTarget(opts.target);
95
+ const isWeb = SERVICE_TYPE_FOR_TARGET[opts.target] === "web";
96
+
97
+ const build = {
98
+ builder: "DOCKERFILE",
99
+ dockerfilePath: opts.dockerfilePath ?? "Dockerfile.railway",
100
+ };
101
+ const deploy: Record<string, unknown> = {
102
+ restartPolicyType: opts.restartPolicy ?? "ON_FAILURE",
103
+ restartPolicyMaxRetries: opts.restartPolicyMaxRetries ?? 10,
104
+ };
105
+ if (isWeb) {
106
+ deploy["healthcheckPath"] = opts.healthcheckPath ?? "/healthz";
107
+ deploy["healthcheckTimeout"] = opts.healthcheckTimeoutSec ?? 300;
108
+ }
109
+ const config = {
110
+ $schema: "https://railway.app/railway.schema.json",
111
+ build,
112
+ deploy,
113
+ };
114
+ return `${JSON.stringify(config, null, 2)}\n`;
115
+ }
116
+
117
+ export type RailwayDockerfileOptions = {
118
+ readonly target: TargetShape;
119
+ readonly baseImage: string;
120
+ };
121
+
122
+ export function railwayDockerfileFor(opts: RailwayDockerfileOptions): string {
123
+ if (!isTargetShape(opts.target)) {
124
+ throw new RailwayAdapterError(`unknown target shape "${opts.target}"`);
125
+ }
126
+ assertRailwayTarget(opts.target);
127
+ const isWeb = SERVICE_TYPE_FOR_TARGET[opts.target] === "web";
128
+ const lines = [
129
+ `# Railway-tuned wrapper around ${opts.baseImage}`,
130
+ `FROM ${opts.baseImage}`,
131
+ "ENV PORT=8080",
132
+ ];
133
+ if (isWeb) {
134
+ lines.push("EXPOSE 8080");
135
+ }
136
+ return `${lines.join("\n")}\n`;
137
+ }
138
+
139
+ export type RailwayDeployRecord = {
140
+ readonly serviceId: string;
141
+ readonly serviceName: string;
142
+ readonly projectId: string;
143
+ };
144
+
145
+ export type DeployToRailwayOptions = {
146
+ readonly projectId: string;
147
+ readonly serviceName: string;
148
+ readonly apiToken?: string;
149
+ readonly apiBaseUrl?: string;
150
+ readonly fetchImpl?: typeof fetch;
151
+ };
152
+
153
+ export const RAILWAY_DEFAULT_API_BASE = "https://backboard.railway.com/graphql/v2";
154
+
155
+ const SERVICE_CREATE_MUTATION = `
156
+ mutation serviceCreate($input: ServiceCreateInput!) {
157
+ serviceCreate(input: $input) {
158
+ id
159
+ name
160
+ }
161
+ }
162
+ `.trim();
163
+
164
+ function resolveApiToken(provided: string | undefined): string {
165
+ const token = provided ?? process.env["RAILWAY_API_TOKEN"];
166
+ if (token === undefined || token.length === 0) {
167
+ throw new RailwayAdapterError(
168
+ "RAILWAY_API_TOKEN is required. Pass `apiToken` explicitly or set the env var.",
169
+ );
170
+ }
171
+ return token;
172
+ }
173
+
174
+ export function scrubApiToken(message: string, apiToken: string | undefined): string {
175
+ if (apiToken === undefined || apiToken.length < 8) return message;
176
+ return message.split(apiToken).join("[REDACTED:RAILWAY_API_TOKEN]");
177
+ }
178
+
179
+ /**
180
+ * Issue a `serviceCreate` GraphQL mutation against the Railway public
181
+ * API. Returns the created service id. All errors run through
182
+ * `scrubApiToken()` before surfacing.
183
+ */
184
+ export async function deployToRailway(opts: DeployToRailwayOptions): Promise<RailwayDeployRecord> {
185
+ assertServiceName(opts.serviceName);
186
+ const apiToken = resolveApiToken(opts.apiToken);
187
+ const url = opts.apiBaseUrl ?? RAILWAY_DEFAULT_API_BASE;
188
+ const fetchFn = opts.fetchImpl ?? fetch;
189
+
190
+ const body = JSON.stringify({
191
+ query: SERVICE_CREATE_MUTATION,
192
+ variables: {
193
+ input: { projectId: opts.projectId, name: opts.serviceName },
194
+ },
195
+ });
196
+
197
+ let response: Response;
198
+ try {
199
+ response = await fetchFn(url, {
200
+ method: "POST",
201
+ headers: {
202
+ Authorization: `Bearer ${apiToken}`,
203
+ "Content-Type": "application/json",
204
+ Accept: "application/json",
205
+ },
206
+ body,
207
+ });
208
+ } catch (err) {
209
+ const msg = err instanceof Error ? err.message : String(err);
210
+ throw new RailwayAdapterError(
211
+ `Railway GraphQL request failed: ${scrubApiToken(msg, apiToken)}`,
212
+ err,
213
+ );
214
+ }
215
+
216
+ const text = await response.text();
217
+ const scrubbed = scrubApiToken(text, apiToken);
218
+ if (!response.ok) {
219
+ throw new RailwayAdapterError(
220
+ `Railway GraphQL returned ${response.status} ${response.statusText}: ${scrubbed}`,
221
+ );
222
+ }
223
+ let parsed: {
224
+ data?: { serviceCreate?: { id?: string; name?: string } };
225
+ errors?: ReadonlyArray<{ message?: string }>;
226
+ };
227
+ try {
228
+ parsed = JSON.parse(text);
229
+ } catch (err) {
230
+ throw new RailwayAdapterError(`Railway GraphQL returned non-JSON body: ${scrubbed}`, err);
231
+ }
232
+ if (parsed.errors && parsed.errors.length > 0) {
233
+ const firstErr = parsed.errors[0]?.message ?? "(no message)";
234
+ throw new RailwayAdapterError(
235
+ `Railway GraphQL responded with errors: ${scrubApiToken(firstErr, apiToken)}`,
236
+ );
237
+ }
238
+ const created = parsed.data?.serviceCreate;
239
+ if (!created?.id) {
240
+ throw new RailwayAdapterError(`Railway GraphQL response missing serviceCreate.id: ${scrubbed}`);
241
+ }
242
+ return {
243
+ serviceId: created.id,
244
+ serviceName: created.name ?? opts.serviceName,
245
+ projectId: opts.projectId,
246
+ };
247
+ }