@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/index.ts CHANGED
@@ -3,36 +3,42 @@ 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
- import type { FreestyleRuntime, FreestyleTerminalRuntime, FreestyleWorkspaceContext } from "./provider.ts";
18
- import { createFreestyleStore } from "./store.ts";
19
+ import type { FreestyleRuntime, FreestyleTerminalRuntime } from "./provider.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,
33
40
  FreestyleProviderConfig,
34
- FreestyleRuntime,
35
- FreestyleWorkspaceContext
41
+ FreestyleRuntime
36
42
  >;
37
43
 
38
44
  export type FreestyleTerminalProviderDefinition = WorkflowProviderDefinition<
@@ -42,7 +48,7 @@ export type FreestyleTerminalProviderDefinition = WorkflowProviderDefinition<
42
48
  >;
43
49
 
44
50
  export function provider(
45
- config: FreestyleProviderDefinition["config"],
51
+ config: FreestyleProviderDefinition["config"] = {},
46
52
  ): FreestyleProviderDefinition {
47
53
  return defineProvider(FREESTYLE_PROVIDER_ID, config, freestyleProviderPlugin);
48
54
  }
@@ -60,63 +66,18 @@ export const defineFreestyleProvider = provider;
60
66
 
61
67
  export const freestyleProviderPlugin: BaseProviderPlugin = {
62
68
  providerId: FREESTYLE_PROVIDER_ID,
63
- createProvider({ provider, storage }) {
69
+ async createProvider({ provider, hostStorage, local }) {
64
70
  const config = parseFreestyleProviderConfig(provider.config);
65
- const { apiKey, ...vm } = config;
66
- let controller: Promise<WorkflowProviderController<FreestyleRuntime, FreestyleWorkspaceContext>> | undefined;
67
-
68
- const load = async () => {
69
- controller ??= create();
70
- return await controller;
71
- };
72
-
73
- const create = async () => {
74
- const store = createFreestyleStore(storage);
75
- const savedIdentity = store.getIdentity();
76
- if (savedIdentity) {
77
- return createFreestyleWorkflowProvider({
78
- apiKey,
79
- identityId: savedIdentity.identityId,
80
- token: savedIdentity.token,
81
- vm,
82
- });
83
- }
84
-
85
- const client = new Freestyle({ apiKey });
86
- const { identity, identityId } = await client.identities.create();
87
- const { token, tokenId } = await identity.tokens.create();
88
- const createdIdentity = store.saveIdentity({
89
- identityId: freestyleIdentityId(identityId),
90
- tokenId: freestyleTokenId(tokenId),
91
- token: freestyleToken(token),
92
- });
93
-
94
- return createFreestyleWorkflowProvider({
95
- apiKey,
96
- identityId: createdIdentity.identityId,
97
- token: createdIdentity.token,
98
- vm,
99
- });
100
- };
101
-
102
- return {
103
- providerId: FREESTYLE_PROVIDER_ID,
104
- runtime: async (context) => await (await load()).runtime(context),
105
- validateArtifact: (ref) => isFreestyleVmSnapshotRef(ref),
106
- workspace: {
107
- canUse: (ref) => isFreestyleVmSnapshotRef(ref),
108
- createWorkspace: async (sourceRef, input) =>
109
- await (await load()).workspace!.createWorkspace(sourceRef, input),
110
- deleteWorkspace: async (workspace) =>
111
- await (await load()).workspace!.deleteWorkspace(workspace),
112
- snapshotWorkspace: async (workspace) =>
113
- await (await load()).workspace!.snapshotWorkspace(workspace),
114
- ssh: async (workspaceOrResourceId, options) =>
115
- await (await load()).workspace!.ssh(workspaceOrResourceId, options),
116
- workspaceContext: async (workspace) =>
117
- await (await load()).workspace!.workspaceContext!(workspace),
118
- },
119
- } satisfies WorkflowProviderController<FreestyleRuntime, FreestyleWorkspaceContext>;
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
+ });
120
81
  },
121
82
  };
122
83
 
@@ -127,6 +88,10 @@ export const freestyleTerminalPlugin: BaseProviderPlugin = {
127
88
  },
128
89
  };
129
90
 
91
+ export {
92
+ createFreestyleAuthenticatedClient,
93
+ createFreestyleProxyFetch,
94
+ } from "./host-auth.ts";
130
95
  export {
131
96
  freestyleIdentityId,
132
97
  freestyleToken,
@@ -138,22 +103,23 @@ export {
138
103
  export {
139
104
  FREESTYLE_PROVIDER_ID,
140
105
  FREESTYLE_TERMINAL_PROVIDER_ID,
141
- createFreestyleProvider,
142
106
  createFreestyleTerminalController,
143
107
  createFreestyleWorkflowController,
144
108
  createFreestyleWorkflowProvider,
145
- isFreestyleVmSnapshotRef,
146
109
  } from "./provider.ts";
147
110
  export { createFreestyleStore } from "./store.ts";
148
111
  export { createFreestyleTerminalSession } from "./terminal-session.ts";
149
112
  export { RIGKIT_PROVIDER_FREESTYLE_VERSION } from "./version.ts";
113
+ export { Freestyle, VmBaseImage, VmSpec, VmWith, VmWithInstance } from "freestyle";
114
+ export type { CreateVmOptions } from "freestyle";
150
115
  export type {
116
+ FreestyleCmuxSshOptions,
117
+ FreestyleCmuxSshOptionsInput,
151
118
  FreestyleRuntime,
119
+ FreestyleSdkVm,
120
+ FreestyleSshInput,
152
121
  FreestyleTerminalRuntime,
153
- FreestyleVmConfig,
154
- FreestyleVmRuntime,
155
- FreestyleVmSnapshotRef,
156
- FreestyleWorkspaceContext,
122
+ FreestyleVscodeUrlOptions,
157
123
  } from "./provider.ts";
158
124
  export type { FreestyleGitRelationship, FreestyleIdentity } from "./store.ts";
159
125
 
@@ -1,127 +1,219 @@
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
- import type { FreestyleWorkspaceContext } from "./provider.ts";
12
-
13
- describe("Freestyle provider command wrapper", () => {
14
- test("sets a root HOME fallback for exec commands", () => {
15
- expect(wrapCommand("printf '%s\n' \"$HOME\"")).toContain("export HOME=${HOME:-/root}");
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
+
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",
37
+ });
38
+ expect(requests).toContain("POST https://api.freestyle.sh/identity/v1/identities/identity-stream/permissions/vm/vm-stream");
16
39
  });
17
40
 
18
- test("allows callers to override HOME explicitly", () => {
19
- const wrapped = wrapCommand("pwd", {
20
- env: { HOME: "/workspace/home" },
41
+ test("creates cmux ssh options with Freestyle-owned ssh settings", async () => {
42
+ globalThis.fetch = (async () => Response.json({})) as unknown as typeof fetch;
43
+
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",
52
+ sshOptions: ["ServerAliveInterval=15"],
53
+ skipDaemonBootstrap: true,
21
54
  });
22
55
 
23
- expect(wrapped).toContain("export HOME=${HOME:-/root}");
24
- expect(wrapped).toContain("export HOME='\\''/workspace/home'\\''");
56
+ expect(ssh).toEqual({
57
+ kind: "ssh",
58
+ destination: "vm-stream+root,token@vm-ssh.freestyle.sh",
59
+ skipDaemonBootstrap: true,
60
+ sshOptions: [
61
+ "StrictHostKeyChecking=no",
62
+ "UserKnownHostsFile=/dev/null",
63
+ "LogLevel=ERROR",
64
+ "IdentitiesOnly=yes",
65
+ "IdentityFile=/dev/null",
66
+ "ControlMaster=no",
67
+ "ServerAliveInterval=15",
68
+ ],
69
+ });
70
+ });
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
+
102
+ test("creates VS Code URLs using the Freestyle ssh authority", async () => {
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());
110
+
111
+ const url = await runtime.vscode.createUrl({ vmId: "vm-stream", cwd: "/workspace/site" });
112
+
113
+ expect(url).toBe(
114
+ "vscode://vscode-remote/ssh-remote+vm-stream%2Broot%3Atoken%40vm-ssh.freestyle.sh/workspace/site?windowId=_blank",
115
+ );
116
+ });
117
+
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
+ });
25
131
  });
26
132
 
27
- test("streams VM command output through run events without replaying buffered output", async () => {
28
- const chunks: ExecOutputChunk[] = [];
29
- const events: WorkflowEvent[] = [];
30
- const provider = new StreamingProvider();
31
- const controller = createFreestyleWorkflowController(provider);
32
- const runtime = await controller.runtime(providerContext(events));
33
- const vm = runtime.vms.fromWorkspace({ resourceId: "vm-stream" });
34
-
35
- const result = await vm.exec("printf ready", {
36
- name: "stream command",
37
- onOutput: (chunk) => {
38
- chunks.push(chunk);
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
+ },
39
144
  },
40
145
  });
41
146
 
42
- expect(result.stdout).toBe("ready\n");
43
- expect(chunks).toEqual([{ stream: "stdout", data: "ready\n" }]);
44
- expect(events).toEqual([
45
- {
46
- type: "command.started",
47
- nodePath: "workflow.step",
48
- commandName: "stream command",
49
- command: "printf ready",
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",
50
154
  },
155
+ command: "gh auth login --hostname github.com",
156
+ })).resolves.toEqual({ finished: true });
157
+
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
+ });
162
+
163
+ test("can keep an SSH terminal open after a successful remote command", () => {
164
+ const command = buildInteractiveSshCommand(
51
165
  {
52
- type: "command.output",
53
- nodePath: "workflow.step",
54
- commandName: "stream command",
55
- stream: "stdout",
56
- data: "ready\n",
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",
57
171
  },
58
- {
59
- type: "command.completed",
60
- nodePath: "workflow.step",
61
- commandName: "stream command",
62
- exitCode: 0,
172
+ "gh auth status -h github.com",
173
+ { keepOpenAfterCommand: true },
174
+ );
175
+
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
+ });
181
+
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
+ },
63
193
  },
64
- ]);
194
+ });
195
+
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
+ });
207
+
208
+ expect(html).toContain("const canFinishWhileRunning = true;");
65
209
  });
66
210
  });
67
211
 
68
- const sshConnection: SshConnection = {
69
- kind: "ssh",
70
- host: "localhost",
71
- username: "root",
72
- auth: { type: "token", token: "token" },
73
- command: "ssh vm-stream",
74
- };
75
-
76
- class StreamingProvider implements BaseDevMachineProvider<FreestyleWorkspaceContext> {
77
- readonly providerId = "freestyle";
78
-
79
- async createVm(): Promise<VmHandle> {
80
- return { vmId: "vm-stream" };
81
- }
82
-
83
- async createVmFromSnapshot(): Promise<VmHandle> {
84
- return { vmId: "vm-stream" };
85
- }
86
-
87
- async exec(_vm: VmHandle, _command: string, options?: ExecOptions): Promise<ExecResult> {
88
- await options?.onOutput?.({ stream: "stdout", data: "ready\n" });
89
- return { stdout: "ready\n", stderr: "", exitCode: 0, ok: true };
90
- }
91
-
92
- async readFile(): Promise<string> {
93
- return "";
94
- }
95
-
96
- async writeFile(): Promise<void> {}
97
-
98
- async snapshot(): Promise<{ snapshotId: string; sourceVmId: string }> {
99
- return { snapshotId: "snap-stream", sourceVmId: "vm-stream" };
100
- }
101
-
102
- async ssh(): Promise<SshConnection> {
103
- return sshConnection;
104
- }
105
-
106
- async workspaceContext(): Promise<FreestyleWorkspaceContext> {
107
- return {
108
- ssh: sshConnection,
109
- host: sshConnection.host,
110
- username: sshConnection.username,
111
- vscodeAuthority: "root@localhost",
112
- };
113
- }
114
-
115
- async deleteVm(): Promise<void> {}
116
- }
117
-
118
- function providerContext(events: WorkflowEvent[]): ProviderRuntimeContext {
212
+ function providerContext(): ProviderRuntimeContext {
119
213
  return {
120
214
  workflow: "workflow",
121
215
  nodePath: "workflow.step",
122
- emit: (event) => {
123
- events.push(event);
124
- },
216
+ emit: () => {},
125
217
  interaction: {
126
218
  present: async () => {
127
219
  throw new Error("unexpected interaction");