@crewhaus/cloud-adapter-flyio 0.1.0 → 0.1.2

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 +306 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/cloud-adapter-flyio",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "One-click deploy adapter for Fly.io — generates a fly.toml + a Fly-tuned Dockerfile and wraps the Machines 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.0.0",
16
- "@crewhaus/errors": "0.0.0"
15
+ "@crewhaus/docker-images": "0.1.2",
16
+ "@crewhaus/errors": "0.1.2"
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
@@ -326,4 +326,310 @@ describe("deployToFly", () => {
326
326
  });
327
327
  expect(seenUrl).toBe(`${FLY_DEFAULT_API_BASE}/apps`);
328
328
  });
329
+
330
+ test('falls back to status "created" when launch body omits state', async () => {
331
+ const fetchImpl = (async (url: string | URL) => {
332
+ const u = String(url);
333
+ if (u.endsWith("/apps")) return new Response("{}", { status: 201 });
334
+ // No `state` field — exercises the `?? "created"` fallback.
335
+ return new Response(JSON.stringify({ id: "m-nostate" }), { status: 200 });
336
+ }) as unknown as typeof fetch;
337
+ const record = await deployToFly({
338
+ target: "channel",
339
+ appName: "my-bot",
340
+ imageRef: "img",
341
+ apiToken: "fo_testtoken_abc",
342
+ fetchImpl,
343
+ });
344
+ expect(record.machineId).toBe("m-nostate");
345
+ expect(record.status).toBe("created");
346
+ });
347
+
348
+ test("throws when launch returns 2xx with a non-JSON body", async () => {
349
+ const fetchImpl = (async (url: string | URL) => {
350
+ const u = String(url);
351
+ if (u.endsWith("/apps")) return new Response("{}", { status: 201 });
352
+ return new Response("not json at all", { status: 200 });
353
+ }) as unknown as typeof fetch;
354
+ let caught: Error | undefined;
355
+ try {
356
+ await deployToFly({
357
+ target: "channel",
358
+ appName: "my-bot",
359
+ imageRef: "img",
360
+ apiToken: "fo_testtoken_abc",
361
+ fetchImpl,
362
+ });
363
+ } catch (err) {
364
+ caught = err as Error;
365
+ }
366
+ expect(caught).toBeInstanceOf(FlyAdapterError);
367
+ expect(caught?.message).toContain("non-JSON body");
368
+ });
369
+
370
+ test("throws when launch body is valid JSON but missing machine id", async () => {
371
+ const fetchImpl = (async (url: string | URL) => {
372
+ const u = String(url);
373
+ if (u.endsWith("/apps")) return new Response("{}", { status: 201 });
374
+ // Valid JSON, but `id` is absent (or non-string).
375
+ return new Response(JSON.stringify({ state: "started" }), { status: 200 });
376
+ }) as unknown as typeof fetch;
377
+ let caught: Error | undefined;
378
+ try {
379
+ await deployToFly({
380
+ target: "channel",
381
+ appName: "my-bot",
382
+ imageRef: "img",
383
+ apiToken: "fo_testtoken_abc",
384
+ fetchImpl,
385
+ });
386
+ } catch (err) {
387
+ caught = err as Error;
388
+ }
389
+ expect(caught).toBeInstanceOf(FlyAdapterError);
390
+ expect(caught?.message).toContain("missing machine id");
391
+ });
392
+
393
+ test("wraps a thrown fetch on app-create and scrubs the token", async () => {
394
+ const fetchImpl = (async () => {
395
+ throw new Error("network down for fo_leakingtoken_full retry");
396
+ }) as unknown as typeof fetch;
397
+ let caught: Error | undefined;
398
+ try {
399
+ await deployToFly({
400
+ target: "channel",
401
+ appName: "my-bot",
402
+ imageRef: "img",
403
+ apiToken: "fo_leakingtoken_full",
404
+ fetchImpl,
405
+ });
406
+ } catch (err) {
407
+ caught = err as Error;
408
+ }
409
+ expect(caught).toBeInstanceOf(FlyAdapterError);
410
+ expect(caught?.message).toContain("Fly Machines API request failed");
411
+ expect(caught?.message).not.toContain("fo_leakingtoken_full");
412
+ expect(caught?.message).toContain("[REDACTED:FLY_API_TOKEN]");
413
+ expect((caught as FlyAdapterError).cause).toBeInstanceOf(Error);
414
+ });
415
+
416
+ test("wraps a non-Error thrown fetch on machine launch", async () => {
417
+ const fetchImpl = (async (url: string | URL) => {
418
+ const u = String(url);
419
+ if (u.endsWith("/apps")) return new Response("{}", { status: 201 });
420
+ // Throw a non-Error so the `String(err)` branch is taken.
421
+ throw "boom-string";
422
+ }) as unknown as typeof fetch;
423
+ let caught: Error | undefined;
424
+ try {
425
+ await deployToFly({
426
+ target: "channel",
427
+ appName: "my-bot",
428
+ imageRef: "img",
429
+ apiToken: "fo_testtoken_abc",
430
+ fetchImpl,
431
+ });
432
+ } catch (err) {
433
+ caught = err as Error;
434
+ }
435
+ expect(caught).toBeInstanceOf(FlyAdapterError);
436
+ expect(caught?.message).toContain("Fly Machines API request failed: boom-string");
437
+ });
438
+
439
+ test("rejects unsupported (one-shot) target shapes before any network call", async () => {
440
+ let called = false;
441
+ const fetchImpl = (async () => {
442
+ called = true;
443
+ return new Response("", { status: 200 });
444
+ }) as unknown as typeof fetch;
445
+ let caught: Error | undefined;
446
+ try {
447
+ await deployToFly({
448
+ target: "eval",
449
+ appName: "my-bot",
450
+ imageRef: "img",
451
+ apiToken: "fo_testtoken_abc",
452
+ fetchImpl,
453
+ });
454
+ } catch (err) {
455
+ caught = err as Error;
456
+ }
457
+ expect(caught).toBeInstanceOf(FlyAdapterError);
458
+ expect(called).toBe(false);
459
+ });
460
+
461
+ test("rejects unknown target shapes before any network call", async () => {
462
+ let called = false;
463
+ const fetchImpl = (async () => {
464
+ called = true;
465
+ return new Response("", { status: 200 });
466
+ }) as unknown as typeof fetch;
467
+ let caught: Error | undefined;
468
+ try {
469
+ await deployToFly({
470
+ target: "mystery" as never,
471
+ appName: "my-bot",
472
+ imageRef: "img",
473
+ apiToken: "fo_testtoken_abc",
474
+ fetchImpl,
475
+ });
476
+ } catch (err) {
477
+ caught = err as Error;
478
+ }
479
+ expect(caught).toBeInstanceOf(FlyAdapterError);
480
+ expect(caught?.message).toContain("unknown target shape");
481
+ expect(called).toBe(false);
482
+ });
483
+
484
+ test("treats an empty-string apiToken as unconfigured", async () => {
485
+ delete process.env[API_TOKEN_ENV];
486
+ let caught: Error | undefined;
487
+ try {
488
+ await deployToFly({
489
+ target: "channel",
490
+ appName: "my-bot",
491
+ imageRef: "img",
492
+ apiToken: "",
493
+ });
494
+ } catch (err) {
495
+ caught = err as Error;
496
+ }
497
+ expect(caught).toBeInstanceOf(FlyAdapterError);
498
+ expect(caught?.message).toContain("FLY_API_TOKEN is required");
499
+ });
500
+
501
+ test("honors an explicit org slug + region in the request payload", async () => {
502
+ let createBody: string | undefined;
503
+ let launchBody: string | undefined;
504
+ const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
505
+ const u = String(url);
506
+ if (u.endsWith("/apps")) {
507
+ createBody = init?.body as string;
508
+ return new Response("{}", { status: 201 });
509
+ }
510
+ launchBody = init?.body as string;
511
+ return new Response(JSON.stringify({ id: "m-1", state: "started" }), { status: 200 });
512
+ }) as unknown as typeof fetch;
513
+ const record = await deployToFly({
514
+ target: "channel",
515
+ appName: "my-bot",
516
+ imageRef: "img",
517
+ apiToken: "fo_testtoken_abc",
518
+ orgSlug: "acme-co",
519
+ region: "fra",
520
+ fetchImpl,
521
+ });
522
+ expect(record.region).toBe("fra");
523
+ expect(JSON.parse(createBody as string).org_slug).toBe("acme-co");
524
+ expect(JSON.parse(launchBody as string).region).toBe("fra");
525
+ });
526
+
527
+ test("surfaces a scrubbed app-create failure for non-409 statuses", async () => {
528
+ const fetchImpl = (async () =>
529
+ new Response("boom fo_leakingtoken_full boom", {
530
+ status: 500,
531
+ statusText: "Internal Server Error",
532
+ })) as unknown as typeof fetch;
533
+ let caught: Error | undefined;
534
+ try {
535
+ await deployToFly({
536
+ target: "channel",
537
+ appName: "my-bot",
538
+ imageRef: "img",
539
+ apiToken: "fo_leakingtoken_full",
540
+ fetchImpl,
541
+ });
542
+ } catch (err) {
543
+ caught = err as Error;
544
+ }
545
+ expect(caught).toBeInstanceOf(FlyAdapterError);
546
+ expect(caught?.message).toContain("Fly app create returned 500");
547
+ expect(caught?.message).not.toContain("fo_leakingtoken_full");
548
+ });
549
+ });
550
+
551
+ describe("security: app name guards the Machines API URL path", () => {
552
+ // Regression: appName is interpolated into `${baseUrl}/apps/${appName}/machines`.
553
+ // assertAppName must reject path-traversal / SSRF payloads BEFORE any fetch runs.
554
+ const PAYLOADS = [
555
+ "../../etc/passwd",
556
+ "app/../../admin",
557
+ "app%2F..%2Fadmin",
558
+ "app name",
559
+ "app\nmachines",
560
+ "evil.example.com",
561
+ "UPPER",
562
+ ];
563
+ for (const payload of PAYLOADS) {
564
+ test(`rejects "${payload}" without calling fetch`, async () => {
565
+ let called = false;
566
+ const fetchImpl = (async () => {
567
+ called = true;
568
+ return new Response("{}", { status: 200 });
569
+ }) as unknown as typeof fetch;
570
+ let caught: Error | undefined;
571
+ try {
572
+ await deployToFly({
573
+ target: "channel",
574
+ appName: payload,
575
+ imageRef: "img",
576
+ apiToken: "fo_testtoken_abc",
577
+ fetchImpl,
578
+ });
579
+ } catch (err) {
580
+ caught = err as Error;
581
+ }
582
+ expect(caught).toBeInstanceOf(FlyAdapterError);
583
+ expect(called).toBe(false);
584
+ });
585
+ }
586
+ });
587
+
588
+ describe("flyTomlFor: unknown vs unsupported shape branches", () => {
589
+ test("rejects an entirely unknown shape (isTargetShape=false)", () => {
590
+ expect(() => flyTomlFor({ target: "mystery" as never, appName: "svc" })).toThrow(
591
+ /unknown target shape/,
592
+ );
593
+ });
594
+
595
+ test("skips env entries whose value is undefined", () => {
596
+ const toml = flyTomlFor({
597
+ target: "channel",
598
+ appName: "svc",
599
+ // `MISSING` is explicitly undefined; it must not emit a line.
600
+ envVars: { KEEP: "1", MISSING: undefined } as unknown as Record<string, string>,
601
+ });
602
+ expect(toml).toContain('KEEP = "1"');
603
+ expect(toml).not.toContain("MISSING");
604
+ });
605
+
606
+ test("omits the [env] block entirely when envVars is empty", () => {
607
+ const toml = flyTomlFor({ target: "channel", appName: "svc", envVars: {} });
608
+ expect(toml).not.toContain("[env]");
609
+ });
610
+
611
+ test("escapes backslashes in env values", () => {
612
+ const toml = flyTomlFor({
613
+ target: "channel",
614
+ appName: "svc",
615
+ envVars: { WIN: "C:\\path" },
616
+ });
617
+ expect(toml).toContain('WIN = "C:\\\\path"');
618
+ });
619
+
620
+ test("uses a custom dockerfile path", () => {
621
+ const toml = flyTomlFor({
622
+ target: "channel",
623
+ appName: "svc",
624
+ dockerfilePath: "ops/Dockerfile.custom",
625
+ });
626
+ expect(toml).toContain('dockerfile = "ops/Dockerfile.custom"');
627
+ });
628
+
629
+ test("supports every web shape and the lone worker shape", () => {
630
+ for (const shape of ["channel", "managed", "voice", "browser"] as const) {
631
+ expect(flyTomlFor({ target: shape, appName: "svc" })).toContain("[http_service]");
632
+ }
633
+ expect(flyTomlFor({ target: "batch", appName: "svc" })).toContain("[processes]");
634
+ });
329
635
  });