@rigkit/provider-freestyle 0.2.3 → 0.2.5
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 +9 -3
- package/package.json +8 -5
- package/src/host-auth.test.ts +375 -0
- package/src/host-auth.ts +591 -0
- package/src/index.ts +37 -59
- package/src/provider.test.ts +188 -113
- package/src/provider.ts +113 -378
- package/src/terminal-session.test.ts +239 -7
- package/src/terminal-session.ts +1181 -332
- package/src/version.ts +1 -1
package/src/provider.ts
CHANGED
|
@@ -1,47 +1,20 @@
|
|
|
1
|
-
import { Freestyle
|
|
2
|
-
import type { CommandOptions, ExecOptions, ExecOutputChunk, ExecResult } from "@rigkit/sdk";
|
|
1
|
+
import { Freestyle } from "freestyle";
|
|
3
2
|
import type {
|
|
4
|
-
BaseDevMachineProvider,
|
|
5
|
-
ProviderRuntimeContext,
|
|
6
|
-
SnapshotHandle,
|
|
7
3
|
SshConnection,
|
|
8
4
|
SshOptions,
|
|
9
|
-
VmHandle,
|
|
10
5
|
WorkflowProviderController,
|
|
11
6
|
} from "@rigkit/engine";
|
|
12
7
|
import type { CmuxOpenSshInput } from "@rigkit/provider-cmux";
|
|
13
8
|
import type { FreestyleIdentityId, FreestyleToken } from "./auth.ts";
|
|
14
9
|
import { createFreestyleTerminalSession } from "./terminal-session.ts";
|
|
15
10
|
|
|
16
|
-
type FreestyleVm = Awaited<ReturnType<Freestyle["vms"]["create"]>>["vm"];
|
|
17
|
-
|
|
18
11
|
export const FREESTYLE_PROVIDER_ID = "freestyle";
|
|
19
12
|
export const FREESTYLE_TERMINAL_PROVIDER_ID = "freestyle-terminal";
|
|
20
13
|
|
|
21
|
-
export type
|
|
22
|
-
image: string;
|
|
23
|
-
cpu?: number;
|
|
24
|
-
memory?: string | number;
|
|
25
|
-
disk?: string | number;
|
|
26
|
-
idleTimeoutSeconds?: number | null;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
export type FreestyleVmSnapshotRef = {
|
|
30
|
-
provider: typeof FREESTYLE_PROVIDER_ID;
|
|
31
|
-
kind: "vmSnapshot";
|
|
32
|
-
snapshotId: string;
|
|
33
|
-
sourceVmId?: string;
|
|
34
|
-
};
|
|
14
|
+
export type FreestyleSdkVm = ReturnType<Freestyle["vms"]["ref"]>;
|
|
35
15
|
|
|
36
|
-
export type
|
|
37
|
-
|
|
38
|
-
exec(command: string, options?: CommandOptions): Promise<ExecResult>;
|
|
39
|
-
probe(command: string, options?: CommandOptions): Promise<ExecResult>;
|
|
40
|
-
exists(path: string): Promise<boolean>;
|
|
41
|
-
readFile(path: string): Promise<string>;
|
|
42
|
-
writeFile(path: string, content: string): Promise<void>;
|
|
43
|
-
snapshotRef(): Promise<FreestyleVmSnapshotRef>;
|
|
44
|
-
ssh(options?: SshOptions): Promise<SshConnection>;
|
|
16
|
+
export type FreestyleSshInput = SshOptions & {
|
|
17
|
+
vmId: string;
|
|
45
18
|
};
|
|
46
19
|
|
|
47
20
|
export type FreestyleCmuxSshOptions = Exclude<CmuxOpenSshInput, string>;
|
|
@@ -49,30 +22,20 @@ export type FreestyleCmuxSshOptions = Exclude<CmuxOpenSshInput, string>;
|
|
|
49
22
|
export type FreestyleCmuxSshOptionsInput = Omit<
|
|
50
23
|
FreestyleCmuxSshOptions,
|
|
51
24
|
"kind" | "destination" | "host" | "username"
|
|
52
|
-
> &
|
|
25
|
+
> & FreestyleSshInput;
|
|
53
26
|
|
|
54
|
-
export type FreestyleVscodeUrlOptions =
|
|
27
|
+
export type FreestyleVscodeUrlOptions = FreestyleSshInput & {
|
|
55
28
|
cwd?: string;
|
|
56
29
|
};
|
|
57
30
|
|
|
58
31
|
export type FreestyleRuntime = {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
fromSnapshot(ref: FreestyleVmSnapshotRef): Promise<FreestyleVmRuntime>;
|
|
62
|
-
fromId(vmId: string): FreestyleVmRuntime;
|
|
63
|
-
delete(vmId: string): Promise<void>;
|
|
64
|
-
};
|
|
32
|
+
readonly client: Freestyle;
|
|
33
|
+
createSSHOptions(input: FreestyleSshInput): Promise<SshConnection>;
|
|
65
34
|
cmux: {
|
|
66
|
-
createSshOptions(
|
|
67
|
-
target: FreestyleVmRuntime | FreestyleVmSnapshotRef,
|
|
68
|
-
options?: FreestyleCmuxSshOptionsInput,
|
|
69
|
-
): Promise<FreestyleCmuxSshOptions>;
|
|
35
|
+
createSshOptions(input: FreestyleCmuxSshOptionsInput): Promise<FreestyleCmuxSshOptions>;
|
|
70
36
|
};
|
|
71
37
|
vscode: {
|
|
72
|
-
createUrl(
|
|
73
|
-
target: FreestyleVmRuntime | FreestyleVmSnapshotRef,
|
|
74
|
-
options?: FreestyleVscodeUrlOptions,
|
|
75
|
-
): Promise<string>;
|
|
38
|
+
createUrl(input: FreestyleVscodeUrlOptions): Promise<string>;
|
|
76
39
|
};
|
|
77
40
|
};
|
|
78
41
|
|
|
@@ -80,41 +43,31 @@ export type FreestyleTerminalRuntime = {
|
|
|
80
43
|
open(
|
|
81
44
|
title: string,
|
|
82
45
|
options: {
|
|
83
|
-
|
|
46
|
+
ssh: SshConnection;
|
|
84
47
|
command?: string;
|
|
48
|
+
keepOpenAfterCommand?: boolean;
|
|
85
49
|
instructions?: string;
|
|
86
50
|
},
|
|
87
51
|
): Promise<{ finished: true }>;
|
|
88
52
|
};
|
|
89
53
|
|
|
90
|
-
export function
|
|
91
|
-
|
|
54
|
+
export function createFreestyleWorkflowProvider(input: {
|
|
55
|
+
client: Freestyle;
|
|
92
56
|
identityId: FreestyleIdentityId;
|
|
93
57
|
token: FreestyleToken;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return new FreestyleProvider(input.apiKey, input.identityId, input.token, input.vm);
|
|
58
|
+
}): WorkflowProviderController<FreestyleRuntime> {
|
|
59
|
+
return createFreestyleWorkflowController(input);
|
|
97
60
|
}
|
|
98
61
|
|
|
99
|
-
export function
|
|
100
|
-
|
|
62
|
+
export function createFreestyleWorkflowController(input: {
|
|
63
|
+
client: Freestyle;
|
|
101
64
|
identityId: FreestyleIdentityId;
|
|
102
65
|
token: FreestyleToken;
|
|
103
|
-
vm: FreestyleVmConfig;
|
|
104
66
|
}): WorkflowProviderController<FreestyleRuntime> {
|
|
105
|
-
return createFreestyleWorkflowController(createFreestyleProvider(input));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function createFreestyleWorkflowController(
|
|
109
|
-
provider: BaseDevMachineProvider,
|
|
110
|
-
): WorkflowProviderController<FreestyleRuntime> {
|
|
111
67
|
return {
|
|
112
68
|
providerId: FREESTYLE_PROVIDER_ID,
|
|
113
|
-
runtime(
|
|
114
|
-
return createFreestyleRuntime(
|
|
115
|
-
},
|
|
116
|
-
validateArtifact(ref) {
|
|
117
|
-
return isFreestyleVmSnapshotRef(ref);
|
|
69
|
+
runtime() {
|
|
70
|
+
return createFreestyleRuntime(input);
|
|
118
71
|
},
|
|
119
72
|
};
|
|
120
73
|
}
|
|
@@ -125,14 +78,17 @@ export function createFreestyleTerminalController(): WorkflowProviderController<
|
|
|
125
78
|
runtime(context) {
|
|
126
79
|
return {
|
|
127
80
|
open: async (title, options) => {
|
|
128
|
-
const
|
|
129
|
-
|
|
81
|
+
const command = buildInteractiveSshCommand(options.ssh, options.command, {
|
|
82
|
+
keepOpenAfterCommand: options.keepOpenAfterCommand,
|
|
83
|
+
});
|
|
130
84
|
const session = createFreestyleTerminalSession({
|
|
131
85
|
title,
|
|
132
86
|
command,
|
|
133
|
-
|
|
87
|
+
displayCommand: options.command,
|
|
88
|
+
canFinishWhileRunning: options.keepOpenAfterCommand,
|
|
134
89
|
instructions: options.instructions,
|
|
135
90
|
nodePath: context.nodePath,
|
|
91
|
+
openExternalTarget: (target) => context.local.open(target),
|
|
136
92
|
});
|
|
137
93
|
return await context.interaction.present(session);
|
|
138
94
|
},
|
|
@@ -141,196 +97,86 @@ export function createFreestyleTerminalController(): WorkflowProviderController<
|
|
|
141
97
|
};
|
|
142
98
|
}
|
|
143
99
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
constructor(apiKey: string, identityId: FreestyleIdentityId, token: FreestyleToken, vmConfig: FreestyleVmConfig) {
|
|
152
|
-
this.client = new Freestyle({ apiKey });
|
|
153
|
-
this.identityId = identityId;
|
|
154
|
-
this.token = token;
|
|
155
|
-
this.vmConfig = vmConfig;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async createVm(): Promise<VmHandle> {
|
|
159
|
-
const { vmId } = await this.client.vms.create({
|
|
160
|
-
baseImage: new VmBaseImage(toDockerFrom(this.vmConfig.image)),
|
|
161
|
-
vcpuCount: this.vmConfig.cpu,
|
|
162
|
-
memSizeGb: parseSizeGb(this.vmConfig.memory),
|
|
163
|
-
rootfsSizeGb: parseSizeGb(this.vmConfig.disk),
|
|
164
|
-
idleTimeoutSeconds: this.vmConfig.idleTimeoutSeconds ?? 3600,
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
const vm = { vmId };
|
|
168
|
-
await this.updateVmPermissions(vm);
|
|
169
|
-
return vm;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async createVmFromSnapshot(input: { snapshotId: string }): Promise<VmHandle> {
|
|
173
|
-
const { vmId } = await this.client.vms.create({
|
|
174
|
-
snapshotId: input.snapshotId,
|
|
175
|
-
idleTimeoutSeconds: this.vmConfig.idleTimeoutSeconds ?? 3600,
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
const vm = { vmId };
|
|
179
|
-
await this.updateVmPermissions(vm);
|
|
180
|
-
return vm;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
async exec(vm: VmHandle, command: string, options?: ExecOptions): Promise<ExecResult> {
|
|
184
|
-
const freestyleVm = this.ref(vm);
|
|
185
|
-
const wrapped = wrapCommand(command, options);
|
|
186
|
-
const result = await freestyleVm.exec({
|
|
187
|
-
command: wrapped,
|
|
188
|
-
timeoutMs: options?.timeoutMs,
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
const stdout = result.stdout ?? "";
|
|
192
|
-
const stderr = result.stderr ?? "";
|
|
193
|
-
const exitCode = result.statusCode ?? 0;
|
|
194
|
-
|
|
195
|
-
if (stdout) await options?.onOutput?.({ stream: "stdout", data: stdout });
|
|
196
|
-
if (stderr) await options?.onOutput?.({ stream: "stderr", data: stderr });
|
|
197
|
-
|
|
198
|
-
return {
|
|
199
|
-
stdout,
|
|
200
|
-
stderr,
|
|
201
|
-
exitCode,
|
|
202
|
-
ok: exitCode === 0,
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async readFile(vm: VmHandle, path: string): Promise<string> {
|
|
207
|
-
const result = await this.exec(vm, `cat ${shellQuote(path)}`);
|
|
208
|
-
if (!result.ok) {
|
|
209
|
-
throw new Error(`Failed to read ${path}: ${result.stderr || result.stdout}`);
|
|
210
|
-
}
|
|
211
|
-
return result.stdout;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
async writeFile(vm: VmHandle, path: string, content: string): Promise<void> {
|
|
215
|
-
const result = await this.exec(vm, `mkdir -p $(dirname ${shellQuote(path)}) && printf '%s' "$RIGKIT_FILE_CONTENT" > ${shellQuote(path)}`, {
|
|
216
|
-
env: { RIGKIT_FILE_CONTENT: content },
|
|
217
|
-
});
|
|
218
|
-
if (!result.ok) {
|
|
219
|
-
throw new Error(`Failed to write ${path}: ${result.stderr || result.stdout}`);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
async snapshot(vm: VmHandle): Promise<SnapshotHandle> {
|
|
224
|
-
const result = await this.ref(vm).snapshot();
|
|
225
|
-
return {
|
|
226
|
-
snapshotId: result.snapshotId,
|
|
227
|
-
sourceVmId: result.sourceVmId,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
async ssh(vm: VmHandle, options?: SshOptions): Promise<SshConnection> {
|
|
232
|
-
const userPart = options?.user ? `+${options.user}` : "";
|
|
233
|
-
const username = `${vm.vmId}${userPart}`;
|
|
234
|
-
return {
|
|
235
|
-
kind: "ssh",
|
|
236
|
-
host: "vm-ssh.freestyle.sh",
|
|
237
|
-
username,
|
|
238
|
-
auth: { type: "token", token: this.token },
|
|
239
|
-
command: `ssh ${username}:${this.token}@vm-ssh.freestyle.sh`,
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
async workspaceContext(vm: VmHandle): Promise<{
|
|
244
|
-
ssh: SshConnection;
|
|
245
|
-
host: string;
|
|
246
|
-
username: string;
|
|
247
|
-
vscodeAuthority: string;
|
|
248
|
-
}> {
|
|
249
|
-
const ssh = await this.ssh(vm);
|
|
250
|
-
return {
|
|
251
|
-
ssh,
|
|
252
|
-
host: ssh.host,
|
|
253
|
-
username: ssh.username,
|
|
254
|
-
vscodeAuthority: vscodeAuthorityForSsh(ssh),
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
async deleteVm(vm: VmHandle): Promise<void> {
|
|
259
|
-
await this.client.vms.delete({ vmId: vm.vmId });
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
private ref(vm: VmHandle): FreestyleVm {
|
|
263
|
-
return this.client.vms.ref({ vmId: vm.vmId });
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
private async updateVmPermissions(vm: VmHandle): Promise<void> {
|
|
267
|
-
const identity = this.client.identities.ref({ identityId: this.identityId });
|
|
100
|
+
function createFreestyleRuntime(input: {
|
|
101
|
+
client: Freestyle;
|
|
102
|
+
identityId: FreestyleIdentityId;
|
|
103
|
+
token: FreestyleToken;
|
|
104
|
+
}): FreestyleRuntime {
|
|
105
|
+
const ensureSSHAccess = async (vmId: string) => {
|
|
106
|
+
const identity = input.client.identities.ref({ identityId: input.identityId });
|
|
268
107
|
try {
|
|
269
|
-
await identity.permissions.vms.grant({ vmId
|
|
108
|
+
await identity.permissions.vms.grant({ vmId });
|
|
270
109
|
} catch (error) {
|
|
271
110
|
if (!isPermissionAlreadyExistsError(error)) {
|
|
272
111
|
throw error;
|
|
273
112
|
}
|
|
274
|
-
await identity.permissions.vms.update({ vmId
|
|
113
|
+
await identity.permissions.vms.update({ vmId });
|
|
275
114
|
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function createFreestyleRuntime(
|
|
280
|
-
provider: BaseDevMachineProvider,
|
|
281
|
-
context: ProviderRuntimeContext,
|
|
282
|
-
): FreestyleRuntime {
|
|
283
|
-
const fromHandle = (vm: VmHandle): FreestyleVmRuntime => createVmRuntime(provider, vm, context);
|
|
115
|
+
};
|
|
284
116
|
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return
|
|
117
|
+
const runtime: FreestyleRuntime = {
|
|
118
|
+
client: input.client,
|
|
119
|
+
createSSHOptions: async ({ vmId, user }) => {
|
|
120
|
+
await ensureSSHAccess(vmId);
|
|
121
|
+
return freestyleSshConnection(vmId, input.token, user);
|
|
290
122
|
},
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
fromSnapshotId: ref.snapshotId,
|
|
298
|
-
});
|
|
299
|
-
return fromHandle(vm);
|
|
123
|
+
cmux: {
|
|
124
|
+
createSshOptions: async (options) => {
|
|
125
|
+
const { vmId, user, ...sshOptions } = options;
|
|
126
|
+
const ssh = await runtime.createSSHOptions({ vmId, user });
|
|
127
|
+
return freestyleCmuxSshOptions(ssh, sshOptions);
|
|
128
|
+
},
|
|
300
129
|
},
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
130
|
+
vscode: {
|
|
131
|
+
createUrl: async ({ vmId, user, cwd }) => {
|
|
132
|
+
const ssh = await runtime.createSSHOptions({ vmId, user });
|
|
133
|
+
return freestyleVscodeUrl(ssh, { cwd });
|
|
134
|
+
},
|
|
304
135
|
},
|
|
305
136
|
};
|
|
306
137
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
): Promise<FreestyleVmRuntime> => isFreestyleVmSnapshotRef(target) ? await vms.fromSnapshot(target) : target;
|
|
138
|
+
return runtime;
|
|
139
|
+
}
|
|
310
140
|
|
|
311
|
-
|
|
312
|
-
createUrl: async (target, options) => {
|
|
313
|
-
const vm = await resolveVm(target);
|
|
314
|
-
const { cwd, user } = options ?? {};
|
|
315
|
-
const ssh = await vm.ssh(user !== undefined ? { user } : undefined);
|
|
316
|
-
return freestyleVscodeUrl(ssh, { cwd });
|
|
317
|
-
},
|
|
318
|
-
};
|
|
141
|
+
const defaultFreestyleVmUser = "root";
|
|
319
142
|
|
|
143
|
+
function freestyleSshConnection(vmId: string, token: FreestyleToken, user: string | undefined): SshConnection {
|
|
144
|
+
const userPart = `+${user ?? defaultFreestyleVmUser}`;
|
|
145
|
+
const username = `${vmId}${userPart}`;
|
|
320
146
|
return {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const ssh = await vm.ssh(user !== undefined ? { user } : undefined);
|
|
327
|
-
return freestyleCmuxSshOptions(ssh, sshOptions);
|
|
328
|
-
},
|
|
329
|
-
},
|
|
330
|
-
vscode,
|
|
147
|
+
kind: "ssh",
|
|
148
|
+
host: "vm-ssh.freestyle.sh",
|
|
149
|
+
username,
|
|
150
|
+
auth: { type: "token", token },
|
|
151
|
+
command: `ssh ${username}:${token}@vm-ssh.freestyle.sh`,
|
|
331
152
|
};
|
|
332
153
|
}
|
|
333
154
|
|
|
155
|
+
function isPermissionAlreadyExistsError(error: unknown): boolean {
|
|
156
|
+
return errorStrings(error).some((value) =>
|
|
157
|
+
normalizeErrorCode(value).includes("PERMISSIONALREADYEXISTS"),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function errorStrings(error: unknown): string[] {
|
|
162
|
+
if (typeof error === "string") return [error];
|
|
163
|
+
if (!error || typeof error !== "object") return [];
|
|
164
|
+
|
|
165
|
+
const record = error as Record<string, unknown>;
|
|
166
|
+
const values: string[] = [];
|
|
167
|
+
for (const key of ["error", "code", "name", "message", "reason"]) {
|
|
168
|
+
const value = record[key];
|
|
169
|
+
if (typeof value === "string") values.push(value);
|
|
170
|
+
else values.push(...errorStrings(value));
|
|
171
|
+
}
|
|
172
|
+
values.push(...errorStrings(record.cause));
|
|
173
|
+
return values;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizeErrorCode(value: string): string {
|
|
177
|
+
return value.replaceAll(/[^a-zA-Z]/g, "").toUpperCase();
|
|
178
|
+
}
|
|
179
|
+
|
|
334
180
|
const freestyleCmuxTokenSshOptions = [
|
|
335
181
|
"StrictHostKeyChecking=no",
|
|
336
182
|
"UserKnownHostsFile=/dev/null",
|
|
@@ -342,7 +188,7 @@ const freestyleCmuxTokenSshOptions = [
|
|
|
342
188
|
|
|
343
189
|
function freestyleCmuxSshOptions(
|
|
344
190
|
connection: SshConnection,
|
|
345
|
-
options: Omit<FreestyleCmuxSshOptionsInput, keyof
|
|
191
|
+
options: Omit<FreestyleCmuxSshOptionsInput, keyof FreestyleSshInput> | undefined,
|
|
346
192
|
): FreestyleCmuxSshOptions {
|
|
347
193
|
const { sshOptions, port, ...rest } = options ?? {};
|
|
348
194
|
const mergedSshOptions = [
|
|
@@ -372,156 +218,45 @@ function freestyleVscodeUrl(connection: SshConnection, options: { cwd?: string }
|
|
|
372
218
|
return `vscode://vscode-remote/ssh-remote+${encodeURIComponent(vscodeAuthorityForSsh(connection))}${options.cwd ?? ""}?windowId=_blank`;
|
|
373
219
|
}
|
|
374
220
|
|
|
375
|
-
function
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
):
|
|
380
|
-
const runCommand = async (command: string, options?: CommandOptions) => {
|
|
381
|
-
const commandName = options?.name ?? command;
|
|
382
|
-
const { name: _name, ...execOptions } = options ?? {};
|
|
383
|
-
const callerOnOutput = execOptions.onOutput;
|
|
384
|
-
const streamed = new Set<ExecOutputChunk["stream"]>();
|
|
385
|
-
const onOutput = async (chunk: ExecOutputChunk) => {
|
|
386
|
-
if (!chunk.data) return;
|
|
387
|
-
streamed.add(chunk.stream);
|
|
388
|
-
context.emit({
|
|
389
|
-
type: "command.output",
|
|
390
|
-
nodePath: context.nodePath,
|
|
391
|
-
commandName,
|
|
392
|
-
stream: chunk.stream,
|
|
393
|
-
data: chunk.data,
|
|
394
|
-
});
|
|
395
|
-
await callerOnOutput?.(chunk);
|
|
396
|
-
};
|
|
397
|
-
context.emit({ type: "command.started", nodePath: context.nodePath, commandName, command });
|
|
398
|
-
const result = await provider.exec(vm, command, {
|
|
399
|
-
...execOptions,
|
|
400
|
-
onOutput,
|
|
401
|
-
});
|
|
402
|
-
if (result.stdout && !streamed.has("stdout")) await onOutput({ stream: "stdout", data: result.stdout });
|
|
403
|
-
if (result.stderr && !streamed.has("stderr")) await onOutput({ stream: "stderr", data: result.stderr });
|
|
404
|
-
context.emit({ type: "command.completed", nodePath: context.nodePath, commandName, exitCode: result.exitCode });
|
|
405
|
-
return { commandName, result };
|
|
406
|
-
};
|
|
407
|
-
|
|
408
|
-
return {
|
|
409
|
-
vmId: vm.vmId,
|
|
410
|
-
exec: async (command, options) => {
|
|
411
|
-
const { commandName, result } = await runCommand(command, options);
|
|
412
|
-
if (!result.ok) {
|
|
413
|
-
throw new Error(commandFailureMessage(commandName, result));
|
|
414
|
-
}
|
|
415
|
-
return result;
|
|
416
|
-
},
|
|
417
|
-
probe: async (command, options) => {
|
|
418
|
-
const { result } = await runCommand(command, options);
|
|
419
|
-
return result;
|
|
420
|
-
},
|
|
421
|
-
exists: async (path) => {
|
|
422
|
-
const result = await provider.exec(vm, `test -e ${shellPath(path)}`);
|
|
423
|
-
return result.ok;
|
|
424
|
-
},
|
|
425
|
-
readFile: (path) => provider.readFile(vm, path),
|
|
426
|
-
writeFile: (path, content) => provider.writeFile(vm, path, content),
|
|
427
|
-
snapshotRef: async () => {
|
|
428
|
-
const snapshot = await provider.snapshot(vm);
|
|
429
|
-
return snapshotRef(snapshot);
|
|
430
|
-
},
|
|
431
|
-
ssh: (options) => provider.ssh(vm, options),
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
function snapshotRef(snapshot: SnapshotHandle): FreestyleVmSnapshotRef {
|
|
436
|
-
return {
|
|
437
|
-
provider: FREESTYLE_PROVIDER_ID,
|
|
438
|
-
kind: "vmSnapshot",
|
|
439
|
-
snapshotId: snapshot.snapshotId,
|
|
440
|
-
sourceVmId: snapshot.sourceVmId,
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
export function isFreestyleVmSnapshotRef(value: unknown): value is FreestyleVmSnapshotRef {
|
|
445
|
-
return Boolean(
|
|
446
|
-
value &&
|
|
447
|
-
typeof value === "object" &&
|
|
448
|
-
(value as FreestyleVmSnapshotRef).provider === FREESTYLE_PROVIDER_ID &&
|
|
449
|
-
(value as FreestyleVmSnapshotRef).kind === "vmSnapshot" &&
|
|
450
|
-
typeof (value as FreestyleVmSnapshotRef).snapshotId === "string",
|
|
451
|
-
);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
function shellPath(path: string): string {
|
|
455
|
-
if (path.startsWith("~/")) return `~/${shellQuote(path.slice(2))}`;
|
|
456
|
-
return shellQuote(path);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
function buildInteractiveSshCommand(connection: SshConnection, remoteCommand: string | undefined): string {
|
|
221
|
+
export function buildInteractiveSshCommand(
|
|
222
|
+
connection: SshConnection,
|
|
223
|
+
remoteCommand: string | undefined,
|
|
224
|
+
options: { keepOpenAfterCommand?: boolean } = {},
|
|
225
|
+
): string {
|
|
460
226
|
if (connection.auth.type === "privateKey") {
|
|
461
227
|
return connection.command;
|
|
462
228
|
}
|
|
463
229
|
|
|
230
|
+
const command = remoteCommand && options.keepOpenAfterCommand
|
|
231
|
+
? keepOpenAfterCommand(remoteCommand)
|
|
232
|
+
: remoteCommand;
|
|
464
233
|
const destination = `${connection.username}:${connection.auth.token}@${connection.host}`;
|
|
465
234
|
const args = ["ssh"];
|
|
466
|
-
if (
|
|
235
|
+
if (command) args.push("-tt", "-q");
|
|
467
236
|
if (connection.port !== undefined) args.push("-p", String(connection.port));
|
|
468
237
|
args.push(destination);
|
|
238
|
+
if (command) args.push(withBrowserOpenFallback(command));
|
|
469
239
|
return args.map((arg) => arg === "ssh" || arg.startsWith("-") ? arg : shellQuote(arg)).join(" ");
|
|
470
240
|
}
|
|
471
241
|
|
|
472
|
-
function
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
function toDockerFrom(image: string): string {
|
|
481
|
-
if (image.trim().startsWith("FROM ")) return image;
|
|
482
|
-
if (image.includes(":")) return `FROM ${image}`;
|
|
483
|
-
|
|
484
|
-
const match = /^([a-z0-9][a-z0-9-]*)-(\d+(?:\.\d+)*)$/i.exec(image);
|
|
485
|
-
if (match) return `FROM ${match[1]}:${match[2]}`;
|
|
486
|
-
|
|
487
|
-
return `FROM ${image}`;
|
|
242
|
+
function withBrowserOpenFallback(command: string): string {
|
|
243
|
+
return [
|
|
244
|
+
'export BROWSER="${BROWSER:-true}"',
|
|
245
|
+
'export GH_BROWSER="${GH_BROWSER:-$BROWSER}"',
|
|
246
|
+
command,
|
|
247
|
+
].join("\n");
|
|
488
248
|
}
|
|
489
249
|
|
|
490
|
-
function
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
export function wrapCommand(command: string, options?: ExecOptions): string {
|
|
500
|
-
const parts: string[] = [
|
|
501
|
-
"set -o pipefail",
|
|
502
|
-
"export HOME=${HOME:-/root}",
|
|
503
|
-
];
|
|
504
|
-
|
|
505
|
-
if (options?.cwd) {
|
|
506
|
-
parts.push(`cd ${shellQuote(options.cwd)}`);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
if (options?.env) {
|
|
510
|
-
for (const [key, value] of Object.entries(options.env)) {
|
|
511
|
-
if (value !== undefined) {
|
|
512
|
-
parts.push(`export ${key}=${shellQuote(value)}`);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
parts.push(command);
|
|
518
|
-
return `bash -lc ${shellQuote(parts.join("\n"))}`;
|
|
250
|
+
function keepOpenAfterCommand(command: string): string {
|
|
251
|
+
return [
|
|
252
|
+
command,
|
|
253
|
+
"status=$?",
|
|
254
|
+
'if [ "$status" -ne 0 ]; then exit "$status"; fi',
|
|
255
|
+
`printf '\\nCommand completed. Type exit to continue.\\n'`,
|
|
256
|
+
'exec "${SHELL:-/bin/bash}" -l',
|
|
257
|
+
].join("\n");
|
|
519
258
|
}
|
|
520
259
|
|
|
521
260
|
function shellQuote(value: string): string {
|
|
522
261
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
523
262
|
}
|
|
524
|
-
|
|
525
|
-
function isPermissionAlreadyExistsError(error: unknown): boolean {
|
|
526
|
-
return error instanceof Error && error.name === "PermissionAlreadyExistsError";
|
|
527
|
-
}
|