@crewhaus/cloud-adapter-railway 0.1.1 → 0.1.3

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.
Files changed (2) hide show
  1. package/package.json +7 -12
  2. package/src/index.test.ts +235 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/cloud-adapter-railway",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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
6
  "main": "src/index.ts",
@@ -12,14 +12,14 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/docker-images": "0.1.1",
16
- "@crewhaus/errors": "0.1.1"
15
+ "@crewhaus/docker-images": "0.1.3",
16
+ "@crewhaus/errors": "0.1.3"
17
17
  },
18
18
  "license": "Apache-2.0",
19
19
  "author": {
20
20
  "name": "Max Meier",
21
- "email": "max@studiomax.io",
22
- "url": "https://studiomax.io"
21
+ "email": "max@crewhaus.ai",
22
+ "url": "https://crewhaus.ai"
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",
@@ -31,12 +31,7 @@
31
31
  "url": "https://github.com/crewhaus/factory/issues"
32
32
  },
33
33
  "publishConfig": {
34
- "access": "restricted"
34
+ "access": "public"
35
35
  },
36
- "files": [
37
- "src",
38
- "README.md",
39
- "LICENSE",
40
- "NOTICE"
41
- ]
36
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
42
37
  }
package/src/index.test.ts CHANGED
@@ -71,6 +71,31 @@ describe("railwayConfigFor", () => {
71
71
  test("rejects unknown shapes", () => {
72
72
  expect(() => railwayConfigFor({ target: "mystery" as never })).toThrow(RailwayAdapterError);
73
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
+ });
74
99
  });
75
100
 
76
101
  describe("railwayDockerfileFor", () => {
@@ -90,6 +115,28 @@ describe("railwayDockerfileFor", () => {
90
115
  RailwayAdapterError,
91
116
  );
92
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
+ });
93
140
  });
94
141
 
95
142
  describe("scrubApiToken", () => {
@@ -206,4 +253,192 @@ describe("deployToRailway", () => {
206
253
  expect(caught).toBeInstanceOf(RailwayAdapterError);
207
254
  expect(called).toBe(false);
208
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
+ });
209
444
  });