@rigkit/provider-freestyle 0.2.3 → 0.2.4
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 +171 -113
- package/src/provider.ts +106 -380
- package/src/terminal-session.test.ts +42 -2
- package/src/terminal-session.ts +633 -308
- 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,12 +78,14 @@ 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,
|
|
136
91
|
});
|
|
@@ -141,196 +96,86 @@ export function createFreestyleTerminalController(): WorkflowProviderController<
|
|
|
141
96
|
};
|
|
142
97
|
}
|
|
143
98
|
|
|
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 });
|
|
99
|
+
function createFreestyleRuntime(input: {
|
|
100
|
+
client: Freestyle;
|
|
101
|
+
identityId: FreestyleIdentityId;
|
|
102
|
+
token: FreestyleToken;
|
|
103
|
+
}): FreestyleRuntime {
|
|
104
|
+
const ensureSSHAccess = async (vmId: string) => {
|
|
105
|
+
const identity = input.client.identities.ref({ identityId: input.identityId });
|
|
268
106
|
try {
|
|
269
|
-
await identity.permissions.vms.grant({ vmId
|
|
107
|
+
await identity.permissions.vms.grant({ vmId });
|
|
270
108
|
} catch (error) {
|
|
271
109
|
if (!isPermissionAlreadyExistsError(error)) {
|
|
272
110
|
throw error;
|
|
273
111
|
}
|
|
274
|
-
await identity.permissions.vms.update({ vmId
|
|
112
|
+
await identity.permissions.vms.update({ vmId });
|
|
275
113
|
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function createFreestyleRuntime(
|
|
280
|
-
provider: BaseDevMachineProvider,
|
|
281
|
-
context: ProviderRuntimeContext,
|
|
282
|
-
): FreestyleRuntime {
|
|
283
|
-
const fromHandle = (vm: VmHandle): FreestyleVmRuntime => createVmRuntime(provider, vm, context);
|
|
114
|
+
};
|
|
284
115
|
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return
|
|
116
|
+
const runtime: FreestyleRuntime = {
|
|
117
|
+
client: input.client,
|
|
118
|
+
createSSHOptions: async ({ vmId, user }) => {
|
|
119
|
+
await ensureSSHAccess(vmId);
|
|
120
|
+
return freestyleSshConnection(vmId, input.token, user);
|
|
290
121
|
},
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
fromSnapshotId: ref.snapshotId,
|
|
298
|
-
});
|
|
299
|
-
return fromHandle(vm);
|
|
122
|
+
cmux: {
|
|
123
|
+
createSshOptions: async (options) => {
|
|
124
|
+
const { vmId, user, ...sshOptions } = options;
|
|
125
|
+
const ssh = await runtime.createSSHOptions({ vmId, user });
|
|
126
|
+
return freestyleCmuxSshOptions(ssh, sshOptions);
|
|
127
|
+
},
|
|
300
128
|
},
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
129
|
+
vscode: {
|
|
130
|
+
createUrl: async ({ vmId, user, cwd }) => {
|
|
131
|
+
const ssh = await runtime.createSSHOptions({ vmId, user });
|
|
132
|
+
return freestyleVscodeUrl(ssh, { cwd });
|
|
133
|
+
},
|
|
304
134
|
},
|
|
305
135
|
};
|
|
306
136
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
): Promise<FreestyleVmRuntime> => isFreestyleVmSnapshotRef(target) ? await vms.fromSnapshot(target) : target;
|
|
137
|
+
return runtime;
|
|
138
|
+
}
|
|
310
139
|
|
|
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
|
-
};
|
|
140
|
+
const defaultFreestyleVmUser = "root";
|
|
319
141
|
|
|
142
|
+
function freestyleSshConnection(vmId: string, token: FreestyleToken, user: string | undefined): SshConnection {
|
|
143
|
+
const userPart = `+${user ?? defaultFreestyleVmUser}`;
|
|
144
|
+
const username = `${vmId}${userPart}`;
|
|
320
145
|
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,
|
|
146
|
+
kind: "ssh",
|
|
147
|
+
host: "vm-ssh.freestyle.sh",
|
|
148
|
+
username,
|
|
149
|
+
auth: { type: "token", token },
|
|
150
|
+
command: `ssh ${username}:${token}@vm-ssh.freestyle.sh`,
|
|
331
151
|
};
|
|
332
152
|
}
|
|
333
153
|
|
|
154
|
+
function isPermissionAlreadyExistsError(error: unknown): boolean {
|
|
155
|
+
return errorStrings(error).some((value) =>
|
|
156
|
+
normalizeErrorCode(value).includes("PERMISSIONALREADYEXISTS"),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function errorStrings(error: unknown): string[] {
|
|
161
|
+
if (typeof error === "string") return [error];
|
|
162
|
+
if (!error || typeof error !== "object") return [];
|
|
163
|
+
|
|
164
|
+
const record = error as Record<string, unknown>;
|
|
165
|
+
const values: string[] = [];
|
|
166
|
+
for (const key of ["error", "code", "name", "message", "reason"]) {
|
|
167
|
+
const value = record[key];
|
|
168
|
+
if (typeof value === "string") values.push(value);
|
|
169
|
+
else values.push(...errorStrings(value));
|
|
170
|
+
}
|
|
171
|
+
values.push(...errorStrings(record.cause));
|
|
172
|
+
return values;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeErrorCode(value: string): string {
|
|
176
|
+
return value.replaceAll(/[^a-zA-Z]/g, "").toUpperCase();
|
|
177
|
+
}
|
|
178
|
+
|
|
334
179
|
const freestyleCmuxTokenSshOptions = [
|
|
335
180
|
"StrictHostKeyChecking=no",
|
|
336
181
|
"UserKnownHostsFile=/dev/null",
|
|
@@ -342,7 +187,7 @@ const freestyleCmuxTokenSshOptions = [
|
|
|
342
187
|
|
|
343
188
|
function freestyleCmuxSshOptions(
|
|
344
189
|
connection: SshConnection,
|
|
345
|
-
options: Omit<FreestyleCmuxSshOptionsInput, keyof
|
|
190
|
+
options: Omit<FreestyleCmuxSshOptionsInput, keyof FreestyleSshInput> | undefined,
|
|
346
191
|
): FreestyleCmuxSshOptions {
|
|
347
192
|
const { sshOptions, port, ...rest } = options ?? {};
|
|
348
193
|
const mergedSshOptions = [
|
|
@@ -372,156 +217,37 @@ function freestyleVscodeUrl(connection: SshConnection, options: { cwd?: string }
|
|
|
372
217
|
return `vscode://vscode-remote/ssh-remote+${encodeURIComponent(vscodeAuthorityForSsh(connection))}${options.cwd ?? ""}?windowId=_blank`;
|
|
373
218
|
}
|
|
374
219
|
|
|
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 {
|
|
220
|
+
export function buildInteractiveSshCommand(
|
|
221
|
+
connection: SshConnection,
|
|
222
|
+
remoteCommand: string | undefined,
|
|
223
|
+
options: { keepOpenAfterCommand?: boolean } = {},
|
|
224
|
+
): string {
|
|
460
225
|
if (connection.auth.type === "privateKey") {
|
|
461
226
|
return connection.command;
|
|
462
227
|
}
|
|
463
228
|
|
|
229
|
+
const command = remoteCommand && options.keepOpenAfterCommand
|
|
230
|
+
? keepOpenAfterCommand(remoteCommand)
|
|
231
|
+
: remoteCommand;
|
|
464
232
|
const destination = `${connection.username}:${connection.auth.token}@${connection.host}`;
|
|
465
233
|
const args = ["ssh"];
|
|
466
|
-
if (
|
|
234
|
+
if (command) args.push("-tt", "-q");
|
|
467
235
|
if (connection.port !== undefined) args.push("-p", String(connection.port));
|
|
468
236
|
args.push(destination);
|
|
237
|
+
if (command) args.push(command);
|
|
469
238
|
return args.map((arg) => arg === "ssh" || arg.startsWith("-") ? arg : shellQuote(arg)).join(" ");
|
|
470
239
|
}
|
|
471
240
|
|
|
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}`;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function parseSizeGb(value: string | number | undefined): number | undefined {
|
|
491
|
-
if (value === undefined) return undefined;
|
|
492
|
-
if (typeof value === "number") return value;
|
|
493
|
-
const trimmed = value.trim().toLowerCase();
|
|
494
|
-
if (trimmed.endsWith("gib")) return Number(trimmed.slice(0, -3));
|
|
495
|
-
if (trimmed.endsWith("gb")) return Number(trimmed.slice(0, -2));
|
|
496
|
-
return Number(trimmed);
|
|
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"))}`;
|
|
241
|
+
function keepOpenAfterCommand(command: string): string {
|
|
242
|
+
return [
|
|
243
|
+
command,
|
|
244
|
+
"status=$?",
|
|
245
|
+
'if [ "$status" -ne 0 ]; then exit "$status"; fi',
|
|
246
|
+
`printf '\\nCommand completed. Type exit to continue.\\n'`,
|
|
247
|
+
'exec "${SHELL:-/bin/bash}" -l',
|
|
248
|
+
].join("\n");
|
|
519
249
|
}
|
|
520
250
|
|
|
521
251
|
function shellQuote(value: string): string {
|
|
522
252
|
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
523
253
|
}
|
|
524
|
-
|
|
525
|
-
function isPermissionAlreadyExistsError(error: unknown): boolean {
|
|
526
|
-
return error instanceof Error && error.name === "PermissionAlreadyExistsError";
|
|
527
|
-
}
|
|
@@ -22,8 +22,8 @@ describe("Freestyle terminal session", () => {
|
|
|
22
22
|
expect(html).toContain("@wterm/dom");
|
|
23
23
|
expect(html).toContain("@wterm/ghostty");
|
|
24
24
|
expect(html).toContain("terminal-window");
|
|
25
|
-
expect(html).toContain("
|
|
26
|
-
expect(html).toContain("
|
|
25
|
+
expect(html).toContain("freestyle.sh");
|
|
26
|
+
expect(html).toContain("Complete task");
|
|
27
27
|
expect(html).toContain("document.addEventListener(\"keydown\"");
|
|
28
28
|
expect(html).toContain("{ capture: true }");
|
|
29
29
|
expect(html).toContain("terminalEl.contains(target)");
|
|
@@ -102,6 +102,46 @@ describe("Freestyle terminal session", () => {
|
|
|
102
102
|
}
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
+
test("can allow finishing while the terminal process is still running", async () => {
|
|
106
|
+
const session = createFreestyleTerminalSession({
|
|
107
|
+
nodePath: "login",
|
|
108
|
+
title: "Keep-open command",
|
|
109
|
+
command: "sleep 5",
|
|
110
|
+
displayCommand: "sleep 5",
|
|
111
|
+
canFinishWhileRunning: true,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
let resolved = false;
|
|
115
|
+
session.completed.then(() => {
|
|
116
|
+
resolved = true;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const messages: unknown[] = [];
|
|
121
|
+
const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
|
|
122
|
+
socketUrl.protocol = "ws:";
|
|
123
|
+
const socket = new WebSocket(socketUrl);
|
|
124
|
+
socket.addEventListener("message", (event) => {
|
|
125
|
+
messages.push(JSON.parse(String(event.data)));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await waitForSocketOpen(socket);
|
|
129
|
+
await waitFor(() =>
|
|
130
|
+
messages.some((message) =>
|
|
131
|
+
isMessage(message, "status") && Boolean(message.canFinish)
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
135
|
+
expect(resolved).toBe(false);
|
|
136
|
+
|
|
137
|
+
socket.send(JSON.stringify({ type: "finish" }));
|
|
138
|
+
await expect(session.completed).resolves.toEqual({ finished: true });
|
|
139
|
+
socket.close();
|
|
140
|
+
} finally {
|
|
141
|
+
session.stop();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
105
145
|
test("answers cursor position reports for terminal UI prompts", async () => {
|
|
106
146
|
const session = createFreestyleTerminalSession({
|
|
107
147
|
nodePath: "prompt",
|