@freestyle-sh/with-web-terminal 0.0.1

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/.env ADDED
@@ -0,0 +1 @@
1
+ FREESTYLE_API_KEY=NYDNxdzbycbhCF2M6DUhK5-7fPaRDNM7F9cerzZtEacSBgHVgjaZhDoJ9F44kbSWqJN
@@ -0,0 +1,73 @@
1
+ import * as freestyle_sandboxes from 'freestyle-sandboxes';
2
+ import { VmWith, VmWithInstance, CreateVmOptions } from 'freestyle-sandboxes';
3
+
4
+ type TtydConfig = {
5
+ /** Port to run ttyd on (default: auto-assigned starting at 7682) */
6
+ port?: number;
7
+ /** Shell or command to run (default: /bin/bash) */
8
+ command?: string;
9
+ /** User to run terminal as (default: current user) */
10
+ user?: string;
11
+ /** Working directory (default: user home) */
12
+ cwd?: string;
13
+ /** Enable basic auth */
14
+ credential?: {
15
+ username: string;
16
+ password: string;
17
+ };
18
+ /** Terminal title shown in browser tab */
19
+ title?: string;
20
+ /** Read-only terminal (no input allowed) */
21
+ readOnly?: boolean;
22
+ };
23
+ type WebTerminalConfig = {
24
+ id: string;
25
+ } & TtydConfig;
26
+ type ResolvedTerminalConfig = {
27
+ id: string;
28
+ port: number;
29
+ command: string;
30
+ user: string;
31
+ cwd: string;
32
+ credential?: {
33
+ username: string;
34
+ password: string;
35
+ };
36
+ title: string;
37
+ readOnly: boolean;
38
+ };
39
+ declare class VmWebTerminal<T extends WebTerminalConfig[] = WebTerminalConfig[]> extends VmWith<VmWebTerminalInstance<T> & TerminalInstances<T>> {
40
+ private resolvedTerminals;
41
+ constructor(terminals: T);
42
+ configure(existingConfig: CreateVmOptions): CreateVmOptions | Promise<CreateVmOptions>;
43
+ createInstance(): VmWebTerminalInstance<T> & TerminalInstances<T>;
44
+ installServiceName(): string;
45
+ }
46
+ declare class WebTerminal {
47
+ readonly id: string;
48
+ readonly port: number;
49
+ readonly command: string;
50
+ private instance;
51
+ constructor({ id, port, command, instance }: {
52
+ id: string;
53
+ port: number;
54
+ command: string;
55
+ instance: VmWebTerminalInstance<any>;
56
+ });
57
+ /** Expose this terminal publicly via Freestyle routing */
58
+ route({ domain }: {
59
+ domain: string;
60
+ }): Promise<void>;
61
+ }
62
+ type TerminalInstances<T extends WebTerminalConfig[]> = {
63
+ [K in T[number]["id"]]: WebTerminal;
64
+ };
65
+ declare class VmWebTerminalInstance<T extends WebTerminalConfig[]> extends VmWithInstance {
66
+ builder: VmWebTerminal<T>;
67
+ constructor(builder: VmWebTerminal<T>, resolvedTerminals: ResolvedTerminalConfig[]);
68
+ /** @internal */
69
+ get _vm(): freestyle_sandboxes.Vm;
70
+ }
71
+
72
+ export { VmWebTerminal, VmWebTerminalInstance, WebTerminal };
73
+ export type { ResolvedTerminalConfig, TerminalInstances, TtydConfig, WebTerminalConfig };
package/dist/index.js ADDED
@@ -0,0 +1,136 @@
1
+ import { VmWith, VmTemplate, VmWithInstance } from 'freestyle-sandboxes';
2
+
3
+ class VmWebTerminal extends VmWith {
4
+ resolvedTerminals;
5
+ constructor(terminals) {
6
+ super();
7
+ let nextPort = 7682;
8
+ this.resolvedTerminals = terminals.map((config) => ({
9
+ id: config.id,
10
+ port: config.port ?? nextPort++,
11
+ command: config.command ?? "bash -l",
12
+ user: config.user ?? "root",
13
+ cwd: config.cwd ?? "/root",
14
+ credential: config.credential,
15
+ title: config.title ?? config.id,
16
+ readOnly: config.readOnly ?? false
17
+ }));
18
+ }
19
+ configure(existingConfig) {
20
+ const installScript = `#!/bin/bash
21
+ set -e
22
+
23
+ TTYD_VERSION="1.7.7"
24
+ curl -fsSL -o /usr/local/bin/ttyd "https://github.com/tsl0922/ttyd/releases/download/\${TTYD_VERSION}/ttyd.x86_64"
25
+ chmod +x /usr/local/bin/ttyd
26
+ /usr/local/bin/ttyd --version
27
+ `;
28
+ const services = this.resolvedTerminals.map((t) => {
29
+ const args = [
30
+ `/usr/local/bin/ttyd`,
31
+ `-p ${t.port}`
32
+ ];
33
+ if (t.credential) {
34
+ if (t.credential.username.length === 0 || t.credential.password.length === 0) {
35
+ throw new Error(
36
+ `Invalid credential for terminal ${t.id}: username and password cannot be empty`
37
+ );
38
+ }
39
+ if (t.credential.username.includes(":") || t.credential.password.includes(":")) {
40
+ throw new Error(
41
+ `Invalid credential for terminal ${t.id}: username and password cannot contain colon (:) character`
42
+ );
43
+ }
44
+ args.push(`--credential ${t.credential.username}:${t.credential.password}`);
45
+ }
46
+ if (t.readOnly) {
47
+ args.push(`--readonly`);
48
+ } else {
49
+ args.push(`--writable`);
50
+ }
51
+ args.push(t.command);
52
+ return {
53
+ name: `web-terminal-${t.id}`,
54
+ mode: "service",
55
+ exec: [args.join(" ")],
56
+ user: t.user,
57
+ cwd: t.cwd,
58
+ restart: "always",
59
+ restartSec: 2,
60
+ after: ["install-ttyd.service", "systemd-sysusers.service"],
61
+ requires: ["systemd-sysusers.service"]
62
+ };
63
+ });
64
+ const config = {
65
+ template: new VmTemplate({
66
+ additionalFiles: {
67
+ "/opt/install-ttyd.sh": { content: installScript }
68
+ },
69
+ systemd: {
70
+ services: [
71
+ {
72
+ name: "install-ttyd",
73
+ mode: "oneshot",
74
+ deleteAfterSuccess: true,
75
+ exec: ["bash /opt/install-ttyd.sh"],
76
+ timeoutSec: 120
77
+ },
78
+ ...services
79
+ ]
80
+ }
81
+ })
82
+ };
83
+ return this.compose(existingConfig, config);
84
+ }
85
+ createInstance() {
86
+ return new VmWebTerminalInstance(this, this.resolvedTerminals);
87
+ }
88
+ installServiceName() {
89
+ return "install-ttyd.service";
90
+ }
91
+ }
92
+ class WebTerminal {
93
+ id;
94
+ port;
95
+ command;
96
+ instance;
97
+ constructor({ id, port, command, instance }) {
98
+ this.id = id;
99
+ this.port = port;
100
+ this.command = command;
101
+ this.instance = instance;
102
+ }
103
+ /** Expose this terminal publicly via Freestyle routing */
104
+ async route({ domain }) {
105
+ const vm = this.instance._vm;
106
+ const freestyle = vm._freestyle;
107
+ console.log(`Routing terminal ${this.id} on vm ${vm.vmId} at port ${this.port} to domain ${domain}`);
108
+ await freestyle.domains.mappings.create({
109
+ domain,
110
+ vmId: vm.vmId,
111
+ vmPort: this.port
112
+ });
113
+ }
114
+ }
115
+ class VmWebTerminalInstance extends VmWithInstance {
116
+ builder;
117
+ constructor(builder, resolvedTerminals) {
118
+ super();
119
+ this.builder = builder;
120
+ for (const config of resolvedTerminals) {
121
+ const terminal = new WebTerminal({
122
+ id: config.id,
123
+ port: config.port,
124
+ command: config.command,
125
+ instance: this
126
+ });
127
+ this[config.id] = terminal;
128
+ }
129
+ }
130
+ /** @internal */
131
+ get _vm() {
132
+ return this.vm;
133
+ }
134
+ }
135
+
136
+ export { VmWebTerminal, VmWebTerminalInstance, WebTerminal };
@@ -0,0 +1,16 @@
1
+ import "dotenv/config";
2
+ import { freestyle } from "freestyle-sandboxes";
3
+ import { VmWebTerminal } from "../src/index.ts";
4
+
5
+ const webTerminal = new VmWebTerminal([{ id: "main" }] as const);
6
+
7
+ const { vm } = await freestyle.vms.create({
8
+ with: {
9
+ terminal: webTerminal,
10
+ },
11
+ });
12
+
13
+ const domain = `${crypto.randomUUID()}.style.dev`;
14
+ await vm.terminal.main.route({ domain });
15
+
16
+ console.log(`Terminal available at: https://${domain}`);
@@ -0,0 +1,43 @@
1
+ import "dotenv/config";
2
+ import { freestyle, VmSpec } from "freestyle-sandboxes";
3
+ import { VmWebTerminal } from "../src/index.ts";
4
+
5
+ const id = crypto.randomUUID().slice(0, 8);
6
+
7
+ const webTerminal = new VmWebTerminal([{ id: "main", command: "bash -lc claude" }] as const);
8
+
9
+ const { vm } = await freestyle.vms.create({
10
+ spec: new VmSpec({
11
+ snapshot: new VmSpec({
12
+ additionalFiles: {
13
+ "/opt/install-claude.sh": {
14
+ content: `#!/bin/bash
15
+
16
+ curl -fsSL https://claude.ai/install.sh | bash
17
+
18
+ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
19
+ `,
20
+ }
21
+ },
22
+ systemd: {
23
+
24
+ services: [
25
+ {
26
+ name: "install-claude",
27
+ mode: "oneshot",
28
+ exec: [
29
+ `bash /opt/install-claude.sh`,
30
+ ],
31
+ },
32
+ ],
33
+ },
34
+ }),
35
+ with: {
36
+ terminal: webTerminal,
37
+ },
38
+ }),
39
+ });
40
+
41
+ await vm.terminal.main.route({ domain: `${id}-claude.style.dev` });
42
+
43
+ console.log(`Main terminal: https://${id}-claude.style.dev`);
@@ -0,0 +1,19 @@
1
+ import "dotenv/config";
2
+ import { freestyle } from "freestyle-sandboxes";
3
+ import { VmWebTerminal } from "../src/index.ts";
4
+
5
+ const webTerminal = new VmWebTerminal([{ id: "main", credential: {
6
+ username: "admin",
7
+ password: "password123",
8
+ }}] as const);
9
+
10
+ const { vm } = await freestyle.vms.create({
11
+ with: {
12
+ terminal: webTerminal,
13
+ },
14
+ });
15
+
16
+ const domain = `${crypto.randomUUID()}.style.dev`;
17
+ await vm.terminal.main.route({ domain });
18
+
19
+ console.log(`Terminal available at: https://${domain}`);
@@ -0,0 +1,22 @@
1
+ import "dotenv/config";
2
+ import { freestyle } from "freestyle-sandboxes";
3
+ import { VmWebTerminal } from "../src/index.ts";
4
+
5
+ const id = crypto.randomUUID().slice(0, 8);
6
+
7
+ const webTerminal = new VmWebTerminal([
8
+ { id: "public" },
9
+ { id: "private", credential: { username: "admin", password: "password123" } },
10
+ ] as const);
11
+
12
+ const { vm } = await freestyle.vms.create({
13
+ with: {
14
+ terminal: webTerminal,
15
+ },
16
+ });
17
+
18
+ await vm.terminal.public.route({ domain: `${id}-noauth.style.dev` });
19
+ await vm.terminal.private.route({ domain: `${id}-auth.style.dev` });
20
+
21
+ console.log(`Public terminal: https://${id}-noauth.style.dev`);
22
+ console.log(`Private terminal: https://admin:password123@${id}-auth.style.dev`);
@@ -0,0 +1,22 @@
1
+ import "dotenv/config";
2
+ import { freestyle } from "freestyle-sandboxes";
3
+ import { VmWebTerminal } from "../src/index.ts";
4
+
5
+ const webTerminal = new VmWebTerminal([
6
+ {
7
+ id: "counter",
8
+ readOnly: true,
9
+ shell: "watch -n1 date",
10
+ },
11
+ ] as const);
12
+
13
+ const { vm } = await freestyle.vms.create({
14
+ with: {
15
+ terminal: webTerminal,
16
+ },
17
+ });
18
+
19
+ const domain = `${crypto.randomUUID().slice(0, 8)}-readonly.style.dev`;
20
+ await vm.terminal.counter.route({ domain });
21
+
22
+ console.log(`Read-only counter: https://${domain}`);
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@freestyle-sh/with-web-terminal",
3
+ "version": "0.0.1",
4
+ "description": "Web terminal for freestyle sandboxes",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "source": "./src/index.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "build": "pkgroll",
17
+ "prepublishOnly": "pnpm run build"
18
+ },
19
+ "keywords": ["ttyd", "terminal", "web-terminal", "freestyle"],
20
+ "author": "",
21
+ "packageManager": "pnpm@10.11.0",
22
+ "devDependencies": {
23
+ "pkgroll": "^2.11.2",
24
+ "typescript": "^5.8.3"
25
+ },
26
+ "dependencies": {
27
+ "freestyle-sandboxes": "^0.1.12"
28
+ }
29
+ }
package/src/index.ts ADDED
@@ -0,0 +1,216 @@
1
+ import {
2
+ VmTemplate,
3
+ type CreateVmOptions,
4
+ VmWith,
5
+ VmWithInstance,
6
+ Freestyle,
7
+ } from "freestyle-sandboxes";
8
+
9
+ // ============================================================================
10
+ // Configuration Types
11
+ // ============================================================================
12
+
13
+ export type TtydConfig = {
14
+ /** Port to run ttyd on (default: auto-assigned starting at 7682) */
15
+ port?: number;
16
+ /** Shell or command to run (default: /bin/bash) */
17
+ command?: string;
18
+ /** User to run terminal as (default: current user) */
19
+ user?: string;
20
+ /** Working directory (default: user home) */
21
+ cwd?: string;
22
+ /** Enable basic auth */
23
+ credential?: { username: string; password: string };
24
+ /** Terminal title shown in browser tab */
25
+ title?: string;
26
+ /** Read-only terminal (no input allowed) */
27
+ readOnly?: boolean;
28
+ };
29
+
30
+ export type WebTerminalConfig = { id: string } & TtydConfig;
31
+
32
+ export type ResolvedTerminalConfig = {
33
+ id: string;
34
+ port: number;
35
+ command: string;
36
+ user: string;
37
+ cwd: string;
38
+ credential?: { username: string; password: string };
39
+ title: string;
40
+ readOnly: boolean;
41
+ };
42
+
43
+ // ============================================================================
44
+ // Builder Class
45
+ // ============================================================================
46
+
47
+ export class VmWebTerminal<
48
+ T extends WebTerminalConfig[] = WebTerminalConfig[]
49
+ > extends VmWith<VmWebTerminalInstance<T> & TerminalInstances<T>> {
50
+ private resolvedTerminals: ResolvedTerminalConfig[];
51
+
52
+ constructor(terminals: T) {
53
+ super();
54
+ // Resolve config once with defaults
55
+ let nextPort = 7682;
56
+ this.resolvedTerminals = terminals.map((config) => ({
57
+ id: config.id,
58
+ port: config.port ?? nextPort++,
59
+ command: config.command ?? "bash -l",
60
+ user: config.user ?? "root",
61
+ cwd: config.cwd ?? "/root",
62
+ credential: config.credential,
63
+ title: config.title ?? config.id,
64
+ readOnly: config.readOnly ?? false,
65
+ }));
66
+ }
67
+
68
+ override configure(
69
+ existingConfig: CreateVmOptions
70
+ ): CreateVmOptions | Promise<CreateVmOptions> {
71
+
72
+ // Generate install script
73
+ const installScript = `#!/bin/bash
74
+ set -e
75
+
76
+ TTYD_VERSION="1.7.7"
77
+ curl -fsSL -o /usr/local/bin/ttyd "https://github.com/tsl0922/ttyd/releases/download/\${TTYD_VERSION}/ttyd.x86_64"
78
+ chmod +x /usr/local/bin/ttyd
79
+ /usr/local/bin/ttyd --version
80
+ `;
81
+
82
+ // Generate systemd service for each terminal
83
+ const services = this.resolvedTerminals.map((t) => {
84
+ const args: string[] = [
85
+ `/usr/local/bin/ttyd`,
86
+ `-p ${t.port}`,
87
+ ];
88
+
89
+ if (t.credential) {
90
+ if (t.credential.username.length === 0 || t.credential.password.length === 0) {
91
+ throw new Error(
92
+ `Invalid credential for terminal ${t.id}: username and password cannot be empty`
93
+ );
94
+ }
95
+ if (t.credential.username.includes(":") || t.credential.password.includes(":")) {
96
+ throw new Error(
97
+ `Invalid credential for terminal ${t.id}: username and password cannot contain colon (:) character`
98
+ );
99
+ }
100
+ args.push(`--credential ${t.credential.username}:${t.credential.password}`);
101
+ }
102
+ if (t.readOnly) {
103
+ args.push(`--readonly`);
104
+ } else {
105
+ args.push(`--writable`);
106
+ }
107
+
108
+ // Shell command at the end
109
+ args.push(t.command);
110
+
111
+ return {
112
+ name: `web-terminal-${t.id}`,
113
+ mode: "service" as const,
114
+ exec: [args.join(" ")],
115
+ user: t.user,
116
+ cwd: t.cwd,
117
+ restart: "always" as const,
118
+ restartSec: 2,
119
+ after: ["install-ttyd.service", "systemd-sysusers.service"],
120
+ requires: ["systemd-sysusers.service"],
121
+ };
122
+ });
123
+
124
+ const config: CreateVmOptions = {
125
+ template: new VmTemplate({
126
+ additionalFiles: {
127
+ "/opt/install-ttyd.sh": { content: installScript },
128
+ },
129
+ systemd: {
130
+ services: [
131
+ {
132
+ name: "install-ttyd",
133
+ mode: "oneshot",
134
+ deleteAfterSuccess: true,
135
+ exec: ["bash /opt/install-ttyd.sh"],
136
+ timeoutSec: 120,
137
+ },
138
+ ...services,
139
+ ],
140
+ },
141
+ }),
142
+ };
143
+
144
+ return this.compose(existingConfig, config);
145
+ }
146
+
147
+ createInstance(): VmWebTerminalInstance<T> & TerminalInstances<T> {
148
+ return new VmWebTerminalInstance(this, this.resolvedTerminals) as VmWebTerminalInstance<T> & TerminalInstances<T>;
149
+ }
150
+
151
+ installServiceName(): string {
152
+ return "install-ttyd.service";
153
+ }
154
+ }
155
+
156
+ // ============================================================================
157
+ // Instance Class (runtime access)
158
+ // ============================================================================
159
+
160
+ export class WebTerminal {
161
+ readonly id: string;
162
+ readonly port: number;
163
+ readonly command: string;
164
+ private instance: VmWebTerminalInstance<any>;
165
+
166
+ constructor({ id, port, command, instance }: { id: string; port: number; command: string; instance: VmWebTerminalInstance<any> }) {
167
+ this.id = id;
168
+ this.port = port;
169
+ this.command = command;
170
+ this.instance = instance;
171
+ }
172
+
173
+ /** Expose this terminal publicly via Freestyle routing */
174
+ async route({ domain }: { domain: string }): Promise<void> {
175
+ const vm = this.instance._vm;
176
+ // @ts-expect-error using internal thing
177
+ const freestyle: Freestyle = vm._freestyle;
178
+ console.log(`Routing terminal ${this.id} on vm ${vm.vmId} at port ${this.port} to domain ${domain}`);
179
+ await freestyle.domains.mappings.create({
180
+ domain: domain,
181
+ vmId: vm.vmId,
182
+ vmPort: this.port,
183
+ })
184
+ }
185
+ }
186
+
187
+ export type TerminalInstances<T extends WebTerminalConfig[]> = {
188
+ [K in T[number]["id"]]: WebTerminal;
189
+ };
190
+
191
+ export class VmWebTerminalInstance<
192
+ T extends WebTerminalConfig[]
193
+ > extends VmWithInstance {
194
+ builder: VmWebTerminal<T>;
195
+
196
+ constructor(builder: VmWebTerminal<T>, resolvedTerminals: ResolvedTerminalConfig[]) {
197
+ super();
198
+ this.builder = builder;
199
+
200
+ // Create terminals as properties using resolved config
201
+ for (const config of resolvedTerminals) {
202
+ const terminal = new WebTerminal({
203
+ id: config.id,
204
+ port: config.port,
205
+ command: config.command,
206
+ instance: this,
207
+ });
208
+ (this as any)[config.id] = terminal;
209
+ }
210
+ }
211
+
212
+ /** @internal */
213
+ get _vm() {
214
+ return this.vm;
215
+ }
216
+ }