@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/src/provider.ts CHANGED
@@ -1,47 +1,20 @@
1
- import { Freestyle, VmBaseImage } from "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 FreestyleVmConfig = {
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 FreestyleVmRuntime = {
37
- readonly vmId: string;
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
- > & SshOptions;
25
+ > & FreestyleSshInput;
53
26
 
54
- export type FreestyleVscodeUrlOptions = SshOptions & {
27
+ export type FreestyleVscodeUrlOptions = FreestyleSshInput & {
55
28
  cwd?: string;
56
29
  };
57
30
 
58
31
  export type FreestyleRuntime = {
59
- vms: {
60
- create(): Promise<FreestyleVmRuntime>;
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
- target: FreestyleVmRuntime;
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 createFreestyleProvider(input: {
91
- apiKey: string;
54
+ export function createFreestyleWorkflowProvider(input: {
55
+ client: Freestyle;
92
56
  identityId: FreestyleIdentityId;
93
57
  token: FreestyleToken;
94
- vm: FreestyleVmConfig;
95
- }): BaseDevMachineProvider {
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 createFreestyleWorkflowProvider(input: {
100
- apiKey: string;
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(context) {
114
- return createFreestyleRuntime(provider, context);
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 terminal = await options.target.ssh();
129
- const command = buildInteractiveSshCommand(terminal, options.command);
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
- remoteCommand: options.command,
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
- class FreestyleProvider implements BaseDevMachineProvider {
145
- readonly providerId = FREESTYLE_PROVIDER_ID;
146
- private readonly client: Freestyle;
147
- private readonly identityId: FreestyleIdentityId;
148
- private readonly token: FreestyleToken;
149
- private readonly vmConfig: FreestyleVmConfig;
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: vm.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: vm.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 vms: FreestyleRuntime["vms"] = {
286
- create: async () => {
287
- const vm = await provider.createVm();
288
- context.emit({ type: "vm.created", providerId: provider.providerId, vmId: vm.vmId });
289
- return fromHandle(vm);
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
- fromSnapshot: async (ref) => {
292
- const vm = await provider.createVmFromSnapshot({ snapshotId: ref.snapshotId });
293
- context.emit({
294
- type: "vm.created",
295
- providerId: provider.providerId,
296
- vmId: vm.vmId,
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
- fromId: (vmId) => fromHandle({ vmId }),
302
- delete: async (vmId) => {
303
- await provider.deleteVm({ vmId });
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
- const resolveVm = async (
308
- target: FreestyleVmRuntime | FreestyleVmSnapshotRef,
309
- ): Promise<FreestyleVmRuntime> => isFreestyleVmSnapshotRef(target) ? await vms.fromSnapshot(target) : target;
138
+ return runtime;
139
+ }
310
140
 
311
- const vscode: FreestyleRuntime["vscode"] = {
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
- vms,
322
- cmux: {
323
- createSshOptions: async (target, options) => {
324
- const vm = await resolveVm(target);
325
- const { user, ...sshOptions } = options ?? {};
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 SshOptions> | undefined,
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 createVmRuntime(
376
- provider: BaseDevMachineProvider,
377
- vm: VmHandle,
378
- context: ProviderRuntimeContext,
379
- ): FreestyleVmRuntime {
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 (remoteCommand) args.push("-tt", "-q");
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 commandFailureMessage(name: string, result: { exitCode: number; stdout: string; stderr: string }): string {
473
- const output = [
474
- result.stdout ? `stdout:\n${result.stdout.trimEnd()}` : "",
475
- result.stderr ? `stderr:\n${result.stderr.trimEnd()}` : "",
476
- ].filter(Boolean).join("\n");
477
- return `Command "${name}" failed with exit code ${result.exitCode}${output ? `\n${output}` : ""}`;
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 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"))}`;
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
- }