castle-web-cli 0.4.117 → 0.4.118
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/dist/ide.d.ts +5 -3
- package/dist/ide.js +161 -175
- package/dist/importBrowse.d.ts +3 -3
- package/dist/importBrowse.js +42 -34
- package/dist/serve.js +21 -5
- package/dist/shell/assets/{index-DiPlPGyg.js → index-DsOo_SWO.js} +1 -1
- package/dist/shell/index.html +1 -1
- package/package.json +1 -1
package/dist/ide.js
CHANGED
|
@@ -6,56 +6,55 @@
|
|
|
6
6
|
// Backend pattern ported from castle-cli's `ide` branch: @lydell/node-pty for
|
|
7
7
|
// the PTY, an @xterm/headless screen + @xterm/addon-serialize so a reconnecting
|
|
8
8
|
// client gets a full replay of the current screen + scrollback.
|
|
9
|
-
import * as fs from
|
|
10
|
-
import * as path from
|
|
11
|
-
import picomatch from
|
|
12
|
-
import { fileURLToPath } from
|
|
13
|
-
import { spawn as spawnPty } from
|
|
14
|
-
import headlessPkg from
|
|
15
|
-
import { SerializeAddon } from
|
|
16
|
-
import { WebSocketServer } from
|
|
17
|
-
import { IMPORTS_DIR, importStatuses, updateImport } from
|
|
18
|
-
import { readEditorConfig, resolveFileTypes, } from
|
|
19
|
-
import { UNSUPPORTED_MEDIA } from
|
|
20
|
-
import { IMPORT_API_PREFIX, handleImportApi } from
|
|
21
|
-
import { readRequestBody, sendJson } from
|
|
22
|
-
import { envForUserShell, installCliShims } from
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import picomatch from 'picomatch';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
import { spawn as spawnPty } from '@lydell/node-pty';
|
|
14
|
+
import headlessPkg from '@xterm/headless';
|
|
15
|
+
import { SerializeAddon } from '@xterm/addon-serialize';
|
|
16
|
+
import { WebSocketServer } from 'ws';
|
|
17
|
+
import { IMPORTS_DIR, importStatuses, updateImport } from './imports.js';
|
|
18
|
+
import { readEditorConfig, resolveFileTypes, } from './editorConfig.js';
|
|
19
|
+
import { UNSUPPORTED_MEDIA } from './unsupportedMedia.js';
|
|
20
|
+
import { IMPORT_API_PREFIX, handleImportApi } from './importBrowse.js';
|
|
21
|
+
import { readRequestBody, sendJson } from './httpJson.js';
|
|
22
|
+
import { envForUserShell, installCliShims } from './byo-auth.js';
|
|
23
23
|
const HeadlessTerminal = headlessPkg.Terminal;
|
|
24
24
|
const DIST_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
25
25
|
// The bundled shell app (vite build output). `/` serves its index.html and
|
|
26
26
|
// `/__castle/ide/<asset>` serves its hashed bundle assets (the vite `base`).
|
|
27
|
-
const SHELL_DIR = path.join(DIST_DIR,
|
|
28
|
-
const PTY_TERM =
|
|
27
|
+
const SHELL_DIR = path.join(DIST_DIR, 'shell');
|
|
28
|
+
const PTY_TERM = 'xterm-256color';
|
|
29
29
|
const INITIAL_COLS = 80;
|
|
30
30
|
const INITIAL_ROWS = 24;
|
|
31
31
|
const SCROLLBACK = 4000;
|
|
32
32
|
const SHELL_MIME = {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
33
|
+
'.html': 'text/html; charset=utf-8',
|
|
34
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
35
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
36
|
+
'.css': 'text/css; charset=utf-8',
|
|
37
|
+
'.json': 'application/json; charset=utf-8',
|
|
38
|
+
'.svg': 'image/svg+xml',
|
|
39
|
+
'.png': 'image/png',
|
|
40
|
+
'.ico': 'image/x-icon',
|
|
41
|
+
'.jpg': 'image/jpeg',
|
|
42
|
+
'.woff': 'font/woff',
|
|
43
|
+
'.woff2': 'font/woff2',
|
|
44
|
+
'.ttf': 'font/ttf',
|
|
45
|
+
'.map': 'application/json; charset=utf-8',
|
|
46
46
|
};
|
|
47
47
|
// Does the deck serve this root-level file itself? Vite serves both the deck
|
|
48
48
|
// root and its `public/` dir at `/`, so either location counts.
|
|
49
49
|
function deckHasFile(deckDir, name) {
|
|
50
|
-
return (fs.existsSync(path.join(deckDir, name)) ||
|
|
51
|
-
fs.existsSync(path.join(deckDir, "public", name)));
|
|
50
|
+
return (fs.existsSync(path.join(deckDir, name)) || fs.existsSync(path.join(deckDir, 'public', name)));
|
|
52
51
|
}
|
|
53
52
|
// Serve a file from the bundled shell dir, guarding against path traversal.
|
|
54
53
|
function serveShellFile(res, asset) {
|
|
55
|
-
const rel = path.normalize(asset).replace(/^(\.\.[/\\])+/,
|
|
54
|
+
const rel = path.normalize(asset).replace(/^(\.\.[/\\])+/, '');
|
|
56
55
|
const filePath = path.join(SHELL_DIR, rel);
|
|
57
56
|
if (!filePath.startsWith(SHELL_DIR + path.sep) &&
|
|
58
|
-
filePath !== path.join(SHELL_DIR,
|
|
57
|
+
filePath !== path.join(SHELL_DIR, 'index.html')) {
|
|
59
58
|
res.writeHead(404).end();
|
|
60
59
|
return true;
|
|
61
60
|
}
|
|
@@ -64,8 +63,8 @@ function serveShellFile(res, asset) {
|
|
|
64
63
|
return true;
|
|
65
64
|
}
|
|
66
65
|
res.writeHead(200, {
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
'content-type': SHELL_MIME[path.extname(filePath)] ?? 'application/octet-stream',
|
|
67
|
+
'cache-control': 'no-store',
|
|
69
68
|
});
|
|
70
69
|
fs.createReadStream(filePath).pipe(res);
|
|
71
70
|
return true;
|
|
@@ -74,34 +73,30 @@ function serveShellFile(res, asset) {
|
|
|
74
73
|
// into the iframe at `/index.html`); `/__castle/ide/<asset>` are the shell's
|
|
75
74
|
// static assets; `/__castle/pty` is the PTY WebSocket (handled via a direct
|
|
76
75
|
// upgrade handler on Vite's HTTP server).
|
|
77
|
-
export const IDE_ASSET_PREFIX =
|
|
78
|
-
export const PTY_WS_PATH =
|
|
76
|
+
export const IDE_ASSET_PREFIX = '/__castle/ide/';
|
|
77
|
+
export const PTY_WS_PATH = '/__castle/pty';
|
|
79
78
|
// Castle's favicon (the same files castle.xyz serves), shipped in the shell
|
|
80
79
|
// bundle and also served from the origin root so every page under the serve
|
|
81
80
|
// gets it -- the shell at `/`, the deck page at `/index.html`, and the
|
|
82
81
|
// browser's implicit `/favicon.ico` probe. The deck wins if it ships its own.
|
|
83
|
-
export const FAVICON_FILES = [
|
|
84
|
-
"favicon.ico",
|
|
85
|
-
"favicon-16x16.png",
|
|
86
|
-
"favicon-32x32.png",
|
|
87
|
-
];
|
|
82
|
+
export const FAVICON_FILES = ['favicon.ico', 'favicon-16x16.png', 'favicon-32x32.png'];
|
|
88
83
|
// `<link rel="icon">` tags for the root-served favicons, injected into the deck
|
|
89
84
|
// page (see serve.ts). The shell's own tags live in `src/shell/index.html`.
|
|
90
85
|
export const FAVICON_LINK_TAGS = [
|
|
91
86
|
'<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />',
|
|
92
87
|
'<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />',
|
|
93
88
|
'<link rel="icon" href="/favicon.ico" sizes="any" />',
|
|
94
|
-
].join(
|
|
89
|
+
].join('\n ');
|
|
95
90
|
// Builtin Files + code-editor panels talk to the deck through these endpoints
|
|
96
91
|
// (the shell no longer routes file browsing / code editing through the kit
|
|
97
92
|
// iframe). `list`/`read`/`write` operate on files within the deck dir;
|
|
98
93
|
// `info` reports which extensions the kit owns a rich editor for (so the shell
|
|
99
94
|
// hands those files to the kit iframe and keeps the builtin editor as the
|
|
100
95
|
// default for everything else).
|
|
101
|
-
export const FILES_API_PREFIX =
|
|
96
|
+
export const FILES_API_PREFIX = '/__castle/files/';
|
|
102
97
|
// Directories never surfaced in the file list / never read or written through
|
|
103
98
|
// the builtin editor: VCS, deck-private state, and dependency trees.
|
|
104
|
-
const FILES_IGNORE_DIRS = new Set([
|
|
99
|
+
const FILES_IGNORE_DIRS = new Set(['.git', '.castle', 'node_modules', 'dist']);
|
|
105
100
|
// Ceiling on one uploaded file. A deck is saved whole (source tar + bundle), so
|
|
106
101
|
// a huge asset is a problem for the deck long before it is a problem here --
|
|
107
102
|
// refuse it at the door with a message rather than let it land and break `save`.
|
|
@@ -111,24 +106,24 @@ const MAX_UPLOAD_BYTES = 32 * 1024 * 1024;
|
|
|
111
106
|
// which is text-only). Anything unlisted is served as opaque bytes -- a viewer
|
|
112
107
|
// that asked for it knows what it is.
|
|
113
108
|
const RAW_MIME = {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
109
|
+
'.png': 'image/png',
|
|
110
|
+
'.jpg': 'image/jpeg',
|
|
111
|
+
'.jpeg': 'image/jpeg',
|
|
112
|
+
'.gif': 'image/gif',
|
|
113
|
+
'.webp': 'image/webp',
|
|
114
|
+
'.avif': 'image/avif',
|
|
115
|
+
'.bmp': 'image/bmp',
|
|
116
|
+
'.svg': 'image/svg+xml',
|
|
117
|
+
'.mp3': 'audio/mpeg',
|
|
118
|
+
'.wav': 'audio/wav',
|
|
119
|
+
'.ogg': 'audio/ogg',
|
|
120
|
+
'.m4a': 'audio/mp4',
|
|
121
|
+
'.aac': 'audio/aac',
|
|
122
|
+
'.flac': 'audio/flac',
|
|
123
|
+
'.webm': 'video/webm',
|
|
124
|
+
'.mp4': 'video/mp4',
|
|
125
|
+
'.m4v': 'video/x-m4v',
|
|
126
|
+
'.mov': 'video/quicktime',
|
|
132
127
|
};
|
|
133
128
|
// Files under `imports/` came from another deck. They are readable like any
|
|
134
129
|
// other deck file -- the engine, editors and pickers all reference them -- but a
|
|
@@ -144,12 +139,12 @@ function isImportPath(rel) {
|
|
|
144
139
|
// that are about to CHANGE the file pass `mutation`, which additionally refuses
|
|
145
140
|
// anything a dependency owns.
|
|
146
141
|
function resolveDeckPath(deckDir, requestedPath, opts = {}) {
|
|
147
|
-
if (typeof requestedPath !==
|
|
148
|
-
return { ok: false, error:
|
|
142
|
+
if (typeof requestedPath !== 'string' || requestedPath.trim() === '') {
|
|
143
|
+
return { ok: false, error: 'Missing file path.' };
|
|
149
144
|
}
|
|
150
|
-
const normalized = path.normalize(requestedPath.replace(/\\/g,
|
|
151
|
-
if (normalized ===
|
|
152
|
-
normalized ===
|
|
145
|
+
const normalized = path.normalize(requestedPath.replace(/\\/g, '/'));
|
|
146
|
+
if (normalized === '.' ||
|
|
147
|
+
normalized === '..' ||
|
|
153
148
|
path.isAbsolute(normalized) ||
|
|
154
149
|
normalized.startsWith(`..${path.sep}`)) {
|
|
155
150
|
return { ok: false, error: `Path outside the deck: ${requestedPath}` };
|
|
@@ -160,10 +155,10 @@ function resolveDeckPath(deckDir, requestedPath, opts = {}) {
|
|
|
160
155
|
}
|
|
161
156
|
const abs = path.resolve(deckDir, normalized);
|
|
162
157
|
const rel = path.relative(deckDir, abs);
|
|
163
|
-
if (rel.startsWith(
|
|
158
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
164
159
|
return { ok: false, error: `Path outside the deck: ${requestedPath}` };
|
|
165
160
|
}
|
|
166
|
-
const relPosix = rel.split(path.sep).join(
|
|
161
|
+
const relPosix = rel.split(path.sep).join('/');
|
|
167
162
|
if (opts.mutation && isImportPath(relPosix)) {
|
|
168
163
|
return {
|
|
169
164
|
ok: false,
|
|
@@ -185,7 +180,7 @@ function listDeckFiles(deckDir) {
|
|
|
185
180
|
return;
|
|
186
181
|
}
|
|
187
182
|
for (const entry of entries) {
|
|
188
|
-
if (entry.name ===
|
|
183
|
+
if (entry.name === '.DS_Store')
|
|
189
184
|
continue;
|
|
190
185
|
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
191
186
|
if (entry.isDirectory()) {
|
|
@@ -198,7 +193,7 @@ function listDeckFiles(deckDir) {
|
|
|
198
193
|
}
|
|
199
194
|
}
|
|
200
195
|
}
|
|
201
|
-
walk(deckDir,
|
|
196
|
+
walk(deckDir, '');
|
|
202
197
|
out.sort((a, b) => a.localeCompare(b));
|
|
203
198
|
return out;
|
|
204
199
|
}
|
|
@@ -208,7 +203,7 @@ function listDeckFiles(deckDir) {
|
|
|
208
203
|
// editors, and the builtin code editor renders everything.
|
|
209
204
|
function kitEditorExtensions(config, fileTypes) {
|
|
210
205
|
if (fileTypes) {
|
|
211
|
-
return fileTypes.filter((t) => t.editor ===
|
|
206
|
+
return fileTypes.filter((t) => t.editor === 'kit').map((t) => t.ext);
|
|
212
207
|
}
|
|
213
208
|
// A deck saved before file types existed named its kit extensions directly.
|
|
214
209
|
return config.extensions ?? [];
|
|
@@ -235,14 +230,14 @@ function filterDeckFiles(files, config) {
|
|
|
235
230
|
function filterImportedFiles(deckDir, imported) {
|
|
236
231
|
const byAlias = new Map();
|
|
237
232
|
for (const file of imported) {
|
|
238
|
-
const [, alias, ...rest] = file.split(
|
|
233
|
+
const [, alias, ...rest] = file.split('/');
|
|
239
234
|
if (!alias || rest.length === 0)
|
|
240
235
|
continue;
|
|
241
236
|
const list = byAlias.get(alias);
|
|
242
237
|
if (list)
|
|
243
|
-
list.push(rest.join(
|
|
238
|
+
list.push(rest.join('/'));
|
|
244
239
|
else
|
|
245
|
-
byAlias.set(alias, [rest.join(
|
|
240
|
+
byAlias.set(alias, [rest.join('/')]);
|
|
246
241
|
}
|
|
247
242
|
const out = [];
|
|
248
243
|
for (const [alias, relFiles] of byAlias) {
|
|
@@ -261,7 +256,7 @@ function readRequestBuffer(req, limit) {
|
|
|
261
256
|
const chunks = [];
|
|
262
257
|
let size = 0;
|
|
263
258
|
let over = false;
|
|
264
|
-
req.on(
|
|
259
|
+
req.on('data', (c) => {
|
|
265
260
|
size += c.length;
|
|
266
261
|
// Past the cap, keep draining but stop KEEPING the bytes. Destroying the
|
|
267
262
|
// request here would reset the connection and the client would see a
|
|
@@ -273,8 +268,8 @@ function readRequestBuffer(req, limit) {
|
|
|
273
268
|
}
|
|
274
269
|
chunks.push(c);
|
|
275
270
|
});
|
|
276
|
-
req.on(
|
|
277
|
-
req.on(
|
|
271
|
+
req.on('end', () => over ? reject(new RangeError('too large')) : resolve(Buffer.concat(chunks)));
|
|
272
|
+
req.on('error', reject);
|
|
278
273
|
});
|
|
279
274
|
}
|
|
280
275
|
function withJsonBody(req, res, handler) {
|
|
@@ -284,7 +279,7 @@ function withJsonBody(req, res, handler) {
|
|
|
284
279
|
body = JSON.parse(await readRequestBody(req));
|
|
285
280
|
}
|
|
286
281
|
catch {
|
|
287
|
-
return sendJson(res, 400, { error:
|
|
282
|
+
return sendJson(res, 400, { error: 'Invalid JSON body.' });
|
|
288
283
|
}
|
|
289
284
|
handler(body);
|
|
290
285
|
})();
|
|
@@ -305,16 +300,16 @@ function sendFailure(res, action, rel, err) {
|
|
|
305
300
|
}
|
|
306
301
|
function handleFilesWrite(deckDir, req, res) {
|
|
307
302
|
withMutationPath(deckDir, req, res, (target, body) => {
|
|
308
|
-
if (typeof body.contents !==
|
|
309
|
-
return sendJson(res, 400, { error:
|
|
303
|
+
if (typeof body.contents !== 'string') {
|
|
304
|
+
return sendJson(res, 400, { error: 'File contents must be a string.' });
|
|
310
305
|
}
|
|
311
306
|
try {
|
|
312
307
|
fs.mkdirSync(path.dirname(target.abs), { recursive: true });
|
|
313
|
-
fs.writeFileSync(target.abs, body.contents,
|
|
308
|
+
fs.writeFileSync(target.abs, body.contents, 'utf8');
|
|
314
309
|
sendJson(res, 200, { ok: true, path: target.rel });
|
|
315
310
|
}
|
|
316
311
|
catch (err) {
|
|
317
|
-
sendFailure(res,
|
|
312
|
+
sendFailure(res, 'write', target.rel, err);
|
|
318
313
|
}
|
|
319
314
|
});
|
|
320
315
|
}
|
|
@@ -329,16 +324,16 @@ function ensureVisiblePath(deckDir, rel) {
|
|
|
329
324
|
// No-op if an existing glob already covers `probe`, a path the new glob would
|
|
330
325
|
// match. Returns whether it wrote.
|
|
331
326
|
function ensureVisibleGlob(deckDir, glob, probe) {
|
|
332
|
-
const file = path.join(deckDir,
|
|
327
|
+
const file = path.join(deckDir, 'castle.json');
|
|
333
328
|
let data;
|
|
334
329
|
try {
|
|
335
|
-
data = JSON.parse(fs.readFileSync(file,
|
|
330
|
+
data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
336
331
|
}
|
|
337
332
|
catch {
|
|
338
333
|
return false; // no castle.json yet (deck never saved) -> treat as not curated
|
|
339
334
|
}
|
|
340
335
|
const visible = data.editor && Array.isArray(data.editor.visiblePaths)
|
|
341
|
-
? data.editor.visiblePaths.filter((v) => typeof v ===
|
|
336
|
+
? data.editor.visiblePaths.filter((v) => typeof v === 'string')
|
|
342
337
|
: null;
|
|
343
338
|
if (!visible || visible.length === 0)
|
|
344
339
|
return false; // not curated -> all visible
|
|
@@ -349,7 +344,7 @@ function ensureVisibleGlob(deckDir, glob, probe) {
|
|
|
349
344
|
visible.push(glob);
|
|
350
345
|
data.editor.visiblePaths = visible;
|
|
351
346
|
try {
|
|
352
|
-
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`,
|
|
347
|
+
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
353
348
|
return true;
|
|
354
349
|
}
|
|
355
350
|
catch {
|
|
@@ -362,7 +357,7 @@ function handleFilesMkdir(deckDir, req, res) {
|
|
|
362
357
|
fs.mkdirSync(target.abs, { recursive: true });
|
|
363
358
|
}
|
|
364
359
|
catch (err) {
|
|
365
|
-
return sendFailure(res,
|
|
360
|
+
return sendFailure(res, 'create folder', target.rel, err);
|
|
366
361
|
}
|
|
367
362
|
const visiblePathAdded = ensureVisiblePath(deckDir, target.rel);
|
|
368
363
|
sendJson(res, 200, { ok: true, path: target.rel, visiblePathAdded });
|
|
@@ -378,8 +373,8 @@ function handleFilesMkdir(deckDir, req, res) {
|
|
|
378
373
|
// the same way mkdir does -- otherwise an upload lands on disk and is invisible
|
|
379
374
|
// in the very panel that put it there.
|
|
380
375
|
function handleFilesUpload(deckDir, req, res) {
|
|
381
|
-
const url = new URL(req.url ??
|
|
382
|
-
const target = resolveDeckPath(deckDir, url.searchParams.get(
|
|
376
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
377
|
+
const target = resolveDeckPath(deckDir, url.searchParams.get('path'), {
|
|
383
378
|
mutation: true,
|
|
384
379
|
});
|
|
385
380
|
if (!target.ok)
|
|
@@ -402,24 +397,24 @@ function handleFilesUpload(deckDir, req, res) {
|
|
|
402
397
|
fs.writeFileSync(target.abs, bytes);
|
|
403
398
|
}
|
|
404
399
|
catch (err) {
|
|
405
|
-
return sendFailure(res,
|
|
400
|
+
return sendFailure(res, 'upload', target.rel, err);
|
|
406
401
|
}
|
|
407
402
|
const dir = path.posix.dirname(target.rel);
|
|
408
|
-
const visiblePathAdded = dir ===
|
|
403
|
+
const visiblePathAdded = dir === '.'
|
|
409
404
|
? ensureVisibleGlob(deckDir, target.rel, target.rel)
|
|
410
405
|
: ensureVisiblePath(deckDir, dir);
|
|
411
406
|
sendJson(res, 200, { ok: true, path: target.rel, visiblePathAdded });
|
|
412
407
|
}, (err) => {
|
|
413
408
|
if (err instanceof RangeError)
|
|
414
409
|
return tooBig();
|
|
415
|
-
sendFailure(res,
|
|
410
|
+
sendFailure(res, 'upload', target.rel, err);
|
|
416
411
|
});
|
|
417
412
|
}
|
|
418
413
|
// Serve a deck file as bytes. `read` returns UTF-8 text in JSON, which mangles
|
|
419
414
|
// anything binary; the shell's image/audio viewers read through this instead.
|
|
420
415
|
function handleFilesRaw(deckDir, req, res) {
|
|
421
|
-
const url = new URL(req.url ??
|
|
422
|
-
const resolved = resolveDeckPath(deckDir, url.searchParams.get(
|
|
416
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
417
|
+
const resolved = resolveDeckPath(deckDir, url.searchParams.get('path'));
|
|
423
418
|
if (!resolved.ok)
|
|
424
419
|
return sendJson(res, 400, { error: resolved.error });
|
|
425
420
|
let stat;
|
|
@@ -433,44 +428,43 @@ function handleFilesRaw(deckDir, req, res) {
|
|
|
433
428
|
return sendJson(res, 404, { error: `Not a file: ${resolved.rel}` });
|
|
434
429
|
}
|
|
435
430
|
const headers = {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
"cache-control": "no-store",
|
|
431
|
+
'content-type': RAW_MIME[path.extname(resolved.abs).toLowerCase()] ?? 'application/octet-stream',
|
|
432
|
+
'cache-control': 'no-store',
|
|
439
433
|
// Seeking an <audio>/<video> is a range request; without this the browser
|
|
440
434
|
// won't scrub (it re-requests from 0 and the playhead snaps back).
|
|
441
|
-
|
|
435
|
+
'accept-ranges': 'bytes',
|
|
442
436
|
};
|
|
443
437
|
const range = parseByteRange(req.headers.range, stat.size);
|
|
444
|
-
if (range ===
|
|
445
|
-
res.writeHead(416, {
|
|
438
|
+
if (range === 'unsatisfiable') {
|
|
439
|
+
res.writeHead(416, { 'content-range': `bytes */${stat.size}` }).end();
|
|
446
440
|
return;
|
|
447
441
|
}
|
|
448
442
|
if (!range) {
|
|
449
|
-
res.writeHead(200, { ...headers,
|
|
443
|
+
res.writeHead(200, { ...headers, 'content-length': String(stat.size) });
|
|
450
444
|
fs.createReadStream(resolved.abs).pipe(res);
|
|
451
445
|
return;
|
|
452
446
|
}
|
|
453
447
|
res.writeHead(206, {
|
|
454
448
|
...headers,
|
|
455
|
-
|
|
456
|
-
|
|
449
|
+
'content-length': String(range.end - range.start + 1),
|
|
450
|
+
'content-range': `bytes ${range.start}-${range.end}/${stat.size}`,
|
|
457
451
|
});
|
|
458
452
|
fs.createReadStream(resolved.abs, { start: range.start, end: range.end }).pipe(res);
|
|
459
453
|
}
|
|
460
454
|
// A single `Range: bytes=<start>-<end>` (the only form a media element sends).
|
|
461
455
|
// null = serve the whole file; 'unsatisfiable' = a range past the end.
|
|
462
456
|
function parseByteRange(header, size) {
|
|
463
|
-
const match = /^bytes=(\d*)-(\d*)$/.exec((header ??
|
|
457
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec((header ?? '').trim());
|
|
464
458
|
if (!match || size === 0)
|
|
465
459
|
return null;
|
|
466
460
|
const [, rawStart, rawEnd] = match;
|
|
467
|
-
if (rawStart ===
|
|
461
|
+
if (rawStart === '' && rawEnd === '')
|
|
468
462
|
return null;
|
|
469
463
|
// `bytes=-N`: the last N bytes.
|
|
470
|
-
const start = rawStart ===
|
|
471
|
-
const end = rawStart ===
|
|
464
|
+
const start = rawStart === '' ? Math.max(0, size - Number(rawEnd)) : Number(rawStart);
|
|
465
|
+
const end = rawStart === '' || rawEnd === '' ? size - 1 : Math.min(Number(rawEnd), size - 1);
|
|
472
466
|
if (!Number.isFinite(start) || start > end || start >= size)
|
|
473
|
-
return
|
|
467
|
+
return 'unsatisfiable';
|
|
474
468
|
return { start, end };
|
|
475
469
|
}
|
|
476
470
|
// A NUL byte near the start is the practical test for "not text": no text file
|
|
@@ -500,7 +494,7 @@ function handleFilesCopy(deckDir, req, res) {
|
|
|
500
494
|
sendJson(res, 200, { ok: true, path: to.rel });
|
|
501
495
|
}
|
|
502
496
|
catch (err) {
|
|
503
|
-
sendFailure(res,
|
|
497
|
+
sendFailure(res, 'copy', from.rel, err);
|
|
504
498
|
}
|
|
505
499
|
});
|
|
506
500
|
}
|
|
@@ -531,9 +525,7 @@ function handleFilesRename(deckDir, req, res) {
|
|
|
531
525
|
// filesystem (default on macOS/Windows) `to` can "exist" only because it is
|
|
532
526
|
// `from` under a different case -- a case-only rename like bounce.jsx ->
|
|
533
527
|
// Bounce.jsx. Allow that by treating same-inode as not-a-collision.
|
|
534
|
-
if (from.abs !== to.abs &&
|
|
535
|
-
fs.existsSync(to.abs) &&
|
|
536
|
-
!isSameFile(from.abs, to.abs)) {
|
|
528
|
+
if (from.abs !== to.abs && fs.existsSync(to.abs) && !isSameFile(from.abs, to.abs)) {
|
|
537
529
|
return sendJson(res, 409, { error: `Already exists: ${to.rel}` });
|
|
538
530
|
}
|
|
539
531
|
try {
|
|
@@ -542,7 +534,7 @@ function handleFilesRename(deckDir, req, res) {
|
|
|
542
534
|
sendJson(res, 200, { ok: true, path: to.rel });
|
|
543
535
|
}
|
|
544
536
|
catch (err) {
|
|
545
|
-
sendFailure(res,
|
|
537
|
+
sendFailure(res, 'rename', from.rel, err);
|
|
546
538
|
}
|
|
547
539
|
});
|
|
548
540
|
}
|
|
@@ -556,7 +548,7 @@ function handleFilesDelete(deckDir, req, res) {
|
|
|
556
548
|
sendJson(res, 200, { ok: true, path: target.rel });
|
|
557
549
|
}
|
|
558
550
|
catch (err) {
|
|
559
|
-
sendFailure(res,
|
|
551
|
+
sendFailure(res, 'delete', target.rel, err);
|
|
560
552
|
}
|
|
561
553
|
});
|
|
562
554
|
}
|
|
@@ -565,7 +557,7 @@ function handleFilesDelete(deckDir, req, res) {
|
|
|
565
557
|
// rejects traversal and protected dirs.
|
|
566
558
|
function handleFilesApi(deckDir, req, res, reqPath) {
|
|
567
559
|
const action = reqPath.slice(FILES_API_PREFIX.length);
|
|
568
|
-
if (action ===
|
|
560
|
+
if (action === 'info') {
|
|
569
561
|
const config = readEditorConfig(deckDir);
|
|
570
562
|
// File types come from the deck AND its imports (a kit is an imported deck,
|
|
571
563
|
// so importing one is what gives a deck scenes and drawings); everything
|
|
@@ -587,7 +579,7 @@ function handleFilesApi(deckDir, req, res, reqPath) {
|
|
|
587
579
|
});
|
|
588
580
|
return true;
|
|
589
581
|
}
|
|
590
|
-
if (action ===
|
|
582
|
+
if (action === 'imports') {
|
|
591
583
|
// Reports only: whether each import is behind the version its deck has now.
|
|
592
584
|
// Taking the update is a deliberate act (`castle-web update-import`), never
|
|
593
585
|
// something that happens to a deck while it is open.
|
|
@@ -596,7 +588,7 @@ function handleFilesApi(deckDir, req, res, reqPath) {
|
|
|
596
588
|
.catch(() => sendJson(res, 200, { imports: [] }));
|
|
597
589
|
return true;
|
|
598
590
|
}
|
|
599
|
-
if (action ===
|
|
591
|
+
if (action === 'update-import') {
|
|
600
592
|
// Takes the update for one import. Deliberate by construction: the editor
|
|
601
593
|
// asks first, and this is the only path from the panel that changes what a
|
|
602
594
|
// dependency contains.
|
|
@@ -606,10 +598,10 @@ function handleFilesApi(deckDir, req, res, reqPath) {
|
|
|
606
598
|
body = JSON.parse(await readRequestBody(req));
|
|
607
599
|
}
|
|
608
600
|
catch {
|
|
609
|
-
return sendJson(res, 400, { error:
|
|
601
|
+
return sendJson(res, 400, { error: 'Invalid JSON body.' });
|
|
610
602
|
}
|
|
611
|
-
if (typeof body.alias !==
|
|
612
|
-
return sendJson(res, 400, { error:
|
|
603
|
+
if (typeof body.alias !== 'string' || !body.alias) {
|
|
604
|
+
return sendJson(res, 400, { error: 'Missing import name.' });
|
|
613
605
|
}
|
|
614
606
|
try {
|
|
615
607
|
await updateImport(deckDir, { alias: body.alias });
|
|
@@ -621,14 +613,14 @@ function handleFilesApi(deckDir, req, res, reqPath) {
|
|
|
621
613
|
})();
|
|
622
614
|
return true;
|
|
623
615
|
}
|
|
624
|
-
if (action ===
|
|
616
|
+
if (action === 'list') {
|
|
625
617
|
// `?all=1` returns the unfiltered listing (the "show hidden files & folders"
|
|
626
618
|
// toggle) -- still minus the always-ignored dirs (node_modules/.castle/...),
|
|
627
619
|
// just without the deck's visible/hidden path curation.
|
|
628
|
-
const url = new URL(req.url ??
|
|
620
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
629
621
|
const listed = listDeckFiles(deckDir);
|
|
630
622
|
let files = listed;
|
|
631
|
-
if (url.searchParams.get(
|
|
623
|
+
if (url.searchParams.get('all') !== '1') {
|
|
632
624
|
// Each deck curates its own files. The importing deck's visible/hidden
|
|
633
625
|
// paths name its own dirs (a kit's name scenes/, drawings/ ...), so
|
|
634
626
|
// applying them to imports would hide every import by omission -- an
|
|
@@ -643,9 +635,9 @@ function handleFilesApi(deckDir, req, res, reqPath) {
|
|
|
643
635
|
sendJson(res, 200, { files });
|
|
644
636
|
return true;
|
|
645
637
|
}
|
|
646
|
-
if (action ===
|
|
647
|
-
const url = new URL(req.url ??
|
|
648
|
-
const resolved = resolveDeckPath(deckDir, url.searchParams.get(
|
|
638
|
+
if (action === 'read') {
|
|
639
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
640
|
+
const resolved = resolveDeckPath(deckDir, url.searchParams.get('path'));
|
|
649
641
|
if (!resolved.ok)
|
|
650
642
|
return (sendJson(res, 400, { error: resolved.error }), true);
|
|
651
643
|
try {
|
|
@@ -656,13 +648,13 @@ function handleFilesApi(deckDir, req, res, reqPath) {
|
|
|
656
648
|
if (isBinary(bytes)) {
|
|
657
649
|
sendJson(res, 200, {
|
|
658
650
|
path: resolved.rel,
|
|
659
|
-
contents:
|
|
651
|
+
contents: '',
|
|
660
652
|
binary: true,
|
|
661
653
|
size: bytes.length,
|
|
662
654
|
});
|
|
663
655
|
}
|
|
664
656
|
else {
|
|
665
|
-
sendJson(res, 200, { path: resolved.rel, contents: bytes.toString(
|
|
657
|
+
sendJson(res, 200, { path: resolved.rel, contents: bytes.toString('utf8') });
|
|
666
658
|
}
|
|
667
659
|
}
|
|
668
660
|
catch {
|
|
@@ -670,51 +662,45 @@ function handleFilesApi(deckDir, req, res, reqPath) {
|
|
|
670
662
|
}
|
|
671
663
|
return true;
|
|
672
664
|
}
|
|
673
|
-
if (action ===
|
|
665
|
+
if (action === 'copy') {
|
|
674
666
|
handleFilesCopy(deckDir, req, res);
|
|
675
667
|
return true;
|
|
676
668
|
}
|
|
677
|
-
if (action ===
|
|
669
|
+
if (action === 'write') {
|
|
678
670
|
handleFilesWrite(deckDir, req, res);
|
|
679
671
|
return true;
|
|
680
672
|
}
|
|
681
|
-
if (action ===
|
|
673
|
+
if (action === 'rename') {
|
|
682
674
|
handleFilesRename(deckDir, req, res);
|
|
683
675
|
return true;
|
|
684
676
|
}
|
|
685
|
-
if (action ===
|
|
677
|
+
if (action === 'delete') {
|
|
686
678
|
handleFilesDelete(deckDir, req, res);
|
|
687
679
|
return true;
|
|
688
680
|
}
|
|
689
|
-
if (action ===
|
|
681
|
+
if (action === 'mkdir') {
|
|
690
682
|
handleFilesMkdir(deckDir, req, res);
|
|
691
683
|
return true;
|
|
692
684
|
}
|
|
693
|
-
if (action ===
|
|
685
|
+
if (action === 'upload') {
|
|
694
686
|
handleFilesUpload(deckDir, req, res);
|
|
695
687
|
return true;
|
|
696
688
|
}
|
|
697
|
-
if (action ===
|
|
689
|
+
if (action === 'raw') {
|
|
698
690
|
handleFilesRaw(deckDir, req, res);
|
|
699
691
|
return true;
|
|
700
692
|
}
|
|
701
|
-
return (sendJson(res, 404, { error: `Unknown files action: ${action}` }),
|
|
702
|
-
true);
|
|
693
|
+
return (sendJson(res, 404, { error: `Unknown files action: ${action}` }), true);
|
|
703
694
|
}
|
|
704
695
|
function defaultShell() {
|
|
705
|
-
if (process.platform ===
|
|
706
|
-
return { command: process.env.COMSPEC ??
|
|
696
|
+
if (process.platform === 'win32') {
|
|
697
|
+
return { command: process.env.COMSPEC ?? 'cmd.exe', args: [] };
|
|
707
698
|
}
|
|
708
699
|
// Pick the first shell that actually exists: $SHELL (dev), then zsh (macOS),
|
|
709
700
|
// then bash / sh. The cloud sandbox image is Debian-based (node:22) with no
|
|
710
701
|
// zsh and no $SHELL set, so a bare `/bin/zsh` fallback fails with execvp
|
|
711
702
|
// ENOENT and the terminal never gets a shell.
|
|
712
|
-
const candidates = [
|
|
713
|
-
process.env.SHELL,
|
|
714
|
-
"/bin/zsh",
|
|
715
|
-
"/bin/bash",
|
|
716
|
-
"/bin/sh",
|
|
717
|
-
].filter((c) => Boolean(c));
|
|
703
|
+
const candidates = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter((c) => Boolean(c));
|
|
718
704
|
const command = candidates.find((c) => {
|
|
719
705
|
try {
|
|
720
706
|
return fs.existsSync(c);
|
|
@@ -722,18 +708,18 @@ function defaultShell() {
|
|
|
722
708
|
catch {
|
|
723
709
|
return false;
|
|
724
710
|
}
|
|
725
|
-
}) ??
|
|
726
|
-
return { command, args: [
|
|
711
|
+
}) ?? '/bin/sh';
|
|
712
|
+
return { command, args: ['-l'] };
|
|
727
713
|
}
|
|
728
714
|
function ptyEnv(shimDir) {
|
|
729
715
|
const env = {
|
|
730
716
|
...envForUserShell(process.env),
|
|
731
717
|
TERM: PTY_TERM,
|
|
732
|
-
COLORTERM:
|
|
733
|
-
CLICOLOR:
|
|
734
|
-
CLICOLOR_FORCE:
|
|
735
|
-
FORCE_COLOR: process.env.FORCE_COLOR ===
|
|
736
|
-
TERM_PROGRAM:
|
|
718
|
+
COLORTERM: 'truecolor',
|
|
719
|
+
CLICOLOR: '1',
|
|
720
|
+
CLICOLOR_FORCE: '1',
|
|
721
|
+
FORCE_COLOR: process.env.FORCE_COLOR === '0' ? '3' : (process.env.FORCE_COLOR ?? '3'),
|
|
722
|
+
TERM_PROGRAM: 'castle-web-ide',
|
|
737
723
|
};
|
|
738
724
|
delete env.NO_COLOR;
|
|
739
725
|
delete env.NODE_DISABLE_COLORS;
|
|
@@ -750,10 +736,10 @@ function clampSize(value, fallback) {
|
|
|
750
736
|
}
|
|
751
737
|
export function rawDataToString(data) {
|
|
752
738
|
if (Array.isArray(data))
|
|
753
|
-
return Buffer.concat(data).toString(
|
|
739
|
+
return Buffer.concat(data).toString('utf8');
|
|
754
740
|
if (Buffer.isBuffer(data))
|
|
755
|
-
return data.toString(
|
|
756
|
-
return Buffer.from(new Uint8Array(data)).toString(
|
|
741
|
+
return data.toString('utf8');
|
|
742
|
+
return Buffer.from(new Uint8Array(data)).toString('utf8');
|
|
757
743
|
}
|
|
758
744
|
function send(socket, body) {
|
|
759
745
|
if (socket.readyState === socket.OPEN)
|
|
@@ -765,7 +751,7 @@ function queueScreenOp(session, op) {
|
|
|
765
751
|
session.renderQueue = session.renderQueue
|
|
766
752
|
.catch(() => undefined)
|
|
767
753
|
.then(op)
|
|
768
|
-
.catch((err) => console.error(
|
|
754
|
+
.catch((err) => console.error('[ide] screen render failed', err));
|
|
769
755
|
}
|
|
770
756
|
async function waitForStableScreen(session) {
|
|
771
757
|
while (true) {
|
|
@@ -815,16 +801,16 @@ export function createIdeServer(opts) {
|
|
|
815
801
|
};
|
|
816
802
|
pty.onData((data) => {
|
|
817
803
|
queueScreenOp(s, () => new Promise((done) => s.screen.write(data, done)));
|
|
818
|
-
broadcast(s, { type:
|
|
804
|
+
broadcast(s, { type: 'output', data });
|
|
819
805
|
});
|
|
820
806
|
pty.onExit(({ exitCode, signal }) => {
|
|
821
807
|
s.exited = { exitCode, signal };
|
|
822
|
-
broadcast(s, { type:
|
|
808
|
+
broadcast(s, { type: 'exit', exitCode, signal });
|
|
823
809
|
// Clients get the `exit` message above (which disables their reconnect),
|
|
824
810
|
// then we close the sockets so they don't sit half-open.
|
|
825
811
|
for (const socket of s.clients) {
|
|
826
812
|
try {
|
|
827
|
-
socket.close(1000,
|
|
813
|
+
socket.close(1000, 'shell exited');
|
|
828
814
|
}
|
|
829
815
|
catch {
|
|
830
816
|
/* ignore */
|
|
@@ -863,12 +849,12 @@ export function createIdeServer(opts) {
|
|
|
863
849
|
if (socket.readyState !== socket.OPEN)
|
|
864
850
|
return;
|
|
865
851
|
send(socket, {
|
|
866
|
-
type:
|
|
852
|
+
type: 'replay',
|
|
867
853
|
data: s.serializeAddon.serialize({ scrollback: SCROLLBACK }),
|
|
868
854
|
});
|
|
869
855
|
if (s.exited) {
|
|
870
856
|
send(socket, {
|
|
871
|
-
type:
|
|
857
|
+
type: 'exit',
|
|
872
858
|
exitCode: s.exited.exitCode,
|
|
873
859
|
signal: s.exited.signal,
|
|
874
860
|
});
|
|
@@ -876,8 +862,8 @@ export function createIdeServer(opts) {
|
|
|
876
862
|
}
|
|
877
863
|
catch (err) {
|
|
878
864
|
send(socket, {
|
|
879
|
-
type:
|
|
880
|
-
error: err instanceof Error ? err.message :
|
|
865
|
+
type: 'error',
|
|
866
|
+
error: err instanceof Error ? err.message : 'could not restore screen',
|
|
881
867
|
});
|
|
882
868
|
}
|
|
883
869
|
finally {
|
|
@@ -885,7 +871,7 @@ export function createIdeServer(opts) {
|
|
|
885
871
|
s.clients.add(socket);
|
|
886
872
|
}
|
|
887
873
|
})();
|
|
888
|
-
socket.on(
|
|
874
|
+
socket.on('message', (raw) => {
|
|
889
875
|
let msg;
|
|
890
876
|
try {
|
|
891
877
|
msg = JSON.parse(rawDataToString(raw));
|
|
@@ -893,21 +879,21 @@ export function createIdeServer(opts) {
|
|
|
893
879
|
catch {
|
|
894
880
|
return;
|
|
895
881
|
}
|
|
896
|
-
if (msg.type ===
|
|
882
|
+
if (msg.type === 'input' && typeof msg.data === 'string') {
|
|
897
883
|
if (!s.exited)
|
|
898
884
|
s.pty.write(msg.data);
|
|
899
885
|
}
|
|
900
|
-
else if (msg.type ===
|
|
886
|
+
else if (msg.type === 'resize') {
|
|
901
887
|
resizeSession(s, Number(msg.cols), Number(msg.rows));
|
|
902
888
|
}
|
|
903
889
|
});
|
|
904
|
-
socket.on(
|
|
890
|
+
socket.on('close', () => {
|
|
905
891
|
s.clients.delete(socket);
|
|
906
892
|
});
|
|
907
893
|
}
|
|
908
894
|
const wss = new WebSocketServer({ noServer: true });
|
|
909
895
|
function handleUpgrade(req, socket, head) {
|
|
910
|
-
const url = new URL(req.url ??
|
|
896
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
911
897
|
if (url.pathname !== PTY_WS_PATH)
|
|
912
898
|
return false;
|
|
913
899
|
wss.handleUpgrade(req, socket, head, (ws) => attachClient(ws));
|
|
@@ -915,8 +901,8 @@ export function createIdeServer(opts) {
|
|
|
915
901
|
}
|
|
916
902
|
function handleHttpRequest(req, res, reqPath) {
|
|
917
903
|
// `/` -> the shell's index.html; `/__castle/ide/<asset>` -> bundle assets.
|
|
918
|
-
if (reqPath ===
|
|
919
|
-
return serveShellFile(res,
|
|
904
|
+
if (reqPath === '/')
|
|
905
|
+
return serveShellFile(res, 'index.html');
|
|
920
906
|
// Root-served favicons, unless the deck ships its own (then fall through
|
|
921
907
|
// to Vite, which serves the deck's file).
|
|
922
908
|
const favicon = FAVICON_FILES.find((name) => reqPath === `/${name}`);
|
|
@@ -924,13 +910,13 @@ export function createIdeServer(opts) {
|
|
|
924
910
|
return serveShellFile(res, favicon);
|
|
925
911
|
}
|
|
926
912
|
if (reqPath.startsWith(IDE_ASSET_PREFIX)) {
|
|
927
|
-
return serveShellFile(res, reqPath.slice(IDE_ASSET_PREFIX.length) ||
|
|
913
|
+
return serveShellFile(res, reqPath.slice(IDE_ASSET_PREFIX.length) || 'index.html');
|
|
928
914
|
}
|
|
929
915
|
if (reqPath.startsWith(FILES_API_PREFIX)) {
|
|
930
916
|
return handleFilesApi(deckDir, req, res, reqPath);
|
|
931
917
|
}
|
|
932
918
|
if (reqPath.startsWith(IMPORT_API_PREFIX)) {
|
|
933
|
-
return handleImportApi(deckDir, req, res, reqPath);
|
|
919
|
+
return handleImportApi(deckDir, req, res, reqPath, opts.restart);
|
|
934
920
|
}
|
|
935
921
|
return false;
|
|
936
922
|
}
|
|
@@ -944,7 +930,7 @@ export function createIdeServer(opts) {
|
|
|
944
930
|
}
|
|
945
931
|
for (const socket of session.clients) {
|
|
946
932
|
try {
|
|
947
|
-
socket.close(1001,
|
|
933
|
+
socket.close(1001, 'serve shutting down');
|
|
948
934
|
}
|
|
949
935
|
catch {
|
|
950
936
|
/* ignore */
|