@rigkit/provider-freestyle 0.1.8
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/README.md +10 -0
- package/package.json +36 -0
- package/src/auth.ts +24 -0
- package/src/index.ts +166 -0
- package/src/provider.test.ts +135 -0
- package/src/provider.ts +500 -0
- package/src/store.test.ts +38 -0
- package/src/store.ts +142 -0
- package/src/terminal-session.test.ts +182 -0
- package/src/terminal-session.ts +821 -0
- package/src/version.ts +1 -0
package/src/provider.ts
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { Freestyle, VmBaseImage } from "freestyle";
|
|
2
|
+
import type { CommandOptions, ExecOptions, ExecOutputChunk, ExecResult, JsonValue, WorkspaceRecord } from "@rigkit/sdk";
|
|
3
|
+
import type {
|
|
4
|
+
BaseDevMachineProvider,
|
|
5
|
+
ProviderRuntimeContext,
|
|
6
|
+
SnapshotHandle,
|
|
7
|
+
SshConnection,
|
|
8
|
+
SshOptions,
|
|
9
|
+
VmHandle,
|
|
10
|
+
WorkflowProviderController,
|
|
11
|
+
} from "@rigkit/engine";
|
|
12
|
+
import type { FreestyleIdentityId, FreestyleToken } from "./auth.ts";
|
|
13
|
+
import { createFreestyleTerminalSession } from "./terminal-session.ts";
|
|
14
|
+
|
|
15
|
+
type FreestyleVm = Awaited<ReturnType<Freestyle["vms"]["create"]>>["vm"];
|
|
16
|
+
|
|
17
|
+
export const FREESTYLE_PROVIDER_ID = "freestyle";
|
|
18
|
+
export const FREESTYLE_TERMINAL_PROVIDER_ID = "freestyle-terminal";
|
|
19
|
+
|
|
20
|
+
export type FreestyleVmConfig = {
|
|
21
|
+
image: string;
|
|
22
|
+
cpu?: number;
|
|
23
|
+
memory?: string | number;
|
|
24
|
+
disk?: string | number;
|
|
25
|
+
idleTimeoutSeconds?: number | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type FreestyleWorkspaceContext = {
|
|
29
|
+
ssh: SshConnection;
|
|
30
|
+
host: string;
|
|
31
|
+
username: string;
|
|
32
|
+
vscodeAuthority: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type FreestyleVmSnapshotRef = {
|
|
36
|
+
provider: typeof FREESTYLE_PROVIDER_ID;
|
|
37
|
+
kind: "vmSnapshot";
|
|
38
|
+
snapshotId: string;
|
|
39
|
+
sourceVmId?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type FreestyleVmRuntime = {
|
|
43
|
+
readonly vmId: string;
|
|
44
|
+
exec(command: string, options?: CommandOptions): Promise<ExecResult>;
|
|
45
|
+
probe(command: string, options?: CommandOptions): Promise<ExecResult>;
|
|
46
|
+
exists(path: string): Promise<boolean>;
|
|
47
|
+
readFile(path: string): Promise<string>;
|
|
48
|
+
writeFile(path: string, content: string): Promise<void>;
|
|
49
|
+
snapshotRef(): Promise<FreestyleVmSnapshotRef>;
|
|
50
|
+
ssh(options?: SshOptions): Promise<SshConnection>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type FreestyleRuntime = {
|
|
54
|
+
vms: {
|
|
55
|
+
create(): Promise<FreestyleVmRuntime>;
|
|
56
|
+
fromSnapshot(ref: FreestyleVmSnapshotRef): Promise<FreestyleVmRuntime>;
|
|
57
|
+
fromWorkspace(workspace: Pick<WorkspaceRecord, "resourceId">): FreestyleVmRuntime;
|
|
58
|
+
};
|
|
59
|
+
openWorkspace(target: FreestyleVmRuntime | FreestyleVmSnapshotRef, options?: { cwd?: string }): Promise<void>;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type FreestyleTerminalRuntime = {
|
|
63
|
+
open(
|
|
64
|
+
title: string,
|
|
65
|
+
options: {
|
|
66
|
+
target: FreestyleVmRuntime;
|
|
67
|
+
command?: string;
|
|
68
|
+
instructions?: string;
|
|
69
|
+
},
|
|
70
|
+
): Promise<{ finished: true }>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function createFreestyleProvider(input: {
|
|
74
|
+
apiKey: string;
|
|
75
|
+
identityId: FreestyleIdentityId;
|
|
76
|
+
token: FreestyleToken;
|
|
77
|
+
vm: FreestyleVmConfig;
|
|
78
|
+
}): BaseDevMachineProvider<FreestyleWorkspaceContext> {
|
|
79
|
+
return new FreestyleProvider(input.apiKey, input.identityId, input.token, input.vm);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createFreestyleWorkflowProvider(input: {
|
|
83
|
+
apiKey: string;
|
|
84
|
+
identityId: FreestyleIdentityId;
|
|
85
|
+
token: FreestyleToken;
|
|
86
|
+
vm: FreestyleVmConfig;
|
|
87
|
+
}): WorkflowProviderController<FreestyleRuntime, FreestyleWorkspaceContext> {
|
|
88
|
+
return createFreestyleWorkflowController(createFreestyleProvider(input));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createFreestyleWorkflowController(
|
|
92
|
+
provider: BaseDevMachineProvider<FreestyleWorkspaceContext>,
|
|
93
|
+
): WorkflowProviderController<FreestyleRuntime, FreestyleWorkspaceContext> {
|
|
94
|
+
return {
|
|
95
|
+
providerId: FREESTYLE_PROVIDER_ID,
|
|
96
|
+
runtime(context) {
|
|
97
|
+
return createFreestyleRuntime(provider, context);
|
|
98
|
+
},
|
|
99
|
+
validateArtifact(ref) {
|
|
100
|
+
return isFreestyleVmSnapshotRef(ref);
|
|
101
|
+
},
|
|
102
|
+
workspace: {
|
|
103
|
+
canUse(ref) {
|
|
104
|
+
return isFreestyleVmSnapshotRef(ref);
|
|
105
|
+
},
|
|
106
|
+
async createWorkspace(ref, input) {
|
|
107
|
+
if (!isFreestyleVmSnapshotRef(ref)) {
|
|
108
|
+
throw new Error(`Freestyle cannot create a workspace from this artifact`);
|
|
109
|
+
}
|
|
110
|
+
const vm = await provider.createVmFromSnapshot({ snapshotId: ref.snapshotId });
|
|
111
|
+
return {
|
|
112
|
+
providerId: FREESTYLE_PROVIDER_ID,
|
|
113
|
+
resourceId: vm.vmId,
|
|
114
|
+
snapshotId: ref.snapshotId,
|
|
115
|
+
sourceRef: ref,
|
|
116
|
+
metadata: { name: input.name },
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
async deleteWorkspace(workspace) {
|
|
120
|
+
await provider.deleteVm({ vmId: workspace.resourceId });
|
|
121
|
+
},
|
|
122
|
+
async snapshotWorkspace(workspace) {
|
|
123
|
+
const snapshot = await provider.snapshot({ vmId: workspace.resourceId });
|
|
124
|
+
return {
|
|
125
|
+
providerId: FREESTYLE_PROVIDER_ID,
|
|
126
|
+
resourceId: workspace.resourceId,
|
|
127
|
+
snapshotId: snapshot.snapshotId,
|
|
128
|
+
sourceRef: snapshotRef(snapshot),
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
async ssh(workspaceOrResourceId, options) {
|
|
132
|
+
return await provider.ssh({ vmId: workspaceOrResourceId }, options);
|
|
133
|
+
},
|
|
134
|
+
async workspaceContext(workspace) {
|
|
135
|
+
const context = await provider.workspaceContext?.({ vmId: workspace.resourceId }, { workspace });
|
|
136
|
+
if (!context) throw new Error(`Freestyle provider does not expose workspace context`);
|
|
137
|
+
return context;
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createFreestyleTerminalController(): WorkflowProviderController<FreestyleTerminalRuntime> {
|
|
144
|
+
return {
|
|
145
|
+
providerId: FREESTYLE_TERMINAL_PROVIDER_ID,
|
|
146
|
+
runtime(context) {
|
|
147
|
+
return {
|
|
148
|
+
open: async (title, options) => {
|
|
149
|
+
const terminal = await options.target.ssh();
|
|
150
|
+
const command = buildInteractiveSshCommand(terminal, options.command);
|
|
151
|
+
const session = createFreestyleTerminalSession({
|
|
152
|
+
title,
|
|
153
|
+
command,
|
|
154
|
+
remoteCommand: options.command,
|
|
155
|
+
instructions: options.instructions,
|
|
156
|
+
nodePath: context.nodePath,
|
|
157
|
+
});
|
|
158
|
+
return await context.interaction.present(session);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
class FreestyleProvider implements BaseDevMachineProvider<FreestyleWorkspaceContext> {
|
|
166
|
+
readonly providerId = FREESTYLE_PROVIDER_ID;
|
|
167
|
+
private readonly client: Freestyle;
|
|
168
|
+
private readonly identityId: FreestyleIdentityId;
|
|
169
|
+
private readonly token: FreestyleToken;
|
|
170
|
+
private readonly vmConfig: FreestyleVmConfig;
|
|
171
|
+
|
|
172
|
+
constructor(apiKey: string, identityId: FreestyleIdentityId, token: FreestyleToken, vmConfig: FreestyleVmConfig) {
|
|
173
|
+
this.client = new Freestyle({ apiKey });
|
|
174
|
+
this.identityId = identityId;
|
|
175
|
+
this.token = token;
|
|
176
|
+
this.vmConfig = vmConfig;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async createVm(): Promise<VmHandle> {
|
|
180
|
+
const { vmId } = await this.client.vms.create({
|
|
181
|
+
baseImage: new VmBaseImage(toDockerFrom(this.vmConfig.image)),
|
|
182
|
+
vcpuCount: this.vmConfig.cpu,
|
|
183
|
+
memSizeGb: parseSizeGb(this.vmConfig.memory),
|
|
184
|
+
rootfsSizeGb: parseSizeGb(this.vmConfig.disk),
|
|
185
|
+
idleTimeoutSeconds: this.vmConfig.idleTimeoutSeconds ?? 3600,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const vm = { vmId };
|
|
189
|
+
await this.updateVmPermissions(vm);
|
|
190
|
+
return vm;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async createVmFromSnapshot(input: { snapshotId: string }): Promise<VmHandle> {
|
|
194
|
+
const { vmId } = await this.client.vms.create({
|
|
195
|
+
snapshotId: input.snapshotId,
|
|
196
|
+
idleTimeoutSeconds: this.vmConfig.idleTimeoutSeconds ?? 3600,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const vm = { vmId };
|
|
200
|
+
await this.updateVmPermissions(vm);
|
|
201
|
+
return vm;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async exec(vm: VmHandle, command: string, options?: ExecOptions): Promise<ExecResult> {
|
|
205
|
+
const freestyleVm = this.ref(vm);
|
|
206
|
+
const wrapped = wrapCommand(command, options);
|
|
207
|
+
const result = await freestyleVm.exec({
|
|
208
|
+
command: wrapped,
|
|
209
|
+
timeoutMs: options?.timeoutMs,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const stdout = result.stdout ?? "";
|
|
213
|
+
const stderr = result.stderr ?? "";
|
|
214
|
+
const exitCode = result.statusCode ?? 0;
|
|
215
|
+
|
|
216
|
+
if (stdout) await options?.onOutput?.({ stream: "stdout", data: stdout });
|
|
217
|
+
if (stderr) await options?.onOutput?.({ stream: "stderr", data: stderr });
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
stdout,
|
|
221
|
+
stderr,
|
|
222
|
+
exitCode,
|
|
223
|
+
ok: exitCode === 0,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async readFile(vm: VmHandle, path: string): Promise<string> {
|
|
228
|
+
const result = await this.exec(vm, `cat ${shellQuote(path)}`);
|
|
229
|
+
if (!result.ok) {
|
|
230
|
+
throw new Error(`Failed to read ${path}: ${result.stderr || result.stdout}`);
|
|
231
|
+
}
|
|
232
|
+
return result.stdout;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async writeFile(vm: VmHandle, path: string, content: string): Promise<void> {
|
|
236
|
+
const result = await this.exec(vm, `mkdir -p $(dirname ${shellQuote(path)}) && printf '%s' "$RIGKIT_FILE_CONTENT" > ${shellQuote(path)}`, {
|
|
237
|
+
env: { RIGKIT_FILE_CONTENT: content },
|
|
238
|
+
});
|
|
239
|
+
if (!result.ok) {
|
|
240
|
+
throw new Error(`Failed to write ${path}: ${result.stderr || result.stdout}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async snapshot(vm: VmHandle): Promise<SnapshotHandle> {
|
|
245
|
+
const result = await this.ref(vm).snapshot();
|
|
246
|
+
return {
|
|
247
|
+
snapshotId: result.snapshotId,
|
|
248
|
+
sourceVmId: result.sourceVmId,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async ssh(vm: VmHandle, options?: SshOptions): Promise<SshConnection> {
|
|
253
|
+
const userPart = options?.user ? `+${options.user}` : "";
|
|
254
|
+
const username = `${vm.vmId}${userPart}`;
|
|
255
|
+
return {
|
|
256
|
+
kind: "ssh",
|
|
257
|
+
host: "vm-ssh.freestyle.sh",
|
|
258
|
+
username,
|
|
259
|
+
auth: { type: "token", token: this.token },
|
|
260
|
+
command: `ssh ${username}:${this.token}@vm-ssh.freestyle.sh`,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async workspaceContext(vm: VmHandle): Promise<FreestyleWorkspaceContext> {
|
|
265
|
+
const ssh = await this.ssh(vm);
|
|
266
|
+
return {
|
|
267
|
+
ssh,
|
|
268
|
+
host: ssh.host,
|
|
269
|
+
username: ssh.username,
|
|
270
|
+
vscodeAuthority: `${ssh.username}:${this.token}@${ssh.host}`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async deleteVm(vm: VmHandle): Promise<void> {
|
|
275
|
+
await this.client.vms.delete({ vmId: vm.vmId });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private ref(vm: VmHandle): FreestyleVm {
|
|
279
|
+
return this.client.vms.ref({ vmId: vm.vmId });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private async updateVmPermissions(vm: VmHandle): Promise<void> {
|
|
283
|
+
const identity = this.client.identities.ref({ identityId: this.identityId });
|
|
284
|
+
try {
|
|
285
|
+
await identity.permissions.vms.grant({ vmId: vm.vmId });
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (!isPermissionAlreadyExistsError(error)) {
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
await identity.permissions.vms.update({ vmId: vm.vmId });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function createFreestyleRuntime(
|
|
296
|
+
provider: BaseDevMachineProvider<FreestyleWorkspaceContext>,
|
|
297
|
+
context: ProviderRuntimeContext,
|
|
298
|
+
): FreestyleRuntime {
|
|
299
|
+
const fromHandle = (vm: VmHandle): FreestyleVmRuntime => createVmRuntime(provider, vm, context);
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
vms: {
|
|
303
|
+
create: async () => {
|
|
304
|
+
const vm = await provider.createVm();
|
|
305
|
+
context.emit({ type: "vm.created", providerId: provider.providerId, vmId: vm.vmId });
|
|
306
|
+
return fromHandle(vm);
|
|
307
|
+
},
|
|
308
|
+
fromSnapshot: async (ref) => {
|
|
309
|
+
const vm = await provider.createVmFromSnapshot({ snapshotId: ref.snapshotId });
|
|
310
|
+
context.emit({
|
|
311
|
+
type: "vm.created",
|
|
312
|
+
providerId: provider.providerId,
|
|
313
|
+
vmId: vm.vmId,
|
|
314
|
+
fromSnapshotId: ref.snapshotId,
|
|
315
|
+
});
|
|
316
|
+
return fromHandle(vm);
|
|
317
|
+
},
|
|
318
|
+
fromWorkspace: (workspace) => fromHandle({ vmId: workspace.resourceId }),
|
|
319
|
+
},
|
|
320
|
+
openWorkspace: async (target, options) => {
|
|
321
|
+
const vm = isFreestyleVmSnapshotRef(target)
|
|
322
|
+
? await createFreestyleRuntime(provider, context).vms.fromSnapshot(target)
|
|
323
|
+
: target;
|
|
324
|
+
const workspaceContext = await provider.workspaceContext?.({ vmId: vm.vmId }, {
|
|
325
|
+
workspace: {
|
|
326
|
+
id: vm.vmId,
|
|
327
|
+
name: vm.vmId,
|
|
328
|
+
providerId: provider.providerId,
|
|
329
|
+
workflow: context.workflow,
|
|
330
|
+
resourceId: vm.vmId,
|
|
331
|
+
sourceRef: null,
|
|
332
|
+
context: {},
|
|
333
|
+
createdAt: new Date().toISOString(),
|
|
334
|
+
updatedAt: new Date().toISOString(),
|
|
335
|
+
metadata: {},
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
if (!workspaceContext?.vscodeAuthority) {
|
|
339
|
+
throw new Error(`Freestyle workspace context did not include a VS Code authority`);
|
|
340
|
+
}
|
|
341
|
+
await context.local.open(
|
|
342
|
+
`vscode://vscode-remote/ssh-remote+${encodeURIComponent(workspaceContext.vscodeAuthority)}${options?.cwd ?? ""}?windowId=_blank`,
|
|
343
|
+
);
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function createVmRuntime(
|
|
349
|
+
provider: BaseDevMachineProvider<FreestyleWorkspaceContext>,
|
|
350
|
+
vm: VmHandle,
|
|
351
|
+
context: ProviderRuntimeContext,
|
|
352
|
+
): FreestyleVmRuntime {
|
|
353
|
+
const runCommand = async (command: string, options?: CommandOptions) => {
|
|
354
|
+
const commandName = options?.name ?? command;
|
|
355
|
+
const { name: _name, ...execOptions } = options ?? {};
|
|
356
|
+
const callerOnOutput = execOptions.onOutput;
|
|
357
|
+
const streamed = new Set<ExecOutputChunk["stream"]>();
|
|
358
|
+
const onOutput = async (chunk: ExecOutputChunk) => {
|
|
359
|
+
if (!chunk.data) return;
|
|
360
|
+
streamed.add(chunk.stream);
|
|
361
|
+
context.emit({
|
|
362
|
+
type: "command.output",
|
|
363
|
+
nodePath: context.nodePath,
|
|
364
|
+
commandName,
|
|
365
|
+
stream: chunk.stream,
|
|
366
|
+
data: chunk.data,
|
|
367
|
+
});
|
|
368
|
+
await callerOnOutput?.(chunk);
|
|
369
|
+
};
|
|
370
|
+
context.emit({ type: "command.started", nodePath: context.nodePath, commandName, command });
|
|
371
|
+
const result = await provider.exec(vm, command, {
|
|
372
|
+
...execOptions,
|
|
373
|
+
onOutput,
|
|
374
|
+
});
|
|
375
|
+
if (result.stdout && !streamed.has("stdout")) await onOutput({ stream: "stdout", data: result.stdout });
|
|
376
|
+
if (result.stderr && !streamed.has("stderr")) await onOutput({ stream: "stderr", data: result.stderr });
|
|
377
|
+
context.emit({ type: "command.completed", nodePath: context.nodePath, commandName, exitCode: result.exitCode });
|
|
378
|
+
return { commandName, result };
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
vmId: vm.vmId,
|
|
383
|
+
exec: async (command, options) => {
|
|
384
|
+
const { commandName, result } = await runCommand(command, options);
|
|
385
|
+
if (!result.ok) {
|
|
386
|
+
throw new Error(commandFailureMessage(commandName, result));
|
|
387
|
+
}
|
|
388
|
+
return result;
|
|
389
|
+
},
|
|
390
|
+
probe: async (command, options) => {
|
|
391
|
+
const { result } = await runCommand(command, options);
|
|
392
|
+
return result;
|
|
393
|
+
},
|
|
394
|
+
exists: async (path) => {
|
|
395
|
+
const result = await provider.exec(vm, `test -e ${shellPath(path)}`);
|
|
396
|
+
return result.ok;
|
|
397
|
+
},
|
|
398
|
+
readFile: (path) => provider.readFile(vm, path),
|
|
399
|
+
writeFile: (path, content) => provider.writeFile(vm, path, content),
|
|
400
|
+
snapshotRef: async () => {
|
|
401
|
+
const snapshot = await provider.snapshot(vm);
|
|
402
|
+
return snapshotRef(snapshot);
|
|
403
|
+
},
|
|
404
|
+
ssh: (options) => provider.ssh(vm, options),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function snapshotRef(snapshot: SnapshotHandle): FreestyleVmSnapshotRef {
|
|
409
|
+
return {
|
|
410
|
+
provider: FREESTYLE_PROVIDER_ID,
|
|
411
|
+
kind: "vmSnapshot",
|
|
412
|
+
snapshotId: snapshot.snapshotId,
|
|
413
|
+
sourceVmId: snapshot.sourceVmId,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function isFreestyleVmSnapshotRef(value: unknown): value is FreestyleVmSnapshotRef {
|
|
418
|
+
return Boolean(
|
|
419
|
+
value &&
|
|
420
|
+
typeof value === "object" &&
|
|
421
|
+
(value as FreestyleVmSnapshotRef).provider === FREESTYLE_PROVIDER_ID &&
|
|
422
|
+
(value as FreestyleVmSnapshotRef).kind === "vmSnapshot" &&
|
|
423
|
+
typeof (value as FreestyleVmSnapshotRef).snapshotId === "string",
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function shellPath(path: string): string {
|
|
428
|
+
if (path.startsWith("~/")) return `~/${shellQuote(path.slice(2))}`;
|
|
429
|
+
return shellQuote(path);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function buildInteractiveSshCommand(connection: SshConnection, remoteCommand: string | undefined): string {
|
|
433
|
+
if (connection.auth.type === "privateKey") {
|
|
434
|
+
return connection.command;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const destination = `${connection.username}:${connection.auth.token}@${connection.host}`;
|
|
438
|
+
const args = ["ssh"];
|
|
439
|
+
if (remoteCommand) args.push("-tt", "-q");
|
|
440
|
+
if (connection.port !== undefined) args.push("-p", String(connection.port));
|
|
441
|
+
args.push(destination);
|
|
442
|
+
return args.map((arg) => arg === "ssh" || arg.startsWith("-") ? arg : shellQuote(arg)).join(" ");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function commandFailureMessage(name: string, result: { exitCode: number; stdout: string; stderr: string }): string {
|
|
446
|
+
const output = [
|
|
447
|
+
result.stdout ? `stdout:\n${result.stdout.trimEnd()}` : "",
|
|
448
|
+
result.stderr ? `stderr:\n${result.stderr.trimEnd()}` : "",
|
|
449
|
+
].filter(Boolean).join("\n");
|
|
450
|
+
return `Command "${name}" failed with exit code ${result.exitCode}${output ? `\n${output}` : ""}`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function toDockerFrom(image: string): string {
|
|
454
|
+
if (image.trim().startsWith("FROM ")) return image;
|
|
455
|
+
if (image.includes(":")) return `FROM ${image}`;
|
|
456
|
+
|
|
457
|
+
const match = /^([a-z0-9][a-z0-9-]*)-(\d+(?:\.\d+)*)$/i.exec(image);
|
|
458
|
+
if (match) return `FROM ${match[1]}:${match[2]}`;
|
|
459
|
+
|
|
460
|
+
return `FROM ${image}`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function parseSizeGb(value: string | number | undefined): number | undefined {
|
|
464
|
+
if (value === undefined) return undefined;
|
|
465
|
+
if (typeof value === "number") return value;
|
|
466
|
+
const trimmed = value.trim().toLowerCase();
|
|
467
|
+
if (trimmed.endsWith("gib")) return Number(trimmed.slice(0, -3));
|
|
468
|
+
if (trimmed.endsWith("gb")) return Number(trimmed.slice(0, -2));
|
|
469
|
+
return Number(trimmed);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function wrapCommand(command: string, options?: ExecOptions): string {
|
|
473
|
+
const parts: string[] = [
|
|
474
|
+
"set -o pipefail",
|
|
475
|
+
"export HOME=${HOME:-/root}",
|
|
476
|
+
];
|
|
477
|
+
|
|
478
|
+
if (options?.cwd) {
|
|
479
|
+
parts.push(`cd ${shellQuote(options.cwd)}`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (options?.env) {
|
|
483
|
+
for (const [key, value] of Object.entries(options.env)) {
|
|
484
|
+
if (value !== undefined) {
|
|
485
|
+
parts.push(`export ${key}=${shellQuote(value)}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
parts.push(command);
|
|
491
|
+
return `bash -lc ${shellQuote(parts.join("\n"))}`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function shellQuote(value: string): string {
|
|
495
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function isPermissionAlreadyExistsError(error: unknown): boolean {
|
|
499
|
+
return error instanceof Error && error.name === "PermissionAlreadyExistsError";
|
|
500
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createStateStore } from "@rigkit/engine";
|
|
6
|
+
import { freestyleIdentityId, freestyleToken, freestyleTokenId } from "./auth.ts";
|
|
7
|
+
import { createFreestyleStore } from "./store.ts";
|
|
8
|
+
|
|
9
|
+
describe("createFreestyleStore", () => {
|
|
10
|
+
test("saves and reuses a single default identity row", async () => {
|
|
11
|
+
const projectDir = mkdtempSync(join(tmpdir(), "provider-freestyle-"));
|
|
12
|
+
const state = createStateStore({ projectDir });
|
|
13
|
+
await state.syncSchema();
|
|
14
|
+
|
|
15
|
+
const store = createFreestyleStore(state.providerStorage("freestyle"));
|
|
16
|
+
|
|
17
|
+
expect(store.getIdentity()).toBeUndefined();
|
|
18
|
+
|
|
19
|
+
const first = store.saveIdentity({
|
|
20
|
+
identityId: freestyleIdentityId("identity-1"),
|
|
21
|
+
tokenId: freestyleTokenId("token-id-1"),
|
|
22
|
+
token: freestyleToken("token-1"),
|
|
23
|
+
});
|
|
24
|
+
expect(store.getIdentity()).toEqual(first);
|
|
25
|
+
|
|
26
|
+
const identityId = freestyleIdentityId("identity-2");
|
|
27
|
+
const tokenId = freestyleTokenId("token-id-2");
|
|
28
|
+
const token = freestyleToken("token-2");
|
|
29
|
+
const second = store.saveIdentity({ identityId, tokenId, token });
|
|
30
|
+
expect(second.id).toBe(first.id);
|
|
31
|
+
expect(second.createdAt).toBe(first.createdAt);
|
|
32
|
+
expect(second.identityId).toBe(identityId);
|
|
33
|
+
expect(second.tokenId).toBe(tokenId);
|
|
34
|
+
expect(second.token).toBe(token);
|
|
35
|
+
expect(store.getIdentity()?.identityId).toBe(identityId);
|
|
36
|
+
expect(store.getIdentity()?.token).toBe(token);
|
|
37
|
+
});
|
|
38
|
+
});
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { ProviderStorage } from "@rigkit/engine";
|
|
2
|
+
import type { JsonValue } from "@rigkit/sdk";
|
|
3
|
+
import {
|
|
4
|
+
freestyleIdentityId,
|
|
5
|
+
freestyleToken,
|
|
6
|
+
freestyleTokenId,
|
|
7
|
+
type FreestyleIdentityId,
|
|
8
|
+
type FreestyleToken,
|
|
9
|
+
type FreestyleTokenId,
|
|
10
|
+
} from "./auth.ts";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_IDENTITY_KEY = "default";
|
|
13
|
+
|
|
14
|
+
export type FreestyleGitRelationship = {
|
|
15
|
+
id: string;
|
|
16
|
+
workspaceId: string;
|
|
17
|
+
vmId: string;
|
|
18
|
+
remoteUrl?: string | null;
|
|
19
|
+
branch?: string | null;
|
|
20
|
+
commitSha?: string | null;
|
|
21
|
+
metadata: Record<string, JsonValue>;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
updatedAt: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type FreestyleIdentity = {
|
|
27
|
+
id: string;
|
|
28
|
+
key: string;
|
|
29
|
+
identityId: FreestyleIdentityId;
|
|
30
|
+
tokenId: FreestyleTokenId;
|
|
31
|
+
token: FreestyleToken;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
updatedAt: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function createFreestyleStore(storage: ProviderStorage) {
|
|
37
|
+
return {
|
|
38
|
+
getIdentity(key = DEFAULT_IDENTITY_KEY): FreestyleIdentity | undefined {
|
|
39
|
+
return parseIdentity(storage.get(identityKey(key))?.value);
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
saveIdentity(input: {
|
|
43
|
+
key?: string;
|
|
44
|
+
identityId: FreestyleIdentityId;
|
|
45
|
+
tokenId: FreestyleTokenId;
|
|
46
|
+
token: FreestyleToken;
|
|
47
|
+
}): FreestyleIdentity {
|
|
48
|
+
const now = new Date().toISOString();
|
|
49
|
+
const key = input.key ?? DEFAULT_IDENTITY_KEY;
|
|
50
|
+
const existing = this.getIdentity(key);
|
|
51
|
+
const identity: FreestyleIdentity = {
|
|
52
|
+
id: existing?.id ?? crypto.randomUUID(),
|
|
53
|
+
key,
|
|
54
|
+
identityId: input.identityId,
|
|
55
|
+
tokenId: input.tokenId,
|
|
56
|
+
token: input.token,
|
|
57
|
+
createdAt: existing?.createdAt ?? now,
|
|
58
|
+
updatedAt: now,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
storage.set(identityKey(key), identity as unknown as JsonValue);
|
|
62
|
+
return identity;
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
getGitRelationship(workspaceId: string): FreestyleGitRelationship | undefined {
|
|
66
|
+
return parseGitRelationship(storage.get(gitRelationshipKey(workspaceId))?.value);
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
saveGitRelationship(input: Omit<FreestyleGitRelationship, "id" | "createdAt" | "updatedAt">): FreestyleGitRelationship {
|
|
70
|
+
const now = new Date().toISOString();
|
|
71
|
+
const existing = this.getGitRelationship(input.workspaceId);
|
|
72
|
+
const relationship: FreestyleGitRelationship = {
|
|
73
|
+
id: existing?.id ?? crypto.randomUUID(),
|
|
74
|
+
createdAt: existing?.createdAt ?? now,
|
|
75
|
+
updatedAt: now,
|
|
76
|
+
...input,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
storage.set(gitRelationshipKey(input.workspaceId), relationship as unknown as JsonValue);
|
|
80
|
+
return relationship;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function identityKey(key: string): string {
|
|
86
|
+
return `identity:${key}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function gitRelationshipKey(workspaceId: string): string {
|
|
90
|
+
return `git:${workspaceId}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseIdentity(value: JsonValue | undefined): FreestyleIdentity | undefined {
|
|
94
|
+
if (!isRecord(value)) return undefined;
|
|
95
|
+
return {
|
|
96
|
+
id: requiredString(value, "id"),
|
|
97
|
+
key: requiredString(value, "key"),
|
|
98
|
+
identityId: freestyleIdentityId(requiredString(value, "identityId")),
|
|
99
|
+
tokenId: freestyleTokenId(requiredString(value, "tokenId")),
|
|
100
|
+
token: freestyleToken(requiredString(value, "token")),
|
|
101
|
+
createdAt: requiredString(value, "createdAt"),
|
|
102
|
+
updatedAt: requiredString(value, "updatedAt"),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseGitRelationship(value: JsonValue | undefined): FreestyleGitRelationship | undefined {
|
|
107
|
+
if (!isRecord(value)) return undefined;
|
|
108
|
+
return {
|
|
109
|
+
id: requiredString(value, "id"),
|
|
110
|
+
workspaceId: requiredString(value, "workspaceId"),
|
|
111
|
+
vmId: requiredString(value, "vmId"),
|
|
112
|
+
remoteUrl: optionalStringOrNull(value, "remoteUrl"),
|
|
113
|
+
branch: optionalStringOrNull(value, "branch"),
|
|
114
|
+
commitSha: optionalStringOrNull(value, "commitSha"),
|
|
115
|
+
metadata: recordValue(value.metadata),
|
|
116
|
+
createdAt: requiredString(value, "createdAt"),
|
|
117
|
+
updatedAt: requiredString(value, "updatedAt"),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function requiredString(record: Record<string, JsonValue>, key: string): string {
|
|
122
|
+
const value = record[key];
|
|
123
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
124
|
+
throw new Error(`Invalid Freestyle provider state: ${key} must be a non-empty string`);
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function optionalStringOrNull(record: Record<string, JsonValue>, key: string): string | null | undefined {
|
|
130
|
+
const value = record[key];
|
|
131
|
+
if (value === undefined || value === null || typeof value === "string") return value;
|
|
132
|
+
throw new Error(`Invalid Freestyle provider state: ${key} must be a string or null`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function recordValue(value: JsonValue | undefined): Record<string, JsonValue> {
|
|
136
|
+
if (isRecord(value)) return value;
|
|
137
|
+
throw new Error(`Invalid Freestyle provider state: metadata must be an object`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isRecord(value: unknown): value is Record<string, JsonValue> {
|
|
141
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
142
|
+
}
|