@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/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,12 +78,14 @@ 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,
136
91
  });
@@ -141,196 +96,86 @@ export function createFreestyleTerminalController(): WorkflowProviderController<
141
96
  };
142
97
  }
143
98
 
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 });
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: vm.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: vm.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 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);
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
- 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);
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
- fromId: (vmId) => fromHandle({ vmId }),
302
- delete: async (vmId) => {
303
- await provider.deleteVm({ vmId });
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
- const resolveVm = async (
308
- target: FreestyleVmRuntime | FreestyleVmSnapshotRef,
309
- ): Promise<FreestyleVmRuntime> => isFreestyleVmSnapshotRef(target) ? await vms.fromSnapshot(target) : target;
137
+ return runtime;
138
+ }
310
139
 
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
- };
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
- 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,
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 SshOptions> | undefined,
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 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 {
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 (remoteCommand) args.push("-tt", "-q");
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 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}`;
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("light red");
26
- expect(html).toContain("Finished");
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",