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