@framers/agentos-ext-cloud-flyio 0.1.0 → 0.1.1

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.
@@ -1,62 +0,0 @@
1
- // @ts-nocheck
2
- /**
3
- * @fileoverview FlyListAppsTool — list all Fly.io apps and their machines.
4
- */
5
-
6
- import type { FlyService, FlyApp } from '../FlyService.js';
7
-
8
- export interface FlyListAppsInput {
9
- appName?: string;
10
- includeMachines?: boolean;
11
- }
12
-
13
- export class FlyListAppsTool {
14
- readonly id = 'flyListApps';
15
- readonly name = 'flyListApps';
16
- readonly displayName = 'List Fly.io Apps';
17
- readonly description = 'List all Fly.io apps in the account, or get details for a specific app. Optionally include machine details (state, region, config) for each app.';
18
- readonly category = 'cloud';
19
- readonly version = '0.1.0';
20
- readonly hasSideEffects = false;
21
-
22
- readonly inputSchema = {
23
- type: 'object' as const,
24
- properties: {
25
- appName: { type: 'string', description: 'Filter to a specific app by name (optional — lists all apps if omitted)' },
26
- includeMachines: { type: 'boolean', description: 'Include machine details for each app (default: true)' },
27
- },
28
- required: [] as string[],
29
- };
30
-
31
- constructor(private service: FlyService) {}
32
-
33
- async execute(args: FlyListAppsInput): Promise<{ success: boolean; data?: FlyApp[]; error?: string }> {
34
- try {
35
- const withMachines = args.includeMachines !== false;
36
-
37
- if (args.appName) {
38
- const app = await this.service.getApp(args.appName);
39
- if (withMachines) {
40
- app.machines = await this.service.listMachines(args.appName);
41
- }
42
- return { success: true, data: [app] };
43
- }
44
-
45
- const apps = await this.service.listApps();
46
-
47
- if (withMachines) {
48
- for (const app of apps) {
49
- try {
50
- app.machines = await this.service.listMachines(app.name);
51
- } catch {
52
- app.machines = [];
53
- }
54
- }
55
- }
56
-
57
- return { success: true, data: apps };
58
- } catch (err: any) {
59
- return { success: false, error: err.message };
60
- }
61
- }
62
- }
@@ -1,116 +0,0 @@
1
- // @ts-nocheck
2
- /**
3
- * @fileoverview FlyScaleAppTool — scale machines for a Fly.io app.
4
- */
5
-
6
- import type { FlyService, FlyMachine, FlyMachineConfig } from '../FlyService.js';
7
-
8
- export interface FlyScaleAppInput {
9
- appName: string;
10
- count: number;
11
- cpus?: number;
12
- cpuKind?: string;
13
- memoryMb?: number;
14
- region?: string;
15
- }
16
-
17
- export class FlyScaleAppTool {
18
- readonly id = 'flyScaleApp';
19
- readonly name = 'flyScaleApp';
20
- readonly displayName = 'Scale Fly.io App';
21
- readonly description = 'Scale a Fly.io app by adjusting the number of machines and optionally their VM size. Can scale up (create new machines cloned from existing config) or scale down (destroy excess machines). Optionally resize CPUs and memory.';
22
- readonly category = 'cloud';
23
- readonly version = '0.1.0';
24
- readonly hasSideEffects = true;
25
-
26
- readonly inputSchema = {
27
- type: 'object' as const,
28
- properties: {
29
- appName: { type: 'string', description: 'Name of the Fly app to scale' },
30
- count: { type: 'number', description: 'Desired number of machines (0 to stop all)' },
31
- cpus: { type: 'number', description: 'CPUs per machine (only applied to new or resized machines)' },
32
- cpuKind: { type: 'string', enum: ['shared', 'performance'], description: 'CPU type (only applied to new or resized machines)' },
33
- memoryMb: { type: 'number', description: 'Memory in MB per machine (only applied to new or resized machines)' },
34
- region: { type: 'string', description: 'Region for new machines (e.g. "iad", "lax", "cdg")' },
35
- },
36
- required: ['appName', 'count'],
37
- };
38
-
39
- constructor(private service: FlyService) {}
40
-
41
- async execute(args: FlyScaleAppInput): Promise<{
42
- success: boolean;
43
- data?: { machines: FlyMachine[]; created: number; destroyed: number; resized: number };
44
- error?: string;
45
- }> {
46
- try {
47
- const existing = await this.service.listMachines(args.appName);
48
- const current = existing.length;
49
- const desired = args.count;
50
- let created = 0;
51
- let destroyed = 0;
52
- let resized = 0;
53
-
54
- // Determine if we need to resize existing machines
55
- const needsResize = args.cpus !== undefined || args.cpuKind !== undefined || args.memoryMb !== undefined;
56
-
57
- // Scale down: destroy excess machines
58
- if (desired < current) {
59
- const toDestroy = existing.slice(desired);
60
- for (const machine of toDestroy) {
61
- await this.service.destroyMachine(args.appName, machine.id);
62
- destroyed++;
63
- }
64
- }
65
-
66
- // Resize remaining machines if needed
67
- if (needsResize) {
68
- const toResize = existing.slice(0, Math.min(desired, current));
69
- for (const machine of toResize) {
70
- const guest = {
71
- cpus: args.cpus ?? machine.config.guest?.cpus ?? 1,
72
- cpu_kind: args.cpuKind ?? machine.config.guest?.cpu_kind ?? 'shared',
73
- memory_mb: args.memoryMb ?? machine.config.guest?.memory_mb ?? 256,
74
- };
75
- await this.service.updateMachine(args.appName, machine.id, { guest });
76
- resized++;
77
- }
78
- }
79
-
80
- // Scale up: create new machines cloned from the first machine's config
81
- if (desired > current) {
82
- const template = existing[0]?.config ?? { image: '' };
83
- const baseConfig: FlyMachineConfig = {
84
- ...template,
85
- guest: {
86
- cpus: args.cpus ?? template.guest?.cpus ?? 1,
87
- cpu_kind: args.cpuKind ?? template.guest?.cpu_kind ?? 'shared',
88
- memory_mb: args.memoryMb ?? template.guest?.memory_mb ?? 256,
89
- },
90
- };
91
-
92
- for (let i = current; i < desired; i++) {
93
- await this.service.createMachine(args.appName, baseConfig, {
94
- region: args.region,
95
- });
96
- created++;
97
- }
98
- }
99
-
100
- // Get final state
101
- const finalMachines = await this.service.listMachines(args.appName);
102
-
103
- return {
104
- success: true,
105
- data: {
106
- machines: finalMachines,
107
- created,
108
- destroyed,
109
- resized,
110
- },
111
- };
112
- } catch (err: any) {
113
- return { success: false, error: err.message };
114
- }
115
- }
116
- }
@@ -1,484 +0,0 @@
1
- // @ts-nocheck
2
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
- import { FlyService } from '../src/FlyService';
4
-
5
- // ---------------------------------------------------------------------------
6
- // Helpers
7
- // ---------------------------------------------------------------------------
8
-
9
- function mockResponse(data: unknown, status = 200): Response {
10
- const body = typeof data === 'string' ? data : JSON.stringify(data);
11
- return {
12
- ok: status >= 200 && status < 300,
13
- status,
14
- statusText: status === 200 ? 'OK' : 'Error',
15
- headers: new Headers(),
16
- text: vi.fn().mockResolvedValue(body),
17
- json: vi.fn().mockResolvedValue(typeof data === 'string' ? {} : data),
18
- clone: vi.fn(),
19
- } as unknown as Response;
20
- }
21
-
22
- function gqlResponse(data: unknown): Response {
23
- return mockResponse({ data });
24
- }
25
-
26
- function gqlErrorResponse(messages: string[]): Response {
27
- return mockResponse({
28
- errors: messages.map((m) => ({ message: m })),
29
- });
30
- }
31
-
32
- // ---------------------------------------------------------------------------
33
- // Tests
34
- // ---------------------------------------------------------------------------
35
-
36
- describe('FlyService', () => {
37
- let service: FlyService;
38
- let mockFetch: ReturnType<typeof vi.fn>;
39
-
40
- beforeEach(() => {
41
- mockFetch = vi.fn();
42
- vi.stubGlobal('fetch', mockFetch);
43
- service = new FlyService({
44
- token: 'fly-test-token-xyz',
45
- baseUrl: 'https://api.machines.dev/v1',
46
- graphqlUrl: 'https://api.fly.io/graphql',
47
- });
48
- });
49
-
50
- afterEach(() => {
51
- vi.restoreAllMocks();
52
- });
53
-
54
- // ── Initialize / Shutdown ───────────────────────────────────────────────
55
-
56
- describe('initialize', () => {
57
- it('should succeed when currentUser query returns an id', async () => {
58
- mockFetch.mockResolvedValueOnce(gqlResponse({ currentUser: { id: 'user-1', email: 'test@fly.io' } }));
59
- await service.initialize();
60
- expect(service.isRunning).toBe(true);
61
- });
62
-
63
- it('should throw when currentUser has no id', async () => {
64
- mockFetch.mockResolvedValueOnce(gqlResponse({ currentUser: { id: null } }));
65
- await expect(service.initialize()).rejects.toThrow('Fly.io auth failed: could not retrieve user');
66
- expect(service.isRunning).toBe(false);
67
- });
68
-
69
- it('should throw when GraphQL returns HTTP error', async () => {
70
- mockFetch.mockResolvedValueOnce(mockResponse('Unauthorized', 401));
71
- await expect(service.initialize()).rejects.toThrow('Fly.io GraphQL 401');
72
- });
73
-
74
- it('should throw on GraphQL errors in response body', async () => {
75
- mockFetch.mockResolvedValueOnce(gqlErrorResponse(['Token revoked']));
76
- await expect(service.initialize()).rejects.toThrow('Fly.io GraphQL: Token revoked');
77
- });
78
- });
79
-
80
- describe('shutdown', () => {
81
- it('should set isRunning to false', async () => {
82
- mockFetch.mockResolvedValueOnce(gqlResponse({ currentUser: { id: 'u1' } }));
83
- await service.initialize();
84
- await service.shutdown();
85
- expect(service.isRunning).toBe(false);
86
- });
87
- });
88
-
89
- // ── Apps ────────────────────────────────────────────────────────────────
90
-
91
- describe('createApp', () => {
92
- it('should create an app and fetch it via GraphQL', async () => {
93
- // POST /apps (REST)
94
- mockFetch.mockResolvedValueOnce(mockResponse({ ok: true }));
95
- // getApp via GraphQL
96
- mockFetch.mockResolvedValueOnce(gqlResponse({
97
- app: {
98
- id: 'app-abc',
99
- name: 'my-fly-app',
100
- organization: { slug: 'personal' },
101
- status: 'deployed',
102
- hostname: 'my-fly-app.fly.dev',
103
- createdAt: '2024-06-01',
104
- },
105
- }));
106
- const app = await service.createApp('my-fly-app');
107
- expect(app.id).toBe('app-abc');
108
- expect(app.name).toBe('my-fly-app');
109
- expect(app.organization).toBe('personal');
110
- expect(app.hostname).toBe('my-fly-app.fly.dev');
111
- });
112
-
113
- it('should pass org_slug when provided', async () => {
114
- mockFetch.mockResolvedValueOnce(mockResponse({ ok: true }));
115
- mockFetch.mockResolvedValueOnce(gqlResponse({
116
- app: {
117
- id: 'app-xyz',
118
- name: 'team-app',
119
- organization: { slug: 'my-team' },
120
- status: 'pending',
121
- hostname: 'team-app.fly.dev',
122
- },
123
- }));
124
- const app = await service.createApp('team-app', 'my-team');
125
- const restBody = JSON.parse(mockFetch.mock.calls[0][1].body);
126
- expect(restBody.org_slug).toBe('my-team');
127
- expect(app.organization).toBe('my-team');
128
- });
129
-
130
- it('should throw when REST create fails', async () => {
131
- mockFetch.mockResolvedValueOnce(mockResponse('Name already taken', 422));
132
- await expect(service.createApp('taken-app')).rejects.toThrow('Fly.io API 422');
133
- });
134
- });
135
-
136
- describe('getApp', () => {
137
- it('should return an app by name via GraphQL', async () => {
138
- mockFetch.mockResolvedValueOnce(gqlResponse({
139
- app: {
140
- id: 'app-1',
141
- name: 'my-app',
142
- organization: { slug: 'personal' },
143
- status: 'deployed',
144
- hostname: 'my-app.fly.dev',
145
- createdAt: '2024-01-01',
146
- },
147
- }));
148
- const app = await service.getApp('my-app');
149
- expect(app.id).toBe('app-1');
150
- expect(app.status).toBe('deployed');
151
- });
152
-
153
- it('should default hostname to name.fly.dev when missing', async () => {
154
- mockFetch.mockResolvedValueOnce(gqlResponse({
155
- app: {
156
- id: 'app-1',
157
- name: 'bare-app',
158
- organization: {},
159
- status: 'pending',
160
- },
161
- }));
162
- const app = await service.getApp('bare-app');
163
- expect(app.hostname).toBe('bare-app.fly.dev');
164
- });
165
-
166
- it('should throw on GraphQL errors', async () => {
167
- mockFetch.mockResolvedValueOnce(gqlErrorResponse(['App not found']));
168
- await expect(service.getApp('missing-app')).rejects.toThrow('Fly.io GraphQL: App not found');
169
- });
170
- });
171
-
172
- describe('listApps', () => {
173
- it('should return a list of apps', async () => {
174
- mockFetch.mockResolvedValueOnce(gqlResponse({
175
- apps: {
176
- nodes: [
177
- {
178
- id: 'a1', name: 'app-one', organization: { slug: 'personal' },
179
- status: 'deployed', hostname: 'app-one.fly.dev', createdAt: '2024-01-01',
180
- },
181
- {
182
- id: 'a2', name: 'app-two', organization: { slug: 'team' },
183
- status: 'suspended', hostname: 'app-two.fly.dev', createdAt: '2024-02-01',
184
- },
185
- ],
186
- },
187
- }));
188
- const apps = await service.listApps();
189
- expect(apps).toHaveLength(2);
190
- expect(apps[0].name).toBe('app-one');
191
- expect(apps[1].organization).toBe('team');
192
- expect(apps[1].status).toBe('suspended');
193
- });
194
-
195
- it('should return empty array when no apps exist', async () => {
196
- mockFetch.mockResolvedValueOnce(gqlResponse({ apps: { nodes: [] } }));
197
- const apps = await service.listApps();
198
- expect(apps).toHaveLength(0);
199
- });
200
- });
201
-
202
- // ── Machines ────────────────────────────────────────────────────────────
203
-
204
- describe('createMachine', () => {
205
- it('should create a machine with config', async () => {
206
- mockFetch.mockResolvedValueOnce(mockResponse({
207
- id: 'mach-1',
208
- name: 'web-1',
209
- state: 'started',
210
- region: 'iad',
211
- instance_id: 'inst-abc',
212
- private_ip: 'fdaa::1',
213
- config: {
214
- image: 'registry.fly.io/my-app:latest',
215
- env: { NODE_ENV: 'production' },
216
- guest: { cpus: 1, cpu_kind: 'shared', memory_mb: 256 },
217
- },
218
- image_ref: { repository: 'registry.fly.io/my-app', tag: 'latest', digest: 'sha256:abc' },
219
- created_at: '2024-06-01',
220
- updated_at: '2024-06-01',
221
- }));
222
- const machine = await service.createMachine('my-app', {
223
- image: 'registry.fly.io/my-app:latest',
224
- env: { NODE_ENV: 'production' },
225
- guest: { cpus: 1, cpu_kind: 'shared', memory_mb: 256 },
226
- }, { name: 'web-1', region: 'iad' });
227
-
228
- expect(machine.id).toBe('mach-1');
229
- expect(machine.name).toBe('web-1');
230
- expect(machine.state).toBe('started');
231
- expect(machine.region).toBe('iad');
232
- expect(machine.config.image).toBe('registry.fly.io/my-app:latest');
233
- expect(machine.config.env?.NODE_ENV).toBe('production');
234
- expect(machine.imageRef?.digest).toBe('sha256:abc');
235
- });
236
-
237
- it('should throw on non-OK response', async () => {
238
- mockFetch.mockResolvedValueOnce(mockResponse('App not found', 404));
239
- await expect(service.createMachine('missing-app', {
240
- image: 'nginx:latest',
241
- })).rejects.toThrow('Fly.io API 404');
242
- });
243
- });
244
-
245
- describe('listMachines', () => {
246
- it('should return a list of machines', async () => {
247
- mockFetch.mockResolvedValueOnce(mockResponse([
248
- {
249
- id: 'm1', name: 'web', state: 'started', region: 'iad',
250
- instance_id: 'i1', private_ip: 'fdaa::1',
251
- config: { image: 'nginx:latest' },
252
- created_at: '2024-01-01', updated_at: '2024-01-01',
253
- },
254
- {
255
- id: 'm2', name: 'worker', state: 'stopped', region: 'lhr',
256
- instance_id: 'i2', private_ip: 'fdaa::2',
257
- config: { image: 'node:20' },
258
- created_at: '2024-01-02', updated_at: '2024-01-02',
259
- },
260
- ]));
261
- const machines = await service.listMachines('my-app');
262
- expect(machines).toHaveLength(2);
263
- expect(machines[0].name).toBe('web');
264
- expect(machines[0].state).toBe('started');
265
- expect(machines[1].name).toBe('worker');
266
- expect(machines[1].region).toBe('lhr');
267
- });
268
-
269
- it('should return empty array when no machines exist', async () => {
270
- mockFetch.mockResolvedValueOnce(mockResponse([]));
271
- const machines = await service.listMachines('empty-app');
272
- expect(machines).toHaveLength(0);
273
- });
274
- });
275
-
276
- describe('destroyMachine', () => {
277
- it('should stop then delete the machine', async () => {
278
- // stop
279
- mockFetch.mockResolvedValueOnce(mockResponse({ ok: true }));
280
- // delete
281
- mockFetch.mockResolvedValueOnce(mockResponse('', 200));
282
- await expect(service.destroyMachine('my-app', 'mach-1')).resolves.toBeUndefined();
283
- expect(mockFetch).toHaveBeenCalledTimes(2);
284
- });
285
-
286
- it('should succeed even if stop fails (machine already stopped)', async () => {
287
- // stop fails
288
- mockFetch.mockRejectedValueOnce(new Error('Machine already stopped'));
289
- // delete succeeds
290
- mockFetch.mockResolvedValueOnce(mockResponse('', 200));
291
- await expect(service.destroyMachine('my-app', 'mach-1')).resolves.toBeUndefined();
292
- });
293
-
294
- it('should treat 404 on delete as success', async () => {
295
- // stop
296
- mockFetch.mockResolvedValueOnce(mockResponse({ ok: true }));
297
- // delete returns 404
298
- mockFetch.mockResolvedValueOnce(mockResponse('Not Found', 404));
299
- await expect(service.destroyMachine('my-app', 'mach-gone')).resolves.toBeUndefined();
300
- });
301
-
302
- it('should throw on non-404 error during delete', async () => {
303
- // stop
304
- mockFetch.mockResolvedValueOnce(mockResponse({ ok: true }));
305
- // delete returns 500
306
- mockFetch.mockResolvedValueOnce(mockResponse('Internal Error', 500));
307
- await expect(service.destroyMachine('my-app', 'mach-1')).rejects.toThrow('Fly.io API 500');
308
- });
309
-
310
- it('should pass force=true as query param when force is set', async () => {
311
- // stop
312
- mockFetch.mockResolvedValueOnce(mockResponse({ ok: true }));
313
- // delete
314
- mockFetch.mockResolvedValueOnce(mockResponse('', 200));
315
- await service.destroyMachine('my-app', 'mach-1', true);
316
- const deleteUrl = mockFetch.mock.calls[1][0] as string;
317
- expect(deleteUrl).toContain('force=true');
318
- });
319
- });
320
-
321
- describe('updateMachine', () => {
322
- it('should update machine config and return the updated machine', async () => {
323
- mockFetch.mockResolvedValueOnce(mockResponse({
324
- id: 'mach-1',
325
- name: 'web',
326
- state: 'started',
327
- region: 'iad',
328
- instance_id: 'i1',
329
- private_ip: 'fdaa::1',
330
- config: {
331
- image: 'nginx:1.26',
332
- guest: { cpus: 2, cpu_kind: 'shared', memory_mb: 512 },
333
- },
334
- created_at: '2024-01-01',
335
- updated_at: '2024-06-01',
336
- }));
337
- const machine = await service.updateMachine('my-app', 'mach-1', {
338
- image: 'nginx:1.26',
339
- guest: { cpus: 2, cpu_kind: 'shared', memory_mb: 512 },
340
- });
341
- expect(machine.config.image).toBe('nginx:1.26');
342
- expect(machine.config.guest?.memory_mb).toBe(512);
343
- });
344
-
345
- it('should throw on failure', async () => {
346
- mockFetch.mockResolvedValueOnce(mockResponse('Bad Request', 400));
347
- await expect(service.updateMachine('my-app', 'mach-1', {
348
- image: 'invalid',
349
- })).rejects.toThrow('Fly.io API 400');
350
- });
351
- });
352
-
353
- // ── Volumes ─────────────────────────────────────────────────────────────
354
-
355
- describe('createVolume', () => {
356
- it('should create a volume and return mapped result', async () => {
357
- mockFetch.mockResolvedValueOnce(mockResponse({
358
- id: 'vol-abc',
359
- name: 'data',
360
- state: 'created',
361
- size_gb: 10,
362
- region: 'iad',
363
- encrypted: true,
364
- created_at: '2024-06-01',
365
- }));
366
- const vol = await service.createVolume('my-app', {
367
- name: 'data',
368
- region: 'iad',
369
- sizeGb: 10,
370
- });
371
- expect(vol.id).toBe('vol-abc');
372
- expect(vol.name).toBe('data');
373
- expect(vol.sizeGb).toBe(10);
374
- expect(vol.encrypted).toBe(true);
375
- });
376
-
377
- it('should default encrypted to true in the request', async () => {
378
- mockFetch.mockResolvedValueOnce(mockResponse({
379
- id: 'vol-xyz', name: 'db', state: 'created', size_gb: 5,
380
- region: 'lhr', encrypted: true, created_at: '2024-06-01',
381
- }));
382
- await service.createVolume('my-app', { name: 'db', region: 'lhr', sizeGb: 5 });
383
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
384
- expect(body.encrypted).toBe(true);
385
- });
386
-
387
- it('should allow overriding encrypted to false', async () => {
388
- mockFetch.mockResolvedValueOnce(mockResponse({
389
- id: 'vol-plain', name: 'tmp', state: 'created', size_gb: 1,
390
- region: 'iad', encrypted: false, created_at: '2024-06-01',
391
- }));
392
- await service.createVolume('my-app', { name: 'tmp', region: 'iad', sizeGb: 1, encrypted: false });
393
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
394
- expect(body.encrypted).toBe(false);
395
- });
396
-
397
- it('should throw on failure', async () => {
398
- mockFetch.mockResolvedValueOnce(mockResponse('App not found', 404));
399
- await expect(service.createVolume('missing', {
400
- name: 'vol', region: 'iad', sizeGb: 1,
401
- })).rejects.toThrow('Fly.io API 404');
402
- });
403
- });
404
-
405
- describe('listVolumes', () => {
406
- it('should return a list of volumes', async () => {
407
- mockFetch.mockResolvedValueOnce(mockResponse([
408
- {
409
- id: 'v1', name: 'data', state: 'created', size_gb: 10, region: 'iad',
410
- encrypted: true, created_at: '2024-01-01', attached_machine_id: 'mach-1',
411
- },
412
- {
413
- id: 'v2', name: 'logs', state: 'created', size_gb: 5, region: 'iad',
414
- encrypted: true, created_at: '2024-01-02',
415
- },
416
- ]));
417
- const volumes = await service.listVolumes('my-app');
418
- expect(volumes).toHaveLength(2);
419
- expect(volumes[0].name).toBe('data');
420
- expect(volumes[0].attachedMachineId).toBe('mach-1');
421
- expect(volumes[1].attachedMachineId).toBeUndefined();
422
- });
423
-
424
- it('should return empty array when no volumes exist', async () => {
425
- mockFetch.mockResolvedValueOnce(mockResponse([]));
426
- const volumes = await service.listVolumes('empty-app');
427
- expect(volumes).toHaveLength(0);
428
- });
429
- });
430
-
431
- // ── Request Details ─────────────────────────────────────────────────────
432
-
433
- describe('REST request format', () => {
434
- it('should send Bearer token to Machines API', async () => {
435
- mockFetch.mockResolvedValueOnce(mockResponse([]));
436
- await service.listMachines('my-app');
437
- const [url, init] = mockFetch.mock.calls[0];
438
- expect(url).toBe('https://api.machines.dev/v1/apps/my-app/machines');
439
- expect(init.headers['Authorization']).toBe('Bearer fly-test-token-xyz');
440
- expect(init.headers['Content-Type']).toBe('application/json');
441
- });
442
- });
443
-
444
- describe('GraphQL request format', () => {
445
- it('should send Bearer token to GraphQL API', async () => {
446
- mockFetch.mockResolvedValueOnce(gqlResponse({ apps: { nodes: [] } }));
447
- await service.listApps();
448
- const [url, init] = mockFetch.mock.calls[0];
449
- expect(url).toBe('https://api.fly.io/graphql');
450
- expect(init.method).toBe('POST');
451
- expect(init.headers['Authorization']).toBe('Bearer fly-test-token-xyz');
452
- });
453
- });
454
-
455
- // ── Default Config ──────────────────────────────────────────────────────
456
-
457
- describe('default config', () => {
458
- it('should use default base URLs when none provided', () => {
459
- const svc = new FlyService({ token: 'test' });
460
- // We can verify by making a request and checking the URL
461
- // Since the defaults are set in the constructor, just verify the service is created
462
- expect(svc.isRunning).toBe(false);
463
- });
464
- });
465
-
466
- // ── Error Handling ──────────────────────────────────────────────────────
467
-
468
- describe('error handling', () => {
469
- it('should join multiple GraphQL errors with semicolons', async () => {
470
- mockFetch.mockResolvedValueOnce(gqlErrorResponse(['Error one', 'Error two']));
471
- await expect(service.listApps()).rejects.toThrow('Fly.io GraphQL: Error one; Error two');
472
- });
473
-
474
- it('should handle HTTP-level errors from GraphQL endpoint', async () => {
475
- mockFetch.mockResolvedValueOnce(mockResponse('Bad Gateway', 502));
476
- await expect(service.listApps()).rejects.toThrow('Fly.io GraphQL 502');
477
- });
478
-
479
- it('should handle HTTP-level errors from Machines API', async () => {
480
- mockFetch.mockResolvedValueOnce(mockResponse('Too Many Requests', 429));
481
- await expect(service.listMachines('app')).rejects.toThrow('Fly.io API 429');
482
- });
483
- });
484
- });