Skip to content

Commit 84ff71e

Browse files
committed
feat: add cpToInstance and cpFromInstance functions
Add manually-maintained cp functionality to the SDK for copying files to/from running instances. This is placed in src/lib/ directory following the SDK's convention for custom extensions. Features: - cpToInstance: Copy local file/directory to instance - cpFromInstance: Copy file/directory from instance to local - WebSocket-based streaming with JSON control messages - Supports files and recursive directory operations - Handles symlinks, permissions, and modification times - Chunked binary data transfer for efficient streaming Adds ws as a peer dependency for WebSocket support.
1 parent 14bd119 commit 84ff71e

File tree

3 files changed

+372
-1
lines changed

3 files changed

+372
-1
lines changed

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,16 @@
2626
"lint": "./scripts/lint",
2727
"fix": "./scripts/format"
2828
},
29-
"dependencies": {},
29+
"dependencies": {
30+
"ws": "^8.14.0"
31+
},
3032
"devDependencies": {
3133
"@arethetypeswrong/cli": "^0.17.0",
3234
"@swc/core": "^1.3.102",
3335
"@swc/jest": "^0.2.29",
3436
"@types/jest": "^29.4.0",
3537
"@types/node": "^20.17.6",
38+
"@types/ws": "^8.5.10",
3639
"@typescript-eslint/eslint-plugin": "8.31.1",
3740
"@typescript-eslint/parser": "8.31.1",
3841
"eslint": "^9.39.1",

src/lib/cp.ts

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
/**
2+
* Copy operations for transferring files to/from running instances.
3+
*
4+
* This module provides functions for copying files between the local filesystem
5+
* and running hypeman instances, similar to `docker cp`.
6+
*/
7+
8+
import * as fs from 'fs';
9+
import * as path from 'path';
10+
11+
/**
12+
* Configuration for copy operations.
13+
*/
14+
export interface CpConfig {
15+
/** Base URL for the hypeman API */
16+
baseURL: string;
17+
/** API key (JWT token) for authentication */
18+
apiKey: string;
19+
}
20+
21+
/**
22+
* Options for copying files to an instance.
23+
*/
24+
export interface CpToInstanceOptions {
25+
/** Instance ID to copy to */
26+
instanceId: string;
27+
/** Local source path */
28+
srcPath: string;
29+
/** Destination path in guest */
30+
dstPath: string;
31+
/** Optional: override file mode/permissions */
32+
mode?: number;
33+
}
34+
35+
/**
36+
* Options for copying files from an instance.
37+
*/
38+
export interface CpFromInstanceOptions {
39+
/** Instance ID to copy from */
40+
instanceId: string;
41+
/** Source path in guest */
42+
srcPath: string;
43+
/** Local destination path */
44+
dstPath: string;
45+
/** Follow symbolic links */
46+
followLinks?: boolean;
47+
}
48+
49+
interface CpRequest {
50+
direction: 'to' | 'from';
51+
guest_path: string;
52+
is_dir?: boolean;
53+
mode?: number;
54+
follow_links?: boolean;
55+
}
56+
57+
interface CpFileHeader {
58+
type: 'header';
59+
path: string;
60+
mode: number;
61+
is_dir: boolean;
62+
is_symlink?: boolean;
63+
link_target?: string;
64+
size: number;
65+
mtime: number;
66+
}
67+
68+
interface CpEndMarker {
69+
type: 'end';
70+
final: boolean;
71+
}
72+
73+
interface CpResult {
74+
type: 'result';
75+
success: boolean;
76+
error?: string;
77+
bytes_written?: number;
78+
}
79+
80+
interface CpError {
81+
type: 'error';
82+
message: string;
83+
path?: string;
84+
}
85+
86+
type CpMessage = CpFileHeader | CpEndMarker | CpResult | CpError;
87+
88+
/**
89+
* Builds the WebSocket URL for the cp endpoint.
90+
*/
91+
function buildWsURL(baseURL: string, instanceId: string): string {
92+
const url = new URL(baseURL);
93+
url.pathname = `/instances/${instanceId}/cp`;
94+
95+
if (url.protocol === 'https:') {
96+
url.protocol = 'wss:';
97+
} else if (url.protocol === 'http:') {
98+
url.protocol = 'ws:';
99+
}
100+
101+
return url.toString();
102+
}
103+
104+
/**
105+
* Copy a file or directory to a running instance.
106+
*
107+
* @example
108+
* ```typescript
109+
* import { cpToInstance } from 'hypeman/lib/cp';
110+
*
111+
* await cpToInstance({
112+
* baseURL: 'https://api.hypeman.dev',
113+
* apiKey: 'your-api-key',
114+
* }, {
115+
* instanceId: 'inst_123',
116+
* srcPath: './local-file.txt',
117+
* dstPath: '/app/file.txt',
118+
* });
119+
* ```
120+
*/
121+
export async function cpToInstance(cfg: CpConfig, opts: CpToInstanceOptions): Promise<void> {
122+
// Check if WebSocket is available (Node.js vs browser)
123+
const WebSocket = getWebSocket();
124+
125+
const wsURL = buildWsURL(cfg.baseURL, opts.instanceId);
126+
127+
// Get file stats
128+
const stats = fs.statSync(opts.srcPath);
129+
const isDir = stats.isDirectory();
130+
const mode = opts.mode ?? stats.mode & 0o777;
131+
132+
// Connect to WebSocket
133+
const ws = new WebSocket(wsURL, {
134+
headers: {
135+
Authorization: `Bearer ${cfg.apiKey}`,
136+
},
137+
});
138+
139+
return new Promise((resolve, reject) => {
140+
ws.on('open', () => {
141+
// Send initial request
142+
const req: CpRequest = {
143+
direction: 'to',
144+
guest_path: opts.dstPath,
145+
is_dir: isDir,
146+
mode: mode,
147+
};
148+
ws.send(JSON.stringify(req));
149+
150+
if (isDir) {
151+
// For directories, just send end marker
152+
ws.send(JSON.stringify({ type: 'end' }));
153+
} else {
154+
// Stream file content
155+
const fileStream = fs.createReadStream(opts.srcPath, {
156+
highWaterMark: 32 * 1024,
157+
});
158+
159+
fileStream.on('data', (chunk: Buffer) => {
160+
ws.send(chunk);
161+
});
162+
163+
fileStream.on('end', () => {
164+
ws.send(JSON.stringify({ type: 'end' }));
165+
});
166+
167+
fileStream.on('error', (err) => {
168+
ws.close();
169+
reject(err);
170+
});
171+
}
172+
});
173+
174+
ws.on('message', (data: Buffer | string) => {
175+
try {
176+
const msg = JSON.parse(data.toString()) as CpMessage;
177+
178+
if (msg.type === 'result') {
179+
if (msg.success) {
180+
ws.close();
181+
resolve();
182+
} else {
183+
ws.close();
184+
reject(new Error(`Copy failed: ${msg.error}`));
185+
}
186+
} else if (msg.type === 'error') {
187+
ws.close();
188+
reject(new Error(`Copy error at ${msg.path}: ${msg.message}`));
189+
}
190+
} catch (e) {
191+
// Ignore non-JSON messages
192+
}
193+
});
194+
195+
ws.on('error', (err) => {
196+
reject(err);
197+
});
198+
199+
ws.on('close', (code, reason) => {
200+
if (code !== 1000) {
201+
reject(new Error(`WebSocket closed: ${code} ${reason}`));
202+
}
203+
});
204+
});
205+
}
206+
207+
/**
208+
* Copy a file or directory from a running instance.
209+
*
210+
* @example
211+
* ```typescript
212+
* import { cpFromInstance } from 'hypeman/lib/cp';
213+
*
214+
* await cpFromInstance({
215+
* baseURL: 'https://api.hypeman.dev',
216+
* apiKey: 'your-api-key',
217+
* }, {
218+
* instanceId: 'inst_123',
219+
* srcPath: '/app/output.txt',
220+
* dstPath: './local-output.txt',
221+
* });
222+
* ```
223+
*/
224+
export async function cpFromInstance(cfg: CpConfig, opts: CpFromInstanceOptions): Promise<void> {
225+
const WebSocket = getWebSocket();
226+
const wsURL = buildWsURL(cfg.baseURL, opts.instanceId);
227+
228+
const ws = new WebSocket(wsURL, {
229+
headers: {
230+
Authorization: `Bearer ${cfg.apiKey}`,
231+
},
232+
});
233+
234+
return new Promise((resolve, reject) => {
235+
let currentFile: fs.WriteStream | null = null;
236+
let currentHeader: CpFileHeader | null = null;
237+
238+
ws.on('open', () => {
239+
// Send initial request
240+
const req: CpRequest = {
241+
direction: 'from',
242+
guest_path: opts.srcPath,
243+
follow_links: opts.followLinks ?? false,
244+
};
245+
ws.send(JSON.stringify(req));
246+
});
247+
248+
ws.on('message', (data: Buffer | string) => {
249+
// Try to parse as JSON first
250+
if (typeof data === 'string' || (Buffer.isBuffer(data) && !isBinaryData(data))) {
251+
try {
252+
const msg = JSON.parse(data.toString()) as CpMessage;
253+
254+
switch (msg.type) {
255+
case 'header': {
256+
// Close previous file if any
257+
if (currentFile) {
258+
currentFile.close();
259+
currentFile = null;
260+
}
261+
262+
currentHeader = msg as CpFileHeader;
263+
const targetPath = path.join(opts.dstPath, currentHeader.path);
264+
265+
if (currentHeader.is_dir) {
266+
fs.mkdirSync(targetPath, { recursive: true, mode: currentHeader.mode });
267+
} else if (currentHeader.is_symlink && currentHeader.link_target) {
268+
try {
269+
fs.unlinkSync(targetPath);
270+
} catch {
271+
// Ignore if doesn't exist
272+
}
273+
fs.symlinkSync(currentHeader.link_target, targetPath);
274+
} else {
275+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
276+
currentFile = fs.createWriteStream(targetPath, { mode: currentHeader.mode });
277+
}
278+
break;
279+
}
280+
281+
case 'end': {
282+
const endMsg = msg as CpEndMarker;
283+
if (currentFile) {
284+
currentFile.close();
285+
// Set modification time
286+
if (currentHeader && currentHeader.mtime > 0) {
287+
const targetPath = path.join(opts.dstPath, currentHeader.path);
288+
const mtime = new Date(currentHeader.mtime * 1000);
289+
fs.utimesSync(targetPath, mtime, mtime);
290+
}
291+
currentFile = null;
292+
currentHeader = null;
293+
}
294+
295+
if (endMsg.final) {
296+
ws.close();
297+
resolve();
298+
}
299+
break;
300+
}
301+
302+
case 'error': {
303+
const errMsg = msg as CpError;
304+
ws.close();
305+
reject(new Error(`Copy error at ${errMsg.path}: ${errMsg.message}`));
306+
break;
307+
}
308+
}
309+
310+
return;
311+
} catch {
312+
// Not JSON, treat as binary data
313+
}
314+
}
315+
316+
// Binary data - write to current file
317+
if (currentFile && Buffer.isBuffer(data)) {
318+
currentFile.write(data);
319+
}
320+
});
321+
322+
ws.on('error', (err) => {
323+
if (currentFile) {
324+
currentFile.close();
325+
}
326+
reject(err);
327+
});
328+
329+
ws.on('close', (code, reason) => {
330+
if (currentFile) {
331+
currentFile.close();
332+
}
333+
if (code !== 1000) {
334+
reject(new Error(`WebSocket closed: ${code} ${reason}`));
335+
}
336+
});
337+
});
338+
}
339+
340+
/**
341+
* Helper to determine if data looks like binary (not JSON).
342+
*/
343+
function isBinaryData(data: Buffer): boolean {
344+
// If it starts with '{' or '[', it's likely JSON
345+
if (data.length > 0 && (data[0] === 0x7b || data[0] === 0x5b)) {
346+
return false;
347+
}
348+
return true;
349+
}
350+
351+
/**
352+
* Get WebSocket implementation (works in Node.js).
353+
*/
354+
function getWebSocket(): typeof import('ws') {
355+
// In Node.js, we use the 'ws' package
356+
// eslint-disable-next-line @typescript-eslint/no-var-requires
357+
return require('ws');
358+
}
359+

src/lib/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Manually-maintained library functionality.
3+
*
4+
* This directory contains custom functionality that extends the auto-generated SDK.
5+
* Files in this directory are not modified by the Stainless generator.
6+
*/
7+
8+
export * from './cp';
9+

0 commit comments

Comments
 (0)