Skip to content

Commit 3998bc6

Browse files
committed
fix(fly-node): use spawn pty for interactive prompts
closed COD-239
1 parent 9a3844b commit 3998bc6

File tree

4 files changed

+56
-24
lines changed

4 files changed

+56
-24
lines changed

packages/fly-node/src/lib/fly.class.spec.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { existsSync } from 'fs';
22
import process from 'process';
33

4-
import { SpawnOptions, spawn } from '@codeware/core/utils';
4+
import { SpawnOptions, spawn, spawnPty } from '@codeware/core/utils';
55
import { Mock, beforeEach, describe, expect, it, vi } from 'vitest';
66

7-
import { Fly } from './fly.class';
7+
import { ExecFlyOptions, Fly } from './fly.class';
88
import {
99
mockAppsListResponse,
1010
mockCreateAppResponse,
@@ -30,7 +30,8 @@ vi.mock('os');
3030
vi.mock('@codeware/core/utils', async () => ({
3131
// Only mock `spawn` function
3232
...(await vi.importActual('@codeware/core/utils')),
33-
spawn: vi.fn()
33+
spawn: vi.fn(),
34+
spawnPty: vi.fn()
3435
}));
3536

3637
describe('Fly', () => {
@@ -64,6 +65,7 @@ describe('Fly', () => {
6465
};
6566

6667
const mockSpawn = vi.mocked(spawn);
68+
const mockSpawnPty = vi.mocked(spawnPty);
6769
const mockExistsSync = vi.mocked(existsSync);
6870

6971
/**
@@ -86,7 +88,7 @@ describe('Fly', () => {
8688
const assertSpawn = (
8789
matchArgs: 'exact' | 'not' | 'some',
8890
args: Array<string>,
89-
options?: SpawnOptions & {
91+
options?: ExecFlyOptions & {
9092
/** The strategy to use for the `--access-token` flag */
9193
accessTokenStrategy?: 'append-to-args' | 'leave-as-is';
9294
}
@@ -101,10 +103,13 @@ describe('Fly', () => {
101103
optionsToCheck = options;
102104
}
103105

106+
// Determine which mock to use
107+
const mockSpawnUsed = options?.prompt ? mockSpawnPty : mockSpawn;
108+
104109
// Check negative test first - ignore options
105110
if (matchArgs === 'not') {
106111
// Assert that the call was not made
107-
expect(mockSpawn).not.toHaveBeenCalledWith(
112+
expect(mockSpawnUsed).not.toHaveBeenCalledWith(
108113
expect.stringMatching(/^fly/),
109114
expect.arrayContaining(args),
110115
expect.anything()
@@ -123,7 +128,7 @@ describe('Fly', () => {
123128
: args;
124129

125130
// Find call that matches our arguments
126-
const matchingCall = mockSpawn.mock.calls.find(
131+
const matchingCall = mockSpawnUsed.mock.calls.find(
127132
(call) =>
128133
call[0].match(/^fly/) &&
129134
argsToCheck.every((arg) => call[1].includes(arg))
@@ -132,7 +137,7 @@ describe('Fly', () => {
132137
// Assert normally when a call could not be found.
133138
// It should fail but provide a better DX to the user.
134139
if (!matchingCall) {
135-
expect(mockSpawn).toHaveBeenCalledWith(
140+
expect(mockSpawnUsed).toHaveBeenCalledWith(
136141
expect.stringMatching(/^fly/),
137142
expect.arrayContaining(argsToCheck),
138143
optionsToCheck

packages/fly-node/src/lib/fly.class.ts

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { existsSync } from 'fs';
22
import { join } from 'path';
33
import { cwd } from 'process';
44

5-
import { SpawnOptions, spawn } from '@codeware/core/utils';
5+
import {
6+
SpawnOptions,
7+
SpawnPtyOptions,
8+
spawn,
9+
spawnPty
10+
} from '@codeware/core/utils';
611
import { ZodError } from 'zod';
712

813
import { AppsCreateTransformedResponseSchema } from './schemas/apps-create.schema';
@@ -42,6 +47,8 @@ import type {
4247
UnsetAppSecretsOptions
4348
} from './types';
4449

50+
export type ExecFlyOptions = SpawnOptions & SpawnPtyOptions;
51+
4552
/**
4653
* Manages deployments to Fly.io using the `flyctl` CLI tool.
4754
*/
@@ -171,7 +178,8 @@ export class Fly {
171178
this.logger.info(
172179
`Detaching Postgres cluster '${cluster}' from application '${app}'`
173180
);
174-
await this.detachPostgres(cluster, app);
181+
const output = await this.detachPostgres(cluster, app);
182+
this.logger.info(output);
175183
}
176184
// Now it's safe to destroy the app
177185
await this.destroyApp(app, options?.force);
@@ -331,6 +339,11 @@ export class Fly {
331339
);
332340
}
333341
};
342+
postgres = {
343+
detach: async (cluster: string, app: string) => {
344+
await this.detachPostgres(cluster, app);
345+
}
346+
};
334347
/**
335348
* Manage application secrets
336349
*/
@@ -667,13 +680,14 @@ export class Fly {
667680

668681
/**
669682
* @private
683+
* @returns Log output from the detach process
670684
* @throws An error if the Postgres database cannot be detached from an app
671685
*/
672-
private async detachPostgres(postgres: string, app: string): Promise<void> {
686+
private async detachPostgres(postgres: string, app: string): Promise<string> {
673687
const args = ['postgres', 'detach', postgres, '--app', app];
674688

675689
// Prompt for confirmation if the database name
676-
await this.execFly(args, {
690+
return await this.execFly<string>(args, {
677691
prompt: (output) => {
678692
if (output.match(/select .* detach/i)) {
679693
return app.replace(/-/g, '_');
@@ -1136,9 +1150,9 @@ ${JSON.stringify(response, null, 2)}`);
11361150
* @returns The JSON output of the command, or the raw output if not JSON
11371151
* @throws An error if the command fails
11381152
*/
1139-
private async execFly<T = void>(
1153+
private async execFly<T>(
11401154
command: string | Array<string>,
1141-
options?: SpawnOptions
1155+
options?: ExecFlyOptions
11421156
): Promise<T>;
11431157
/**
11441158
* @private
@@ -1152,15 +1166,15 @@ ${JSON.stringify(response, null, 2)}`);
11521166
* @returns The JSON output of the command, or the raw output if not JSON
11531167
* @throws An error if the command fails and `onError` is `throwOnError`
11541168
*/
1155-
private async execFly<T = null>(
1169+
private async execFly<T>(
11561170
command: string | Array<string>,
1157-
options?: SpawnOptions,
1171+
options?: ExecFlyOptions,
11581172
onError?: 'nullOnError',
11591173
skipToken?: boolean
11601174
): Promise<T>;
11611175
private async execFly<T>(
11621176
command: string | Array<string>,
1163-
options?: SpawnOptions,
1177+
options?: ExecFlyOptions,
11641178
onError: 'nullOnError' | 'throwOnError' = 'throwOnError',
11651179
skipToken?: boolean
11661180
): Promise<T> {
@@ -1176,14 +1190,21 @@ ${JSON.stringify(response, null, 2)}`);
11761190

11771191
const flyCmd = 'flyctl';
11781192
const toLogCmd = `${flyCmd} ${args.join(' ')}`;
1179-
let stdout = '';
1193+
let output = '';
11801194

11811195
try {
11821196
this.traceCLI('CALL', toLogCmd);
1183-
const result = await spawn(flyCmd, args, options);
1184-
stdout = result.stdout.trim();
1185-
const stderr = result.stderr.trim();
1186-
this.traceCLI('RESULT', `${stdout}${stderr ? `\n${stderr}` : ''}`);
1197+
// If the command requires interactive input, use spawnPty
1198+
if (options?.prompt) {
1199+
output = await spawnPty(flyCmd, args, { prompt: options.prompt });
1200+
this.traceCLI('RESULT', output);
1201+
} else {
1202+
// Otherwise use spawn
1203+
const result = await spawn(flyCmd, args, options);
1204+
output = result.stdout.trim();
1205+
const stderr = result.stderr.trim();
1206+
this.traceCLI('RESULT', `${output}${stderr ? `\n${stderr}` : ''}`);
1207+
}
11871208
} catch (error) {
11881209
const errorMsg = (error as Error).message;
11891210
this.traceCLI('RESULT', errorMsg);
@@ -1195,13 +1216,13 @@ ${errorMsg}`);
11951216
}
11961217

11971218
try {
1198-
return JSON.parse(stdout) as T;
1219+
return JSON.parse(output) as T;
11991220
} catch {
1200-
if (stdout) {
1221+
if (output) {
12011222
if (this.logger.traceCLI) {
12021223
this.logger.info(`Failed to parse as JSON, returning raw output`);
12031224
}
1204-
return stdout as T;
1225+
return output as T;
12051226
}
12061227
}
12071228
return undefined as T;

packages/nx-fly-deployment-action/src/lib/fly-deployment.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ vi.mock('fs', async () => {
2727
__esModule: true
2828
};
2929
});
30+
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => ({
31+
spawn: vi.fn()
32+
}));
3033
vi.mock('@actions/core');
3134
vi.mock('@actions/exec');
3235
vi.mock('@actions/github', () => ({

packages/nx-fly-deployment-action/src/lib/main.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import * as main from './main';
1313
import type { ActionInputs } from './schemas/action-inputs.schema';
1414
import type { ActionOutputs } from './schemas/action-outputs.schema';
1515

16+
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => ({
17+
spawn: vi.fn()
18+
}));
1619
vi.mock('@actions/core');
1720
vi.mock('./fly-deployment');
1821

0 commit comments

Comments
 (0)