@crewhaus/cloud-adapter-railway 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,71 @@
1
+ import { type TargetShape } from "@crewhaus/docker-images";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+ /**
4
+ * Section 44 — `@crewhaus/cloud-adapter-railway`
5
+ *
6
+ * One-click deploy adapter for [Railway](https://railway.com). Three surfaces:
7
+ *
8
+ * - `railwayConfigFor({target, …})` — emits a deterministic
9
+ * `railway.json` config: builder = DOCKERFILE, healthcheckPath
10
+ * for web shapes, restart policy. Railway reads this at repo root.
11
+ *
12
+ * - `railwayDockerfileFor({target, baseImage})` — wraps the §32
13
+ * docker-images base with a Railway-tuned final stage. Railway
14
+ * injects `$PORT` at runtime; web shapes EXPOSE 8080 for clarity.
15
+ *
16
+ * - `deployToRailway({apiToken, …})` — sends a GraphQL `serviceCreate`
17
+ * mutation against `https://backboard.railway.com/graphql/v2`.
18
+ * Live calls gated on `RAILWAY_API_TOKEN` env (or explicit
19
+ * `apiToken`) plus injectable `fetchImpl` test seam.
20
+ *
21
+ * SECURITY (T8 credential-leak guard): `scrubApiToken()` runs every
22
+ * error path. Same approach as `cloud-adapter-render` / `-flyio`.
23
+ *
24
+ * Targets: daemon shapes (channel, managed, batch, voice, browser).
25
+ */
26
+ export declare class RailwayAdapterError extends CrewhausError {
27
+ readonly name = "RailwayAdapterError";
28
+ constructor(message: string, cause?: unknown);
29
+ }
30
+ export declare const RAILWAY_TARGET_SHAPES: readonly ["channel", "managed", "batch", "voice", "browser"];
31
+ export type RailwayTargetShape = (typeof RAILWAY_TARGET_SHAPES)[number];
32
+ export declare function isRailwayTargetShape(value: unknown): value is RailwayTargetShape;
33
+ export type RailwayRestartPolicy = "NEVER" | "ON_FAILURE" | "ALWAYS";
34
+ export type RailwayConfigOptions = {
35
+ readonly target: TargetShape;
36
+ readonly dockerfilePath?: string;
37
+ readonly healthcheckPath?: string;
38
+ readonly healthcheckTimeoutSec?: number;
39
+ readonly restartPolicy?: RailwayRestartPolicy;
40
+ readonly restartPolicyMaxRetries?: number;
41
+ };
42
+ /**
43
+ * Stable JSON for the railway.json file. Keys are emitted in a fixed
44
+ * order so two equivalent inputs produce byte-identical output.
45
+ */
46
+ export declare function railwayConfigFor(opts: RailwayConfigOptions): string;
47
+ export type RailwayDockerfileOptions = {
48
+ readonly target: TargetShape;
49
+ readonly baseImage: string;
50
+ };
51
+ export declare function railwayDockerfileFor(opts: RailwayDockerfileOptions): string;
52
+ export type RailwayDeployRecord = {
53
+ readonly serviceId: string;
54
+ readonly serviceName: string;
55
+ readonly projectId: string;
56
+ };
57
+ export type DeployToRailwayOptions = {
58
+ readonly projectId: string;
59
+ readonly serviceName: string;
60
+ readonly apiToken?: string;
61
+ readonly apiBaseUrl?: string;
62
+ readonly fetchImpl?: typeof fetch;
63
+ };
64
+ export declare const RAILWAY_DEFAULT_API_BASE = "https://backboard.railway.com/graphql/v2";
65
+ export declare function scrubApiToken(message: string, apiToken: string | undefined): string;
66
+ /**
67
+ * Issue a `serviceCreate` GraphQL mutation against the Railway public
68
+ * API. Returns the created service id. All errors run through
69
+ * `scrubApiToken()` before surfacing.
70
+ */
71
+ export declare function deployToRailway(opts: DeployToRailwayOptions): Promise<RailwayDeployRecord>;
package/dist/index.js ADDED
@@ -0,0 +1,183 @@
1
+ import { isTargetShape } from "@crewhaus/docker-images";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+ /**
4
+ * Section 44 — `@crewhaus/cloud-adapter-railway`
5
+ *
6
+ * One-click deploy adapter for [Railway](https://railway.com). Three surfaces:
7
+ *
8
+ * - `railwayConfigFor({target, …})` — emits a deterministic
9
+ * `railway.json` config: builder = DOCKERFILE, healthcheckPath
10
+ * for web shapes, restart policy. Railway reads this at repo root.
11
+ *
12
+ * - `railwayDockerfileFor({target, baseImage})` — wraps the §32
13
+ * docker-images base with a Railway-tuned final stage. Railway
14
+ * injects `$PORT` at runtime; web shapes EXPOSE 8080 for clarity.
15
+ *
16
+ * - `deployToRailway({apiToken, …})` — sends a GraphQL `serviceCreate`
17
+ * mutation against `https://backboard.railway.com/graphql/v2`.
18
+ * Live calls gated on `RAILWAY_API_TOKEN` env (or explicit
19
+ * `apiToken`) plus injectable `fetchImpl` test seam.
20
+ *
21
+ * SECURITY (T8 credential-leak guard): `scrubApiToken()` runs every
22
+ * error path. Same approach as `cloud-adapter-render` / `-flyio`.
23
+ *
24
+ * Targets: daemon shapes (channel, managed, batch, voice, browser).
25
+ */
26
+ export class RailwayAdapterError extends CrewhausError {
27
+ name = "RailwayAdapterError";
28
+ constructor(message, cause) {
29
+ super("config", message, cause);
30
+ }
31
+ }
32
+ export const RAILWAY_TARGET_SHAPES = [
33
+ "channel",
34
+ "managed",
35
+ "batch",
36
+ "voice",
37
+ "browser",
38
+ ];
39
+ export function isRailwayTargetShape(value) {
40
+ return typeof value === "string" && RAILWAY_TARGET_SHAPES.includes(value);
41
+ }
42
+ const SERVICE_TYPE_FOR_TARGET = {
43
+ channel: "web",
44
+ managed: "web",
45
+ voice: "web",
46
+ browser: "web",
47
+ batch: "worker",
48
+ };
49
+ const SERVICE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
50
+ function assertRailwayTarget(target) {
51
+ if (!isRailwayTargetShape(target)) {
52
+ throw new RailwayAdapterError(`target shape "${target}" is not supported by Railway. Supported daemon shapes: ${RAILWAY_TARGET_SHAPES.join(", ")}.`);
53
+ }
54
+ }
55
+ function assertServiceName(name) {
56
+ if (!SERVICE_NAME_PATTERN.test(name)) {
57
+ throw new RailwayAdapterError(`service name "${name}" is invalid. Must be lowercase alphanumerics + hyphens, 1-63 chars, no leading/trailing hyphen.`);
58
+ }
59
+ }
60
+ /**
61
+ * Stable JSON for the railway.json file. Keys are emitted in a fixed
62
+ * order so two equivalent inputs produce byte-identical output.
63
+ */
64
+ export function railwayConfigFor(opts) {
65
+ if (!isTargetShape(opts.target)) {
66
+ throw new RailwayAdapterError(`unknown target shape "${opts.target}"`);
67
+ }
68
+ assertRailwayTarget(opts.target);
69
+ const isWeb = SERVICE_TYPE_FOR_TARGET[opts.target] === "web";
70
+ const build = {
71
+ builder: "DOCKERFILE",
72
+ dockerfilePath: opts.dockerfilePath ?? "Dockerfile.railway",
73
+ };
74
+ const deploy = {
75
+ restartPolicyType: opts.restartPolicy ?? "ON_FAILURE",
76
+ restartPolicyMaxRetries: opts.restartPolicyMaxRetries ?? 10,
77
+ };
78
+ if (isWeb) {
79
+ deploy["healthcheckPath"] = opts.healthcheckPath ?? "/healthz";
80
+ deploy["healthcheckTimeout"] = opts.healthcheckTimeoutSec ?? 300;
81
+ }
82
+ const config = {
83
+ $schema: "https://railway.app/railway.schema.json",
84
+ build,
85
+ deploy,
86
+ };
87
+ return `${JSON.stringify(config, null, 2)}\n`;
88
+ }
89
+ export function railwayDockerfileFor(opts) {
90
+ if (!isTargetShape(opts.target)) {
91
+ throw new RailwayAdapterError(`unknown target shape "${opts.target}"`);
92
+ }
93
+ assertRailwayTarget(opts.target);
94
+ const isWeb = SERVICE_TYPE_FOR_TARGET[opts.target] === "web";
95
+ const lines = [
96
+ `# Railway-tuned wrapper around ${opts.baseImage}`,
97
+ `FROM ${opts.baseImage}`,
98
+ "ENV PORT=8080",
99
+ ];
100
+ if (isWeb) {
101
+ lines.push("EXPOSE 8080");
102
+ }
103
+ return `${lines.join("\n")}\n`;
104
+ }
105
+ export const RAILWAY_DEFAULT_API_BASE = "https://backboard.railway.com/graphql/v2";
106
+ const SERVICE_CREATE_MUTATION = `
107
+ mutation serviceCreate($input: ServiceCreateInput!) {
108
+ serviceCreate(input: $input) {
109
+ id
110
+ name
111
+ }
112
+ }
113
+ `.trim();
114
+ function resolveApiToken(provided) {
115
+ const token = provided ?? process.env["RAILWAY_API_TOKEN"];
116
+ if (token === undefined || token.length === 0) {
117
+ throw new RailwayAdapterError("RAILWAY_API_TOKEN is required. Pass `apiToken` explicitly or set the env var.");
118
+ }
119
+ return token;
120
+ }
121
+ export function scrubApiToken(message, apiToken) {
122
+ if (apiToken === undefined || apiToken.length < 8)
123
+ return message;
124
+ return message.split(apiToken).join("[REDACTED:RAILWAY_API_TOKEN]");
125
+ }
126
+ /**
127
+ * Issue a `serviceCreate` GraphQL mutation against the Railway public
128
+ * API. Returns the created service id. All errors run through
129
+ * `scrubApiToken()` before surfacing.
130
+ */
131
+ export async function deployToRailway(opts) {
132
+ assertServiceName(opts.serviceName);
133
+ const apiToken = resolveApiToken(opts.apiToken);
134
+ const url = opts.apiBaseUrl ?? RAILWAY_DEFAULT_API_BASE;
135
+ const fetchFn = opts.fetchImpl ?? fetch;
136
+ const body = JSON.stringify({
137
+ query: SERVICE_CREATE_MUTATION,
138
+ variables: {
139
+ input: { projectId: opts.projectId, name: opts.serviceName },
140
+ },
141
+ });
142
+ let response;
143
+ try {
144
+ response = await fetchFn(url, {
145
+ method: "POST",
146
+ headers: {
147
+ Authorization: `Bearer ${apiToken}`,
148
+ "Content-Type": "application/json",
149
+ Accept: "application/json",
150
+ },
151
+ body,
152
+ });
153
+ }
154
+ catch (err) {
155
+ const msg = err instanceof Error ? err.message : String(err);
156
+ throw new RailwayAdapterError(`Railway GraphQL request failed: ${scrubApiToken(msg, apiToken)}`, err);
157
+ }
158
+ const text = await response.text();
159
+ const scrubbed = scrubApiToken(text, apiToken);
160
+ if (!response.ok) {
161
+ throw new RailwayAdapterError(`Railway GraphQL returned ${response.status} ${response.statusText}: ${scrubbed}`);
162
+ }
163
+ let parsed;
164
+ try {
165
+ parsed = JSON.parse(text);
166
+ }
167
+ catch (err) {
168
+ throw new RailwayAdapterError(`Railway GraphQL returned non-JSON body: ${scrubbed}`, err);
169
+ }
170
+ if (parsed.errors && parsed.errors.length > 0) {
171
+ const firstErr = parsed.errors[0]?.message ?? "(no message)";
172
+ throw new RailwayAdapterError(`Railway GraphQL responded with errors: ${scrubApiToken(firstErr, apiToken)}`);
173
+ }
174
+ const created = parsed.data?.serviceCreate;
175
+ if (!created?.id) {
176
+ throw new RailwayAdapterError(`Railway GraphQL response missing serviceCreate.id: ${scrubbed}`);
177
+ }
178
+ return {
179
+ serviceId: created.id,
180
+ serviceName: created.name ?? opts.serviceName,
181
+ projectId: opts.projectId,
182
+ };
183
+ }
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@crewhaus/cloud-adapter-railway",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
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",
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,444 +0,0 @@
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
- test("honors healthcheck overrides on web shapes", () => {
76
- const parsed = JSON.parse(
77
- railwayConfigFor({
78
- target: "voice",
79
- healthcheckPath: "/ready",
80
- healthcheckTimeoutSec: 42,
81
- }),
82
- );
83
- expect(parsed.deploy.healthcheckPath).toBe("/ready");
84
- expect(parsed.deploy.healthcheckTimeout).toBe(42);
85
- });
86
-
87
- test("worker shape still carries restart defaults", () => {
88
- const parsed = JSON.parse(railwayConfigFor({ target: "batch" }));
89
- expect(parsed.deploy.restartPolicyType).toBe("ON_FAILURE");
90
- expect(parsed.deploy.restartPolicyMaxRetries).toBe(10);
91
- expect(parsed.build.dockerfilePath).toBe("Dockerfile.railway");
92
- });
93
-
94
- test("emits a stable schema url and trailing newline", () => {
95
- const json = railwayConfigFor({ target: "channel" });
96
- expect(json.endsWith("\n")).toBe(true);
97
- expect(JSON.parse(json).$schema).toBe("https://railway.app/railway.schema.json");
98
- });
99
- });
100
-
101
- describe("railwayDockerfileFor", () => {
102
- test("web shape EXPOSEs 8080", () => {
103
- const df = railwayDockerfileFor({ target: "channel", baseImage: "crewhaus/channel:0.1.0" });
104
- expect(df).toContain("FROM crewhaus/channel:0.1.0");
105
- expect(df).toContain("EXPOSE 8080");
106
- });
107
-
108
- test("worker shape does not EXPOSE", () => {
109
- const df = railwayDockerfileFor({ target: "batch", baseImage: "crewhaus/batch:0.1.0" });
110
- expect(df).not.toContain("EXPOSE");
111
- });
112
-
113
- test("rejects unsupported shapes", () => {
114
- expect(() => railwayDockerfileFor({ target: "cli", baseImage: "x" })).toThrow(
115
- RailwayAdapterError,
116
- );
117
- });
118
-
119
- test("rejects unknown shapes", () => {
120
- expect(() => railwayDockerfileFor({ target: "mystery" as never, baseImage: "x" })).toThrow(
121
- RailwayAdapterError,
122
- );
123
- });
124
-
125
- test("pins PORT and a trailing newline for every shape", () => {
126
- const df = railwayDockerfileFor({ target: "batch", baseImage: "crewhaus/batch:0.1.0" });
127
- expect(df).toContain("ENV PORT=8080");
128
- expect(df.endsWith("\n")).toBe(true);
129
- expect(df).toContain("# Railway-tuned wrapper around crewhaus/batch:0.1.0");
130
- });
131
- });
132
-
133
- describe("RailwayAdapterError", () => {
134
- test("carries the config error code", () => {
135
- const err = new RailwayAdapterError("boom");
136
- expect(err.name).toBe("RailwayAdapterError");
137
- expect(err.code).toBe("config");
138
- expect(err).toBeInstanceOf(Error);
139
- });
140
- });
141
-
142
- describe("scrubApiToken", () => {
143
- test("redacts", () => {
144
- expect(scrubApiToken("token rw_abcdef1234567890 leaked", "rw_abcdef1234567890")).toBe(
145
- "token [REDACTED:RAILWAY_API_TOKEN] leaked",
146
- );
147
- });
148
- test("ignores short tokens", () => {
149
- expect(scrubApiToken("untouched", "abc")).toBe("untouched");
150
- });
151
- });
152
-
153
- describe("deployToRailway", () => {
154
- const ORIGINAL_TOKEN = process.env[API_TOKEN_ENV];
155
- afterEach(() => {
156
- if (ORIGINAL_TOKEN === undefined) delete process.env[API_TOKEN_ENV];
157
- else process.env[API_TOKEN_ENV] = ORIGINAL_TOKEN;
158
- });
159
-
160
- test("posts a serviceCreate mutation", async () => {
161
- let observed: { url: string; body: string } | undefined;
162
- const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
163
- observed = { url: String(url), body: String(init?.body ?? "") };
164
- return new Response(
165
- JSON.stringify({ data: { serviceCreate: { id: "svc-123", name: "my-bot" } } }),
166
- { status: 200 },
167
- );
168
- }) as unknown as typeof fetch;
169
- const record = await deployToRailway({
170
- projectId: "p-456",
171
- serviceName: "my-bot",
172
- apiToken: "rw_testtoken_abc",
173
- fetchImpl,
174
- });
175
- expect(observed?.url).toBe(RAILWAY_DEFAULT_API_BASE);
176
- expect(observed?.body).toContain("serviceCreate");
177
- expect(observed?.body).toContain("p-456");
178
- expect(record.serviceId).toBe("svc-123");
179
- expect(record.serviceName).toBe("my-bot");
180
- expect(record.projectId).toBe("p-456");
181
- });
182
-
183
- test("surfaces GraphQL errors", async () => {
184
- const fetchImpl = (async () =>
185
- new Response(JSON.stringify({ errors: [{ message: "project not found" }] }), {
186
- status: 200,
187
- })) as unknown as typeof fetch;
188
- let caught: Error | undefined;
189
- try {
190
- await deployToRailway({
191
- projectId: "missing",
192
- serviceName: "my-bot",
193
- apiToken: "rw_testtoken_abc",
194
- fetchImpl,
195
- });
196
- } catch (err) {
197
- caught = err as Error;
198
- }
199
- expect(caught).toBeInstanceOf(RailwayAdapterError);
200
- expect(caught?.message).toContain("project not found");
201
- });
202
-
203
- test("scrubs token from HTTP error bodies", async () => {
204
- const fetchImpl = (async () =>
205
- new Response("unauthorized — token rw_leakingtoken_full denied", {
206
- status: 401,
207
- statusText: "Unauthorized",
208
- })) as unknown as typeof fetch;
209
- let caught: Error | undefined;
210
- try {
211
- await deployToRailway({
212
- projectId: "p",
213
- serviceName: "my-bot",
214
- apiToken: "rw_leakingtoken_full",
215
- fetchImpl,
216
- });
217
- } catch (err) {
218
- caught = err as Error;
219
- }
220
- expect(caught?.message).not.toContain("rw_leakingtoken_full");
221
- expect(caught?.message).toContain("[REDACTED:RAILWAY_API_TOKEN]");
222
- });
223
-
224
- test("throws clearly when no token configured", async () => {
225
- delete process.env[API_TOKEN_ENV];
226
- let caught: Error | undefined;
227
- try {
228
- await deployToRailway({ projectId: "p", serviceName: "my-bot" });
229
- } catch (err) {
230
- caught = err as Error;
231
- }
232
- expect(caught).toBeInstanceOf(RailwayAdapterError);
233
- expect(caught?.message).toContain("RAILWAY_API_TOKEN is required");
234
- });
235
-
236
- test("validates service name before network", async () => {
237
- let called = false;
238
- const fetchImpl = (async () => {
239
- called = true;
240
- return new Response("", { status: 200 });
241
- }) as unknown as typeof fetch;
242
- let caught: Error | undefined;
243
- try {
244
- await deployToRailway({
245
- projectId: "p",
246
- serviceName: "BadCaps",
247
- apiToken: "rw_testtoken_abc",
248
- fetchImpl,
249
- });
250
- } catch (err) {
251
- caught = err as Error;
252
- }
253
- expect(caught).toBeInstanceOf(RailwayAdapterError);
254
- expect(called).toBe(false);
255
- });
256
-
257
- test("wraps and scrubs network/transport failures", async () => {
258
- const fetchImpl = (async () => {
259
- throw new Error("connect ECONNREFUSED via proxy auth rw_leakingtoken_full");
260
- }) as unknown as typeof fetch;
261
- let caught: Error | undefined;
262
- try {
263
- await deployToRailway({
264
- projectId: "p",
265
- serviceName: "my-bot",
266
- apiToken: "rw_leakingtoken_full",
267
- fetchImpl,
268
- });
269
- } catch (err) {
270
- caught = err as Error;
271
- }
272
- expect(caught).toBeInstanceOf(RailwayAdapterError);
273
- expect(caught?.message).toContain("Railway GraphQL request failed");
274
- expect(caught?.message).toContain("ECONNREFUSED");
275
- expect(caught?.message).not.toContain("rw_leakingtoken_full");
276
- expect(caught?.message).toContain("[REDACTED:RAILWAY_API_TOKEN]");
277
- expect((caught as RailwayAdapterError).cause).toBeInstanceOf(Error);
278
- });
279
-
280
- test("stringifies non-Error transport rejections", async () => {
281
- const fetchImpl = (async () => {
282
- throw "boom-string-reject";
283
- }) as unknown as typeof fetch;
284
- let caught: Error | undefined;
285
- try {
286
- await deployToRailway({
287
- projectId: "p",
288
- serviceName: "my-bot",
289
- apiToken: "rw_testtoken_abc",
290
- fetchImpl,
291
- });
292
- } catch (err) {
293
- caught = err as Error;
294
- }
295
- expect(caught).toBeInstanceOf(RailwayAdapterError);
296
- expect(caught?.message).toContain("boom-string-reject");
297
- });
298
-
299
- test("rejects and scrubs a non-JSON 200 body", async () => {
300
- const fetchImpl = (async () =>
301
- new Response("<html>gateway error rw_leakingtoken_full</html>", {
302
- status: 200,
303
- })) as unknown as typeof fetch;
304
- let caught: Error | undefined;
305
- try {
306
- await deployToRailway({
307
- projectId: "p",
308
- serviceName: "my-bot",
309
- apiToken: "rw_leakingtoken_full",
310
- fetchImpl,
311
- });
312
- } catch (err) {
313
- caught = err as Error;
314
- }
315
- expect(caught).toBeInstanceOf(RailwayAdapterError);
316
- expect(caught?.message).toContain("non-JSON body");
317
- expect(caught?.message).not.toContain("rw_leakingtoken_full");
318
- expect(caught?.message).toContain("[REDACTED:RAILWAY_API_TOKEN]");
319
- });
320
-
321
- test("rejects and scrubs when serviceCreate.id is missing", async () => {
322
- const fetchImpl = (async () =>
323
- new Response(
324
- JSON.stringify({
325
- data: { serviceCreate: { name: "my-bot" } },
326
- meta: "trace rw_leakingtoken_full",
327
- }),
328
- { status: 200 },
329
- )) as unknown as typeof fetch;
330
- let caught: Error | undefined;
331
- try {
332
- await deployToRailway({
333
- projectId: "p",
334
- serviceName: "my-bot",
335
- apiToken: "rw_leakingtoken_full",
336
- fetchImpl,
337
- });
338
- } catch (err) {
339
- caught = err as Error;
340
- }
341
- expect(caught).toBeInstanceOf(RailwayAdapterError);
342
- expect(caught?.message).toContain("missing serviceCreate.id");
343
- expect(caught?.message).not.toContain("rw_leakingtoken_full");
344
- expect(caught?.message).toContain("[REDACTED:RAILWAY_API_TOKEN]");
345
- });
346
-
347
- test("surfaces HTTP status errors with status line", async () => {
348
- const fetchImpl = (async () =>
349
- new Response("internal error", {
350
- status: 500,
351
- statusText: "Internal Server Error",
352
- })) as unknown as typeof fetch;
353
- let caught: Error | undefined;
354
- try {
355
- await deployToRailway({
356
- projectId: "p",
357
- serviceName: "my-bot",
358
- apiToken: "rw_testtoken_abc",
359
- fetchImpl,
360
- });
361
- } catch (err) {
362
- caught = err as Error;
363
- }
364
- expect(caught).toBeInstanceOf(RailwayAdapterError);
365
- expect(caught?.message).toContain("500");
366
- expect(caught?.message).toContain("Internal Server Error");
367
- });
368
-
369
- test("falls back to the requested service name when the API omits it", async () => {
370
- const fetchImpl = (async () =>
371
- new Response(JSON.stringify({ data: { serviceCreate: { id: "svc-xyz" } } }), {
372
- status: 200,
373
- })) as unknown as typeof fetch;
374
- const record = await deployToRailway({
375
- projectId: "p-789",
376
- serviceName: "named-bot",
377
- apiToken: "rw_testtoken_abc",
378
- fetchImpl,
379
- });
380
- expect(record.serviceId).toBe("svc-xyz");
381
- expect(record.serviceName).toBe("named-bot");
382
- expect(record.projectId).toBe("p-789");
383
- });
384
-
385
- test("reads RAILWAY_API_TOKEN from the environment when apiToken is omitted", async () => {
386
- process.env[API_TOKEN_ENV] = "rw_env_token_value";
387
- let observedAuth: string | undefined;
388
- const fetchImpl = (async (_url: string | URL, init?: RequestInit) => {
389
- const headers = new Headers(init?.headers);
390
- observedAuth = headers.get("Authorization") ?? undefined;
391
- return new Response(
392
- JSON.stringify({ data: { serviceCreate: { id: "svc-env", name: "n" } } }),
393
- {
394
- status: 200,
395
- },
396
- );
397
- }) as unknown as typeof fetch;
398
- const record = await deployToRailway({
399
- projectId: "p",
400
- serviceName: "my-bot",
401
- fetchImpl,
402
- });
403
- expect(record.serviceId).toBe("svc-env");
404
- expect(observedAuth).toBe("Bearer rw_env_token_value");
405
- });
406
-
407
- test("honors an explicit apiBaseUrl override", async () => {
408
- let observedUrl: string | undefined;
409
- const fetchImpl = (async (url: string | URL) => {
410
- observedUrl = String(url);
411
- return new Response(JSON.stringify({ data: { serviceCreate: { id: "svc-1", name: "n" } } }), {
412
- status: 200,
413
- });
414
- }) as unknown as typeof fetch;
415
- await deployToRailway({
416
- projectId: "p",
417
- serviceName: "my-bot",
418
- apiToken: "rw_testtoken_abc",
419
- apiBaseUrl: "https://example.test/graphql",
420
- fetchImpl,
421
- });
422
- expect(observedUrl).toBe("https://example.test/graphql");
423
- });
424
-
425
- test("surfaces a generic message when a GraphQL error lacks a message field", async () => {
426
- const fetchImpl = (async () =>
427
- new Response(JSON.stringify({ errors: [{}] }), {
428
- status: 200,
429
- })) as unknown as typeof fetch;
430
- let caught: Error | undefined;
431
- try {
432
- await deployToRailway({
433
- projectId: "p",
434
- serviceName: "my-bot",
435
- apiToken: "rw_testtoken_abc",
436
- fetchImpl,
437
- });
438
- } catch (err) {
439
- caught = err as Error;
440
- }
441
- expect(caught).toBeInstanceOf(RailwayAdapterError);
442
- expect(caught?.message).toContain("(no message)");
443
- });
444
- });
package/src/index.ts DELETED
@@ -1,247 +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-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
- }