@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/index.ts CHANGED
@@ -3,30 +3,37 @@ import {
3
3
  type WorkflowProviderDefinition,
4
4
  } from "@rigkit/sdk";
5
5
  import type { BaseProviderPlugin } from "@rigkit/engine";
6
- import type { WorkflowProviderController } from "@rigkit/engine";
7
- import { Freestyle } from "freestyle";
8
6
  import * as z from "zod/v4-mini";
9
7
  import { freestyleIdentityId, freestyleToken, freestyleTokenId } from "./auth.ts";
8
+ import {
9
+ createFreestyleAuthenticatedClient,
10
+ createFreestyleProxyFetch,
11
+ type FreestyleProviderAuthConfig,
12
+ } from "./host-auth.ts";
10
13
  import {
11
14
  FREESTYLE_PROVIDER_ID,
12
15
  FREESTYLE_TERMINAL_PROVIDER_ID,
13
16
  createFreestyleTerminalController,
14
17
  createFreestyleWorkflowProvider,
15
- isFreestyleVmSnapshotRef,
16
18
  } from "./provider.ts";
17
19
  import type { FreestyleRuntime, FreestyleTerminalRuntime } from "./provider.ts";
18
- import { createFreestyleStore } from "./store.ts";
19
20
 
20
21
  const freestyleProviderConfigSchema = z.object({
21
- apiKey: z.string().check(z.minLength(1)),
22
- image: z.string().check(z.minLength(1)),
23
- cpu: z.optional(z.number()),
24
- memory: z.optional(z.union([z.string(), z.number()])),
25
- disk: z.optional(z.union([z.string(), z.number()])),
26
- idleTimeoutSeconds: z.optional(z.nullable(z.number())),
22
+ auth: z.optional(z.object({
23
+ apiKey: z.optional(z.string().check(z.minLength(1))),
24
+ profile: z.optional(z.string().check(z.minLength(1))),
25
+ teamId: z.optional(z.string().check(z.minLength(1))),
26
+ apiUrl: z.optional(z.string().check(z.minLength(1))),
27
+ dashboardUrl: z.optional(z.string().check(z.minLength(1))),
28
+ stackApiUrl: z.optional(z.string().check(z.minLength(1))),
29
+ stackAppUrl: z.optional(z.string().check(z.minLength(1))),
30
+ stackProjectId: z.optional(z.string().check(z.minLength(1))),
31
+ stackPublishableClientKey: z.optional(z.string().check(z.minLength(1))),
32
+ })),
27
33
  });
28
34
 
29
35
  export type FreestyleProviderConfig = z.output<typeof freestyleProviderConfigSchema>;
36
+ export type { FreestyleProviderAuthConfig };
30
37
 
31
38
  export type FreestyleProviderDefinition = WorkflowProviderDefinition<
32
39
  typeof FREESTYLE_PROVIDER_ID,
@@ -41,7 +48,7 @@ export type FreestyleTerminalProviderDefinition = WorkflowProviderDefinition<
41
48
  >;
42
49
 
43
50
  export function provider(
44
- config: FreestyleProviderDefinition["config"],
51
+ config: FreestyleProviderDefinition["config"] = {},
45
52
  ): FreestyleProviderDefinition {
46
53
  return defineProvider(FREESTYLE_PROVIDER_ID, config, freestyleProviderPlugin);
47
54
  }
@@ -59,50 +66,18 @@ export const defineFreestyleProvider = provider;
59
66
 
60
67
  export const freestyleProviderPlugin: BaseProviderPlugin = {
61
68
  providerId: FREESTYLE_PROVIDER_ID,
62
- createProvider({ provider, storage }) {
69
+ async createProvider({ provider, hostStorage, local }) {
63
70
  const config = parseFreestyleProviderConfig(provider.config);
64
- const { apiKey, ...vm } = config;
65
- let controller: Promise<WorkflowProviderController<FreestyleRuntime>> | undefined;
66
-
67
- const load = async () => {
68
- controller ??= create();
69
- return await controller;
70
- };
71
-
72
- const create = async () => {
73
- const store = createFreestyleStore(storage);
74
- const savedIdentity = store.getIdentity();
75
- if (savedIdentity) {
76
- return createFreestyleWorkflowProvider({
77
- apiKey,
78
- identityId: savedIdentity.identityId,
79
- token: savedIdentity.token,
80
- vm,
81
- });
82
- }
83
-
84
- const client = new Freestyle({ apiKey });
85
- const { identity, identityId } = await client.identities.create();
86
- const { token, tokenId } = await identity.tokens.create();
87
- const createdIdentity = store.saveIdentity({
88
- identityId: freestyleIdentityId(identityId),
89
- tokenId: freestyleTokenId(tokenId),
90
- token: freestyleToken(token),
91
- });
92
-
93
- return createFreestyleWorkflowProvider({
94
- apiKey,
95
- identityId: createdIdentity.identityId,
96
- token: createdIdentity.token,
97
- vm,
98
- });
99
- };
100
-
101
- return {
102
- providerId: FREESTYLE_PROVIDER_ID,
103
- runtime: async (context) => await (await load()).runtime(context),
104
- validateArtifact: (ref) => isFreestyleVmSnapshotRef(ref),
105
- } satisfies WorkflowProviderController<FreestyleRuntime>;
71
+ const authenticated = await createFreestyleAuthenticatedClient({
72
+ auth: config.auth,
73
+ hostStorage,
74
+ local,
75
+ });
76
+ return createFreestyleWorkflowProvider({
77
+ client: authenticated.client,
78
+ identityId: authenticated.identityId,
79
+ token: authenticated.token,
80
+ });
106
81
  },
107
82
  };
108
83
 
@@ -113,6 +88,10 @@ export const freestyleTerminalPlugin: BaseProviderPlugin = {
113
88
  },
114
89
  };
115
90
 
91
+ export {
92
+ createFreestyleAuthenticatedClient,
93
+ createFreestyleProxyFetch,
94
+ } from "./host-auth.ts";
116
95
  export {
117
96
  freestyleIdentityId,
118
97
  freestyleToken,
@@ -124,24 +103,23 @@ export {
124
103
  export {
125
104
  FREESTYLE_PROVIDER_ID,
126
105
  FREESTYLE_TERMINAL_PROVIDER_ID,
127
- createFreestyleProvider,
128
106
  createFreestyleTerminalController,
129
107
  createFreestyleWorkflowController,
130
108
  createFreestyleWorkflowProvider,
131
- isFreestyleVmSnapshotRef,
132
109
  } from "./provider.ts";
133
110
  export { createFreestyleStore } from "./store.ts";
134
111
  export { createFreestyleTerminalSession } from "./terminal-session.ts";
135
112
  export { RIGKIT_PROVIDER_FREESTYLE_VERSION } from "./version.ts";
113
+ export { Freestyle, VmBaseImage, VmSpec, VmWith, VmWithInstance } from "freestyle";
114
+ export type { CreateVmOptions } from "freestyle";
136
115
  export type {
137
116
  FreestyleCmuxSshOptions,
138
117
  FreestyleCmuxSshOptionsInput,
139
118
  FreestyleRuntime,
119
+ FreestyleSdkVm,
120
+ FreestyleSshInput,
140
121
  FreestyleTerminalRuntime,
141
122
  FreestyleVscodeUrlOptions,
142
- FreestyleVmConfig,
143
- FreestyleVmRuntime,
144
- FreestyleVmSnapshotRef,
145
123
  } from "./provider.ts";
146
124
  export type { FreestyleGitRelationship, FreestyleIdentity } from "./store.ts";
147
125
 
@@ -1,82 +1,61 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { ExecOptions, ExecOutputChunk, ExecResult } from "@rigkit/sdk";
3
- import type {
4
- BaseDevMachineProvider,
5
- ProviderRuntimeContext,
6
- SshConnection,
7
- VmHandle,
8
- WorkflowEvent,
9
- } from "@rigkit/engine";
10
- import { createFreestyleWorkflowController, wrapCommand } from "./provider.ts";
11
-
12
- describe("Freestyle provider command wrapper", () => {
13
- test("sets a root HOME fallback for exec commands", () => {
14
- expect(wrapCommand("printf '%s\n' \"$HOME\"")).toContain("export HOME=${HOME:-/root}");
15
- });
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { Freestyle } from "freestyle";
3
+ import type { ProviderInteractionSession, ProviderRuntimeContext } from "@rigkit/engine";
4
+ import { freestyleIdentityId, freestyleToken } from "./auth.ts";
5
+ import {
6
+ buildInteractiveSshCommand,
7
+ createFreestyleTerminalController,
8
+ createFreestyleWorkflowController,
9
+ } from "./provider.ts";
10
+
11
+ const previousFetch = globalThis.fetch;
12
+
13
+ afterEach(() => {
14
+ globalThis.fetch = previousFetch;
15
+ });
16
16
 
17
- test("allows callers to override HOME explicitly", () => {
18
- const wrapped = wrapCommand("pwd", {
19
- env: { HOME: "/workspace/home" },
17
+ describe("Freestyle provider host adapters", () => {
18
+ test("creates SSH options and grants VM access internally", async () => {
19
+ const requests: string[] = [];
20
+ globalThis.fetch = (async (resource, init) => {
21
+ requests.push(`${init?.method ?? "GET"} ${String(resource)}`);
22
+ return Response.json({});
23
+ }) as typeof fetch;
24
+
25
+ const runtime = await createFreestyleWorkflowController({
26
+ client: new Freestyle({ apiKey: "test-key" }),
27
+ identityId: freestyleIdentityId("identity-stream"),
28
+ token: freestyleToken("token"),
29
+ }).runtime(providerContext());
30
+
31
+ await expect(runtime.createSSHOptions({ vmId: "vm-stream" })).resolves.toEqual({
32
+ kind: "ssh",
33
+ host: "vm-ssh.freestyle.sh",
34
+ username: "vm-stream+root",
35
+ auth: { type: "token", token: "token" },
36
+ command: "ssh vm-stream+root:token@vm-ssh.freestyle.sh",
20
37
  });
21
-
22
- expect(wrapped).toContain("export HOME=${HOME:-/root}");
23
- expect(wrapped).toContain("export HOME='\\''/workspace/home'\\''");
24
- });
25
-
26
- test("streams VM command output through run events without replaying buffered output", async () => {
27
- const chunks: ExecOutputChunk[] = [];
28
- const events: WorkflowEvent[] = [];
29
- const provider = new StreamingProvider();
30
- const controller = createFreestyleWorkflowController(provider);
31
- const runtime = await controller.runtime(providerContext(events));
32
- const vm = runtime.vms.fromId("vm-stream");
33
-
34
- const result = await vm.exec("printf ready", {
35
- name: "stream command",
36
- onOutput: (chunk) => {
37
- chunks.push(chunk);
38
- },
39
- });
40
-
41
- expect(result.stdout).toBe("ready\n");
42
- expect(chunks).toEqual([{ stream: "stdout", data: "ready\n" }]);
43
- expect(events).toEqual([
44
- {
45
- type: "command.started",
46
- nodePath: "workflow.step",
47
- commandName: "stream command",
48
- command: "printf ready",
49
- },
50
- {
51
- type: "command.output",
52
- nodePath: "workflow.step",
53
- commandName: "stream command",
54
- stream: "stdout",
55
- data: "ready\n",
56
- },
57
- {
58
- type: "command.completed",
59
- nodePath: "workflow.step",
60
- commandName: "stream command",
61
- exitCode: 0,
62
- },
63
- ]);
38
+ expect(requests).toContain("POST https://api.freestyle.sh/identity/v1/identities/identity-stream/permissions/vm/vm-stream");
64
39
  });
65
40
 
66
41
  test("creates cmux ssh options with Freestyle-owned ssh settings", async () => {
67
- const provider = new StreamingProvider();
68
- const controller = createFreestyleWorkflowController(provider);
69
- const runtime = await controller.runtime(providerContext([]));
70
- const vm = runtime.vms.fromId("vm-stream");
42
+ globalThis.fetch = (async () => Response.json({})) as unknown as typeof fetch;
71
43
 
72
- const ssh = await runtime.cmux.createSshOptions(vm, {
44
+ const runtime = await createFreestyleWorkflowController({
45
+ client: new Freestyle({ apiKey: "test-key" }),
46
+ identityId: freestyleIdentityId("identity-stream"),
47
+ token: freestyleToken("token"),
48
+ }).runtime(providerContext());
49
+
50
+ const ssh = await runtime.cmux.createSshOptions({
51
+ vmId: "vm-stream",
73
52
  sshOptions: ["ServerAliveInterval=15"],
74
53
  skipDaemonBootstrap: true,
75
54
  });
76
55
 
77
56
  expect(ssh).toEqual({
78
57
  kind: "ssh",
79
- destination: "root,token@localhost",
58
+ destination: "vm-stream+root,token@vm-ssh.freestyle.sh",
80
59
  skipDaemonBootstrap: true,
81
60
  sshOptions: [
82
61
  "StrictHostKeyChecking=no",
@@ -90,71 +69,168 @@ describe("Freestyle provider command wrapper", () => {
90
69
  });
91
70
  });
92
71
 
72
+ test("treats existing VM permissions as idempotent for cmux ssh options", async () => {
73
+ const calls: string[] = [];
74
+ const runtime = await createFreestyleWorkflowController({
75
+ client: {
76
+ identities: {
77
+ ref: () => ({
78
+ permissions: {
79
+ vms: {
80
+ grant: async () => {
81
+ calls.push("grant");
82
+ throw new Error("PERMISSION_ALREADY_EXISTS: Permission already exists");
83
+ },
84
+ update: async () => {
85
+ calls.push("update");
86
+ },
87
+ },
88
+ },
89
+ }),
90
+ },
91
+ } as unknown as Freestyle,
92
+ identityId: freestyleIdentityId("identity-stream"),
93
+ token: freestyleToken("token"),
94
+ }).runtime(providerContext());
95
+
96
+ await expect(runtime.cmux.createSshOptions({ vmId: "vm-stream" })).resolves.toMatchObject({
97
+ destination: "vm-stream+root,token@vm-ssh.freestyle.sh",
98
+ });
99
+ expect(calls).toEqual(["grant", "update"]);
100
+ });
101
+
93
102
  test("creates VS Code URLs using the Freestyle ssh authority", async () => {
94
- const provider = new StreamingProvider();
95
- const controller = createFreestyleWorkflowController(provider);
96
- const runtime = await controller.runtime(providerContext([]));
97
- const vm = runtime.vms.fromId("vm-stream");
103
+ globalThis.fetch = (async () => Response.json({})) as unknown as typeof fetch;
104
+
105
+ const runtime = await createFreestyleWorkflowController({
106
+ client: new Freestyle({ apiKey: "test-key" }),
107
+ identityId: freestyleIdentityId("identity-stream"),
108
+ token: freestyleToken("token"),
109
+ }).runtime(providerContext());
98
110
 
99
- const url = await runtime.vscode.createUrl(vm, { cwd: "/workspace/site" });
111
+ const url = await runtime.vscode.createUrl({ vmId: "vm-stream", cwd: "/workspace/site" });
100
112
 
101
113
  expect(url).toBe(
102
- "vscode://vscode-remote/ssh-remote+root%3Atoken%40localhost/workspace/site?windowId=_blank",
114
+ "vscode://vscode-remote/ssh-remote+vm-stream%2Broot%3Atoken%40vm-ssh.freestyle.sh/workspace/site?windowId=_blank",
103
115
  );
104
116
  });
105
- });
106
117
 
107
- const sshConnection: SshConnection = {
108
- kind: "ssh",
109
- host: "localhost",
110
- username: "root",
111
- auth: { type: "token", token: "token" },
112
- command: "ssh vm-stream",
113
- };
118
+ test("honors explicit SSH users", async () => {
119
+ globalThis.fetch = (async () => Response.json({})) as unknown as typeof fetch;
120
+
121
+ const runtime = await createFreestyleWorkflowController({
122
+ client: new Freestyle({ apiKey: "test-key" }),
123
+ identityId: freestyleIdentityId("identity-stream"),
124
+ token: freestyleToken("token"),
125
+ }).runtime(providerContext());
126
+
127
+ await expect(runtime.createSSHOptions({ vmId: "vm-stream", user: "ubuntu" })).resolves.toMatchObject({
128
+ username: "vm-stream+ubuntu",
129
+ command: "ssh vm-stream+ubuntu:token@vm-ssh.freestyle.sh",
130
+ });
131
+ });
114
132
 
115
- class StreamingProvider implements BaseDevMachineProvider {
116
- readonly providerId = "freestyle";
133
+ test("runs terminal commands as SSH remote commands instead of typed startup input", async () => {
134
+ let html = "";
135
+ const runtime = await createFreestyleTerminalController().runtime({
136
+ ...providerContext(),
137
+ interaction: {
138
+ present: async <Result>(session: ProviderInteractionSession<Result>) => {
139
+ const response = await fetch(session.url);
140
+ html = await response.text();
141
+ session.stop();
142
+ return { finished: true } as Result;
143
+ },
144
+ },
145
+ });
117
146
 
118
- async createVm(): Promise<VmHandle> {
119
- return { vmId: "vm-stream" };
120
- }
147
+ await expect(runtime.open("GitHub auth", {
148
+ ssh: {
149
+ kind: "ssh",
150
+ host: "vm-ssh.freestyle.sh",
151
+ username: "vm-stream+root",
152
+ auth: { type: "token", token: "token" },
153
+ command: "ssh vm-stream+root:token@vm-ssh.freestyle.sh",
154
+ },
155
+ command: "gh auth login --hostname github.com",
156
+ })).resolves.toEqual({ finished: true });
121
157
 
122
- async createVmFromSnapshot(): Promise<VmHandle> {
123
- return { vmId: "vm-stream" };
124
- }
158
+ expect(html).toContain("gh auth login --hostname github.com");
159
+ expect(html).toContain("const startupInput = null;");
160
+ expect(html).toContain("const canFinishWhileRunning = false;");
161
+ });
125
162
 
126
- async exec(_vm: VmHandle, _command: string, options?: ExecOptions): Promise<ExecResult> {
127
- await options?.onOutput?.({ stream: "stdout", data: "ready\n" });
128
- return { stdout: "ready\n", stderr: "", exitCode: 0, ok: true };
129
- }
163
+ test("sets remote browser open fallbacks for SSH terminal commands", () => {
164
+ const command = buildInteractiveSshCommand(
165
+ {
166
+ kind: "ssh",
167
+ host: "vm-ssh.freestyle.sh",
168
+ username: "vm-stream+root",
169
+ auth: { type: "token", token: "token" },
170
+ command: "ssh vm-stream+root:token@vm-ssh.freestyle.sh",
171
+ },
172
+ "gh auth login --hostname github.com --web",
173
+ );
130
174
 
131
- async readFile(): Promise<string> {
132
- return "";
133
- }
175
+ expect(command).toContain('export BROWSER="${BROWSER:-true}"');
176
+ expect(command).toContain('export GH_BROWSER="${GH_BROWSER:-$BROWSER}"');
177
+ expect(command).toContain("gh auth login --hostname github.com --web");
178
+ });
134
179
 
135
- async writeFile(): Promise<void> {}
180
+ test("can keep an SSH terminal open after a successful remote command", () => {
181
+ const command = buildInteractiveSshCommand(
182
+ {
183
+ kind: "ssh",
184
+ host: "vm-ssh.freestyle.sh",
185
+ username: "vm-stream+root",
186
+ auth: { type: "token", token: "token" },
187
+ command: "ssh vm-stream+root:token@vm-ssh.freestyle.sh",
188
+ },
189
+ "gh auth status -h github.com",
190
+ { keepOpenAfterCommand: true },
191
+ );
136
192
 
137
- async snapshot(): Promise<{ snapshotId: string; sourceVmId: string }> {
138
- return { snapshotId: "snap-stream", sourceVmId: "vm-stream" };
139
- }
193
+ expect(command).toContain("gh auth status -h github.com");
194
+ expect(command).toContain("status=$?");
195
+ expect(command).toContain('if [ "$status" -ne 0 ]; then exit "$status"; fi');
196
+ expect(command).toContain('exec "${SHELL:-/bin/bash}" -l');
197
+ });
140
198
 
141
- async ssh(): Promise<SshConnection> {
142
- return sshConnection;
143
- }
199
+ test("allows finishing while a keep-open SSH command is running", async () => {
200
+ let html = "";
201
+ const runtime = await createFreestyleTerminalController().runtime({
202
+ ...providerContext(),
203
+ interaction: {
204
+ present: async <Result>(session: ProviderInteractionSession<Result>) => {
205
+ const response = await fetch(session.url);
206
+ html = await response.text();
207
+ session.stop();
208
+ return { finished: true } as Result;
209
+ },
210
+ },
211
+ });
144
212
 
145
- async deleteVm(): Promise<void> {}
146
- }
213
+ await runtime.open("GitHub auth", {
214
+ ssh: {
215
+ kind: "ssh",
216
+ host: "vm-ssh.freestyle.sh",
217
+ username: "vm-stream+root",
218
+ auth: { type: "token", token: "token" },
219
+ command: "ssh vm-stream+root:token@vm-ssh.freestyle.sh",
220
+ },
221
+ command: "gh auth login --hostname github.com",
222
+ keepOpenAfterCommand: true,
223
+ });
147
224
 
148
- function providerContext(
149
- events: WorkflowEvent[],
150
- local: Partial<ProviderRuntimeContext["local"]> = {},
151
- ): ProviderRuntimeContext {
225
+ expect(html).toContain("const canFinishWhileRunning = true;");
226
+ });
227
+ });
228
+
229
+ function providerContext(): ProviderRuntimeContext {
152
230
  return {
153
231
  workflow: "workflow",
154
232
  nodePath: "workflow.step",
155
- emit: (event) => {
156
- events.push(event);
157
- },
233
+ emit: () => {},
158
234
  interaction: {
159
235
  present: async () => {
160
236
  throw new Error("unexpected interaction");
@@ -162,7 +238,6 @@ function providerContext(
162
238
  },
163
239
  local: {
164
240
  open: async () => {},
165
- ...local,
166
241
  },
167
242
  metadata: () => {},
168
243
  };