@wpelevator/tug 0.2.1 → 0.4.0
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.
- package/README.md +38 -4
- package/dist/agent.d.ts +66 -6
- package/dist/agent.js +851 -170
- package/dist/archive.js +9 -3
- package/dist/artifacts.js +97 -34
- package/dist/build.d.ts +1 -1
- package/dist/build.js +17 -8
- package/dist/cli-build.js +34 -8
- package/dist/cli-config.js +16 -4
- package/dist/cli-install.d.ts +10 -0
- package/dist/cli-install.js +38 -4
- package/dist/cli-live-transport.d.ts +3 -0
- package/dist/cli-live-transport.js +230 -0
- package/dist/cli-live.d.ts +77 -0
- package/dist/cli-live.js +249 -0
- package/dist/cli.js +408 -90
- package/dist/compose.d.ts +35 -9
- package/dist/compose.js +240 -132
- package/dist/container-spec.d.ts +56 -0
- package/dist/container-spec.js +359 -0
- package/dist/control.d.ts +5 -3
- package/dist/control.js +1047 -146
- package/dist/db.d.ts +1 -0
- package/dist/db.js +7 -1
- package/dist/docker-api.d.ts +232 -0
- package/dist/docker-api.js +416 -0
- package/dist/ingress.d.ts +16 -8
- package/dist/ingress.js +84 -29
- package/dist/live.d.ts +83 -0
- package/dist/live.js +276 -0
- package/dist/main.js +3 -1
- package/dist/mesh.js +32 -13
- package/dist/normalize.d.ts +20 -0
- package/dist/normalize.js +75 -0
- package/dist/protocol.d.ts +158 -0
- package/dist/protocol.js +16 -2
- package/dist/reconciler.d.ts +89 -0
- package/dist/reconciler.js +362 -0
- package/dist/runners.d.ts +1 -6
- package/dist/runners.js +1 -5
- package/dist/secrets.js +3 -1
- package/dist/templates/control-compose.yml +1 -0
- package/dist/templates/install-docker.sh +12 -7
- package/dist/tokens.d.ts +1 -1
- package/dist/tokens.js +35 -9
- package/dist/util.d.ts +7 -0
- package/dist/util.js +73 -16
- package/package.json +8 -4
- package/dist/rollout.d.ts +0 -72
- package/dist/rollout.js +0 -319
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { CliLiveError } from "./cli-live.js";
|
|
3
|
+
import { LIVE_MAX_FRAME_BYTES as MAX_FRAME, LIVE_MAX_BUFFERED_BYTES as MAX_BUFFERED, LIVE_START_TIMEOUT_MS, } from "./protocol.js";
|
|
4
|
+
const MAX_EVENTS = 1024;
|
|
5
|
+
/** Credentials travel only in the upgrade header, never a URL or frame. */
|
|
6
|
+
export function createCliLiveTransport(controlUrl, operatorToken) {
|
|
7
|
+
const url = new URL(controlUrl);
|
|
8
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
9
|
+
throw new CliLiveError('live control URL must use http or https');
|
|
10
|
+
if (url.username || url.password || url.search || url.hash)
|
|
11
|
+
throw new CliLiveError('live control URL must not contain credentials, query or fragment');
|
|
12
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
13
|
+
url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v1/live`;
|
|
14
|
+
return {
|
|
15
|
+
open(request, signal) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
if (signal.aborted) {
|
|
18
|
+
reject(new CliLiveError('live connection cancelled', 130));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const socket = new WebSocket(url, {
|
|
22
|
+
headers: operatorToken ? { Authorization: `Bearer ${operatorToken}` } : {},
|
|
23
|
+
maxPayload: MAX_FRAME,
|
|
24
|
+
handshakeTimeout: LIVE_START_TIMEOUT_MS,
|
|
25
|
+
perMessageDeflate: false,
|
|
26
|
+
});
|
|
27
|
+
let opened = false;
|
|
28
|
+
let closed = false;
|
|
29
|
+
let ended = false;
|
|
30
|
+
let queuedBytes = 0;
|
|
31
|
+
let sessionId;
|
|
32
|
+
const queue = [];
|
|
33
|
+
let notify;
|
|
34
|
+
const wake = () => {
|
|
35
|
+
notify?.();
|
|
36
|
+
notify = undefined;
|
|
37
|
+
};
|
|
38
|
+
const enqueue = (event, size = 0) => {
|
|
39
|
+
if (ended)
|
|
40
|
+
return;
|
|
41
|
+
if (queue.length >= MAX_EVENTS || queuedBytes + size > MAX_BUFFERED) {
|
|
42
|
+
queue.length = 0;
|
|
43
|
+
queuedBytes = 0;
|
|
44
|
+
event = { type: 'error', message: 'live response exceeded the receive buffer limit' };
|
|
45
|
+
size = 0;
|
|
46
|
+
}
|
|
47
|
+
queue.push({ event, size });
|
|
48
|
+
queuedBytes += size;
|
|
49
|
+
if (event.type === 'error' || event.type === 'exit' || event.type === 'disconnect')
|
|
50
|
+
ended = true;
|
|
51
|
+
wake();
|
|
52
|
+
if (event.type === 'error')
|
|
53
|
+
socket.terminate();
|
|
54
|
+
};
|
|
55
|
+
const sendFrame = (frame) => new Promise((done, failed) => {
|
|
56
|
+
const data = JSON.stringify({ v: 1, ...frame });
|
|
57
|
+
if (Buffer.byteLength(data) > MAX_FRAME ||
|
|
58
|
+
socket.bufferedAmount + Buffer.byteLength(data) > MAX_BUFFERED) {
|
|
59
|
+
failed(new CliLiveError('live request exceeded the send buffer limit'));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
63
|
+
failed(new CliLiveError('live connection is closed'));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
socket.send(data, (error) => {
|
|
67
|
+
if (error)
|
|
68
|
+
failed(error);
|
|
69
|
+
else
|
|
70
|
+
done();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
const send = async (message) => {
|
|
74
|
+
if (message.type === 'stdin') {
|
|
75
|
+
// Bound individual frames even when a piped input stream yields a large chunk.
|
|
76
|
+
for (let offset = 0; offset < message.data.byteLength; offset += 48 * 1024)
|
|
77
|
+
await sendFrame({
|
|
78
|
+
type: 'live.stdin',
|
|
79
|
+
data: Buffer.from(message.data.subarray(offset, offset + 48 * 1024)).toString('base64'),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
else if (message.type === 'resize')
|
|
83
|
+
await sendFrame({ type: 'live.resize', columns: message.columns, rows: message.rows });
|
|
84
|
+
else
|
|
85
|
+
await sendFrame({ type: message.type === 'cancel' ? 'live.cancel' : 'live.stdin-end' });
|
|
86
|
+
};
|
|
87
|
+
const session = {
|
|
88
|
+
events: {
|
|
89
|
+
async *[Symbol.asyncIterator]() {
|
|
90
|
+
while (true) {
|
|
91
|
+
const item = queue.shift();
|
|
92
|
+
if (item) {
|
|
93
|
+
queuedBytes -= item.size;
|
|
94
|
+
yield item.event;
|
|
95
|
+
}
|
|
96
|
+
else if (ended || closed)
|
|
97
|
+
return;
|
|
98
|
+
else
|
|
99
|
+
await new Promise((ready) => {
|
|
100
|
+
notify = ready;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
send,
|
|
106
|
+
close() {
|
|
107
|
+
signal.removeEventListener('abort', abort);
|
|
108
|
+
enqueue({ type: 'disconnect', message: 'live session closed' });
|
|
109
|
+
if (socket.readyState !== WebSocket.CLOSED)
|
|
110
|
+
socket.terminate();
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
const abort = () => {
|
|
114
|
+
if (!opened)
|
|
115
|
+
reject(new CliLiveError('live connection cancelled', 130));
|
|
116
|
+
enqueue({ type: 'disconnect', message: 'live connection cancelled' });
|
|
117
|
+
socket.terminate();
|
|
118
|
+
};
|
|
119
|
+
const onError = (error) => {
|
|
120
|
+
if (!opened)
|
|
121
|
+
reject(new CliLiveError(`live connection failed: ${error.message}`));
|
|
122
|
+
enqueue({ type: 'error', message: `live connection failed: ${error.message}` });
|
|
123
|
+
};
|
|
124
|
+
const onMessage = (data, binary) => {
|
|
125
|
+
if (ended)
|
|
126
|
+
return;
|
|
127
|
+
try {
|
|
128
|
+
const bytes = Array.isArray(data)
|
|
129
|
+
? Buffer.concat(data)
|
|
130
|
+
: Buffer.from(data instanceof ArrayBuffer ? new Uint8Array(data) : data);
|
|
131
|
+
if (binary || bytes.byteLength > MAX_FRAME)
|
|
132
|
+
throw new Error('invalid or oversized live frame');
|
|
133
|
+
const frame = JSON.parse(bytes.toString('utf8'));
|
|
134
|
+
if (!frame ||
|
|
135
|
+
typeof frame !== 'object' ||
|
|
136
|
+
frame.v !== 1 ||
|
|
137
|
+
typeof frame.type !== 'string')
|
|
138
|
+
throw new Error('invalid live frame envelope');
|
|
139
|
+
if (frame.type !== 'live.error' &&
|
|
140
|
+
(typeof frame.sessionId !== 'string' || !frame.sessionId))
|
|
141
|
+
throw new Error('invalid live session identifier');
|
|
142
|
+
if (frame.sessionId !== undefined &&
|
|
143
|
+
(typeof frame.sessionId !== 'string' ||
|
|
144
|
+
!frame.sessionId ||
|
|
145
|
+
(sessionId !== undefined && sessionId !== frame.sessionId)))
|
|
146
|
+
throw new Error('invalid live session identifier');
|
|
147
|
+
if (typeof frame.sessionId === 'string')
|
|
148
|
+
sessionId = frame.sessionId;
|
|
149
|
+
switch (frame.type) {
|
|
150
|
+
case 'live.started':
|
|
151
|
+
if (typeof frame.node !== 'string' || !frame.node)
|
|
152
|
+
throw new Error('invalid live started frame');
|
|
153
|
+
if (opened)
|
|
154
|
+
throw new Error('duplicate live started frame');
|
|
155
|
+
opened = true;
|
|
156
|
+
resolve(session);
|
|
157
|
+
break;
|
|
158
|
+
case 'live.output': {
|
|
159
|
+
if (!opened)
|
|
160
|
+
throw new Error('live output arrived before startup');
|
|
161
|
+
if ((frame.stream !== 'stdout' && frame.stream !== 'stderr') ||
|
|
162
|
+
typeof frame.data !== 'string' ||
|
|
163
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(frame.data))
|
|
164
|
+
throw new Error('invalid live output frame');
|
|
165
|
+
const decoded = Buffer.from(frame.data, 'base64');
|
|
166
|
+
if (decoded.toString('base64') !== frame.data)
|
|
167
|
+
throw new Error('invalid live output encoding');
|
|
168
|
+
enqueue({ type: frame.stream, data: decoded }, bytes.byteLength);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case 'live.result':
|
|
172
|
+
if (!opened)
|
|
173
|
+
throw new Error('live result arrived before startup');
|
|
174
|
+
if (!Object.hasOwn(frame, 'value'))
|
|
175
|
+
throw new Error('invalid live result frame');
|
|
176
|
+
enqueue({ type: 'result', value: frame.value }, bytes.byteLength);
|
|
177
|
+
break;
|
|
178
|
+
case 'live.exit':
|
|
179
|
+
if (!opened)
|
|
180
|
+
throw new Error('live exit arrived before startup');
|
|
181
|
+
if (!Number.isInteger(frame.code) ||
|
|
182
|
+
Number(frame.code) < 0 ||
|
|
183
|
+
Number(frame.code) > 255)
|
|
184
|
+
throw new Error('invalid live exit frame');
|
|
185
|
+
enqueue({ type: 'exit', code: Number(frame.code) });
|
|
186
|
+
break;
|
|
187
|
+
case 'live.error':
|
|
188
|
+
if (typeof frame.message !== 'string' || !frame.message)
|
|
189
|
+
throw new Error('invalid live error frame');
|
|
190
|
+
if (!opened) {
|
|
191
|
+
ended = true;
|
|
192
|
+
reject(new CliLiveError(frame.message));
|
|
193
|
+
socket.terminate();
|
|
194
|
+
}
|
|
195
|
+
else
|
|
196
|
+
enqueue({ type: 'error', message: frame.message });
|
|
197
|
+
break;
|
|
198
|
+
default:
|
|
199
|
+
throw new Error('unknown live frame type');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
const message = error instanceof Error ? error.message : 'invalid live frame';
|
|
204
|
+
if (!opened) {
|
|
205
|
+
ended = true;
|
|
206
|
+
reject(new CliLiveError(message));
|
|
207
|
+
socket.terminate();
|
|
208
|
+
}
|
|
209
|
+
else
|
|
210
|
+
enqueue({ type: 'error', message });
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
const onClose = () => {
|
|
214
|
+
closed = true;
|
|
215
|
+
signal.removeEventListener('abort', abort);
|
|
216
|
+
socket.off('message', onMessage).off('error', onError);
|
|
217
|
+
if (!opened)
|
|
218
|
+
reject(new CliLiveError('live connection closed before opening'));
|
|
219
|
+
enqueue({ type: 'disconnect', message: 'live connection disconnected before exit' });
|
|
220
|
+
wake();
|
|
221
|
+
};
|
|
222
|
+
socket.on('message', onMessage).on('error', onError).once('close', onClose);
|
|
223
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
224
|
+
socket.once('open', () => {
|
|
225
|
+
void sendFrame({ type: 'live.open', request }).catch(onError);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import type { Readable, Writable } from 'node:stream';
|
|
3
|
+
import type { LiveRequest } from './protocol.ts';
|
|
4
|
+
export type { LiveRequest } from './protocol.ts';
|
|
5
|
+
export interface LiveInput {
|
|
6
|
+
project: string;
|
|
7
|
+
service?: string;
|
|
8
|
+
node?: string;
|
|
9
|
+
command?: string[];
|
|
10
|
+
interactive?: boolean;
|
|
11
|
+
tty?: boolean;
|
|
12
|
+
tail?: string | number;
|
|
13
|
+
}
|
|
14
|
+
/** Command arguments are passed verbatim, never joined into a shell command. */
|
|
15
|
+
export declare function parseLiveRequest(operation: LiveRequest['operation'], input: LiveInput): LiveRequest;
|
|
16
|
+
export type LiveClientMessage = {
|
|
17
|
+
type: 'stdin';
|
|
18
|
+
data: Uint8Array;
|
|
19
|
+
} | {
|
|
20
|
+
type: 'stdin-end';
|
|
21
|
+
} | {
|
|
22
|
+
type: 'resize';
|
|
23
|
+
columns: number;
|
|
24
|
+
rows: number;
|
|
25
|
+
} | {
|
|
26
|
+
type: 'cancel';
|
|
27
|
+
};
|
|
28
|
+
export type LiveEvent = {
|
|
29
|
+
type: 'stdout' | 'stderr';
|
|
30
|
+
data: Uint8Array;
|
|
31
|
+
} | {
|
|
32
|
+
type: 'result';
|
|
33
|
+
value: unknown;
|
|
34
|
+
} | {
|
|
35
|
+
type: 'exit';
|
|
36
|
+
code: number;
|
|
37
|
+
} | {
|
|
38
|
+
type: 'error';
|
|
39
|
+
message: string;
|
|
40
|
+
} | {
|
|
41
|
+
type: 'disconnect';
|
|
42
|
+
message?: string;
|
|
43
|
+
};
|
|
44
|
+
/** Adapters must bound received buffering and unblock event iteration when closed. */
|
|
45
|
+
export interface CliLiveSession {
|
|
46
|
+
events: AsyncIterable<LiveEvent>;
|
|
47
|
+
send(message: LiveClientMessage): void | Promise<void>;
|
|
48
|
+
close(): void | Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
export interface CliLiveTransport {
|
|
51
|
+
/** Respect abort during connection establishment; a late session is closed by the runner. */
|
|
52
|
+
open(request: LiveRequest, signal: AbortSignal): Promise<CliLiveSession>;
|
|
53
|
+
}
|
|
54
|
+
export type CliLiveStdin = Readable & {
|
|
55
|
+
isTTY?: boolean;
|
|
56
|
+
isRaw?: boolean;
|
|
57
|
+
setRawMode?: (enabled: boolean) => unknown;
|
|
58
|
+
};
|
|
59
|
+
export type CliLiveStdout = Writable & {
|
|
60
|
+
isTTY?: boolean;
|
|
61
|
+
columns?: number;
|
|
62
|
+
rows?: number;
|
|
63
|
+
};
|
|
64
|
+
export interface CliLiveIO {
|
|
65
|
+
stdin?: CliLiveStdin;
|
|
66
|
+
stdout?: CliLiveStdout;
|
|
67
|
+
stderr?: Writable;
|
|
68
|
+
signals?: EventEmitter;
|
|
69
|
+
signal?: AbortSignal;
|
|
70
|
+
}
|
|
71
|
+
export declare class CliLiveError extends Error {
|
|
72
|
+
readonly exitCode: number;
|
|
73
|
+
constructor(message: string, exitCode?: number);
|
|
74
|
+
}
|
|
75
|
+
export declare function formatLiveResult(value: unknown): string;
|
|
76
|
+
/** Runs one session without exiting the process or owning the caller's streams. */
|
|
77
|
+
export declare function runCliLive(request: LiveRequest, transport: CliLiveTransport, io?: CliLiveIO): Promise<number>;
|
package/dist/cli-live.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
/** Command arguments are passed verbatim, never joined into a shell command. */
|
|
3
|
+
export function parseLiveRequest(operation, input) {
|
|
4
|
+
const name = (value, label) => {
|
|
5
|
+
if (!value || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(value))
|
|
6
|
+
throw new Error(`${label} must be a nonempty name containing letters, numbers, dots, underscores or hyphens`);
|
|
7
|
+
return value;
|
|
8
|
+
};
|
|
9
|
+
const common = {
|
|
10
|
+
project: name(input.project, 'project'),
|
|
11
|
+
...(input.node === undefined ? {} : { node: name(input.node, 'node') }),
|
|
12
|
+
};
|
|
13
|
+
if (operation === 'ps')
|
|
14
|
+
return { operation, ...common };
|
|
15
|
+
const service = name(input.service, 'service');
|
|
16
|
+
if (operation === 'inspect')
|
|
17
|
+
return { operation, ...common, service };
|
|
18
|
+
if (operation === 'logs') {
|
|
19
|
+
const raw = String(input.tail ?? 200);
|
|
20
|
+
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(Number(raw)))
|
|
21
|
+
throw new Error('tail must be a nonnegative safe integer');
|
|
22
|
+
return { operation, ...common, service, follow: true, tail: Number(raw) };
|
|
23
|
+
}
|
|
24
|
+
const command = input.command?.length ? [...input.command] : ['sh'];
|
|
25
|
+
if (command.some((part) => typeof part !== 'string' || part.includes('\0')) || !command[0])
|
|
26
|
+
throw new Error('exec command must have a nonempty executable and no NUL bytes');
|
|
27
|
+
return {
|
|
28
|
+
operation,
|
|
29
|
+
...common,
|
|
30
|
+
service,
|
|
31
|
+
command,
|
|
32
|
+
interactive: input.interactive ?? false,
|
|
33
|
+
tty: input.tty ?? false,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export class CliLiveError extends Error {
|
|
37
|
+
exitCode;
|
|
38
|
+
constructor(message, exitCode = 1) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'CliLiveError';
|
|
41
|
+
this.exitCode = exitCode;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function formatLiveResult(value) {
|
|
45
|
+
const json = JSON.stringify(value, null, 2);
|
|
46
|
+
if (json === undefined)
|
|
47
|
+
throw new CliLiveError('remote operation returned no JSON result');
|
|
48
|
+
return `${json}\n`;
|
|
49
|
+
}
|
|
50
|
+
/** Runs one session without exiting the process or owning the caller's streams. */
|
|
51
|
+
export async function runCliLive(request, transport, io = {}) {
|
|
52
|
+
const stdin = io.stdin ?? process.stdin;
|
|
53
|
+
const stdout = io.stdout ?? process.stdout;
|
|
54
|
+
const stderr = io.stderr ?? process.stderr;
|
|
55
|
+
const signals = io.signals ?? process;
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
let session;
|
|
58
|
+
let complete = false;
|
|
59
|
+
let rawChanged = false;
|
|
60
|
+
let inputAttached = false;
|
|
61
|
+
const wasRaw = Boolean(stdin.isRaw);
|
|
62
|
+
const wasFlowing = stdin.readableFlowing === true;
|
|
63
|
+
let failure;
|
|
64
|
+
let wake;
|
|
65
|
+
const stopped = new Promise((resolve) => {
|
|
66
|
+
wake = resolve;
|
|
67
|
+
});
|
|
68
|
+
let queuedBytes = 0;
|
|
69
|
+
let sending = Promise.resolve();
|
|
70
|
+
const fail = (error) => {
|
|
71
|
+
failure ??= error instanceof Error ? error : new Error(String(error));
|
|
72
|
+
wake();
|
|
73
|
+
};
|
|
74
|
+
const send = (message) => {
|
|
75
|
+
const size = message.type === 'stdin' ? message.data.byteLength : 0;
|
|
76
|
+
queuedBytes += size;
|
|
77
|
+
if (queuedBytes > 1024 * 1024) {
|
|
78
|
+
fail(new CliLiveError('stdin exceeded the live session buffer limit'));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
sending = sending
|
|
82
|
+
.then(async () => {
|
|
83
|
+
if (!complete && !controller.signal.aborted)
|
|
84
|
+
await session.send(message);
|
|
85
|
+
})
|
|
86
|
+
.catch(fail)
|
|
87
|
+
.finally(() => {
|
|
88
|
+
queuedBytes -= size;
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
const cancel = () => {
|
|
92
|
+
controller.abort();
|
|
93
|
+
wake();
|
|
94
|
+
};
|
|
95
|
+
const onData = (data) => {
|
|
96
|
+
send({ type: 'stdin', data: Buffer.from(data) });
|
|
97
|
+
};
|
|
98
|
+
const onEnd = () => {
|
|
99
|
+
send({ type: 'stdin-end' });
|
|
100
|
+
};
|
|
101
|
+
const onStreamError = (error) => {
|
|
102
|
+
fail(error);
|
|
103
|
+
};
|
|
104
|
+
const resize = () => {
|
|
105
|
+
if (Number.isInteger(stdout.columns) &&
|
|
106
|
+
stdout.columns > 0 &&
|
|
107
|
+
Number.isInteger(stdout.rows) &&
|
|
108
|
+
stdout.rows > 0)
|
|
109
|
+
send({ type: 'resize', columns: stdout.columns, rows: stdout.rows });
|
|
110
|
+
};
|
|
111
|
+
const write = async (stream, data) => {
|
|
112
|
+
await new Promise((resolve, reject) => {
|
|
113
|
+
stream.write(data, (error) => {
|
|
114
|
+
if (error)
|
|
115
|
+
reject(error);
|
|
116
|
+
else
|
|
117
|
+
resolve();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
const consume = async () => {
|
|
122
|
+
let result;
|
|
123
|
+
let hasResult = false;
|
|
124
|
+
for await (const event of session.events) {
|
|
125
|
+
if (complete || controller.signal.aborted)
|
|
126
|
+
return 130;
|
|
127
|
+
switch (event.type) {
|
|
128
|
+
case 'stdout':
|
|
129
|
+
await write(stdout, event.data);
|
|
130
|
+
break;
|
|
131
|
+
case 'stderr':
|
|
132
|
+
await write(stderr, event.data);
|
|
133
|
+
break;
|
|
134
|
+
case 'result':
|
|
135
|
+
result = event.value;
|
|
136
|
+
hasResult = true;
|
|
137
|
+
break;
|
|
138
|
+
case 'error':
|
|
139
|
+
throw new CliLiveError(event.message);
|
|
140
|
+
case 'disconnect':
|
|
141
|
+
throw new CliLiveError(event.message ?? 'live session disconnected before exit');
|
|
142
|
+
case 'exit':
|
|
143
|
+
if (!Number.isInteger(event.code) || event.code < 0 || event.code > 255)
|
|
144
|
+
throw new CliLiveError('remote operation returned an invalid exit code');
|
|
145
|
+
if (event.code === 0 && (request.operation === 'ps' || request.operation === 'inspect')) {
|
|
146
|
+
if (!hasResult)
|
|
147
|
+
throw new CliLiveError('remote operation exited without a JSON result');
|
|
148
|
+
await write(stdout, formatLiveResult(result));
|
|
149
|
+
}
|
|
150
|
+
return event.code;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
throw new CliLiveError('live session disconnected before exit');
|
|
154
|
+
};
|
|
155
|
+
signals.on('SIGINT', cancel);
|
|
156
|
+
io.signal?.addEventListener('abort', cancel, { once: true });
|
|
157
|
+
stdout.on('error', onStreamError);
|
|
158
|
+
stderr.on('error', onStreamError);
|
|
159
|
+
try {
|
|
160
|
+
if (io.signal?.aborted)
|
|
161
|
+
cancel();
|
|
162
|
+
if (controller.signal.aborted)
|
|
163
|
+
return 130;
|
|
164
|
+
if (request.operation === 'exec' &&
|
|
165
|
+
request.tty &&
|
|
166
|
+
(!stdin.isTTY || !stdout.isTTY || !stdin.setRawMode))
|
|
167
|
+
throw new CliLiveError('TTY exec requires a local terminal on stdin and stdout');
|
|
168
|
+
const openRequest = request.operation === 'exec' && request.tty
|
|
169
|
+
? {
|
|
170
|
+
...request,
|
|
171
|
+
...(stdout.columns && stdout.rows
|
|
172
|
+
? { columns: stdout.columns, rows: stdout.rows }
|
|
173
|
+
: {}),
|
|
174
|
+
}
|
|
175
|
+
: request;
|
|
176
|
+
const opening = transport.open(openRequest, controller.signal).then(async (opened) => {
|
|
177
|
+
if (complete || controller.signal.aborted) {
|
|
178
|
+
await opened.close();
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
return opened;
|
|
182
|
+
});
|
|
183
|
+
session = await Promise.race([opening, stopped.then(() => undefined)]);
|
|
184
|
+
if (!session) {
|
|
185
|
+
if (failure)
|
|
186
|
+
throw failure;
|
|
187
|
+
return 130;
|
|
188
|
+
}
|
|
189
|
+
if (request.operation === 'exec') {
|
|
190
|
+
if (request.tty) {
|
|
191
|
+
rawChanged = !wasRaw;
|
|
192
|
+
if (rawChanged)
|
|
193
|
+
stdin.setRawMode(true);
|
|
194
|
+
stdout.on('resize', resize);
|
|
195
|
+
resize();
|
|
196
|
+
}
|
|
197
|
+
if (request.interactive) {
|
|
198
|
+
inputAttached = true;
|
|
199
|
+
stdin.on('data', onData).on('end', onEnd).on('error', onStreamError);
|
|
200
|
+
stdin.resume();
|
|
201
|
+
if (stdin.readableEnded)
|
|
202
|
+
onEnd();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const code = await Promise.race([
|
|
206
|
+
consume(),
|
|
207
|
+
stopped.then(() => {
|
|
208
|
+
if (failure)
|
|
209
|
+
throw failure;
|
|
210
|
+
return 130;
|
|
211
|
+
}),
|
|
212
|
+
]);
|
|
213
|
+
if (failure)
|
|
214
|
+
throw failure;
|
|
215
|
+
return code;
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
complete = true;
|
|
219
|
+
signals.off('SIGINT', cancel);
|
|
220
|
+
io.signal?.removeEventListener('abort', cancel);
|
|
221
|
+
stdout.off('resize', resize);
|
|
222
|
+
if (inputAttached) {
|
|
223
|
+
stdin.off('data', onData).off('end', onEnd).off('error', onStreamError);
|
|
224
|
+
if (!wasFlowing)
|
|
225
|
+
stdin.pause();
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
if (rawChanged)
|
|
229
|
+
stdin.setRawMode?.(wasRaw);
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
try {
|
|
233
|
+
if (session) {
|
|
234
|
+
if (controller.signal.aborted || failure) {
|
|
235
|
+
try {
|
|
236
|
+
await session.send({ type: 'cancel' });
|
|
237
|
+
}
|
|
238
|
+
catch { }
|
|
239
|
+
}
|
|
240
|
+
await session.close();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
stdout.off('error', onStreamError);
|
|
245
|
+
stderr.off('error', onStreamError);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|