@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/src/provider.ts CHANGED
@@ -1,141 +1,73 @@
1
- import { Freestyle, VmBaseImage } from "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 FreestyleVmConfig = {
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 FreestyleWorkspaceContext = {
29
- ssh: SshConnection;
30
- host: string;
31
- username: string;
32
- vscodeAuthority: string;
16
+ export type FreestyleSshInput = SshOptions & {
17
+ vmId: string;
33
18
  };
34
19
 
35
- export type FreestyleVmSnapshotRef = {
36
- provider: typeof FREESTYLE_PROVIDER_ID;
37
- kind: "vmSnapshot";
38
- snapshotId: string;
39
- sourceVmId?: string;
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 FreestyleVmRuntime = {
43
- readonly vmId: string;
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
- vms: {
55
- create(): Promise<FreestyleVmRuntime>;
56
- fromSnapshot(ref: FreestyleVmSnapshotRef): Promise<FreestyleVmRuntime>;
57
- fromWorkspace(workspace: Pick<WorkspaceRecord, "resourceId">): FreestyleVmRuntime;
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
- target: FreestyleVmRuntime;
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 createFreestyleProvider(input: {
74
- apiKey: string;
54
+ export function createFreestyleWorkflowProvider(input: {
55
+ client: Freestyle;
75
56
  identityId: FreestyleIdentityId;
76
57
  token: FreestyleToken;
77
- vm: FreestyleVmConfig;
78
- }): BaseDevMachineProvider<FreestyleWorkspaceContext> {
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 createFreestyleWorkflowProvider(input: {
83
- apiKey: string;
62
+ export function createFreestyleWorkflowController(input: {
63
+ client: Freestyle;
84
64
  identityId: FreestyleIdentityId;
85
65
  token: FreestyleToken;
86
- vm: FreestyleVmConfig;
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(context) {
97
- return createFreestyleRuntime(provider, context);
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 terminal = await options.target.ssh();
150
- const command = buildInteractiveSshCommand(terminal, options.command);
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
- remoteCommand: options.command,
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
- class FreestyleProvider implements BaseDevMachineProvider<FreestyleWorkspaceContext> {
166
- readonly providerId = FREESTYLE_PROVIDER_ID;
167
- private readonly client: Freestyle;
168
- private readonly identityId: FreestyleIdentityId;
169
- private readonly token: FreestyleToken;
170
- private readonly vmConfig: FreestyleVmConfig;
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: vm.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: vm.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
- return {
302
- vms: {
303
- create: async () => {
304
- const vm = await provider.createVm();
305
- context.emit({ type: "vm.created", providerId: provider.providerId, vmId: vm.vmId });
306
- return fromHandle(vm);
307
- },
308
- fromSnapshot: async (ref) => {
309
- const vm = await provider.createVmFromSnapshot({ snapshotId: ref.snapshotId });
310
- context.emit({
311
- type: "vm.created",
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
- openWorkspace: async (target, options) => {
321
- const vm = isFreestyleVmSnapshotRef(target)
322
- ? await createFreestyleRuntime(provider, context).vms.fromSnapshot(target)
323
- : target;
324
- const workspaceContext = await provider.workspaceContext?.({ vmId: vm.vmId }, {
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
- function createVmRuntime(
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
- vmId: vm.vmId,
383
- exec: async (command, options) => {
384
- const { commandName, result } = await runCommand(command, options);
385
- if (!result.ok) {
386
- throw new Error(commandFailureMessage(commandName, result));
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 snapshotRef(snapshot: SnapshotHandle): FreestyleVmSnapshotRef {
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
- provider: FREESTYLE_PROVIDER_ID,
411
- kind: "vmSnapshot",
412
- snapshotId: snapshot.snapshotId,
413
- sourceVmId: snapshot.sourceVmId,
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
- export function isFreestyleVmSnapshotRef(value: unknown): value is FreestyleVmSnapshotRef {
418
- return Boolean(
419
- value &&
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 shellPath(path: string): string {
428
- if (path.startsWith("~/")) return `~/${shellQuote(path.slice(2))}`;
429
- return shellQuote(path);
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 buildInteractiveSshCommand(connection: SshConnection, remoteCommand: string | undefined): string {
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 (remoteCommand) args.push("-tt", "-q");
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 commandFailureMessage(name: string, result: { exitCode: number; stdout: string; stderr: string }): string {
446
- const output = [
447
- result.stdout ? `stdout:\n${result.stdout.trimEnd()}` : "",
448
- result.stderr ? `stderr:\n${result.stderr.trimEnd()}` : "",
449
- ].filter(Boolean).join("\n");
450
- return `Command "${name}" failed with exit code ${result.exitCode}${output ? `\n${output}` : ""}`;
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
- }