@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/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
- });
16
-
17
- test("allows callers to override HOME explicitly", () => {
18
- const wrapped = wrapCommand("pwd", {
19
- env: { HOME: "/workspace/home" },
20
- });
21
-
22
- expect(wrapped).toContain("export HOME=${HOME:-/root}");
23
- expect(wrapped).toContain("export HOME='\\''/workspace/home'\\''");
24
- });
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
+ });
25
16
 
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
- },
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",
39
37
  });
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,151 @@ 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;
114
120
 
115
- class StreamingProvider implements BaseDevMachineProvider {
116
- readonly providerId = "freestyle";
121
+ const runtime = await createFreestyleWorkflowController({
122
+ client: new Freestyle({ apiKey: "test-key" }),
123
+ identityId: freestyleIdentityId("identity-stream"),
124
+ token: freestyleToken("token"),
125
+ }).runtime(providerContext());
117
126
 
118
- async createVm(): Promise<VmHandle> {
119
- return { vmId: "vm-stream" };
120
- }
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
+ });
132
+
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
+ });
121
146
 
122
- async createVmFromSnapshot(): Promise<VmHandle> {
123
- return { vmId: "vm-stream" };
124
- }
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 });
125
157
 
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
- }
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
+ });
130
162
 
131
- async readFile(): Promise<string> {
132
- return "";
133
- }
163
+ test("can keep an SSH terminal open after a successful remote command", () => {
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 status -h github.com",
173
+ { keepOpenAfterCommand: true },
174
+ );
134
175
 
135
- async writeFile(): Promise<void> {}
176
+ expect(command).toContain("gh auth status -h github.com");
177
+ expect(command).toContain("status=$?");
178
+ expect(command).toContain('if [ "$status" -ne 0 ]; then exit "$status"; fi');
179
+ expect(command).toContain('exec "${SHELL:-/bin/bash}" -l');
180
+ });
136
181
 
137
- async snapshot(): Promise<{ snapshotId: string; sourceVmId: string }> {
138
- return { snapshotId: "snap-stream", sourceVmId: "vm-stream" };
139
- }
182
+ test("allows finishing while a keep-open SSH command is running", async () => {
183
+ let html = "";
184
+ const runtime = await createFreestyleTerminalController().runtime({
185
+ ...providerContext(),
186
+ interaction: {
187
+ present: async <Result>(session: ProviderInteractionSession<Result>) => {
188
+ const response = await fetch(session.url);
189
+ html = await response.text();
190
+ session.stop();
191
+ return { finished: true } as Result;
192
+ },
193
+ },
194
+ });
140
195
 
141
- async ssh(): Promise<SshConnection> {
142
- return sshConnection;
143
- }
196
+ await runtime.open("GitHub auth", {
197
+ ssh: {
198
+ kind: "ssh",
199
+ host: "vm-ssh.freestyle.sh",
200
+ username: "vm-stream+root",
201
+ auth: { type: "token", token: "token" },
202
+ command: "ssh vm-stream+root:token@vm-ssh.freestyle.sh",
203
+ },
204
+ command: "gh auth login --hostname github.com",
205
+ keepOpenAfterCommand: true,
206
+ });
144
207
 
145
- async deleteVm(): Promise<void> {}
146
- }
208
+ expect(html).toContain("const canFinishWhileRunning = true;");
209
+ });
210
+ });
147
211
 
148
- function providerContext(
149
- events: WorkflowEvent[],
150
- local: Partial<ProviderRuntimeContext["local"]> = {},
151
- ): ProviderRuntimeContext {
212
+ function providerContext(): ProviderRuntimeContext {
152
213
  return {
153
214
  workflow: "workflow",
154
215
  nodePath: "workflow.step",
155
- emit: (event) => {
156
- events.push(event);
157
- },
216
+ emit: () => {},
158
217
  interaction: {
159
218
  present: async () => {
160
219
  throw new Error("unexpected interaction");
@@ -162,7 +221,6 @@ function providerContext(
162
221
  },
163
222
  local: {
164
223
  open: async () => {},
165
- ...local,
166
224
  },
167
225
  metadata: () => {},
168
226
  };