@illuma-ai/code-sandbox 1.1.0 → 1.2.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/src/types.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * @illuma-ai/code-sandbox — Type definitions
3
3
  *
4
4
  * Core types for the browser-native code sandbox.
5
- * Covers file maps, runtime states, git operations, and component props.
5
+ * Covers file maps, runtime states, imperative handle, and component props.
6
6
  */
7
7
 
8
8
  // ---------------------------------------------------------------------------
@@ -138,11 +138,9 @@ export interface RuntimeState {
138
138
  files: FileMap;
139
139
  /**
140
140
  * Baseline file snapshot — the files as they were before the latest
141
- * update (initial load, or the version before the last GitHub poll).
142
- *
143
- * Used by the diff viewer to show what changed. Empty on first load
144
- * (everything is "new"), populated after the first file update
145
- * (GitHub poll, updateFiles, etc.).
141
+ * update (via updateFiles). Used by the diff viewer to show what changed.
142
+ * Empty on first load (everything is "new"), populated after the first
143
+ * file update.
146
144
  */
147
145
  originalFiles: FileMap;
148
146
  /**
@@ -163,53 +161,117 @@ export interface RuntimeState {
163
161
  }
164
162
 
165
163
  // ---------------------------------------------------------------------------
166
- // Git (GitHub API integration)
164
+ // Imperative Handle
167
165
  // ---------------------------------------------------------------------------
168
166
 
169
- export interface GitHubRepo {
170
- owner: string;
171
- repo: string;
172
- branch?: string; // default: 'main'
173
- path?: string; // subdirectory to clone (default: root)
174
- }
167
+ /**
168
+ * Imperative handle exposed by CodeSandbox via React.forwardRef.
169
+ *
170
+ * This is the primary API for host applications (like Ranger) to
171
+ * control the sandbox programmatically. The sandbox is a "dumb renderer"
172
+ * — the host pushes files in and reads state out.
173
+ *
174
+ * @example
175
+ * ```tsx
176
+ * const ref = useRef<CodeSandboxHandle>(null);
177
+ *
178
+ * // Push files from any source
179
+ * ref.current?.updateFiles(fileMap);
180
+ *
181
+ * // Push a single file (e.g., agent modified one file)
182
+ * ref.current?.updateFile('server.js', newContent);
183
+ *
184
+ * // Read current state
185
+ * const files = ref.current?.getFiles();
186
+ * const changes = ref.current?.getChangedFiles();
187
+ * const errors = ref.current?.getErrors();
188
+ * ```
189
+ */
190
+ export interface CodeSandboxHandle {
191
+ /**
192
+ * Replace the entire file set. Diffs against current files, writes only
193
+ * changed files to Nodepod FS, and restarts the server if anything changed.
194
+ *
195
+ * The previous file set becomes `originalFiles` for diff tracking.
196
+ *
197
+ * @param files - Complete new file set
198
+ * @param options - Optional: restartServer (default true)
199
+ */
200
+ updateFiles: (
201
+ files: FileMap,
202
+ options?: { restartServer?: boolean },
203
+ ) => Promise<void>;
175
204
 
176
- export interface GitCommitRequest {
177
- owner: string;
178
- repo: string;
179
- /** Branch to create for the commit */
180
- branch: string;
181
- /** Base branch to fork from (default: 'main') */
182
- baseBranch?: string;
183
- /** Map of file paths to new contents (only changed files) */
184
- changes: FileMap;
185
- /** Commit message */
186
- message: string;
187
- }
205
+ /**
206
+ * Update a single file. Writes to Nodepod FS and updates state.
207
+ * Does NOT restart the server — call `restart()` manually if needed,
208
+ * or use `updateFiles()` for bulk updates with auto-restart.
209
+ *
210
+ * @param path - File path (e.g., "server.js", "public/index.html")
211
+ * @param content - New file content
212
+ */
213
+ updateFile: (path: string, content: string) => Promise<void>;
188
214
 
189
- export interface GitPRRequest extends GitCommitRequest {
190
- /** PR title */
191
- title: string;
192
- /** PR body/description */
193
- body?: string;
194
- }
215
+ /**
216
+ * Force restart the server process. Kills the current process
217
+ * and re-runs the entry command.
218
+ */
219
+ restart: () => Promise<void>;
195
220
 
196
- export interface GitPRResult {
197
- number: number;
198
- url: string;
199
- branch: string;
221
+ /**
222
+ * Get the current file map (all files including edits).
223
+ */
224
+ getFiles: () => FileMap;
225
+
226
+ /**
227
+ * Get only files that have been modified relative to originalFiles.
228
+ * Returns a FileMap containing only the changed files.
229
+ */
230
+ getChangedFiles: () => FileMap;
231
+
232
+ /**
233
+ * Get the per-file change status map (new/modified/deleted).
234
+ * Missing keys should be treated as "unchanged".
235
+ */
236
+ getFileChanges: () => Record<string, FileChangeStatus>;
237
+
238
+ /**
239
+ * Get all structured errors collected during this session.
240
+ */
241
+ getErrors: () => SandboxError[];
242
+
243
+ /**
244
+ * Get the current runtime state snapshot.
245
+ */
246
+ getState: () => RuntimeState;
200
247
  }
201
248
 
202
249
  // ---------------------------------------------------------------------------
203
250
  // Component Props
204
251
  // ---------------------------------------------------------------------------
205
252
 
253
+ /**
254
+ * Props for the CodeSandbox component.
255
+ *
256
+ * The sandbox is a pure renderer — it receives files via props and
257
+ * exposes an imperative handle for programmatic updates. It does NOT
258
+ * interact with any storage backend directly.
259
+ *
260
+ * File sources (for initial load, pick ONE):
261
+ * - `files` — pass a FileMap directly
262
+ * - `template` — use a built-in template name (e.g., "fullstack-starter")
263
+ *
264
+ * For live updates after mount, use the imperative handle:
265
+ * ```tsx
266
+ * const ref = useRef<CodeSandboxHandle>(null);
267
+ * <CodeSandbox ref={ref} files={initialFiles} />
268
+ * // Later:
269
+ * ref.current.updateFiles(newFiles);
270
+ * ```
271
+ */
206
272
  export interface CodeSandboxProps {
207
- /** Pass files directly */
273
+ /** Pass files directly (from any source) */
208
274
  files?: FileMap;
209
- /** OR load from GitHub */
210
- github?: GitHubRepo;
211
- /** GitHub personal access token (for private repos and write-back) */
212
- gitToken?: string;
213
275
  /** OR use a built-in template name */
214
276
  template?: string;
215
277
  /** Command to start the dev server (default inferred from package.json or template) */
@@ -220,9 +282,9 @@ export interface CodeSandboxProps {
220
282
  env?: Record<string, string>;
221
283
 
222
284
  // Callbacks
223
- /** Fires when a file is modified in the editor */
285
+ /** Fires when a file is modified in the editor by the user */
224
286
  onFileChange?: (path: string, content: string) => void;
225
- /** Fires when the dev server is ready */
287
+ /** Fires when the dev server is ready (initial boot or after restart) */
226
288
  onServerReady?: (port: number, url: string) => void;
227
289
  /** Fires on boot progress changes */
228
290
  onProgress?: (progress: BootProgress) => void;
@@ -238,6 +300,12 @@ export interface CodeSandboxProps {
238
300
  * Max 2 auto-fix attempts per error is recommended (see Ranger pattern).
239
301
  */
240
302
  onSandboxError?: (error: SandboxError) => void;
303
+ /**
304
+ * Fires after updateFiles() completes (files written + server restarted).
305
+ * Useful for the host to know when the sandbox has finished processing
306
+ * a file push.
307
+ */
308
+ onFilesUpdated?: (fileChanges: Record<string, FileChangeStatus>) => void;
241
309
 
242
310
  // Layout
243
311
  /** CSS class name for the root element */
@@ -1,57 +0,0 @@
1
- /**
2
- * GitService — GitHub API integration for loading and saving project files.
3
- *
4
- * Flow:
5
- * - Clone: GET repo tree → GET file contents → return FileMap
6
- * - Commit: POST branch → PUT files → POST PR
7
- *
8
- * All operations use the GitHub REST API (no git CLI needed).
9
- * Works entirely in the browser — no server required.
10
- */
11
- import type { FileMap, GitCommitRequest, GitHubRepo, GitPRRequest, GitPRResult } from "../types";
12
- /**
13
- * GitHub API service for cloning repos and creating PRs.
14
- *
15
- * @example
16
- * ```ts
17
- * const git = new GitService('ghp_xxxxx');
18
- * const files = await git.cloneRepo({ owner: 'user', repo: 'my-app' });
19
- * // ... user edits files ...
20
- * const pr = await git.createPR({
21
- * owner: 'user', repo: 'my-app',
22
- * branch: 'sandbox/changes', changes: editedFiles,
23
- * message: 'Update from sandbox', title: 'Sandbox changes',
24
- * });
25
- * ```
26
- */
27
- export declare class GitService {
28
- private token;
29
- constructor(token: string);
30
- /**
31
- * Clone a GitHub repository into a flat file map.
32
- *
33
- * Uses the Git Trees API for a single request to get the full file listing,
34
- * then fetches text file contents in parallel batches.
35
- *
36
- * @param repo - GitHub repository coordinates
37
- * @param onProgress - Optional progress callback (0-100)
38
- * @returns FileMap of path → content
39
- */
40
- cloneRepo(repo: GitHubRepo, onProgress?: (percent: number, message: string) => void): Promise<FileMap>;
41
- /**
42
- * Create a new branch with file changes and open a pull request.
43
- *
44
- * Steps:
45
- * 1. Get the base branch's HEAD SHA
46
- * 2. Create a new branch from that SHA
47
- * 3. For each changed file, update its contents on the new branch
48
- * 4. Create a pull request
49
- */
50
- createPR(request: GitPRRequest): Promise<GitPRResult>;
51
- /**
52
- * Commit changes without creating a PR.
53
- */
54
- commit(request: GitCommitRequest): Promise<string>;
55
- /** Fetch with auth headers */
56
- private fetch;
57
- }
@@ -1,415 +0,0 @@
1
- /**
2
- * GitService — GitHub API integration for loading and saving project files.
3
- *
4
- * Flow:
5
- * - Clone: GET repo tree → GET file contents → return FileMap
6
- * - Commit: POST branch → PUT files → POST PR
7
- *
8
- * All operations use the GitHub REST API (no git CLI needed).
9
- * Works entirely in the browser — no server required.
10
- */
11
-
12
- import type {
13
- FileMap,
14
- GitCommitRequest,
15
- GitHubRepo,
16
- GitPRRequest,
17
- GitPRResult,
18
- } from "../types";
19
-
20
- const GITHUB_API = "https://api.github.com";
21
-
22
- /** File extensions to treat as text (others are skipped) */
23
- const TEXT_EXTENSIONS = new Set([
24
- ".js",
25
- ".jsx",
26
- ".ts",
27
- ".tsx",
28
- ".json",
29
- ".html",
30
- ".htm",
31
- ".css",
32
- ".scss",
33
- ".sass",
34
- ".less",
35
- ".md",
36
- ".mdx",
37
- ".txt",
38
- ".yml",
39
- ".yaml",
40
- ".toml",
41
- ".xml",
42
- ".svg",
43
- ".env",
44
- ".gitignore",
45
- ".eslintrc",
46
- ".prettierrc",
47
- ".sh",
48
- ".bash",
49
- ".zsh",
50
- ".py",
51
- ".rb",
52
- ".go",
53
- ".rs",
54
- ".java",
55
- ".kt",
56
- ".c",
57
- ".cpp",
58
- ".h",
59
- ".hpp",
60
- ".cs",
61
- ".php",
62
- ".sql",
63
- ".graphql",
64
- ".lock",
65
- ".mjs",
66
- ".cjs",
67
- ".mts",
68
- ".cts",
69
- ".vue",
70
- ".svelte",
71
- ".astro",
72
- ]);
73
-
74
- /** Directories to skip when cloning */
75
- const SKIP_DIRS = new Set([
76
- "node_modules",
77
- ".git",
78
- "dist",
79
- "build",
80
- ".next",
81
- ".cache",
82
- "__pycache__",
83
- ".venv",
84
- "venv",
85
- "coverage",
86
- ".nyc_output",
87
- ]);
88
-
89
- /** Max file size to fetch (skip large files) */
90
- const MAX_FILE_SIZE = 500_000; // 500KB
91
-
92
- /**
93
- * GitHub API service for cloning repos and creating PRs.
94
- *
95
- * @example
96
- * ```ts
97
- * const git = new GitService('ghp_xxxxx');
98
- * const files = await git.cloneRepo({ owner: 'user', repo: 'my-app' });
99
- * // ... user edits files ...
100
- * const pr = await git.createPR({
101
- * owner: 'user', repo: 'my-app',
102
- * branch: 'sandbox/changes', changes: editedFiles,
103
- * message: 'Update from sandbox', title: 'Sandbox changes',
104
- * });
105
- * ```
106
- */
107
- export class GitService {
108
- private token: string;
109
-
110
- constructor(token: string) {
111
- this.token = token;
112
- }
113
-
114
- // -------------------------------------------------------------------------
115
- // Clone: fetch repo files as a FileMap
116
- // -------------------------------------------------------------------------
117
-
118
- /**
119
- * Clone a GitHub repository into a flat file map.
120
- *
121
- * Uses the Git Trees API for a single request to get the full file listing,
122
- * then fetches text file contents in parallel batches.
123
- *
124
- * @param repo - GitHub repository coordinates
125
- * @param onProgress - Optional progress callback (0-100)
126
- * @returns FileMap of path → content
127
- */
128
- async cloneRepo(
129
- repo: GitHubRepo,
130
- onProgress?: (percent: number, message: string) => void,
131
- ): Promise<FileMap> {
132
- const { owner, repo: repoName, branch = "main", path: subPath } = repo;
133
-
134
- onProgress?.(5, "Fetching repository structure...");
135
-
136
- // 1. Get the tree (recursive) for the branch
137
- const treeUrl = `${GITHUB_API}/repos/${owner}/${repoName}/git/trees/${branch}?recursive=1`;
138
- const treeRes = await this.fetch(treeUrl);
139
- const treeData = await treeRes.json();
140
-
141
- if (!treeData.tree) {
142
- throw new Error(
143
- `Failed to fetch repo tree: ${treeData.message || "Unknown error"}`,
144
- );
145
- }
146
-
147
- // 2. Filter to text files, skip large/binary/excluded dirs
148
- const filesToFetch: Array<{ path: string; sha: string; size: number }> = [];
149
-
150
- for (const item of treeData.tree) {
151
- if (item.type !== "blob") {
152
- continue;
153
- }
154
-
155
- // Apply subPath filter if specified
156
- if (subPath && !item.path.startsWith(subPath)) {
157
- continue;
158
- }
159
-
160
- // Skip excluded directories
161
- const parts = item.path.split("/");
162
- if (parts.some((p: string) => SKIP_DIRS.has(p))) {
163
- continue;
164
- }
165
-
166
- // Skip files that are too large
167
- if (item.size > MAX_FILE_SIZE) {
168
- continue;
169
- }
170
-
171
- // Only include text files
172
- const ext = "." + item.path.split(".").pop()?.toLowerCase();
173
- const basename = parts[parts.length - 1];
174
- // Include files with known extensions, dotfiles, or extensionless config files
175
- if (
176
- TEXT_EXTENSIONS.has(ext) ||
177
- basename.startsWith(".") ||
178
- !basename.includes(".")
179
- ) {
180
- filesToFetch.push({ path: item.path, sha: item.sha, size: item.size });
181
- }
182
- }
183
-
184
- onProgress?.(15, `Found ${filesToFetch.length} files to fetch...`);
185
-
186
- // 3. Fetch file contents in parallel batches
187
- const files: FileMap = {};
188
- const batchSize = 20;
189
-
190
- for (let i = 0; i < filesToFetch.length; i += batchSize) {
191
- const batch = filesToFetch.slice(i, i + batchSize);
192
-
193
- const results = await Promise.all(
194
- batch.map(async (file) => {
195
- try {
196
- const contentUrl = `${GITHUB_API}/repos/${owner}/${repoName}/contents/${file.path}?ref=${branch}`;
197
- const res = await this.fetch(contentUrl);
198
- const data = await res.json();
199
-
200
- if (data.encoding === "base64" && data.content) {
201
- const content = atob(data.content.replace(/\n/g, ""));
202
- // Strip subPath prefix if specified
203
- const relativePath = subPath
204
- ? file.path.replace(new RegExp(`^${subPath}/?`), "")
205
- : file.path;
206
- return { path: relativePath, content };
207
- }
208
- return null;
209
- } catch {
210
- return null;
211
- }
212
- }),
213
- );
214
-
215
- for (const result of results) {
216
- if (result) {
217
- files[result.path] = result.content;
218
- }
219
- }
220
-
221
- const percent =
222
- 15 + Math.round(((i + batch.length) / filesToFetch.length) * 80);
223
- onProgress?.(
224
- percent,
225
- `Fetching files... (${Math.min(i + batchSize, filesToFetch.length)}/${filesToFetch.length})`,
226
- );
227
- }
228
-
229
- onProgress?.(100, `Loaded ${Object.keys(files).length} files`);
230
-
231
- return files;
232
- }
233
-
234
- // -------------------------------------------------------------------------
235
- // Commit + PR: write changes back to GitHub
236
- // -------------------------------------------------------------------------
237
-
238
- /**
239
- * Create a new branch with file changes and open a pull request.
240
- *
241
- * Steps:
242
- * 1. Get the base branch's HEAD SHA
243
- * 2. Create a new branch from that SHA
244
- * 3. For each changed file, update its contents on the new branch
245
- * 4. Create a pull request
246
- */
247
- async createPR(request: GitPRRequest): Promise<GitPRResult> {
248
- const {
249
- owner,
250
- repo,
251
- branch,
252
- baseBranch = "main",
253
- changes,
254
- message,
255
- title,
256
- body,
257
- } = request;
258
-
259
- // 1. Get base branch HEAD SHA
260
- const refRes = await this.fetch(
261
- `${GITHUB_API}/repos/${owner}/${repo}/git/ref/heads/${baseBranch}`,
262
- );
263
- const refData = await refRes.json();
264
- const baseSha = refData.object.sha;
265
-
266
- // 2. Create new branch
267
- await this.fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/refs`, {
268
- method: "POST",
269
- body: JSON.stringify({
270
- ref: `refs/heads/${branch}`,
271
- sha: baseSha,
272
- }),
273
- });
274
-
275
- // 3. Commit each changed file
276
- for (const [path, content] of Object.entries(changes)) {
277
- // Get current file SHA (if it exists)
278
- let fileSha: string | undefined;
279
- try {
280
- const fileRes = await this.fetch(
281
- `${GITHUB_API}/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
282
- );
283
- const fileData = await fileRes.json();
284
- fileSha = fileData.sha;
285
- } catch {
286
- // File doesn't exist yet — that's fine, we'll create it
287
- }
288
-
289
- await this.fetch(
290
- `${GITHUB_API}/repos/${owner}/${repo}/contents/${path}`,
291
- {
292
- method: "PUT",
293
- body: JSON.stringify({
294
- message,
295
- content: btoa(content),
296
- branch,
297
- ...(fileSha ? { sha: fileSha } : {}),
298
- }),
299
- },
300
- );
301
- }
302
-
303
- // 4. Create pull request
304
- const prRes = await this.fetch(
305
- `${GITHUB_API}/repos/${owner}/${repo}/pulls`,
306
- {
307
- method: "POST",
308
- body: JSON.stringify({
309
- title,
310
- body:
311
- body ||
312
- `Changes from code sandbox.\n\nModified files:\n${Object.keys(
313
- changes,
314
- )
315
- .map((p) => `- ${p}`)
316
- .join("\n")}`,
317
- head: branch,
318
- base: baseBranch,
319
- }),
320
- },
321
- );
322
-
323
- const prData = await prRes.json();
324
-
325
- return {
326
- number: prData.number,
327
- url: prData.html_url,
328
- branch,
329
- };
330
- }
331
-
332
- /**
333
- * Commit changes without creating a PR.
334
- */
335
- async commit(request: GitCommitRequest): Promise<string> {
336
- const {
337
- owner,
338
- repo,
339
- branch,
340
- baseBranch = "main",
341
- changes,
342
- message,
343
- } = request;
344
-
345
- // Get base SHA
346
- const refRes = await this.fetch(
347
- `${GITHUB_API}/repos/${owner}/${repo}/git/ref/heads/${baseBranch}`,
348
- );
349
- const refData = await refRes.json();
350
- const baseSha = refData.object.sha;
351
-
352
- // Create branch (may already exist)
353
- try {
354
- await this.fetch(`${GITHUB_API}/repos/${owner}/${repo}/git/refs`, {
355
- method: "POST",
356
- body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: baseSha }),
357
- });
358
- } catch {
359
- // Branch already exists — continue
360
- }
361
-
362
- // Commit files
363
- for (const [path, content] of Object.entries(changes)) {
364
- let fileSha: string | undefined;
365
- try {
366
- const fileRes = await this.fetch(
367
- `${GITHUB_API}/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
368
- );
369
- const fileData = await fileRes.json();
370
- fileSha = fileData.sha;
371
- } catch {
372
- // New file
373
- }
374
-
375
- await this.fetch(
376
- `${GITHUB_API}/repos/${owner}/${repo}/contents/${path}`,
377
- {
378
- method: "PUT",
379
- body: JSON.stringify({
380
- message,
381
- content: btoa(content),
382
- branch,
383
- ...(fileSha ? { sha: fileSha } : {}),
384
- }),
385
- },
386
- );
387
- }
388
-
389
- return branch;
390
- }
391
-
392
- // -------------------------------------------------------------------------
393
- // Private
394
- // -------------------------------------------------------------------------
395
-
396
- /** Fetch with auth headers */
397
- private async fetch(url: string, init?: RequestInit): Promise<Response> {
398
- const res = await fetch(url, {
399
- ...init,
400
- headers: {
401
- Authorization: `Bearer ${this.token}`,
402
- Accept: "application/vnd.github.v3+json",
403
- "Content-Type": "application/json",
404
- ...(init?.headers || {}),
405
- },
406
- });
407
-
408
- if (!res.ok) {
409
- const body = await res.text();
410
- throw new Error(`GitHub API error ${res.status}: ${body}`);
411
- }
412
-
413
- return res;
414
- }
415
- }