devsurface 0.2.0 → 0.4.0
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/CHANGELOG.md +38 -0
- package/README.md +100 -12
- package/action/dist/index.js +644 -0
- package/action.yml +39 -0
- package/dist/cli/index.js +1865 -333
- package/dist/cli/index.js.map +1 -1
- package/package.json +9 -6
- package/src/web/dist/assets/index-BO8glxtu.js +10 -0
- package/src/web/dist/assets/index-Bj8suDpq.css +1 -0
- package/src/web/dist/index.html +2 -2
- package/src/web/dist/assets/index-CdzG3b92.js +0 -10
- package/src/web/dist/assets/index-l7i8vzTo.css +0 -1
package/dist/cli/index.js
CHANGED
|
@@ -10,6 +10,27 @@ import pc from "picocolors";
|
|
|
10
10
|
import { promises as fs9 } from "fs";
|
|
11
11
|
import path9 from "path";
|
|
12
12
|
|
|
13
|
+
// src/core/documentation.ts
|
|
14
|
+
function extractScriptReferences(content) {
|
|
15
|
+
const references = /* @__PURE__ */ new Set();
|
|
16
|
+
const commandRegexes = [
|
|
17
|
+
/\bnpm\s+run\s+([A-Za-z0-9:_-]+)/g,
|
|
18
|
+
/\bpnpm\s+run\s+([A-Za-z0-9:_-]+)/g,
|
|
19
|
+
/\bbun\s+run\s+([A-Za-z0-9:_-]+)/g,
|
|
20
|
+
/\byarn\s+run\s+([A-Za-z0-9:_-]+)/g,
|
|
21
|
+
/\bnpm\s+(test|start|build)\b/g,
|
|
22
|
+
/\bpnpm\s+(test|start|build)\b/g,
|
|
23
|
+
/\byarn\s+(test|start|build)\b/g,
|
|
24
|
+
/\bbun\s+(test|start|build)\b/g
|
|
25
|
+
];
|
|
26
|
+
for (const regex of commandRegexes) {
|
|
27
|
+
for (const match of content.matchAll(regex)) {
|
|
28
|
+
references.add(match[1]);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return Array.from(references);
|
|
32
|
+
}
|
|
33
|
+
|
|
13
34
|
// src/core/scanner/index.ts
|
|
14
35
|
import { promises as fs8 } from "fs";
|
|
15
36
|
import path8 from "path";
|
|
@@ -18,6 +39,16 @@ import path8 from "path";
|
|
|
18
39
|
import { promises as fs } from "fs";
|
|
19
40
|
import path from "path";
|
|
20
41
|
|
|
42
|
+
// src/core/security/url.ts
|
|
43
|
+
function isSafeHttpUrl(value) {
|
|
44
|
+
try {
|
|
45
|
+
const url = new URL(value);
|
|
46
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
21
52
|
// src/core/config/defaults.ts
|
|
22
53
|
var CONFIG_FILE_NAME = "devsurface.config.json";
|
|
23
54
|
var defaultConfig = {
|
|
@@ -49,6 +80,10 @@ var defaultConfig = {
|
|
|
49
80
|
|
|
50
81
|
// src/core/config/load.ts
|
|
51
82
|
var MAX_CONFIGURED_PORTS = 32;
|
|
83
|
+
function isWithinRoot(root, target) {
|
|
84
|
+
const relative = path.relative(root, target);
|
|
85
|
+
return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
86
|
+
}
|
|
52
87
|
function isRecord(value) {
|
|
53
88
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
54
89
|
}
|
|
@@ -125,6 +160,14 @@ function validateConfig(raw) {
|
|
|
125
160
|
if (raw.services !== void 0 && !isRecord(raw.services)) {
|
|
126
161
|
warnings.push("services must be an object.");
|
|
127
162
|
}
|
|
163
|
+
let docs;
|
|
164
|
+
if (typeof raw.docs === "string" && raw.docs.length > 0) {
|
|
165
|
+
if (isSafeHttpUrl(raw.docs)) {
|
|
166
|
+
docs = raw.docs;
|
|
167
|
+
} else {
|
|
168
|
+
warnings.push("docs must be an http or https URL.");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
128
171
|
return {
|
|
129
172
|
config: {
|
|
130
173
|
name: typeof raw.name === "string" ? raw.name : void 0,
|
|
@@ -134,7 +177,7 @@ function validateConfig(raw) {
|
|
|
134
177
|
ports: toPorts(raw.ports, warnings),
|
|
135
178
|
env,
|
|
136
179
|
services,
|
|
137
|
-
docs
|
|
180
|
+
docs
|
|
138
181
|
},
|
|
139
182
|
warnings
|
|
140
183
|
};
|
|
@@ -142,10 +185,17 @@ function validateConfig(raw) {
|
|
|
142
185
|
async function loadConfig(root) {
|
|
143
186
|
const configPath = path.join(root, CONFIG_FILE_NAME);
|
|
144
187
|
try {
|
|
145
|
-
const
|
|
188
|
+
const [realRoot, realConfigPath] = await Promise.all([
|
|
189
|
+
fs.realpath(root),
|
|
190
|
+
fs.realpath(configPath)
|
|
191
|
+
]);
|
|
192
|
+
if (!isWithinRoot(realRoot, realConfigPath)) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
const content = await fs.readFile(realConfigPath, "utf8");
|
|
146
196
|
const parsed = JSON.parse(content);
|
|
147
197
|
const { config, warnings } = validateConfig(parsed);
|
|
148
|
-
return { path:
|
|
198
|
+
return { path: realConfigPath, config, warnings };
|
|
149
199
|
} catch (error) {
|
|
150
200
|
const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
151
201
|
if (code === "ENOENT") {
|
|
@@ -162,7 +212,7 @@ async function loadConfig(root) {
|
|
|
162
212
|
}
|
|
163
213
|
}
|
|
164
214
|
|
|
165
|
-
// src/core/
|
|
215
|
+
// src/core/docker/compose.ts
|
|
166
216
|
import { promises as fs3 } from "fs";
|
|
167
217
|
import os from "os";
|
|
168
218
|
import path3 from "path";
|
|
@@ -173,7 +223,7 @@ import { parse as parseYaml } from "yaml";
|
|
|
173
223
|
import { constants } from "fs";
|
|
174
224
|
import { promises as fs2 } from "fs";
|
|
175
225
|
import path2 from "path";
|
|
176
|
-
function
|
|
226
|
+
function isWithinRoot2(root, target) {
|
|
177
227
|
const resolvedRoot = path2.resolve(root);
|
|
178
228
|
const resolvedTarget = path2.resolve(target);
|
|
179
229
|
const relative = path2.relative(resolvedRoot, resolvedTarget);
|
|
@@ -193,7 +243,7 @@ function executableNames(command) {
|
|
|
193
243
|
return extensions.map((extension) => `${command}${extension}`);
|
|
194
244
|
}
|
|
195
245
|
async function executableOutsideRoot(root, candidate) {
|
|
196
|
-
if (
|
|
246
|
+
if (isWithinRoot2(root, candidate)) {
|
|
197
247
|
return null;
|
|
198
248
|
}
|
|
199
249
|
try {
|
|
@@ -201,7 +251,7 @@ async function executableOutsideRoot(root, candidate) {
|
|
|
201
251
|
fs2.realpath(root),
|
|
202
252
|
fs2.realpath(candidate)
|
|
203
253
|
]);
|
|
204
|
-
if (
|
|
254
|
+
if (isWithinRoot2(realRoot, realCandidate)) {
|
|
205
255
|
return null;
|
|
206
256
|
}
|
|
207
257
|
await fs2.access(realCandidate, constants.X_OK);
|
|
@@ -216,7 +266,7 @@ async function resolveExecutableOutsideRoot(root, command) {
|
|
|
216
266
|
}
|
|
217
267
|
for (const entry of pathEntries(process.env.PATH ?? "")) {
|
|
218
268
|
const directory = path2.resolve(entry);
|
|
219
|
-
if (
|
|
269
|
+
if (isWithinRoot2(root, directory)) {
|
|
220
270
|
continue;
|
|
221
271
|
}
|
|
222
272
|
for (const executableName of executableNames(command)) {
|
|
@@ -229,50 +279,38 @@ async function resolveExecutableOutsideRoot(root, command) {
|
|
|
229
279
|
return null;
|
|
230
280
|
}
|
|
231
281
|
|
|
232
|
-
// src/core/
|
|
282
|
+
// src/core/docker/compose.ts
|
|
233
283
|
var COMPOSE_FILES = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"];
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
return await fileExists(filePath) ? filePath : null;
|
|
247
|
-
})
|
|
248
|
-
);
|
|
249
|
-
return checks.filter((filePath) => filePath !== null);
|
|
250
|
-
}
|
|
251
|
-
async function extractServices(composePath) {
|
|
252
|
-
try {
|
|
253
|
-
const content = await fs3.readFile(composePath, "utf8");
|
|
254
|
-
const parsed = parseYaml(content);
|
|
255
|
-
if (typeof parsed === "object" && parsed !== null && "services" in parsed && typeof parsed.services === "object" && parsed.services !== null) {
|
|
256
|
-
return Object.keys(parsed.services);
|
|
257
|
-
}
|
|
258
|
-
} catch {
|
|
259
|
-
return [];
|
|
260
|
-
}
|
|
261
|
-
return [];
|
|
262
|
-
}
|
|
263
|
-
function isWithinRoot2(root, target) {
|
|
284
|
+
var COMMAND_OUTPUT_LIMIT = 2e5;
|
|
285
|
+
var ESC = String.fromCharCode(27);
|
|
286
|
+
var ANSI_CSI_SEQUENCE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, "g");
|
|
287
|
+
var DockerOperationError = class extends Error {
|
|
288
|
+
constructor(code, message) {
|
|
289
|
+
super(message);
|
|
290
|
+
this.code = code;
|
|
291
|
+
this.name = "DockerOperationError";
|
|
292
|
+
}
|
|
293
|
+
code;
|
|
294
|
+
};
|
|
295
|
+
function isWithinRoot3(root, target) {
|
|
264
296
|
const relative = path3.relative(path3.resolve(root), path3.resolve(target));
|
|
265
297
|
return relative === "" || !relative.startsWith("..") && !path3.isAbsolute(relative);
|
|
266
298
|
}
|
|
267
299
|
function dockerCommandCwd(root) {
|
|
268
300
|
const candidates = [os.homedir(), os.tmpdir(), path3.parse(path3.resolve(root)).root];
|
|
269
|
-
return candidates.find((candidate) => !
|
|
301
|
+
return candidates.find((candidate) => !isWithinRoot3(root, candidate)) ?? os.homedir();
|
|
270
302
|
}
|
|
271
|
-
|
|
303
|
+
function appendBounded(current, chunk, limit) {
|
|
304
|
+
const combined = current + chunk;
|
|
305
|
+
return combined.length <= limit ? combined : combined.slice(-limit);
|
|
306
|
+
}
|
|
307
|
+
var runDockerCommand = async (root, args, options = {}) => {
|
|
272
308
|
const dockerExecutable = await resolveExecutableOutsideRoot(root, "docker");
|
|
273
309
|
if (dockerExecutable === null) {
|
|
274
|
-
return { code: null, stdout: "", stderr: "" };
|
|
310
|
+
return { code: null, stdout: "", stderr: "", error: "not-found" };
|
|
275
311
|
}
|
|
312
|
+
const timeoutMs = options.timeoutMs ?? 5e3;
|
|
313
|
+
const outputLimit = options.outputLimit ?? COMMAND_OUTPUT_LIMIT;
|
|
276
314
|
return await new Promise((resolve) => {
|
|
277
315
|
const child = spawn(dockerExecutable, args, {
|
|
278
316
|
cwd: dockerCommandCwd(root),
|
|
@@ -281,36 +319,152 @@ async function runDockerCommand(root, args, timeoutMs = 2500) {
|
|
|
281
319
|
let settled = false;
|
|
282
320
|
let stdout = "";
|
|
283
321
|
let stderr = "";
|
|
284
|
-
const
|
|
322
|
+
const finish = (result) => {
|
|
323
|
+
if (settled) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
285
326
|
settled = true;
|
|
327
|
+
clearTimeout(timeout);
|
|
328
|
+
resolve(result);
|
|
329
|
+
};
|
|
330
|
+
const timeout = setTimeout(() => {
|
|
286
331
|
child.kill();
|
|
287
|
-
|
|
332
|
+
finish({ code: null, stdout, stderr, error: "timeout" });
|
|
288
333
|
}, timeoutMs);
|
|
289
334
|
child.stdout?.on("data", (chunk) => {
|
|
290
|
-
stdout
|
|
335
|
+
stdout = appendBounded(stdout, chunk.toString(), outputLimit);
|
|
291
336
|
});
|
|
292
337
|
child.stderr?.on("data", (chunk) => {
|
|
293
|
-
stderr
|
|
338
|
+
stderr = appendBounded(stderr, chunk.toString(), outputLimit);
|
|
294
339
|
});
|
|
295
340
|
child.on("error", () => {
|
|
296
|
-
|
|
297
|
-
if (!settled) {
|
|
298
|
-
settled = true;
|
|
299
|
-
resolve({ code: null, stdout, stderr });
|
|
300
|
-
}
|
|
341
|
+
finish({ code: null, stdout, stderr, error: "spawn" });
|
|
301
342
|
});
|
|
302
343
|
child.on("close", (code) => {
|
|
303
|
-
|
|
304
|
-
if (!settled) {
|
|
305
|
-
settled = true;
|
|
306
|
-
resolve({ code, stdout, stderr });
|
|
307
|
-
}
|
|
344
|
+
finish({ code, stdout, stderr, error: null });
|
|
308
345
|
});
|
|
309
346
|
});
|
|
347
|
+
};
|
|
348
|
+
async function findComposeFiles(root) {
|
|
349
|
+
const resolvedRoot = await fs3.realpath(root).catch(() => path3.resolve(root));
|
|
350
|
+
const matches = [];
|
|
351
|
+
for (const file of COMPOSE_FILES) {
|
|
352
|
+
const candidate = path3.join(root, file);
|
|
353
|
+
try {
|
|
354
|
+
const [stat, realCandidate] = await Promise.all([fs3.stat(candidate), fs3.realpath(candidate)]);
|
|
355
|
+
if (stat.isFile() && isWithinRoot3(resolvedRoot, realCandidate)) {
|
|
356
|
+
matches.push(realCandidate);
|
|
357
|
+
}
|
|
358
|
+
} catch {
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return matches;
|
|
362
|
+
}
|
|
363
|
+
async function serviceNamesFromFiles(composeFiles) {
|
|
364
|
+
const names = /* @__PURE__ */ new Set();
|
|
365
|
+
for (const composeFile of composeFiles) {
|
|
366
|
+
try {
|
|
367
|
+
const parsed = parseYaml(await fs3.readFile(composeFile, "utf8"));
|
|
368
|
+
if (typeof parsed !== "object" || parsed === null || !("services" in parsed) || typeof parsed.services !== "object" || parsed.services === null || Array.isArray(parsed.services)) {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
for (const name of Object.keys(parsed.services)) {
|
|
372
|
+
if (name.length > 0) {
|
|
373
|
+
names.add(name);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} catch {
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return Array.from(names);
|
|
380
|
+
}
|
|
381
|
+
function composeArgs(root, composeFiles, args) {
|
|
382
|
+
return [
|
|
383
|
+
"compose",
|
|
384
|
+
...composeFiles.flatMap((composeFile) => ["-f", composeFile]),
|
|
385
|
+
"--project-directory",
|
|
386
|
+
root,
|
|
387
|
+
...args
|
|
388
|
+
];
|
|
389
|
+
}
|
|
390
|
+
function cleanMessage(value) {
|
|
391
|
+
return value.replace(ANSI_CSI_SEQUENCE, "").trim().slice(-1e3);
|
|
392
|
+
}
|
|
393
|
+
function resultMessage(result) {
|
|
394
|
+
return cleanMessage(result.stderr || result.stdout);
|
|
395
|
+
}
|
|
396
|
+
function daemonStatus(result, platform2) {
|
|
397
|
+
if (result.error === "not-found") {
|
|
398
|
+
return {
|
|
399
|
+
status: "not-installed",
|
|
400
|
+
running: false,
|
|
401
|
+
message: "Docker CLI was not found. Install Docker and refresh this page."
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
if (result.code === 0) {
|
|
405
|
+
return { status: "running", running: true, message: null };
|
|
406
|
+
}
|
|
407
|
+
if (platform2 === "win32" || platform2 === "darwin") {
|
|
408
|
+
return {
|
|
409
|
+
status: "stopped",
|
|
410
|
+
running: false,
|
|
411
|
+
message: "Docker is installed, but its engine is not responding. Start Docker Desktop and refresh."
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
const detail = resultMessage(result);
|
|
415
|
+
return {
|
|
416
|
+
status: result.error === "timeout" ? "unknown" : "stopped",
|
|
417
|
+
running: false,
|
|
418
|
+
message: detail ? `Docker is installed, but its daemon is not responding: ${detail}` : "Docker is installed, but its daemon is not responding. Start Docker and refresh."
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function exitCodeFromRow(record) {
|
|
422
|
+
if (typeof record.ExitCode === "number") {
|
|
423
|
+
return record.ExitCode;
|
|
424
|
+
}
|
|
425
|
+
if (typeof record.ExitCode === "string" && /^\d+$/.test(record.ExitCode)) {
|
|
426
|
+
return Number(record.ExitCode);
|
|
427
|
+
}
|
|
428
|
+
if (typeof record.Status === "string") {
|
|
429
|
+
const match = /\bExited\s+\((\d+)\)/i.exec(record.Status);
|
|
430
|
+
if (match?.[1]) {
|
|
431
|
+
return Number(match[1]);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
function serviceStatusFromRow(record) {
|
|
437
|
+
const state = typeof record.State === "string" ? record.State.toLowerCase() : "";
|
|
438
|
+
const exitCode = exitCodeFromRow(record);
|
|
439
|
+
if (state === "running") {
|
|
440
|
+
return "running";
|
|
441
|
+
}
|
|
442
|
+
if (exitCode !== null && exitCode > 0) {
|
|
443
|
+
return "error";
|
|
444
|
+
}
|
|
445
|
+
if (state === "created" || state === "exited" || state === "stopped") {
|
|
446
|
+
return "stopped";
|
|
447
|
+
}
|
|
448
|
+
if (state === "dead" || state === "restarting" || state === "paused") {
|
|
449
|
+
return "error";
|
|
450
|
+
}
|
|
451
|
+
return "unknown";
|
|
310
452
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
453
|
+
function addComposeStatusRow(statuses, row) {
|
|
454
|
+
if (typeof row !== "object" || row === null) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const record = row;
|
|
458
|
+
if (typeof record.Service !== "string") {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const detail = typeof record.Status === "string" && record.Status.trim() ? record.Status.trim() : typeof record.State === "string" && record.State.trim() ? record.State.trim() : null;
|
|
462
|
+
statuses.set(record.Service, {
|
|
463
|
+
name: record.Service,
|
|
464
|
+
status: serviceStatusFromRow(record),
|
|
465
|
+
statusDetail: detail,
|
|
466
|
+
containerId: typeof record.ID === "string" && record.ID.length > 0 ? record.ID : null
|
|
467
|
+
});
|
|
314
468
|
}
|
|
315
469
|
function parseComposePs(output) {
|
|
316
470
|
const statuses = /* @__PURE__ */ new Map();
|
|
@@ -320,82 +474,181 @@ function parseComposePs(output) {
|
|
|
320
474
|
}
|
|
321
475
|
try {
|
|
322
476
|
const parsed = JSON.parse(compactOutput);
|
|
323
|
-
const
|
|
324
|
-
for (const row of rows2) {
|
|
477
|
+
for (const row of Array.isArray(parsed) ? parsed : [parsed]) {
|
|
325
478
|
addComposeStatusRow(statuses, row);
|
|
326
479
|
}
|
|
327
480
|
return statuses;
|
|
328
481
|
} catch {
|
|
329
482
|
}
|
|
330
|
-
const
|
|
331
|
-
for (const row of rows) {
|
|
483
|
+
for (const line of compactOutput.split(/\r?\n/)) {
|
|
332
484
|
try {
|
|
333
|
-
|
|
334
|
-
addComposeStatusRow(statuses, parsed);
|
|
485
|
+
addComposeStatusRow(statuses, JSON.parse(line));
|
|
335
486
|
} catch {
|
|
336
|
-
return
|
|
487
|
+
return /* @__PURE__ */ new Map();
|
|
337
488
|
}
|
|
338
489
|
}
|
|
339
490
|
return statuses;
|
|
340
491
|
}
|
|
341
|
-
function
|
|
342
|
-
|
|
343
|
-
|
|
492
|
+
function unknownServices(serviceNames) {
|
|
493
|
+
return serviceNames.map((name) => ({
|
|
494
|
+
name,
|
|
495
|
+
status: "unknown",
|
|
496
|
+
statusDetail: null,
|
|
497
|
+
containerId: null
|
|
498
|
+
}));
|
|
499
|
+
}
|
|
500
|
+
function commandFailureMessage(action, result) {
|
|
501
|
+
const detail = resultMessage(result);
|
|
502
|
+
return detail ? `Docker Compose ${action} failed: ${detail}` : `Docker Compose ${action} failed.`;
|
|
503
|
+
}
|
|
504
|
+
var DockerComposeController = class {
|
|
505
|
+
constructor(root, options = {}) {
|
|
506
|
+
this.root = root;
|
|
507
|
+
this.runner = options.runner ?? runDockerCommand;
|
|
508
|
+
this.platform = options.platform ?? process.platform;
|
|
509
|
+
}
|
|
510
|
+
root;
|
|
511
|
+
runner;
|
|
512
|
+
platform;
|
|
513
|
+
async definition() {
|
|
514
|
+
const composeFiles = await findComposeFiles(this.root);
|
|
515
|
+
if (composeFiles.length === 0) {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
composeFiles,
|
|
520
|
+
serviceNames: await serviceNamesFromFiles(composeFiles)
|
|
521
|
+
};
|
|
344
522
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
523
|
+
async requireService(service) {
|
|
524
|
+
const definition = await this.definition();
|
|
525
|
+
if (definition === null) {
|
|
526
|
+
throw new DockerOperationError("compose-not-found", "No Docker Compose file was found.");
|
|
527
|
+
}
|
|
528
|
+
if (!definition.serviceNames.includes(service)) {
|
|
529
|
+
throw new DockerOperationError(
|
|
530
|
+
"service-not-found",
|
|
531
|
+
`Docker Compose service "${service}" was not found.`
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return definition;
|
|
348
535
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
status
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
536
|
+
async requireDaemon() {
|
|
537
|
+
const result = await this.runner(this.root, ["info"], { timeoutMs: 5e3 });
|
|
538
|
+
const daemon = daemonStatus(result, this.platform);
|
|
539
|
+
if (daemon.status === "not-installed") {
|
|
540
|
+
throw new DockerOperationError("docker-not-installed", daemon.message ?? "Docker not found.");
|
|
541
|
+
}
|
|
542
|
+
if (!daemon.running) {
|
|
543
|
+
throw new DockerOperationError(
|
|
544
|
+
"docker-not-running",
|
|
545
|
+
daemon.message ?? "Docker is not running."
|
|
546
|
+
);
|
|
547
|
+
}
|
|
359
548
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
})
|
|
549
|
+
async inspect() {
|
|
550
|
+
const definition = await this.definition();
|
|
551
|
+
if (definition === null) {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
const infoResult = await this.runner(this.root, ["info"], { timeoutMs: 5e3 });
|
|
555
|
+
const daemon = daemonStatus(infoResult, this.platform);
|
|
556
|
+
if (!daemon.running) {
|
|
557
|
+
return {
|
|
558
|
+
composeFiles: definition.composeFiles,
|
|
559
|
+
services: unknownServices(definition.serviceNames),
|
|
560
|
+
dockerRunning: false,
|
|
561
|
+
daemonStatus: daemon.status,
|
|
562
|
+
message: daemon.message
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
const ps = await this.runner(
|
|
566
|
+
this.root,
|
|
567
|
+
composeArgs(this.root, definition.composeFiles, ["ps", "--all", "--format", "json"]),
|
|
568
|
+
{ timeoutMs: 8e3 }
|
|
569
|
+
);
|
|
570
|
+
if (ps.code !== 0 || ps.error !== null) {
|
|
571
|
+
return {
|
|
572
|
+
composeFiles: definition.composeFiles,
|
|
573
|
+
services: definition.serviceNames.map((name) => ({
|
|
574
|
+
name,
|
|
575
|
+
status: "error",
|
|
576
|
+
statusDetail: null,
|
|
577
|
+
containerId: null
|
|
578
|
+
})),
|
|
579
|
+
dockerRunning: true,
|
|
580
|
+
daemonStatus: "running",
|
|
581
|
+
message: commandFailureMessage("status check", ps)
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
const statuses = parseComposePs(ps.stdout);
|
|
585
|
+
return {
|
|
586
|
+
composeFiles: definition.composeFiles,
|
|
587
|
+
services: definition.serviceNames.map(
|
|
588
|
+
(name) => statuses.get(name) ?? {
|
|
589
|
+
name,
|
|
590
|
+
status: "stopped",
|
|
591
|
+
statusDetail: null,
|
|
592
|
+
containerId: null
|
|
593
|
+
}
|
|
594
|
+
),
|
|
595
|
+
dockerRunning: true,
|
|
596
|
+
daemonStatus: "running",
|
|
597
|
+
message: null
|
|
598
|
+
};
|
|
366
599
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
name: service,
|
|
379
|
-
status: "stopped",
|
|
380
|
-
containerId: null
|
|
600
|
+
async action(service, action) {
|
|
601
|
+
const definition = await this.requireService(service);
|
|
602
|
+
await this.requireDaemon();
|
|
603
|
+
const composeCommand = action === "start" ? ["up", "-d", "--", service] : ["stop", "--", service];
|
|
604
|
+
const result = await this.runner(
|
|
605
|
+
this.root,
|
|
606
|
+
composeArgs(this.root, definition.composeFiles, composeCommand),
|
|
607
|
+
{ timeoutMs: 6e4 }
|
|
608
|
+
);
|
|
609
|
+
if (result.code !== 0 || result.error !== null) {
|
|
610
|
+
throw new DockerOperationError("command-failed", commandFailureMessage(action, result));
|
|
381
611
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
return null;
|
|
612
|
+
return {
|
|
613
|
+
service,
|
|
614
|
+
action,
|
|
615
|
+
output: cleanMessage(result.stdout || result.stderr)
|
|
616
|
+
};
|
|
388
617
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
618
|
+
async start(service) {
|
|
619
|
+
return await this.action(service, "start");
|
|
620
|
+
}
|
|
621
|
+
async stop(service) {
|
|
622
|
+
return await this.action(service, "stop");
|
|
623
|
+
}
|
|
624
|
+
async logs(service) {
|
|
625
|
+
const definition = await this.requireService(service);
|
|
626
|
+
await this.requireDaemon();
|
|
627
|
+
const result = await this.runner(
|
|
628
|
+
this.root,
|
|
629
|
+
composeArgs(this.root, definition.composeFiles, [
|
|
630
|
+
"logs",
|
|
631
|
+
"--no-color",
|
|
632
|
+
"--tail",
|
|
633
|
+
"200",
|
|
634
|
+
"--",
|
|
635
|
+
service
|
|
636
|
+
]),
|
|
637
|
+
{ timeoutMs: 15e3, outputLimit: COMMAND_OUTPUT_LIMIT }
|
|
638
|
+
);
|
|
639
|
+
if (result.code !== 0 || result.error !== null) {
|
|
640
|
+
throw new DockerOperationError("command-failed", commandFailureMessage("logs", result));
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
service,
|
|
644
|
+
logs: appendBounded("", `${result.stdout}${result.stderr}`, COMMAND_OUTPUT_LIMIT)
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
// src/core/scanner/docker.ts
|
|
650
|
+
async function detectDocker(root) {
|
|
651
|
+
return await new DockerComposeController(root).inspect();
|
|
399
652
|
}
|
|
400
653
|
|
|
401
654
|
// src/core/scanner/env.ts
|
|
@@ -529,7 +782,7 @@ function detectFramework(packageJson) {
|
|
|
529
782
|
// src/core/scanner/git.ts
|
|
530
783
|
import { promises as fs5 } from "fs";
|
|
531
784
|
import path5 from "path";
|
|
532
|
-
function
|
|
785
|
+
function isWithinRoot4(root, target) {
|
|
533
786
|
const resolvedRoot = path5.resolve(root);
|
|
534
787
|
const resolvedTarget = path5.resolve(target);
|
|
535
788
|
const relative = path5.relative(resolvedRoot, resolvedTarget);
|
|
@@ -548,14 +801,14 @@ async function resolveGitDirectory(root) {
|
|
|
548
801
|
if (match) {
|
|
549
802
|
const gitDir = match[1].trim();
|
|
550
803
|
const resolvedGitDir = path5.isAbsolute(gitDir) ? path5.resolve(gitDir) : path5.resolve(root, gitDir);
|
|
551
|
-
if (!
|
|
804
|
+
if (!isWithinRoot4(root, resolvedGitDir)) {
|
|
552
805
|
return null;
|
|
553
806
|
}
|
|
554
807
|
const [realRoot, realGitDir] = await Promise.all([
|
|
555
808
|
fs5.realpath(root),
|
|
556
809
|
fs5.realpath(resolvedGitDir)
|
|
557
810
|
]);
|
|
558
|
-
return
|
|
811
|
+
return isWithinRoot4(realRoot, realGitDir) ? resolvedGitDir : null;
|
|
559
812
|
}
|
|
560
813
|
}
|
|
561
814
|
} catch {
|
|
@@ -613,7 +866,7 @@ async function detectPackageManager(root) {
|
|
|
613
866
|
// src/core/scanner/packageJson.ts
|
|
614
867
|
import { promises as fs7 } from "fs";
|
|
615
868
|
import path7 from "path";
|
|
616
|
-
function
|
|
869
|
+
function isWithinRoot5(root, target) {
|
|
617
870
|
const relative = path7.relative(root, target);
|
|
618
871
|
return relative === "" || !relative.startsWith("..") && !path7.isAbsolute(relative);
|
|
619
872
|
}
|
|
@@ -624,7 +877,7 @@ async function readPackageJson(root) {
|
|
|
624
877
|
fs7.realpath(root),
|
|
625
878
|
fs7.realpath(packageJsonPath)
|
|
626
879
|
]);
|
|
627
|
-
if (!
|
|
880
|
+
if (!isWithinRoot5(realRoot, realPackageJsonPath)) {
|
|
628
881
|
return null;
|
|
629
882
|
}
|
|
630
883
|
const content = await fs7.readFile(realPackageJsonPath, "utf8");
|
|
@@ -727,13 +980,18 @@ function extractScripts(packageJson) {
|
|
|
727
980
|
}
|
|
728
981
|
|
|
729
982
|
// src/core/scanner/index.ts
|
|
983
|
+
function isWithinRoot6(root, target) {
|
|
984
|
+
const relative = path8.relative(path8.resolve(root), path8.resolve(target));
|
|
985
|
+
return relative === "" || !relative.startsWith("..") && !path8.isAbsolute(relative);
|
|
986
|
+
}
|
|
730
987
|
async function findFirstFile(root, candidates) {
|
|
988
|
+
const resolvedRoot = await fs8.realpath(root).catch(() => path8.resolve(root));
|
|
731
989
|
for (const candidate of candidates) {
|
|
732
990
|
const filePath = path8.join(root, candidate);
|
|
733
991
|
try {
|
|
734
|
-
const stat = await fs8.stat(filePath);
|
|
735
|
-
if (stat.isFile()) {
|
|
736
|
-
return { path:
|
|
992
|
+
const [stat, realPath] = await Promise.all([fs8.stat(filePath), fs8.realpath(filePath)]);
|
|
993
|
+
if (stat.isFile() && isWithinRoot6(resolvedRoot, realPath)) {
|
|
994
|
+
return { path: realPath, exists: true };
|
|
737
995
|
}
|
|
738
996
|
} catch {
|
|
739
997
|
}
|
|
@@ -744,8 +1002,9 @@ function configuredPorts(configPorts) {
|
|
|
744
1002
|
return Array.isArray(configPorts) ? configPorts : [];
|
|
745
1003
|
}
|
|
746
1004
|
async function scanProject(root = process.cwd()) {
|
|
747
|
-
const
|
|
748
|
-
const
|
|
1005
|
+
const resolvedRoot = await fs8.realpath(root).catch(() => path8.resolve(root));
|
|
1006
|
+
const config = await loadConfig(resolvedRoot);
|
|
1007
|
+
const packageJson = await readPackageJson(resolvedRoot);
|
|
749
1008
|
const scripts = extractScripts(packageJson) ?? {};
|
|
750
1009
|
const framework = detectFramework(packageJson);
|
|
751
1010
|
const portsToProbe = [
|
|
@@ -754,17 +1013,17 @@ async function scanProject(root = process.cwd()) {
|
|
|
754
1013
|
...defaultPortsForFramework(framework)
|
|
755
1014
|
];
|
|
756
1015
|
const [packageManager, env, docker, git, ports, readme, license] = await Promise.all([
|
|
757
|
-
detectPackageManager(
|
|
758
|
-
detectEnv(
|
|
759
|
-
detectDocker(
|
|
760
|
-
detectGit(
|
|
1016
|
+
detectPackageManager(resolvedRoot),
|
|
1017
|
+
detectEnv(resolvedRoot, config?.config),
|
|
1018
|
+
detectDocker(resolvedRoot),
|
|
1019
|
+
detectGit(resolvedRoot),
|
|
761
1020
|
detectPorts(portsToProbe),
|
|
762
|
-
findFirstFile(
|
|
763
|
-
findFirstFile(
|
|
1021
|
+
findFirstFile(resolvedRoot, ["README.md", "README"]),
|
|
1022
|
+
findFirstFile(resolvedRoot, ["LICENSE", "LICENSE.md", "COPYING"])
|
|
764
1023
|
]);
|
|
765
1024
|
return {
|
|
766
|
-
root,
|
|
767
|
-
projectName: config?.config.name ?? packageJson?.data.name ?? path8.basename(
|
|
1025
|
+
root: resolvedRoot,
|
|
1026
|
+
projectName: config?.config.name ?? packageJson?.data.name ?? path8.basename(resolvedRoot),
|
|
768
1027
|
packageJson,
|
|
769
1028
|
packageManager: packageManager ?? (packageJson ? "npm" : null),
|
|
770
1029
|
scripts,
|
|
@@ -798,25 +1057,6 @@ async function readIfPresent2(filePath) {
|
|
|
798
1057
|
return null;
|
|
799
1058
|
}
|
|
800
1059
|
}
|
|
801
|
-
function extractReadmeScriptReferences(readmeContent) {
|
|
802
|
-
const references = /* @__PURE__ */ new Set();
|
|
803
|
-
const commandRegexes = [
|
|
804
|
-
/\bnpm\s+run\s+([A-Za-z0-9:_-]+)/g,
|
|
805
|
-
/\bpnpm\s+run\s+([A-Za-z0-9:_-]+)/g,
|
|
806
|
-
/\bbun\s+run\s+([A-Za-z0-9:_-]+)/g,
|
|
807
|
-
/\byarn\s+run\s+([A-Za-z0-9:_-]+)/g,
|
|
808
|
-
/\bnpm\s+(test|start|build)\b/g,
|
|
809
|
-
/\bpnpm\s+(test|start|build)\b/g,
|
|
810
|
-
/\byarn\s+(test|start|build)\b/g,
|
|
811
|
-
/\bbun\s+(test|start|build)\b/g
|
|
812
|
-
];
|
|
813
|
-
for (const regex of commandRegexes) {
|
|
814
|
-
for (const match of readmeContent.matchAll(regex)) {
|
|
815
|
-
references.add(match[1]);
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
return Array.from(references);
|
|
819
|
-
}
|
|
820
1060
|
function warning(id, severity, title, message, target) {
|
|
821
1061
|
return { id, severity, title, message, target };
|
|
822
1062
|
}
|
|
@@ -888,7 +1128,7 @@ async function runDoctor(root = process.cwd(), scan) {
|
|
|
888
1128
|
} else {
|
|
889
1129
|
const readme = await readIfPresent2(result.readme.path);
|
|
890
1130
|
if (readme !== null) {
|
|
891
|
-
const references =
|
|
1131
|
+
const references = extractScriptReferences(readme);
|
|
892
1132
|
const missingScripts = references.filter((script) => result.scripts[script] === void 0);
|
|
893
1133
|
if (missingScripts.length > 0) {
|
|
894
1134
|
warnings.push(
|
|
@@ -918,7 +1158,7 @@ async function runDoctor(root = process.cwd(), scan) {
|
|
|
918
1158
|
"docker-not-running",
|
|
919
1159
|
"warning",
|
|
920
1160
|
"Docker Compose found but Docker is not running",
|
|
921
|
-
"A compose file exists, but
|
|
1161
|
+
result.docker.message ?? "A compose file exists, but Docker is not available."
|
|
922
1162
|
)
|
|
923
1163
|
);
|
|
924
1164
|
}
|
|
@@ -942,18 +1182,15 @@ async function runDoctor(root = process.cwd(), scan) {
|
|
|
942
1182
|
)
|
|
943
1183
|
);
|
|
944
1184
|
}
|
|
945
|
-
if (!result.license.exists) {
|
|
946
|
-
warnings.push(warning("missing-license", "info", "No LICENSE", "No LICENSE file was found."));
|
|
947
|
-
}
|
|
948
1185
|
return warnings;
|
|
949
1186
|
}
|
|
950
1187
|
|
|
951
|
-
// src/
|
|
952
|
-
var
|
|
1188
|
+
// src/core/security/text.ts
|
|
1189
|
+
var ESC2 = String.fromCharCode(27);
|
|
953
1190
|
var BEL = String.fromCharCode(7);
|
|
954
|
-
var OSC_SEQUENCE = new RegExp(`${
|
|
955
|
-
var CSI_SEQUENCE = new RegExp(`${
|
|
956
|
-
var ESCAPE_SEQUENCE = new RegExp(`${
|
|
1191
|
+
var OSC_SEQUENCE = new RegExp(`${ESC2}\\][\\s\\S]*?(?:${BEL}|${ESC2}\\\\)`, "g");
|
|
1192
|
+
var CSI_SEQUENCE = new RegExp(`${ESC2}\\[[0-?]*[ -/]*[@-~]`, "g");
|
|
1193
|
+
var ESCAPE_SEQUENCE = new RegExp(`${ESC2}[@-Z\\\\-_]`, "g");
|
|
957
1194
|
function stripControlCharacters(value) {
|
|
958
1195
|
let result = "";
|
|
959
1196
|
for (const character of value) {
|
|
@@ -964,13 +1201,13 @@ function stripControlCharacters(value) {
|
|
|
964
1201
|
}
|
|
965
1202
|
return result;
|
|
966
1203
|
}
|
|
967
|
-
function
|
|
1204
|
+
function safeDisplayText(value) {
|
|
968
1205
|
return stripControlCharacters(
|
|
969
1206
|
String(value).replace(OSC_SEQUENCE, "").replace(CSI_SEQUENCE, "").replace(ESCAPE_SEQUENCE, "")
|
|
970
1207
|
);
|
|
971
1208
|
}
|
|
972
|
-
function
|
|
973
|
-
return values.length > 0 ? values.map((value) =>
|
|
1209
|
+
function safeDisplayList(values) {
|
|
1210
|
+
return values.length > 0 ? values.map((value) => safeDisplayText(value)).join(", ") : "none";
|
|
974
1211
|
}
|
|
975
1212
|
|
|
976
1213
|
// src/cli/commands/doctor.ts
|
|
@@ -990,8 +1227,8 @@ async function doctorCommand(cwd = process.cwd()) {
|
|
|
990
1227
|
return;
|
|
991
1228
|
}
|
|
992
1229
|
for (const item of warnings) {
|
|
993
|
-
console.log(`${colorSeverity(item.severity)} ${pc.bold(
|
|
994
|
-
console.log(` ${
|
|
1230
|
+
console.log(`${colorSeverity(item.severity)} ${pc.bold(safeDisplayText(item.title))}`);
|
|
1231
|
+
console.log(` ${safeDisplayText(item.message)}`);
|
|
995
1232
|
}
|
|
996
1233
|
}
|
|
997
1234
|
|
|
@@ -1017,6 +1254,14 @@ import pc3 from "picocolors";
|
|
|
1017
1254
|
|
|
1018
1255
|
// src/core/process/runner.ts
|
|
1019
1256
|
import spawn2 from "cross-spawn";
|
|
1257
|
+
|
|
1258
|
+
// src/core/security/dangerousCommand.ts
|
|
1259
|
+
var DANGEROUS_COMMAND = /\b(rm\s+-rf|docker\s+volume\s+rm|drop\s+database|prisma\s+migrate\s+reset|git\s+clean\s+-fdx?)\b/i;
|
|
1260
|
+
function isDangerousCommand(command) {
|
|
1261
|
+
return DANGEROUS_COMMAND.test(command);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// src/core/process/runner.ts
|
|
1020
1265
|
function getPackageRunCommand(packageManager, script) {
|
|
1021
1266
|
const manager = packageManager ?? "npm";
|
|
1022
1267
|
const args = ["run", script];
|
|
@@ -1077,6 +1322,95 @@ async function resolvePackageInstallCommand(options) {
|
|
|
1077
1322
|
command: executable
|
|
1078
1323
|
};
|
|
1079
1324
|
}
|
|
1325
|
+
function splitCommandLine(command) {
|
|
1326
|
+
const tokens = [];
|
|
1327
|
+
let current = "";
|
|
1328
|
+
let quote = null;
|
|
1329
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
1330
|
+
const character = command[index] ?? "";
|
|
1331
|
+
if (quote !== null) {
|
|
1332
|
+
if (character === quote) {
|
|
1333
|
+
quote = null;
|
|
1334
|
+
} else if (character === "\\" && quote === '"') {
|
|
1335
|
+
const next = command[index + 1];
|
|
1336
|
+
if (next === '"' || next === "\\") {
|
|
1337
|
+
index += 1;
|
|
1338
|
+
current += next ?? "";
|
|
1339
|
+
} else {
|
|
1340
|
+
current += character;
|
|
1341
|
+
}
|
|
1342
|
+
} else {
|
|
1343
|
+
current += character;
|
|
1344
|
+
}
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
if (character === '"' || character === "'") {
|
|
1348
|
+
quote = character;
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1351
|
+
if (/\s/.test(character)) {
|
|
1352
|
+
if (current.length > 0) {
|
|
1353
|
+
tokens.push(current);
|
|
1354
|
+
current = "";
|
|
1355
|
+
}
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
current += character;
|
|
1359
|
+
}
|
|
1360
|
+
if (current.length > 0) {
|
|
1361
|
+
tokens.push(current);
|
|
1362
|
+
}
|
|
1363
|
+
return tokens;
|
|
1364
|
+
}
|
|
1365
|
+
function containsShellMetacharacters(command) {
|
|
1366
|
+
let quote = null;
|
|
1367
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
1368
|
+
const character = command[index] ?? "";
|
|
1369
|
+
if (quote !== null) {
|
|
1370
|
+
if (character === quote) {
|
|
1371
|
+
quote = null;
|
|
1372
|
+
}
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
if (character === '"' || character === "'") {
|
|
1376
|
+
quote = character;
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
if (character === "\n" || character === "\r") {
|
|
1380
|
+
return true;
|
|
1381
|
+
}
|
|
1382
|
+
if (";|&<>".includes(character)) {
|
|
1383
|
+
return true;
|
|
1384
|
+
}
|
|
1385
|
+
if (character === "`") {
|
|
1386
|
+
return true;
|
|
1387
|
+
}
|
|
1388
|
+
if (character === "$" && (command[index + 1] === "(" || command[index + 1] === "{")) {
|
|
1389
|
+
return true;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
return false;
|
|
1393
|
+
}
|
|
1394
|
+
async function resolveConfiguredCommand(cwd, command) {
|
|
1395
|
+
const trimmed = command.trim();
|
|
1396
|
+
if (trimmed.length === 0 || containsShellMetacharacters(trimmed)) {
|
|
1397
|
+
return null;
|
|
1398
|
+
}
|
|
1399
|
+
const tokens = splitCommandLine(trimmed);
|
|
1400
|
+
if (tokens.length === 0) {
|
|
1401
|
+
return null;
|
|
1402
|
+
}
|
|
1403
|
+
const [executableName, ...args] = tokens;
|
|
1404
|
+
const executable = await resolveExecutableOutsideRoot(cwd, executableName);
|
|
1405
|
+
if (executable === null) {
|
|
1406
|
+
return null;
|
|
1407
|
+
}
|
|
1408
|
+
return {
|
|
1409
|
+
command: executable,
|
|
1410
|
+
args,
|
|
1411
|
+
displayCommand: trimmed
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1080
1414
|
async function runPackageScriptToTerminal(options) {
|
|
1081
1415
|
const runCommand2 = await resolvePackageRunCommand(options);
|
|
1082
1416
|
if (runCommand2 === null) {
|
|
@@ -1121,14 +1455,14 @@ async function runCommand(script, cwd = process.cwd()) {
|
|
|
1121
1455
|
// src/cli/commands/scan.ts
|
|
1122
1456
|
import pc4 from "picocolors";
|
|
1123
1457
|
function formatList(values) {
|
|
1124
|
-
return
|
|
1458
|
+
return safeDisplayList(values);
|
|
1125
1459
|
}
|
|
1126
1460
|
function printScanResult(scan) {
|
|
1127
|
-
console.log(pc4.bold(`Project: ${
|
|
1128
|
-
console.log(`Type: ${
|
|
1129
|
-
console.log(`Manager: ${
|
|
1461
|
+
console.log(pc4.bold(`Project: ${safeDisplayText(scan.projectName)}`));
|
|
1462
|
+
console.log(`Type: ${safeDisplayText(scan.framework?.type ?? "Unknown")}`);
|
|
1463
|
+
console.log(`Manager: ${safeDisplayText(scan.packageManager ?? "unknown")}`);
|
|
1130
1464
|
console.log(`Scripts: ${formatList(Object.keys(scan.scripts))}`);
|
|
1131
|
-
console.log(`Git: ${
|
|
1465
|
+
console.log(`Git: ${safeDisplayText(scan.git?.branch ?? "not detected")}`);
|
|
1132
1466
|
console.log(`README: ${scan.readme.exists ? "found" : "missing"}`);
|
|
1133
1467
|
console.log(`LICENSE: ${scan.license.exists ? "found" : "missing"}`);
|
|
1134
1468
|
if (scan.env !== null) {
|
|
@@ -1151,18 +1485,533 @@ async function scanCommand(cwd = process.cwd()) {
|
|
|
1151
1485
|
// src/cli/commands/start.ts
|
|
1152
1486
|
import pc5 from "picocolors";
|
|
1153
1487
|
|
|
1154
|
-
//
|
|
1155
|
-
import
|
|
1156
|
-
import
|
|
1488
|
+
// node_modules/open/index.js
|
|
1489
|
+
import process7 from "process";
|
|
1490
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
1491
|
+
import path11 from "path";
|
|
1157
1492
|
import { fileURLToPath } from "url";
|
|
1158
|
-
import {
|
|
1159
|
-
import
|
|
1160
|
-
import {
|
|
1161
|
-
import open2 from "open";
|
|
1493
|
+
import { promisify as promisify5 } from "util";
|
|
1494
|
+
import childProcess from "child_process";
|
|
1495
|
+
import fs15, { constants as fsConstants2 } from "fs/promises";
|
|
1162
1496
|
|
|
1163
|
-
//
|
|
1164
|
-
import
|
|
1165
|
-
import
|
|
1497
|
+
// node_modules/wsl-utils/index.js
|
|
1498
|
+
import process3 from "process";
|
|
1499
|
+
import fs14, { constants as fsConstants } from "fs/promises";
|
|
1500
|
+
|
|
1501
|
+
// node_modules/is-wsl/index.js
|
|
1502
|
+
import process2 from "process";
|
|
1503
|
+
import os2 from "os";
|
|
1504
|
+
import fs13 from "fs";
|
|
1505
|
+
|
|
1506
|
+
// node_modules/is-inside-container/index.js
|
|
1507
|
+
import fs12 from "fs";
|
|
1508
|
+
|
|
1509
|
+
// node_modules/is-docker/index.js
|
|
1510
|
+
import fs11 from "fs";
|
|
1511
|
+
var isDockerCached;
|
|
1512
|
+
function hasDockerEnv() {
|
|
1513
|
+
try {
|
|
1514
|
+
fs11.statSync("/.dockerenv");
|
|
1515
|
+
return true;
|
|
1516
|
+
} catch {
|
|
1517
|
+
return false;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
function hasDockerCGroup() {
|
|
1521
|
+
try {
|
|
1522
|
+
return fs11.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
|
|
1523
|
+
} catch {
|
|
1524
|
+
return false;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
function isDocker() {
|
|
1528
|
+
if (isDockerCached === void 0) {
|
|
1529
|
+
isDockerCached = hasDockerEnv() || hasDockerCGroup();
|
|
1530
|
+
}
|
|
1531
|
+
return isDockerCached;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// node_modules/is-inside-container/index.js
|
|
1535
|
+
var cachedResult;
|
|
1536
|
+
var hasContainerEnv = () => {
|
|
1537
|
+
try {
|
|
1538
|
+
fs12.statSync("/run/.containerenv");
|
|
1539
|
+
return true;
|
|
1540
|
+
} catch {
|
|
1541
|
+
return false;
|
|
1542
|
+
}
|
|
1543
|
+
};
|
|
1544
|
+
function isInsideContainer() {
|
|
1545
|
+
if (cachedResult === void 0) {
|
|
1546
|
+
cachedResult = hasContainerEnv() || isDocker();
|
|
1547
|
+
}
|
|
1548
|
+
return cachedResult;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// node_modules/is-wsl/index.js
|
|
1552
|
+
var isWsl = () => {
|
|
1553
|
+
if (process2.platform !== "linux") {
|
|
1554
|
+
return false;
|
|
1555
|
+
}
|
|
1556
|
+
if (os2.release().toLowerCase().includes("microsoft")) {
|
|
1557
|
+
if (isInsideContainer()) {
|
|
1558
|
+
return false;
|
|
1559
|
+
}
|
|
1560
|
+
return true;
|
|
1561
|
+
}
|
|
1562
|
+
try {
|
|
1563
|
+
if (fs13.readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft")) {
|
|
1564
|
+
return !isInsideContainer();
|
|
1565
|
+
}
|
|
1566
|
+
} catch {
|
|
1567
|
+
}
|
|
1568
|
+
if (fs13.existsSync("/proc/sys/fs/binfmt_misc/WSLInterop") || fs13.existsSync("/run/WSL")) {
|
|
1569
|
+
return !isInsideContainer();
|
|
1570
|
+
}
|
|
1571
|
+
return false;
|
|
1572
|
+
};
|
|
1573
|
+
var is_wsl_default = process2.env.__IS_WSL_TEST__ ? isWsl : isWsl();
|
|
1574
|
+
|
|
1575
|
+
// node_modules/wsl-utils/index.js
|
|
1576
|
+
var wslDrivesMountPoint = /* @__PURE__ */ (() => {
|
|
1577
|
+
const defaultMountPoint = "/mnt/";
|
|
1578
|
+
let mountPoint;
|
|
1579
|
+
return async function() {
|
|
1580
|
+
if (mountPoint) {
|
|
1581
|
+
return mountPoint;
|
|
1582
|
+
}
|
|
1583
|
+
const configFilePath = "/etc/wsl.conf";
|
|
1584
|
+
let isConfigFileExists = false;
|
|
1585
|
+
try {
|
|
1586
|
+
await fs14.access(configFilePath, fsConstants.F_OK);
|
|
1587
|
+
isConfigFileExists = true;
|
|
1588
|
+
} catch {
|
|
1589
|
+
}
|
|
1590
|
+
if (!isConfigFileExists) {
|
|
1591
|
+
return defaultMountPoint;
|
|
1592
|
+
}
|
|
1593
|
+
const configContent = await fs14.readFile(configFilePath, { encoding: "utf8" });
|
|
1594
|
+
const configMountPoint = /(?<!#.*)root\s*=\s*(?<mountPoint>.*)/g.exec(configContent);
|
|
1595
|
+
if (!configMountPoint) {
|
|
1596
|
+
return defaultMountPoint;
|
|
1597
|
+
}
|
|
1598
|
+
mountPoint = configMountPoint.groups.mountPoint.trim();
|
|
1599
|
+
mountPoint = mountPoint.endsWith("/") ? mountPoint : `${mountPoint}/`;
|
|
1600
|
+
return mountPoint;
|
|
1601
|
+
};
|
|
1602
|
+
})();
|
|
1603
|
+
var powerShellPathFromWsl = async () => {
|
|
1604
|
+
const mountPoint = await wslDrivesMountPoint();
|
|
1605
|
+
return `${mountPoint}c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`;
|
|
1606
|
+
};
|
|
1607
|
+
var powerShellPath = async () => {
|
|
1608
|
+
if (is_wsl_default) {
|
|
1609
|
+
return powerShellPathFromWsl();
|
|
1610
|
+
}
|
|
1611
|
+
return `${process3.env.SYSTEMROOT || process3.env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
|
|
1612
|
+
};
|
|
1613
|
+
|
|
1614
|
+
// node_modules/define-lazy-prop/index.js
|
|
1615
|
+
function defineLazyProperty(object, propertyName, valueGetter) {
|
|
1616
|
+
const define = (value) => Object.defineProperty(object, propertyName, { value, enumerable: true, writable: true });
|
|
1617
|
+
Object.defineProperty(object, propertyName, {
|
|
1618
|
+
configurable: true,
|
|
1619
|
+
enumerable: true,
|
|
1620
|
+
get() {
|
|
1621
|
+
const result = valueGetter();
|
|
1622
|
+
define(result);
|
|
1623
|
+
return result;
|
|
1624
|
+
},
|
|
1625
|
+
set(value) {
|
|
1626
|
+
define(value);
|
|
1627
|
+
}
|
|
1628
|
+
});
|
|
1629
|
+
return object;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// node_modules/default-browser/index.js
|
|
1633
|
+
import { promisify as promisify4 } from "util";
|
|
1634
|
+
import process6 from "process";
|
|
1635
|
+
import { execFile as execFile4 } from "child_process";
|
|
1636
|
+
|
|
1637
|
+
// node_modules/default-browser-id/index.js
|
|
1638
|
+
import { promisify } from "util";
|
|
1639
|
+
import process4 from "process";
|
|
1640
|
+
import { execFile } from "child_process";
|
|
1641
|
+
var execFileAsync = promisify(execFile);
|
|
1642
|
+
async function defaultBrowserId() {
|
|
1643
|
+
if (process4.platform !== "darwin") {
|
|
1644
|
+
throw new Error("macOS only");
|
|
1645
|
+
}
|
|
1646
|
+
const { stdout } = await execFileAsync("defaults", ["read", "com.apple.LaunchServices/com.apple.launchservices.secure", "LSHandlers"]);
|
|
1647
|
+
const match = /LSHandlerRoleAll = "(?!-)(?<id>[^"]+?)";\s+?LSHandlerURLScheme = (?:http|https);/.exec(stdout);
|
|
1648
|
+
const browserId = match?.groups.id ?? "com.apple.Safari";
|
|
1649
|
+
if (browserId === "com.apple.safari") {
|
|
1650
|
+
return "com.apple.Safari";
|
|
1651
|
+
}
|
|
1652
|
+
return browserId;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// node_modules/run-applescript/index.js
|
|
1656
|
+
import process5 from "process";
|
|
1657
|
+
import { promisify as promisify2 } from "util";
|
|
1658
|
+
import { execFile as execFile2, execFileSync } from "child_process";
|
|
1659
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
1660
|
+
async function runAppleScript(script, { humanReadableOutput = true, signal } = {}) {
|
|
1661
|
+
if (process5.platform !== "darwin") {
|
|
1662
|
+
throw new Error("macOS only");
|
|
1663
|
+
}
|
|
1664
|
+
const outputArguments = humanReadableOutput ? [] : ["-ss"];
|
|
1665
|
+
const execOptions = {};
|
|
1666
|
+
if (signal) {
|
|
1667
|
+
execOptions.signal = signal;
|
|
1668
|
+
}
|
|
1669
|
+
const { stdout } = await execFileAsync2("osascript", ["-e", script, outputArguments], execOptions);
|
|
1670
|
+
return stdout.trim();
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// node_modules/bundle-name/index.js
|
|
1674
|
+
async function bundleName(bundleId) {
|
|
1675
|
+
return runAppleScript(`tell application "Finder" to set app_path to application file id "${bundleId}" as string
|
|
1676
|
+
tell application "System Events" to get value of property list item "CFBundleName" of property list file (app_path & ":Contents:Info.plist")`);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// node_modules/default-browser/windows.js
|
|
1680
|
+
import { promisify as promisify3 } from "util";
|
|
1681
|
+
import { execFile as execFile3 } from "child_process";
|
|
1682
|
+
var execFileAsync3 = promisify3(execFile3);
|
|
1683
|
+
var windowsBrowserProgIds = {
|
|
1684
|
+
MSEdgeHTM: { name: "Edge", id: "com.microsoft.edge" },
|
|
1685
|
+
// The missing `L` is correct.
|
|
1686
|
+
MSEdgeBHTML: { name: "Edge Beta", id: "com.microsoft.edge.beta" },
|
|
1687
|
+
MSEdgeDHTML: { name: "Edge Dev", id: "com.microsoft.edge.dev" },
|
|
1688
|
+
AppXq0fevzme2pys62n3e0fbqa7peapykr8v: { name: "Edge", id: "com.microsoft.edge.old" },
|
|
1689
|
+
ChromeHTML: { name: "Chrome", id: "com.google.chrome" },
|
|
1690
|
+
ChromeBHTML: { name: "Chrome Beta", id: "com.google.chrome.beta" },
|
|
1691
|
+
ChromeDHTML: { name: "Chrome Dev", id: "com.google.chrome.dev" },
|
|
1692
|
+
ChromiumHTM: { name: "Chromium", id: "org.chromium.Chromium" },
|
|
1693
|
+
BraveHTML: { name: "Brave", id: "com.brave.Browser" },
|
|
1694
|
+
BraveBHTML: { name: "Brave Beta", id: "com.brave.Browser.beta" },
|
|
1695
|
+
BraveDHTML: { name: "Brave Dev", id: "com.brave.Browser.dev" },
|
|
1696
|
+
BraveSSHTM: { name: "Brave Nightly", id: "com.brave.Browser.nightly" },
|
|
1697
|
+
FirefoxURL: { name: "Firefox", id: "org.mozilla.firefox" },
|
|
1698
|
+
OperaStable: { name: "Opera", id: "com.operasoftware.Opera" },
|
|
1699
|
+
VivaldiHTM: { name: "Vivaldi", id: "com.vivaldi.Vivaldi" },
|
|
1700
|
+
"IE.HTTP": { name: "Internet Explorer", id: "com.microsoft.ie" }
|
|
1701
|
+
};
|
|
1702
|
+
var _windowsBrowserProgIdMap = new Map(Object.entries(windowsBrowserProgIds));
|
|
1703
|
+
var UnknownBrowserError = class extends Error {
|
|
1704
|
+
};
|
|
1705
|
+
async function defaultBrowser(_execFileAsync = execFileAsync3) {
|
|
1706
|
+
const { stdout } = await _execFileAsync("reg", [
|
|
1707
|
+
"QUERY",
|
|
1708
|
+
" HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice",
|
|
1709
|
+
"/v",
|
|
1710
|
+
"ProgId"
|
|
1711
|
+
]);
|
|
1712
|
+
const match = /ProgId\s*REG_SZ\s*(?<id>\S+)/.exec(stdout);
|
|
1713
|
+
if (!match) {
|
|
1714
|
+
throw new UnknownBrowserError(`Cannot find Windows browser in stdout: ${JSON.stringify(stdout)}`);
|
|
1715
|
+
}
|
|
1716
|
+
const { id } = match.groups;
|
|
1717
|
+
const dotIndex = id.lastIndexOf(".");
|
|
1718
|
+
const hyphenIndex = id.lastIndexOf("-");
|
|
1719
|
+
const baseIdByDot = dotIndex === -1 ? void 0 : id.slice(0, dotIndex);
|
|
1720
|
+
const baseIdByHyphen = hyphenIndex === -1 ? void 0 : id.slice(0, hyphenIndex);
|
|
1721
|
+
return windowsBrowserProgIds[id] ?? windowsBrowserProgIds[baseIdByDot] ?? windowsBrowserProgIds[baseIdByHyphen] ?? { name: id, id };
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// node_modules/default-browser/index.js
|
|
1725
|
+
var execFileAsync4 = promisify4(execFile4);
|
|
1726
|
+
var titleize = (string) => string.toLowerCase().replaceAll(/(?:^|\s|-)\S/g, (x) => x.toUpperCase());
|
|
1727
|
+
async function defaultBrowser2() {
|
|
1728
|
+
if (process6.platform === "darwin") {
|
|
1729
|
+
const id = await defaultBrowserId();
|
|
1730
|
+
const name = await bundleName(id);
|
|
1731
|
+
return { name, id };
|
|
1732
|
+
}
|
|
1733
|
+
if (process6.platform === "linux") {
|
|
1734
|
+
const { stdout } = await execFileAsync4("xdg-mime", ["query", "default", "x-scheme-handler/http"]);
|
|
1735
|
+
const id = stdout.trim();
|
|
1736
|
+
const name = titleize(id.replace(/.desktop$/, "").replace("-", " "));
|
|
1737
|
+
return { name, id };
|
|
1738
|
+
}
|
|
1739
|
+
if (process6.platform === "win32") {
|
|
1740
|
+
return defaultBrowser();
|
|
1741
|
+
}
|
|
1742
|
+
throw new Error("Only macOS, Linux, and Windows are supported");
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// node_modules/open/index.js
|
|
1746
|
+
var execFile5 = promisify5(childProcess.execFile);
|
|
1747
|
+
var __dirname = path11.dirname(fileURLToPath(import.meta.url));
|
|
1748
|
+
var localXdgOpenPath = path11.join(__dirname, "xdg-open");
|
|
1749
|
+
var { platform, arch } = process7;
|
|
1750
|
+
async function getWindowsDefaultBrowserFromWsl() {
|
|
1751
|
+
const powershellPath = await powerShellPath();
|
|
1752
|
+
const rawCommand = String.raw`(Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice").ProgId`;
|
|
1753
|
+
const encodedCommand = Buffer2.from(rawCommand, "utf16le").toString("base64");
|
|
1754
|
+
const { stdout } = await execFile5(
|
|
1755
|
+
powershellPath,
|
|
1756
|
+
[
|
|
1757
|
+
"-NoProfile",
|
|
1758
|
+
"-NonInteractive",
|
|
1759
|
+
"-ExecutionPolicy",
|
|
1760
|
+
"Bypass",
|
|
1761
|
+
"-EncodedCommand",
|
|
1762
|
+
encodedCommand
|
|
1763
|
+
],
|
|
1764
|
+
{ encoding: "utf8" }
|
|
1765
|
+
);
|
|
1766
|
+
const progId = stdout.trim();
|
|
1767
|
+
const browserMap = {
|
|
1768
|
+
ChromeHTML: "com.google.chrome",
|
|
1769
|
+
BraveHTML: "com.brave.Browser",
|
|
1770
|
+
MSEdgeHTM: "com.microsoft.edge",
|
|
1771
|
+
FirefoxURL: "org.mozilla.firefox"
|
|
1772
|
+
};
|
|
1773
|
+
return browserMap[progId] ? { id: browserMap[progId] } : {};
|
|
1774
|
+
}
|
|
1775
|
+
var pTryEach = async (array, mapper) => {
|
|
1776
|
+
let latestError;
|
|
1777
|
+
for (const item of array) {
|
|
1778
|
+
try {
|
|
1779
|
+
return await mapper(item);
|
|
1780
|
+
} catch (error) {
|
|
1781
|
+
latestError = error;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
throw latestError;
|
|
1785
|
+
};
|
|
1786
|
+
var baseOpen = async (options) => {
|
|
1787
|
+
options = {
|
|
1788
|
+
wait: false,
|
|
1789
|
+
background: false,
|
|
1790
|
+
newInstance: false,
|
|
1791
|
+
allowNonzeroExitCode: false,
|
|
1792
|
+
...options
|
|
1793
|
+
};
|
|
1794
|
+
if (Array.isArray(options.app)) {
|
|
1795
|
+
return pTryEach(options.app, (singleApp) => baseOpen({
|
|
1796
|
+
...options,
|
|
1797
|
+
app: singleApp
|
|
1798
|
+
}));
|
|
1799
|
+
}
|
|
1800
|
+
let { name: app, arguments: appArguments = [] } = options.app ?? {};
|
|
1801
|
+
appArguments = [...appArguments];
|
|
1802
|
+
if (Array.isArray(app)) {
|
|
1803
|
+
return pTryEach(app, (appName) => baseOpen({
|
|
1804
|
+
...options,
|
|
1805
|
+
app: {
|
|
1806
|
+
name: appName,
|
|
1807
|
+
arguments: appArguments
|
|
1808
|
+
}
|
|
1809
|
+
}));
|
|
1810
|
+
}
|
|
1811
|
+
if (app === "browser" || app === "browserPrivate") {
|
|
1812
|
+
const ids = {
|
|
1813
|
+
"com.google.chrome": "chrome",
|
|
1814
|
+
"google-chrome.desktop": "chrome",
|
|
1815
|
+
"com.brave.Browser": "brave",
|
|
1816
|
+
"org.mozilla.firefox": "firefox",
|
|
1817
|
+
"firefox.desktop": "firefox",
|
|
1818
|
+
"com.microsoft.msedge": "edge",
|
|
1819
|
+
"com.microsoft.edge": "edge",
|
|
1820
|
+
"com.microsoft.edgemac": "edge",
|
|
1821
|
+
"microsoft-edge.desktop": "edge"
|
|
1822
|
+
};
|
|
1823
|
+
const flags = {
|
|
1824
|
+
chrome: "--incognito",
|
|
1825
|
+
brave: "--incognito",
|
|
1826
|
+
firefox: "--private-window",
|
|
1827
|
+
edge: "--inPrivate"
|
|
1828
|
+
};
|
|
1829
|
+
const browser = is_wsl_default ? await getWindowsDefaultBrowserFromWsl() : await defaultBrowser2();
|
|
1830
|
+
if (browser.id in ids) {
|
|
1831
|
+
const browserName = ids[browser.id];
|
|
1832
|
+
if (app === "browserPrivate") {
|
|
1833
|
+
appArguments.push(flags[browserName]);
|
|
1834
|
+
}
|
|
1835
|
+
return baseOpen({
|
|
1836
|
+
...options,
|
|
1837
|
+
app: {
|
|
1838
|
+
name: apps[browserName],
|
|
1839
|
+
arguments: appArguments
|
|
1840
|
+
}
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
throw new Error(`${browser.name} is not supported as a default browser`);
|
|
1844
|
+
}
|
|
1845
|
+
let command;
|
|
1846
|
+
const cliArguments = [];
|
|
1847
|
+
const childProcessOptions = {};
|
|
1848
|
+
if (platform === "darwin") {
|
|
1849
|
+
command = "open";
|
|
1850
|
+
if (options.wait) {
|
|
1851
|
+
cliArguments.push("--wait-apps");
|
|
1852
|
+
}
|
|
1853
|
+
if (options.background) {
|
|
1854
|
+
cliArguments.push("--background");
|
|
1855
|
+
}
|
|
1856
|
+
if (options.newInstance) {
|
|
1857
|
+
cliArguments.push("--new");
|
|
1858
|
+
}
|
|
1859
|
+
if (app) {
|
|
1860
|
+
cliArguments.push("-a", app);
|
|
1861
|
+
}
|
|
1862
|
+
} else if (platform === "win32" || is_wsl_default && !isInsideContainer() && !app) {
|
|
1863
|
+
command = await powerShellPath();
|
|
1864
|
+
cliArguments.push(
|
|
1865
|
+
"-NoProfile",
|
|
1866
|
+
"-NonInteractive",
|
|
1867
|
+
"-ExecutionPolicy",
|
|
1868
|
+
"Bypass",
|
|
1869
|
+
"-EncodedCommand"
|
|
1870
|
+
);
|
|
1871
|
+
if (!is_wsl_default) {
|
|
1872
|
+
childProcessOptions.windowsVerbatimArguments = true;
|
|
1873
|
+
}
|
|
1874
|
+
const encodedArguments = ["Start"];
|
|
1875
|
+
if (options.wait) {
|
|
1876
|
+
encodedArguments.push("-Wait");
|
|
1877
|
+
}
|
|
1878
|
+
if (app) {
|
|
1879
|
+
encodedArguments.push(`"\`"${app}\`""`);
|
|
1880
|
+
if (options.target) {
|
|
1881
|
+
appArguments.push(options.target);
|
|
1882
|
+
}
|
|
1883
|
+
} else if (options.target) {
|
|
1884
|
+
encodedArguments.push(`"${options.target}"`);
|
|
1885
|
+
}
|
|
1886
|
+
if (appArguments.length > 0) {
|
|
1887
|
+
appArguments = appArguments.map((argument) => `"\`"${argument}\`""`);
|
|
1888
|
+
encodedArguments.push("-ArgumentList", appArguments.join(","));
|
|
1889
|
+
}
|
|
1890
|
+
options.target = Buffer2.from(encodedArguments.join(" "), "utf16le").toString("base64");
|
|
1891
|
+
} else {
|
|
1892
|
+
if (app) {
|
|
1893
|
+
command = app;
|
|
1894
|
+
} else {
|
|
1895
|
+
const isBundled = !__dirname || __dirname === "/";
|
|
1896
|
+
let exeLocalXdgOpen = false;
|
|
1897
|
+
try {
|
|
1898
|
+
await fs15.access(localXdgOpenPath, fsConstants2.X_OK);
|
|
1899
|
+
exeLocalXdgOpen = true;
|
|
1900
|
+
} catch {
|
|
1901
|
+
}
|
|
1902
|
+
const useSystemXdgOpen = process7.versions.electron ?? (platform === "android" || isBundled || !exeLocalXdgOpen);
|
|
1903
|
+
command = useSystemXdgOpen ? "xdg-open" : localXdgOpenPath;
|
|
1904
|
+
}
|
|
1905
|
+
if (appArguments.length > 0) {
|
|
1906
|
+
cliArguments.push(...appArguments);
|
|
1907
|
+
}
|
|
1908
|
+
if (!options.wait) {
|
|
1909
|
+
childProcessOptions.stdio = "ignore";
|
|
1910
|
+
childProcessOptions.detached = true;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
if (platform === "darwin" && appArguments.length > 0) {
|
|
1914
|
+
cliArguments.push("--args", ...appArguments);
|
|
1915
|
+
}
|
|
1916
|
+
if (options.target) {
|
|
1917
|
+
cliArguments.push(options.target);
|
|
1918
|
+
}
|
|
1919
|
+
const subprocess = childProcess.spawn(command, cliArguments, childProcessOptions);
|
|
1920
|
+
if (options.wait) {
|
|
1921
|
+
return new Promise((resolve, reject) => {
|
|
1922
|
+
subprocess.once("error", reject);
|
|
1923
|
+
subprocess.once("close", (exitCode) => {
|
|
1924
|
+
if (!options.allowNonzeroExitCode && exitCode > 0) {
|
|
1925
|
+
reject(new Error(`Exited with code ${exitCode}`));
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
resolve(subprocess);
|
|
1929
|
+
});
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
subprocess.unref();
|
|
1933
|
+
return subprocess;
|
|
1934
|
+
};
|
|
1935
|
+
var open = (target, options) => {
|
|
1936
|
+
if (typeof target !== "string") {
|
|
1937
|
+
throw new TypeError("Expected a `target`");
|
|
1938
|
+
}
|
|
1939
|
+
return baseOpen({
|
|
1940
|
+
...options,
|
|
1941
|
+
target
|
|
1942
|
+
});
|
|
1943
|
+
};
|
|
1944
|
+
function detectArchBinary(binary) {
|
|
1945
|
+
if (typeof binary === "string" || Array.isArray(binary)) {
|
|
1946
|
+
return binary;
|
|
1947
|
+
}
|
|
1948
|
+
const { [arch]: archBinary } = binary;
|
|
1949
|
+
if (!archBinary) {
|
|
1950
|
+
throw new Error(`${arch} is not supported`);
|
|
1951
|
+
}
|
|
1952
|
+
return archBinary;
|
|
1953
|
+
}
|
|
1954
|
+
function detectPlatformBinary({ [platform]: platformBinary }, { wsl }) {
|
|
1955
|
+
if (wsl && is_wsl_default) {
|
|
1956
|
+
return detectArchBinary(wsl);
|
|
1957
|
+
}
|
|
1958
|
+
if (!platformBinary) {
|
|
1959
|
+
throw new Error(`${platform} is not supported`);
|
|
1960
|
+
}
|
|
1961
|
+
return detectArchBinary(platformBinary);
|
|
1962
|
+
}
|
|
1963
|
+
var apps = {};
|
|
1964
|
+
defineLazyProperty(apps, "chrome", () => detectPlatformBinary({
|
|
1965
|
+
darwin: "google chrome",
|
|
1966
|
+
win32: "chrome",
|
|
1967
|
+
linux: ["google-chrome", "google-chrome-stable", "chromium"]
|
|
1968
|
+
}, {
|
|
1969
|
+
wsl: {
|
|
1970
|
+
ia32: "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
|
1971
|
+
x64: ["/mnt/c/Program Files/Google/Chrome/Application/chrome.exe", "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe"]
|
|
1972
|
+
}
|
|
1973
|
+
}));
|
|
1974
|
+
defineLazyProperty(apps, "brave", () => detectPlatformBinary({
|
|
1975
|
+
darwin: "brave browser",
|
|
1976
|
+
win32: "brave",
|
|
1977
|
+
linux: ["brave-browser", "brave"]
|
|
1978
|
+
}, {
|
|
1979
|
+
wsl: {
|
|
1980
|
+
ia32: "/mnt/c/Program Files (x86)/BraveSoftware/Brave-Browser/Application/brave.exe",
|
|
1981
|
+
x64: ["/mnt/c/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe", "/mnt/c/Program Files (x86)/BraveSoftware/Brave-Browser/Application/brave.exe"]
|
|
1982
|
+
}
|
|
1983
|
+
}));
|
|
1984
|
+
defineLazyProperty(apps, "firefox", () => detectPlatformBinary({
|
|
1985
|
+
darwin: "firefox",
|
|
1986
|
+
win32: String.raw`C:\Program Files\Mozilla Firefox\firefox.exe`,
|
|
1987
|
+
linux: "firefox"
|
|
1988
|
+
}, {
|
|
1989
|
+
wsl: "/mnt/c/Program Files/Mozilla Firefox/firefox.exe"
|
|
1990
|
+
}));
|
|
1991
|
+
defineLazyProperty(apps, "edge", () => detectPlatformBinary({
|
|
1992
|
+
darwin: "microsoft edge",
|
|
1993
|
+
win32: "msedge",
|
|
1994
|
+
linux: ["microsoft-edge", "microsoft-edge-dev"]
|
|
1995
|
+
}, {
|
|
1996
|
+
wsl: "/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"
|
|
1997
|
+
}));
|
|
1998
|
+
defineLazyProperty(apps, "browser", () => "browser");
|
|
1999
|
+
defineLazyProperty(apps, "browserPrivate", () => "browserPrivate");
|
|
2000
|
+
var open_default = open;
|
|
2001
|
+
|
|
2002
|
+
// src/server/index.ts
|
|
2003
|
+
import { promises as fs19 } from "fs";
|
|
2004
|
+
import path15 from "path";
|
|
2005
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2006
|
+
import { createAdaptorServer } from "@hono/node-server";
|
|
2007
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
2008
|
+
import { Hono } from "hono";
|
|
2009
|
+
|
|
2010
|
+
// src/core/process/manager.ts
|
|
2011
|
+
import { EventEmitter } from "events";
|
|
2012
|
+
import spawn3 from "cross-spawn";
|
|
2013
|
+
var LOG_MESSAGE_LIMIT = 16384;
|
|
2014
|
+
var LOG_ENTRY_LIMIT = 1e3;
|
|
1166
2015
|
function killChildProcessTree(child) {
|
|
1167
2016
|
if (child.pid === void 0) {
|
|
1168
2017
|
child.kill();
|
|
@@ -1187,7 +2036,7 @@ var ProcessManager = class extends EventEmitter {
|
|
|
1187
2036
|
start(options) {
|
|
1188
2037
|
const child = spawn3(options.command, options.args, {
|
|
1189
2038
|
cwd: options.cwd,
|
|
1190
|
-
shell:
|
|
2039
|
+
shell: false,
|
|
1191
2040
|
windowsHide: true
|
|
1192
2041
|
});
|
|
1193
2042
|
const pid = String(child.pid ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
@@ -1270,16 +2119,17 @@ var ProcessManager = class extends EventEmitter {
|
|
|
1270
2119
|
});
|
|
1271
2120
|
}
|
|
1272
2121
|
emitLog(record, stream, message) {
|
|
2122
|
+
const boundedMessage = message.length <= LOG_MESSAGE_LIMIT ? message : message.slice(-LOG_MESSAGE_LIMIT);
|
|
1273
2123
|
const event = {
|
|
1274
2124
|
pid: record.pid,
|
|
1275
2125
|
script: record.script,
|
|
1276
2126
|
stream,
|
|
1277
|
-
message,
|
|
2127
|
+
message: boundedMessage,
|
|
1278
2128
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1279
2129
|
};
|
|
1280
2130
|
this.logs.push(event);
|
|
1281
|
-
if (this.logs.length >
|
|
1282
|
-
this.logs.splice(0, this.logs.length -
|
|
2131
|
+
if (this.logs.length > LOG_ENTRY_LIMIT) {
|
|
2132
|
+
this.logs.splice(0, this.logs.length - LOG_ENTRY_LIMIT);
|
|
1283
2133
|
}
|
|
1284
2134
|
this.emit("log", event);
|
|
1285
2135
|
}
|
|
@@ -1299,12 +2149,243 @@ var ProcessManager = class extends EventEmitter {
|
|
|
1299
2149
|
}
|
|
1300
2150
|
};
|
|
1301
2151
|
|
|
2152
|
+
// src/core/hub/registry.ts
|
|
2153
|
+
import { createHash } from "crypto";
|
|
2154
|
+
import { promises as fs17 } from "fs";
|
|
2155
|
+
import os3 from "os";
|
|
2156
|
+
import path13 from "path";
|
|
2157
|
+
|
|
2158
|
+
// src/core/hub/workspaceRoots.ts
|
|
2159
|
+
import { promises as fs16 } from "fs";
|
|
2160
|
+
import path12 from "path";
|
|
2161
|
+
function isWithinRoot7(root, target) {
|
|
2162
|
+
const relative = path12.relative(root, target);
|
|
2163
|
+
return relative === "" || !relative.startsWith("..") && !path12.isAbsolute(relative);
|
|
2164
|
+
}
|
|
2165
|
+
async function configuredWorkspaceRoots() {
|
|
2166
|
+
const raw = process.env.DEVSURFACE_WORKSPACE_ROOTS;
|
|
2167
|
+
if (!raw) {
|
|
2168
|
+
return [];
|
|
2169
|
+
}
|
|
2170
|
+
const roots = [];
|
|
2171
|
+
for (const entry of raw.split(",")) {
|
|
2172
|
+
const trimmed = entry.trim();
|
|
2173
|
+
if (!trimmed) {
|
|
2174
|
+
continue;
|
|
2175
|
+
}
|
|
2176
|
+
try {
|
|
2177
|
+
roots.push(await fs16.realpath(path12.resolve(trimmed)));
|
|
2178
|
+
} catch {
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
return roots;
|
|
2182
|
+
}
|
|
2183
|
+
async function assertWithinWorkspaceRoots(targetPath) {
|
|
2184
|
+
const roots = await configuredWorkspaceRoots();
|
|
2185
|
+
if (roots.length === 0) {
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
for (const root of roots) {
|
|
2189
|
+
if (isWithinRoot7(root, targetPath)) {
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
throw new Error("Path must be inside a configured workspace root.");
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
// src/core/hub/registry.ts
|
|
2197
|
+
function workspaceId(realPath) {
|
|
2198
|
+
const base = path13.basename(realPath).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 32) || "workspace";
|
|
2199
|
+
const hash = createHash("sha256").update(realPath).digest("hex").slice(0, 6);
|
|
2200
|
+
return `${base}-${hash}`;
|
|
2201
|
+
}
|
|
2202
|
+
function defaultDataDir() {
|
|
2203
|
+
return process.env.DEVSURFACE_DATA_DIR ?? path13.join(os3.homedir(), ".devsurface");
|
|
2204
|
+
}
|
|
2205
|
+
async function readPackageName(dirPath) {
|
|
2206
|
+
try {
|
|
2207
|
+
const raw = JSON.parse(await fs17.readFile(path13.join(dirPath, "package.json"), "utf8"));
|
|
2208
|
+
return typeof raw?.name === "string" && raw.name.length > 0 ? raw.name : null;
|
|
2209
|
+
} catch {
|
|
2210
|
+
return null;
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
var WorkspaceRegistry = class {
|
|
2214
|
+
filePath;
|
|
2215
|
+
seeded = false;
|
|
2216
|
+
constructor(dataDir) {
|
|
2217
|
+
const dir = dataDir ?? defaultDataDir();
|
|
2218
|
+
this.filePath = path13.join(dir, "workspaces.json");
|
|
2219
|
+
}
|
|
2220
|
+
async list() {
|
|
2221
|
+
await this.seedFromEnv();
|
|
2222
|
+
return await this.read();
|
|
2223
|
+
}
|
|
2224
|
+
async add(dirPath) {
|
|
2225
|
+
const realDir = await this.resolveDir(dirPath);
|
|
2226
|
+
await assertWithinWorkspaceRoots(realDir);
|
|
2227
|
+
const entries = await this.read();
|
|
2228
|
+
const existing = entries.find((entry2) => entry2.path === realDir);
|
|
2229
|
+
if (existing) {
|
|
2230
|
+
return existing;
|
|
2231
|
+
}
|
|
2232
|
+
const name = await readPackageName(realDir) ?? path13.basename(realDir);
|
|
2233
|
+
const entry = {
|
|
2234
|
+
id: workspaceId(realDir),
|
|
2235
|
+
name,
|
|
2236
|
+
path: realDir,
|
|
2237
|
+
addedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2238
|
+
};
|
|
2239
|
+
entries.push(entry);
|
|
2240
|
+
await this.write(entries);
|
|
2241
|
+
return entry;
|
|
2242
|
+
}
|
|
2243
|
+
async remove(id) {
|
|
2244
|
+
const entries = await this.read();
|
|
2245
|
+
const filtered = entries.filter((entry) => entry.id !== id);
|
|
2246
|
+
if (filtered.length === entries.length) {
|
|
2247
|
+
return false;
|
|
2248
|
+
}
|
|
2249
|
+
await this.write(filtered);
|
|
2250
|
+
return true;
|
|
2251
|
+
}
|
|
2252
|
+
async findByPath(dirPath) {
|
|
2253
|
+
try {
|
|
2254
|
+
const realDir = await fs17.realpath(path13.resolve(dirPath));
|
|
2255
|
+
const entries = await this.read();
|
|
2256
|
+
return entries.find((entry) => entry.path === realDir) ?? null;
|
|
2257
|
+
} catch {
|
|
2258
|
+
return null;
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
async resolve(id) {
|
|
2262
|
+
const entries = await this.read();
|
|
2263
|
+
const entry = entries.find((item) => item.id === id);
|
|
2264
|
+
if (!entry) {
|
|
2265
|
+
return null;
|
|
2266
|
+
}
|
|
2267
|
+
try {
|
|
2268
|
+
const realDir = await this.resolveDir(entry.path);
|
|
2269
|
+
await assertWithinWorkspaceRoots(realDir);
|
|
2270
|
+
if (realDir !== entry.path) {
|
|
2271
|
+
const updated = { ...entry, path: realDir };
|
|
2272
|
+
await this.write(entries.map((item) => item.id === id ? updated : item));
|
|
2273
|
+
return updated;
|
|
2274
|
+
}
|
|
2275
|
+
return entry;
|
|
2276
|
+
} catch {
|
|
2277
|
+
await this.remove(id);
|
|
2278
|
+
return null;
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
async resolveDir(dirPath) {
|
|
2282
|
+
const resolved = path13.resolve(dirPath);
|
|
2283
|
+
const realDir = await fs17.realpath(resolved);
|
|
2284
|
+
const stat = await fs17.stat(realDir);
|
|
2285
|
+
if (!stat.isDirectory()) {
|
|
2286
|
+
throw new Error(`${dirPath} is not a directory.`);
|
|
2287
|
+
}
|
|
2288
|
+
return realDir;
|
|
2289
|
+
}
|
|
2290
|
+
async read() {
|
|
2291
|
+
try {
|
|
2292
|
+
const content = await fs17.readFile(this.filePath, "utf8");
|
|
2293
|
+
const parsed = JSON.parse(content);
|
|
2294
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
2295
|
+
} catch {
|
|
2296
|
+
return [];
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
async write(entries) {
|
|
2300
|
+
await fs17.mkdir(path13.dirname(this.filePath), { recursive: true });
|
|
2301
|
+
await fs17.writeFile(this.filePath, JSON.stringify(entries, null, 2) + "\n", "utf8");
|
|
2302
|
+
}
|
|
2303
|
+
async seedFromEnv() {
|
|
2304
|
+
if (this.seeded) {
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2307
|
+
this.seeded = true;
|
|
2308
|
+
const seedValue = process.env.DEVSURFACE_WORKSPACES;
|
|
2309
|
+
if (!seedValue) {
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
const paths = seedValue.split(",").map((p) => p.trim()).filter(Boolean);
|
|
2313
|
+
for (const p of paths) {
|
|
2314
|
+
try {
|
|
2315
|
+
await this.add(p);
|
|
2316
|
+
} catch {
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
};
|
|
2321
|
+
|
|
2322
|
+
// src/core/hub/runtime.ts
|
|
2323
|
+
var Hub = class {
|
|
2324
|
+
registry;
|
|
2325
|
+
runtimes = /* @__PURE__ */ new Map();
|
|
2326
|
+
cleanupInstalled = false;
|
|
2327
|
+
constructor(options) {
|
|
2328
|
+
this.registry = new WorkspaceRegistry(options?.dataDir);
|
|
2329
|
+
}
|
|
2330
|
+
get(id) {
|
|
2331
|
+
return this.runtimes.get(id) ?? null;
|
|
2332
|
+
}
|
|
2333
|
+
ensure(entry) {
|
|
2334
|
+
const existing = this.runtimes.get(entry.id);
|
|
2335
|
+
if (existing) {
|
|
2336
|
+
return existing;
|
|
2337
|
+
}
|
|
2338
|
+
const runtime = {
|
|
2339
|
+
id: entry.id,
|
|
2340
|
+
root: entry.path,
|
|
2341
|
+
processManager: new ProcessManager(),
|
|
2342
|
+
dockerController: new DockerComposeController(entry.path)
|
|
2343
|
+
};
|
|
2344
|
+
this.runtimes.set(entry.id, runtime);
|
|
2345
|
+
return runtime;
|
|
2346
|
+
}
|
|
2347
|
+
async listSummaries() {
|
|
2348
|
+
const entries = await this.registry.list();
|
|
2349
|
+
return entries.map((entry) => {
|
|
2350
|
+
const runtime = this.runtimes.get(entry.id);
|
|
2351
|
+
const running = runtime ? runtime.processManager.list().filter((p) => p.status === "running").length : 0;
|
|
2352
|
+
return {
|
|
2353
|
+
id: entry.id,
|
|
2354
|
+
name: entry.name,
|
|
2355
|
+
path: entry.path,
|
|
2356
|
+
addedAt: entry.addedAt,
|
|
2357
|
+
runningProcesses: running
|
|
2358
|
+
};
|
|
2359
|
+
});
|
|
2360
|
+
}
|
|
2361
|
+
killAll() {
|
|
2362
|
+
for (const runtime of this.runtimes.values()) {
|
|
2363
|
+
runtime.processManager.killAll();
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
attachCleanupHandlers() {
|
|
2367
|
+
if (this.cleanupInstalled) {
|
|
2368
|
+
return;
|
|
2369
|
+
}
|
|
2370
|
+
this.cleanupInstalled = true;
|
|
2371
|
+
process.once("exit", () => {
|
|
2372
|
+
this.killAll();
|
|
2373
|
+
});
|
|
2374
|
+
process.once("SIGINT", () => {
|
|
2375
|
+
this.killAll();
|
|
2376
|
+
process.exit(130);
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
};
|
|
2380
|
+
|
|
1302
2381
|
// src/server/routes/api.ts
|
|
1303
2382
|
import { constants as constants2, existsSync } from "fs";
|
|
1304
|
-
import { promises as
|
|
1305
|
-
import
|
|
2383
|
+
import { promises as fs18 } from "fs";
|
|
2384
|
+
import path14 from "path";
|
|
1306
2385
|
import spawn4 from "cross-spawn";
|
|
1307
|
-
|
|
2386
|
+
|
|
2387
|
+
// src/version.ts
|
|
2388
|
+
var DEV_SURFACE_VERSION = "0.4.0";
|
|
1308
2389
|
|
|
1309
2390
|
// src/server/localAccess.ts
|
|
1310
2391
|
var LOCAL_HOSTNAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
|
|
@@ -1345,10 +2426,154 @@ function isSameOrigin(requestUrl, origin) {
|
|
|
1345
2426
|
}
|
|
1346
2427
|
}
|
|
1347
2428
|
|
|
2429
|
+
// src/server/listenConfig.ts
|
|
2430
|
+
var DEFAULT_HOST = "127.0.0.1";
|
|
2431
|
+
var DEFAULT_PORT = 4567;
|
|
2432
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
|
|
2433
|
+
var CONTAINER_HOSTS = /* @__PURE__ */ new Set(["0.0.0.0", "::"]);
|
|
2434
|
+
function resolveHost() {
|
|
2435
|
+
const envHost = process.env.DEVSURFACE_HOST;
|
|
2436
|
+
if (!envHost) {
|
|
2437
|
+
return DEFAULT_HOST;
|
|
2438
|
+
}
|
|
2439
|
+
if (LOOPBACK_HOSTS.has(envHost)) {
|
|
2440
|
+
return envHost;
|
|
2441
|
+
}
|
|
2442
|
+
if (CONTAINER_HOSTS.has(envHost) && process.env.DEVSURFACE_CONTAINER === "true") {
|
|
2443
|
+
return envHost;
|
|
2444
|
+
}
|
|
2445
|
+
if (CONTAINER_HOSTS.has(envHost)) {
|
|
2446
|
+
throw new Error(
|
|
2447
|
+
"All-interface DevSurface binding is only allowed when DEVSURFACE_CONTAINER=true. DevSurface binds to 127.0.0.1 on bare metal."
|
|
2448
|
+
);
|
|
2449
|
+
}
|
|
2450
|
+
throw new Error("DEVSURFACE_HOST must be a loopback host, or 0.0.0.0 inside a container.");
|
|
2451
|
+
}
|
|
2452
|
+
var listenHost = DEFAULT_HOST;
|
|
2453
|
+
function setListenHost(host) {
|
|
2454
|
+
listenHost = host;
|
|
2455
|
+
}
|
|
2456
|
+
function getListenHost() {
|
|
2457
|
+
return listenHost;
|
|
2458
|
+
}
|
|
2459
|
+
function normalizeRemoteAddress(raw) {
|
|
2460
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
2461
|
+
return null;
|
|
2462
|
+
}
|
|
2463
|
+
if (raw.startsWith("::ffff:")) {
|
|
2464
|
+
return raw.slice("::ffff:".length);
|
|
2465
|
+
}
|
|
2466
|
+
return raw;
|
|
2467
|
+
}
|
|
2468
|
+
function isLoopbackRemoteAddress(raw) {
|
|
2469
|
+
const address = normalizeRemoteAddress(raw);
|
|
2470
|
+
if (!address) {
|
|
2471
|
+
return false;
|
|
2472
|
+
}
|
|
2473
|
+
if (address === "::1" || address === "127.0.0.1") {
|
|
2474
|
+
return true;
|
|
2475
|
+
}
|
|
2476
|
+
return address.startsWith("127.");
|
|
2477
|
+
}
|
|
2478
|
+
function parseIpv4(address) {
|
|
2479
|
+
const parts = address.split(".");
|
|
2480
|
+
if (parts.length !== 4) {
|
|
2481
|
+
return null;
|
|
2482
|
+
}
|
|
2483
|
+
const octets = parts.map((part) => Number(part));
|
|
2484
|
+
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
|
|
2485
|
+
return null;
|
|
2486
|
+
}
|
|
2487
|
+
return octets;
|
|
2488
|
+
}
|
|
2489
|
+
function isPrivateRemoteAddress(raw) {
|
|
2490
|
+
const address = normalizeRemoteAddress(raw);
|
|
2491
|
+
if (!address) {
|
|
2492
|
+
return false;
|
|
2493
|
+
}
|
|
2494
|
+
if (isLoopbackRemoteAddress(address)) {
|
|
2495
|
+
return true;
|
|
2496
|
+
}
|
|
2497
|
+
if (address.startsWith("fe80:")) {
|
|
2498
|
+
return true;
|
|
2499
|
+
}
|
|
2500
|
+
const ipv4 = parseIpv4(address);
|
|
2501
|
+
if (!ipv4) {
|
|
2502
|
+
return false;
|
|
2503
|
+
}
|
|
2504
|
+
const [a, b] = ipv4;
|
|
2505
|
+
if (a === 10) {
|
|
2506
|
+
return true;
|
|
2507
|
+
}
|
|
2508
|
+
if (a === 192 && b === 168) {
|
|
2509
|
+
return true;
|
|
2510
|
+
}
|
|
2511
|
+
if (a === 172 && b >= 16 && b <= 31) {
|
|
2512
|
+
return true;
|
|
2513
|
+
}
|
|
2514
|
+
return false;
|
|
2515
|
+
}
|
|
2516
|
+
function isAllowedRemoteAddress(raw, host) {
|
|
2517
|
+
if (host === "0.0.0.0" || host === "::") {
|
|
2518
|
+
return isPrivateRemoteAddress(raw);
|
|
2519
|
+
}
|
|
2520
|
+
return isLoopbackRemoteAddress(raw);
|
|
2521
|
+
}
|
|
2522
|
+
function isAllowedClientConnection(raw, host = getListenHost()) {
|
|
2523
|
+
if (raw === void 0) {
|
|
2524
|
+
return true;
|
|
2525
|
+
}
|
|
2526
|
+
return isAllowedRemoteAddress(raw, host);
|
|
2527
|
+
}
|
|
2528
|
+
function initializeListenHost() {
|
|
2529
|
+
const host = resolveHost();
|
|
2530
|
+
setListenHost(host);
|
|
2531
|
+
return host;
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
// src/server/accessControl.ts
|
|
2535
|
+
function remoteAddressFromRequest(request) {
|
|
2536
|
+
return request?.socket?.remoteAddress;
|
|
2537
|
+
}
|
|
2538
|
+
function createApiAccessMiddleware() {
|
|
2539
|
+
return async (context, next) => {
|
|
2540
|
+
const host = context.req.header("host") ?? new URL(context.req.url).host;
|
|
2541
|
+
if (!isAllowedLocalHostHeader(host)) {
|
|
2542
|
+
return context.json({ error: "Non-local host rejected." }, 403);
|
|
2543
|
+
}
|
|
2544
|
+
const env = context.env;
|
|
2545
|
+
const remoteAddress = remoteAddressFromRequest(env?.incoming);
|
|
2546
|
+
if (!isAllowedClientConnection(remoteAddress, getListenHost())) {
|
|
2547
|
+
return context.json({ error: "Remote client rejected." }, 403);
|
|
2548
|
+
}
|
|
2549
|
+
await next();
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
// src/server/mutationToken.ts
|
|
2554
|
+
import { randomBytes, timingSafeEqual } from "crypto";
|
|
2555
|
+
function createMutationToken() {
|
|
2556
|
+
return randomBytes(32).toString("hex");
|
|
2557
|
+
}
|
|
2558
|
+
function hasValidMutationToken(provided, expected) {
|
|
2559
|
+
if (typeof provided !== "string" || provided.length === 0) {
|
|
2560
|
+
return false;
|
|
2561
|
+
}
|
|
2562
|
+
if (provided.length !== expected.length) {
|
|
2563
|
+
return false;
|
|
2564
|
+
}
|
|
2565
|
+
return timingSafeEqual(Buffer.from(provided, "utf8"), Buffer.from(expected, "utf8"));
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
// src/server/terminal.ts
|
|
2569
|
+
function isAllowedTerminalCommand(command) {
|
|
2570
|
+
return /^[A-Za-z0-9._+-]+$/.test(command);
|
|
2571
|
+
}
|
|
2572
|
+
|
|
1348
2573
|
// src/server/routes/api.ts
|
|
1349
|
-
function
|
|
1350
|
-
const relative =
|
|
1351
|
-
return relative === "" || !relative.startsWith("..") && !
|
|
2574
|
+
function isWithinRoot8(root, target) {
|
|
2575
|
+
const relative = path14.relative(path14.resolve(root), path14.resolve(target));
|
|
2576
|
+
return relative === "" || !relative.startsWith("..") && !path14.isAbsolute(relative);
|
|
1352
2577
|
}
|
|
1353
2578
|
function isAllowedMutationOrigin(requestUrl, origin) {
|
|
1354
2579
|
if (origin === null) {
|
|
@@ -1356,6 +2581,23 @@ function isAllowedMutationOrigin(requestUrl, origin) {
|
|
|
1356
2581
|
}
|
|
1357
2582
|
return isAllowedLocalOrigin(origin) && isSameOrigin(requestUrl, origin);
|
|
1358
2583
|
}
|
|
2584
|
+
function registerMutationGuard(app, mutationToken) {
|
|
2585
|
+
app.use("/api/*", createApiAccessMiddleware());
|
|
2586
|
+
app.use("/api/*", async (context, next) => {
|
|
2587
|
+
if (context.req.method === "GET" || context.req.method === "HEAD") {
|
|
2588
|
+
await next();
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
const origin = context.req.header("origin") ?? null;
|
|
2592
|
+
const secFetchSite = context.req.header("sec-fetch-site") ?? null;
|
|
2593
|
+
const intent = context.req.header("x-devsurface-intent") ?? null;
|
|
2594
|
+
const token = context.req.header("x-devsurface-token") ?? null;
|
|
2595
|
+
if (!hasMutationIntent(intent) || !hasValidMutationToken(token, mutationToken) || isCrossSiteFetch(secFetchSite) || !isAllowedMutationOrigin(context.req.url, origin)) {
|
|
2596
|
+
return context.json({ error: "Cross-origin mutation rejected." }, 403);
|
|
2597
|
+
}
|
|
2598
|
+
await next();
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
1359
2601
|
function isCrossSiteFetch(secFetchSite) {
|
|
1360
2602
|
return secFetchSite === "cross-site";
|
|
1361
2603
|
}
|
|
@@ -1363,35 +2605,35 @@ function hasMutationIntent(intent) {
|
|
|
1363
2605
|
return intent === "dashboard";
|
|
1364
2606
|
}
|
|
1365
2607
|
async function realPathWithinRoot(root, target) {
|
|
1366
|
-
if (!
|
|
2608
|
+
if (!isWithinRoot8(root, target)) {
|
|
1367
2609
|
return false;
|
|
1368
2610
|
}
|
|
1369
2611
|
try {
|
|
1370
|
-
const [realRoot, realTarget] = await Promise.all([
|
|
1371
|
-
return
|
|
2612
|
+
const [realRoot, realTarget] = await Promise.all([fs18.realpath(root), fs18.realpath(target)]);
|
|
2613
|
+
return isWithinRoot8(realRoot, realTarget);
|
|
1372
2614
|
} catch {
|
|
1373
2615
|
return false;
|
|
1374
2616
|
}
|
|
1375
2617
|
}
|
|
1376
2618
|
async function writableDestinationWithinRoot(root, destination) {
|
|
1377
|
-
if (!
|
|
2619
|
+
if (!isWithinRoot8(root, destination)) {
|
|
1378
2620
|
return false;
|
|
1379
2621
|
}
|
|
1380
2622
|
try {
|
|
1381
2623
|
const [realRoot, realParent] = await Promise.all([
|
|
1382
|
-
|
|
1383
|
-
|
|
2624
|
+
fs18.realpath(root),
|
|
2625
|
+
fs18.realpath(path14.dirname(destination))
|
|
1384
2626
|
]);
|
|
1385
|
-
return
|
|
2627
|
+
return isWithinRoot8(realRoot, realParent);
|
|
1386
2628
|
} catch {
|
|
1387
2629
|
return false;
|
|
1388
2630
|
}
|
|
1389
2631
|
}
|
|
1390
2632
|
async function copyFileExclusive(source, destination) {
|
|
1391
|
-
const content = await
|
|
2633
|
+
const content = await fs18.readFile(source);
|
|
1392
2634
|
let handle2 = null;
|
|
1393
2635
|
try {
|
|
1394
|
-
handle2 = await
|
|
2636
|
+
handle2 = await fs18.open(
|
|
1395
2637
|
destination,
|
|
1396
2638
|
constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY,
|
|
1397
2639
|
384
|
|
@@ -1418,15 +2660,15 @@ function resolveCommandPromptExecutable() {
|
|
|
1418
2660
|
return process.env.ComSpec ?? "cmd.exe";
|
|
1419
2661
|
}
|
|
1420
2662
|
function findExecutable(command) {
|
|
1421
|
-
if (
|
|
2663
|
+
if (path14.isAbsolute(command)) {
|
|
1422
2664
|
return existsSync(command) ? command : null;
|
|
1423
2665
|
}
|
|
1424
2666
|
const pathValue = process.env.PATH ?? "";
|
|
1425
|
-
for (const directory of pathValue.split(
|
|
2667
|
+
for (const directory of pathValue.split(path14.delimiter)) {
|
|
1426
2668
|
if (directory.length === 0) {
|
|
1427
2669
|
continue;
|
|
1428
2670
|
}
|
|
1429
|
-
const candidate =
|
|
2671
|
+
const candidate = path14.join(directory, command);
|
|
1430
2672
|
if (existsSync(candidate)) {
|
|
1431
2673
|
return candidate;
|
|
1432
2674
|
}
|
|
@@ -1467,8 +2709,8 @@ function openTerminalAt(root) {
|
|
|
1467
2709
|
if (process.platform === "darwin") {
|
|
1468
2710
|
return launchDetached("open", ["-a", "Terminal", root], root);
|
|
1469
2711
|
}
|
|
1470
|
-
const configuredTerminal = process.env.TERMINAL;
|
|
1471
|
-
if (configuredTerminal !== void 0 && findExecutable(configuredTerminal) !== null) {
|
|
2712
|
+
const configuredTerminal = process.env.TERMINAL?.trim();
|
|
2713
|
+
if (configuredTerminal !== void 0 && configuredTerminal.length > 0 && isAllowedTerminalCommand(configuredTerminal) && findExecutable(configuredTerminal) !== null) {
|
|
1472
2714
|
return launchDetached(configuredTerminal, [], root);
|
|
1473
2715
|
}
|
|
1474
2716
|
const linuxTerminals = [
|
|
@@ -1492,72 +2734,111 @@ function openTerminalAt(root) {
|
|
|
1492
2734
|
}
|
|
1493
2735
|
return launchDetached(terminal.command, terminal.args, root);
|
|
1494
2736
|
}
|
|
1495
|
-
function
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
return context.json({ error: "Non-local host rejected." }, 403);
|
|
2737
|
+
function handleDockerError(error, context) {
|
|
2738
|
+
if (error instanceof DockerOperationError) {
|
|
2739
|
+
if (error.code === "compose-not-found" || error.code === "service-not-found") {
|
|
2740
|
+
return context.json({ error: error.message, code: error.code }, 404);
|
|
1500
2741
|
}
|
|
1501
|
-
if (
|
|
1502
|
-
|
|
1503
|
-
const secFetchSite = context.req.header("sec-fetch-site") ?? null;
|
|
1504
|
-
const intent = context.req.header("x-devsurface-intent") ?? null;
|
|
1505
|
-
if (!hasMutationIntent(intent) || isCrossSiteFetch(secFetchSite) || !isAllowedMutationOrigin(context.req.url, origin)) {
|
|
1506
|
-
return context.json({ error: "Cross-origin mutation rejected." }, 403);
|
|
1507
|
-
}
|
|
2742
|
+
if (error.code === "docker-not-installed" || error.code === "docker-not-running") {
|
|
2743
|
+
return context.json({ error: error.message, code: error.code }, 503);
|
|
1508
2744
|
}
|
|
1509
|
-
|
|
2745
|
+
return context.json({ error: error.message, code: error.code }, 502);
|
|
2746
|
+
}
|
|
2747
|
+
throw error;
|
|
2748
|
+
}
|
|
2749
|
+
function registerWorkspaceRoutes(app, resolveWorkspace) {
|
|
2750
|
+
app.get("/api/workspaces/:id/project", async (context) => {
|
|
2751
|
+
const ws = await resolveWorkspace(context.req.param("id"));
|
|
2752
|
+
if (!ws) return context.json({ error: "Workspace not found." }, 404);
|
|
2753
|
+
return context.json(await scanProject(ws.root));
|
|
1510
2754
|
});
|
|
1511
|
-
app.get("/api/
|
|
1512
|
-
|
|
2755
|
+
app.get("/api/workspaces/:id/health", async (context) => {
|
|
2756
|
+
const ws = await resolveWorkspace(context.req.param("id"));
|
|
2757
|
+
if (!ws) return context.json({ error: "Workspace not found." }, 404);
|
|
2758
|
+
return context.json(await runDoctor(ws.root));
|
|
1513
2759
|
});
|
|
1514
|
-
app.get("/api/
|
|
1515
|
-
|
|
2760
|
+
app.get("/api/workspaces/:id/processes", async (context) => {
|
|
2761
|
+
const ws = await resolveWorkspace(context.req.param("id"));
|
|
2762
|
+
if (!ws) return context.json({ error: "Workspace not found." }, 404);
|
|
2763
|
+
return context.json(ws.processManager.list());
|
|
1516
2764
|
});
|
|
1517
|
-
app.get("/api/
|
|
1518
|
-
|
|
2765
|
+
app.get("/api/workspaces/:id/logs", async (context) => {
|
|
2766
|
+
const ws = await resolveWorkspace(context.req.param("id"));
|
|
2767
|
+
if (!ws) return context.json({ error: "Workspace not found." }, 404);
|
|
2768
|
+
return context.json(ws.processManager.listLogs());
|
|
1519
2769
|
});
|
|
1520
|
-
app.get("/api/logs", (context) => {
|
|
1521
|
-
|
|
2770
|
+
app.get("/api/workspaces/:id/docker/:service/logs", async (context) => {
|
|
2771
|
+
const ws = await resolveWorkspace(context.req.param("id"));
|
|
2772
|
+
if (!ws) return context.json({ error: "Workspace not found." }, 404);
|
|
2773
|
+
const service = decodeURIComponent(context.req.param("service"));
|
|
2774
|
+
try {
|
|
2775
|
+
return context.json(await ws.dockerController.logs(service));
|
|
2776
|
+
} catch (error) {
|
|
2777
|
+
return handleDockerError(error, context);
|
|
2778
|
+
}
|
|
1522
2779
|
});
|
|
1523
|
-
app.post("/api/
|
|
2780
|
+
app.post("/api/workspaces/:id/docker/:service/start", async (context) => {
|
|
2781
|
+
const ws = await resolveWorkspace(context.req.param("id"));
|
|
2782
|
+
if (!ws) return context.json({ error: "Workspace not found." }, 404);
|
|
2783
|
+
const service = decodeURIComponent(context.req.param("service"));
|
|
2784
|
+
try {
|
|
2785
|
+
return context.json(await ws.dockerController.start(service));
|
|
2786
|
+
} catch (error) {
|
|
2787
|
+
return handleDockerError(error, context);
|
|
2788
|
+
}
|
|
2789
|
+
});
|
|
2790
|
+
app.post("/api/workspaces/:id/docker/:service/stop", async (context) => {
|
|
2791
|
+
const ws = await resolveWorkspace(context.req.param("id"));
|
|
2792
|
+
if (!ws) return context.json({ error: "Workspace not found." }, 404);
|
|
2793
|
+
const service = decodeURIComponent(context.req.param("service"));
|
|
2794
|
+
try {
|
|
2795
|
+
return context.json(await ws.dockerController.stop(service));
|
|
2796
|
+
} catch (error) {
|
|
2797
|
+
return handleDockerError(error, context);
|
|
2798
|
+
}
|
|
2799
|
+
});
|
|
2800
|
+
app.post("/api/workspaces/:id/run/:script", async (context) => {
|
|
2801
|
+
const ws = await resolveWorkspace(context.req.param("id"));
|
|
2802
|
+
if (!ws) return context.json({ error: "Workspace not found." }, 404);
|
|
1524
2803
|
const script = decodeURIComponent(context.req.param("script"));
|
|
1525
|
-
const scan = await scanProject(
|
|
2804
|
+
const scan = await scanProject(ws.root);
|
|
1526
2805
|
const packageScript = scan.scripts[script];
|
|
1527
2806
|
if (packageScript === void 0) {
|
|
1528
2807
|
return context.json({ error: `Script "${script}" was not found.` }, 404);
|
|
1529
2808
|
}
|
|
2809
|
+
if (isDangerousCommand(packageScript)) {
|
|
2810
|
+
return context.json({ error: "Refusing to run dangerous script." }, 403);
|
|
2811
|
+
}
|
|
1530
2812
|
const command = await resolvePackageRunCommand({
|
|
1531
|
-
cwd:
|
|
2813
|
+
cwd: ws.root,
|
|
1532
2814
|
packageManager: scan.packageManager,
|
|
1533
2815
|
script
|
|
1534
2816
|
});
|
|
1535
2817
|
if (command === null) {
|
|
1536
2818
|
return context.json({ error: "Package manager executable was not found." }, 503);
|
|
1537
2819
|
}
|
|
1538
|
-
const processInfo =
|
|
1539
|
-
cwd:
|
|
2820
|
+
const processInfo = ws.processManager.start({
|
|
2821
|
+
cwd: ws.root,
|
|
1540
2822
|
script,
|
|
1541
2823
|
command: command.command,
|
|
1542
2824
|
args: command.args,
|
|
1543
2825
|
displayCommand: command.displayCommand
|
|
1544
2826
|
});
|
|
1545
|
-
return context.json({
|
|
1546
|
-
...processInfo,
|
|
1547
|
-
packageScript
|
|
1548
|
-
});
|
|
2827
|
+
return context.json({ ...processInfo, packageScript });
|
|
1549
2828
|
});
|
|
1550
|
-
app.post("/api/install", async (context) => {
|
|
1551
|
-
const
|
|
2829
|
+
app.post("/api/workspaces/:id/install", async (context) => {
|
|
2830
|
+
const ws = await resolveWorkspace(context.req.param("id"));
|
|
2831
|
+
if (!ws) return context.json({ error: "Workspace not found." }, 404);
|
|
2832
|
+
const scan = await scanProject(ws.root);
|
|
1552
2833
|
const command = await resolvePackageInstallCommand({
|
|
1553
|
-
cwd:
|
|
2834
|
+
cwd: ws.root,
|
|
1554
2835
|
packageManager: scan.packageManager
|
|
1555
2836
|
});
|
|
1556
2837
|
if (command === null) {
|
|
1557
2838
|
return context.json({ error: "Package manager executable was not found." }, 503);
|
|
1558
2839
|
}
|
|
1559
|
-
const processInfo =
|
|
1560
|
-
cwd:
|
|
2840
|
+
const processInfo = ws.processManager.start({
|
|
2841
|
+
cwd: ws.root,
|
|
1561
2842
|
script: "install",
|
|
1562
2843
|
command: command.command,
|
|
1563
2844
|
args: command.args,
|
|
@@ -1565,51 +2846,69 @@ function registerApiRoutes(app, options) {
|
|
|
1565
2846
|
});
|
|
1566
2847
|
return context.json(processInfo);
|
|
1567
2848
|
});
|
|
1568
|
-
app.post("/api/commands/:name", async (context) => {
|
|
2849
|
+
app.post("/api/workspaces/:id/commands/:name", async (context) => {
|
|
2850
|
+
const ws = await resolveWorkspace(context.req.param("id"));
|
|
2851
|
+
if (!ws) return context.json({ error: "Workspace not found." }, 404);
|
|
1569
2852
|
const name = decodeURIComponent(context.req.param("name"));
|
|
1570
|
-
const scan = await scanProject(
|
|
2853
|
+
const scan = await scanProject(ws.root);
|
|
1571
2854
|
const configuredCommand = scan.config?.config.commands?.[name] ?? null;
|
|
1572
2855
|
if (configuredCommand === null) {
|
|
1573
2856
|
return context.json({ error: `Configured command "${name}" was not found.` }, 404);
|
|
1574
2857
|
}
|
|
1575
|
-
|
|
1576
|
-
|
|
2858
|
+
if (isDangerousCommand(configuredCommand)) {
|
|
2859
|
+
return context.json({ error: "Refusing to run dangerous command." }, 403);
|
|
2860
|
+
}
|
|
2861
|
+
const resolvedCommand = await resolveConfiguredCommand(ws.root, configuredCommand);
|
|
2862
|
+
if (resolvedCommand === null) {
|
|
2863
|
+
return context.json(
|
|
2864
|
+
{
|
|
2865
|
+
error: "Configured command uses unsupported shell syntax. Use a simple executable with arguments, or move complex logic into a package.json script."
|
|
2866
|
+
},
|
|
2867
|
+
400
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2870
|
+
const processInfo = ws.processManager.start({
|
|
2871
|
+
cwd: ws.root,
|
|
1577
2872
|
script: name,
|
|
1578
|
-
command:
|
|
1579
|
-
args:
|
|
1580
|
-
displayCommand:
|
|
1581
|
-
shell: true
|
|
1582
|
-
});
|
|
1583
|
-
return context.json({
|
|
1584
|
-
...processInfo,
|
|
1585
|
-
configuredCommand
|
|
2873
|
+
command: resolvedCommand.command,
|
|
2874
|
+
args: resolvedCommand.args,
|
|
2875
|
+
displayCommand: resolvedCommand.displayCommand
|
|
1586
2876
|
});
|
|
2877
|
+
return context.json({ ...processInfo, configuredCommand });
|
|
1587
2878
|
});
|
|
1588
|
-
app.post("/api/open/folder", async (context) => {
|
|
1589
|
-
await
|
|
2879
|
+
app.post("/api/workspaces/:id/open/folder", async (context) => {
|
|
2880
|
+
const ws = await resolveWorkspace(context.req.param("id"));
|
|
2881
|
+
if (!ws) return context.json({ error: "Workspace not found." }, 404);
|
|
2882
|
+
await open_default(ws.root);
|
|
1590
2883
|
return context.json({ opened: true, target: "folder" });
|
|
1591
2884
|
});
|
|
1592
|
-
app.post("/api/open/package", async (context) => {
|
|
1593
|
-
const
|
|
1594
|
-
if (!
|
|
2885
|
+
app.post("/api/workspaces/:id/open/package", async (context) => {
|
|
2886
|
+
const ws = await resolveWorkspace(context.req.param("id"));
|
|
2887
|
+
if (!ws) return context.json({ error: "Workspace not found." }, 404);
|
|
2888
|
+
const packagePath = path14.join(ws.root, "package.json");
|
|
2889
|
+
if (!await realPathWithinRoot(ws.root, packagePath)) {
|
|
1595
2890
|
return context.json({ error: "package.json was not found inside the project root." }, 404);
|
|
1596
2891
|
}
|
|
1597
|
-
await
|
|
2892
|
+
await open_default(packagePath);
|
|
1598
2893
|
return context.json({ opened: true, target: "package" });
|
|
1599
2894
|
});
|
|
1600
|
-
app.post("/api/open/terminal", (context) => {
|
|
1601
|
-
const
|
|
2895
|
+
app.post("/api/workspaces/:id/open/terminal", async (context) => {
|
|
2896
|
+
const ws = await resolveWorkspace(context.req.param("id"));
|
|
2897
|
+
if (!ws) return context.json({ error: "Workspace not found." }, 404);
|
|
2898
|
+
const opened = openTerminalAt(ws.root);
|
|
1602
2899
|
return context.json({ opened, target: "terminal" }, opened ? 200 : 501);
|
|
1603
2900
|
});
|
|
1604
|
-
app.post("/api/env/copy", async (context) => {
|
|
1605
|
-
const
|
|
2901
|
+
app.post("/api/workspaces/:id/env/copy", async (context) => {
|
|
2902
|
+
const ws = await resolveWorkspace(context.req.param("id"));
|
|
2903
|
+
if (!ws) return context.json({ error: "Workspace not found." }, 404);
|
|
2904
|
+
const scan = await scanProject(ws.root);
|
|
1606
2905
|
const examplePath = scan.env?.examplePath ?? null;
|
|
1607
2906
|
const localPath = scan.env?.localPath ?? null;
|
|
1608
2907
|
if (examplePath === null) {
|
|
1609
2908
|
return context.json({ error: ".env.example was not found." }, 404);
|
|
1610
2909
|
}
|
|
1611
|
-
const destination = localPath ??
|
|
1612
|
-
if (!await realPathWithinRoot(
|
|
2910
|
+
const destination = localPath ?? path14.join(ws.root, scan.config?.config.env?.local ?? ".env");
|
|
2911
|
+
if (!await realPathWithinRoot(ws.root, examplePath) || !await writableDestinationWithinRoot(ws.root, destination)) {
|
|
1613
2912
|
return context.json({ error: "Refusing to copy env files outside the project root." }, 400);
|
|
1614
2913
|
}
|
|
1615
2914
|
const copyResult = await copyFileExclusive(examplePath, destination);
|
|
@@ -1618,12 +2917,79 @@ function registerApiRoutes(app, options) {
|
|
|
1618
2917
|
}
|
|
1619
2918
|
return context.json({ copied: true });
|
|
1620
2919
|
});
|
|
1621
|
-
app.delete("/api/run/:pid", (context) => {
|
|
2920
|
+
app.delete("/api/workspaces/:id/run/:pid", async (context) => {
|
|
2921
|
+
const ws = await resolveWorkspace(context.req.param("id"));
|
|
2922
|
+
if (!ws) return context.json({ error: "Workspace not found." }, 404);
|
|
1622
2923
|
const pid = decodeURIComponent(context.req.param("pid"));
|
|
1623
|
-
const stopped =
|
|
2924
|
+
const stopped = ws.processManager.stop(pid);
|
|
1624
2925
|
return context.json({ stopped });
|
|
1625
2926
|
});
|
|
1626
2927
|
}
|
|
2928
|
+
function registerHubApiRoutes(app, options) {
|
|
2929
|
+
const { hub } = options;
|
|
2930
|
+
registerMutationGuard(app, options.mutationToken);
|
|
2931
|
+
async function resolveWorkspace(id) {
|
|
2932
|
+
const entry = await hub.registry.resolve(id);
|
|
2933
|
+
if (!entry) return null;
|
|
2934
|
+
const runtime = hub.ensure(entry);
|
|
2935
|
+
return {
|
|
2936
|
+
root: runtime.root,
|
|
2937
|
+
processManager: runtime.processManager,
|
|
2938
|
+
dockerController: runtime.dockerController
|
|
2939
|
+
};
|
|
2940
|
+
}
|
|
2941
|
+
app.get("/api/session", (context) => {
|
|
2942
|
+
return context.json({ token: options.mutationToken });
|
|
2943
|
+
});
|
|
2944
|
+
app.get("/api/hub/status", (context) => {
|
|
2945
|
+
return context.json({ status: "running", version: DEV_SURFACE_VERSION });
|
|
2946
|
+
});
|
|
2947
|
+
app.get("/api/workspaces", async (context) => {
|
|
2948
|
+
return context.json(await hub.listSummaries());
|
|
2949
|
+
});
|
|
2950
|
+
app.post("/api/workspaces", async (context) => {
|
|
2951
|
+
const body = await context.req.json().catch(() => null);
|
|
2952
|
+
if (!body?.path) {
|
|
2953
|
+
return context.json({ error: "path is required." }, 400);
|
|
2954
|
+
}
|
|
2955
|
+
try {
|
|
2956
|
+
const entry = await hub.registry.add(body.path);
|
|
2957
|
+
return context.json(entry, 201);
|
|
2958
|
+
} catch (error) {
|
|
2959
|
+
return context.json({ error: error instanceof Error ? error.message : "Invalid path." }, 400);
|
|
2960
|
+
}
|
|
2961
|
+
});
|
|
2962
|
+
app.delete("/api/workspaces/:id", async (context) => {
|
|
2963
|
+
const id = context.req.param("id");
|
|
2964
|
+
const runtime = hub.get(id);
|
|
2965
|
+
if (runtime) {
|
|
2966
|
+
runtime.processManager.killAll();
|
|
2967
|
+
}
|
|
2968
|
+
const removed = await hub.registry.remove(id);
|
|
2969
|
+
return context.json({ removed }, removed ? 200 : 404);
|
|
2970
|
+
});
|
|
2971
|
+
registerWorkspaceRoutes(app, resolveWorkspace);
|
|
2972
|
+
app.get("/api/project", async (context) => {
|
|
2973
|
+
const entries = await hub.registry.list();
|
|
2974
|
+
if (entries.length === 0) return context.json({ error: "No workspaces registered." }, 404);
|
|
2975
|
+
return context.json(await scanProject(hub.ensure(entries[0]).root));
|
|
2976
|
+
});
|
|
2977
|
+
app.get("/api/health", async (context) => {
|
|
2978
|
+
const entries = await hub.registry.list();
|
|
2979
|
+
if (entries.length === 0) return context.json({ error: "No workspaces registered." }, 404);
|
|
2980
|
+
return context.json(await runDoctor(hub.ensure(entries[0]).root));
|
|
2981
|
+
});
|
|
2982
|
+
app.get("/api/processes", async (context) => {
|
|
2983
|
+
const entries = await hub.registry.list();
|
|
2984
|
+
if (entries.length === 0) return context.json([]);
|
|
2985
|
+
return context.json(hub.ensure(entries[0]).processManager.list());
|
|
2986
|
+
});
|
|
2987
|
+
app.get("/api/logs", async (context) => {
|
|
2988
|
+
const entries = await hub.registry.list();
|
|
2989
|
+
if (entries.length === 0) return context.json([]);
|
|
2990
|
+
return context.json(hub.ensure(entries[0]).processManager.listLogs());
|
|
2991
|
+
});
|
|
2992
|
+
}
|
|
1627
2993
|
|
|
1628
2994
|
// src/server/routes/ws.ts
|
|
1629
2995
|
import { WebSocket, WebSocketServer } from "ws";
|
|
@@ -1631,6 +2997,9 @@ function isAllowedWebSocketRequest(request) {
|
|
|
1631
2997
|
const origin = request.headers.origin;
|
|
1632
2998
|
const host = request.headers.host;
|
|
1633
2999
|
const secFetchSite = request.headers["sec-fetch-site"];
|
|
3000
|
+
if (!isAllowedClientConnection(remoteAddressFromRequest(request), getListenHost())) {
|
|
3001
|
+
return false;
|
|
3002
|
+
}
|
|
1634
3003
|
if (typeof host !== "string" || !isAllowedLocalHostHeader(host)) {
|
|
1635
3004
|
return false;
|
|
1636
3005
|
}
|
|
@@ -1649,32 +3018,63 @@ function isAllowedWebSocketRequest(request) {
|
|
|
1649
3018
|
return false;
|
|
1650
3019
|
}
|
|
1651
3020
|
}
|
|
1652
|
-
function
|
|
3021
|
+
function workspaceIdFromUrl(url) {
|
|
3022
|
+
if (!url) return null;
|
|
3023
|
+
try {
|
|
3024
|
+
const parsed = new URL(url, "http://localhost");
|
|
3025
|
+
return parsed.searchParams.get("workspace");
|
|
3026
|
+
} catch {
|
|
3027
|
+
return null;
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
function setupHubWebSocket(server, hub) {
|
|
1653
3031
|
const wss = new WebSocketServer({
|
|
1654
3032
|
server,
|
|
1655
3033
|
path: "/ws",
|
|
1656
3034
|
verifyClient: (info) => isAllowedWebSocketRequest(info.req)
|
|
1657
3035
|
});
|
|
1658
|
-
|
|
3036
|
+
const clientWorkspaces = /* @__PURE__ */ new WeakMap();
|
|
3037
|
+
const attachedManagers = /* @__PURE__ */ new Set();
|
|
3038
|
+
function attachManager(workspaceId2, processManager) {
|
|
3039
|
+
if (attachedManagers.has(workspaceId2)) {
|
|
3040
|
+
return;
|
|
3041
|
+
}
|
|
3042
|
+
attachedManagers.add(workspaceId2);
|
|
3043
|
+
processManager.on("log", (event) => {
|
|
3044
|
+
broadcastToWorkspace(workspaceId2, { type: "log", event });
|
|
3045
|
+
});
|
|
3046
|
+
processManager.on("process", (processInfo) => {
|
|
3047
|
+
broadcastToWorkspace(workspaceId2, { type: "process", process: processInfo });
|
|
3048
|
+
});
|
|
3049
|
+
}
|
|
3050
|
+
function broadcastToWorkspace(workspaceId2, payload) {
|
|
1659
3051
|
const serialized = JSON.stringify(payload);
|
|
1660
3052
|
for (const client of wss.clients) {
|
|
1661
|
-
if (client.readyState === WebSocket.OPEN) {
|
|
3053
|
+
if (client.readyState === WebSocket.OPEN && clientWorkspaces.get(client) === workspaceId2) {
|
|
1662
3054
|
client.send(serialized);
|
|
1663
3055
|
}
|
|
1664
3056
|
}
|
|
1665
3057
|
}
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
3058
|
+
wss.on("connection", async (socket, request) => {
|
|
3059
|
+
const workspaceId2 = workspaceIdFromUrl(request.url);
|
|
3060
|
+
if (!workspaceId2) {
|
|
3061
|
+
socket.close(4e3, "Missing workspace query parameter.");
|
|
3062
|
+
return;
|
|
3063
|
+
}
|
|
3064
|
+
const entry = await hub.registry.resolve(workspaceId2);
|
|
3065
|
+
if (!entry) {
|
|
3066
|
+
socket.close(4004, "Workspace not found.");
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
const runtime = hub.ensure(entry);
|
|
3070
|
+
clientWorkspaces.set(socket, workspaceId2);
|
|
3071
|
+
attachManager(workspaceId2, runtime.processManager);
|
|
1673
3072
|
socket.send(
|
|
1674
3073
|
JSON.stringify({
|
|
1675
3074
|
type: "hello",
|
|
1676
|
-
|
|
1677
|
-
|
|
3075
|
+
workspace: workspaceId2,
|
|
3076
|
+
processes: runtime.processManager.list(),
|
|
3077
|
+
logs: runtime.processManager.listLogs()
|
|
1678
3078
|
})
|
|
1679
3079
|
);
|
|
1680
3080
|
});
|
|
@@ -1682,51 +3082,44 @@ function setupWebSocket(server, processManager) {
|
|
|
1682
3082
|
}
|
|
1683
3083
|
|
|
1684
3084
|
// src/server/index.ts
|
|
1685
|
-
|
|
1686
|
-
var DEFAULT_PORT = 4567;
|
|
1687
|
-
function assertLocalHost(host) {
|
|
1688
|
-
if (host !== HOST) {
|
|
1689
|
-
throw new Error("DevSurface must bind only to 127.0.0.1.");
|
|
1690
|
-
}
|
|
1691
|
-
}
|
|
1692
|
-
async function fileExists2(filePath) {
|
|
3085
|
+
async function fileExists(filePath) {
|
|
1693
3086
|
try {
|
|
1694
|
-
await
|
|
3087
|
+
await fs19.access(filePath);
|
|
1695
3088
|
return true;
|
|
1696
3089
|
} catch {
|
|
1697
3090
|
return false;
|
|
1698
3091
|
}
|
|
1699
3092
|
}
|
|
1700
3093
|
async function findWebDistDir() {
|
|
1701
|
-
const moduleDir =
|
|
3094
|
+
const moduleDir = path15.dirname(fileURLToPath2(import.meta.url));
|
|
1702
3095
|
const candidates = [
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
3096
|
+
path15.join(moduleDir, "..", "web", "dist"),
|
|
3097
|
+
path15.join(moduleDir, "..", "..", "src", "web", "dist"),
|
|
3098
|
+
path15.join(moduleDir, "web", "dist")
|
|
1706
3099
|
];
|
|
1707
3100
|
for (const candidate of candidates) {
|
|
1708
|
-
if (await
|
|
3101
|
+
if (await fileExists(path15.join(candidate, "index.html"))) {
|
|
1709
3102
|
return candidate;
|
|
1710
3103
|
}
|
|
1711
3104
|
}
|
|
1712
3105
|
return null;
|
|
1713
3106
|
}
|
|
1714
|
-
function toListenError(error, port) {
|
|
3107
|
+
function toListenError(error, host, port) {
|
|
1715
3108
|
const code = error instanceof Error ? error.code : void 0;
|
|
1716
3109
|
if (code === "EADDRINUSE") {
|
|
1717
3110
|
return new Error(
|
|
1718
|
-
`Port ${port} is already in use on ${
|
|
3111
|
+
`Port ${port} is already in use on ${host}. Stop the other process or run DevSurface with --port ${port + 1}.`,
|
|
1719
3112
|
{ cause: error }
|
|
1720
3113
|
);
|
|
1721
3114
|
}
|
|
1722
3115
|
if (code === "EACCES") {
|
|
1723
|
-
return new Error(`DevSurface does not have permission to bind to ${
|
|
3116
|
+
return new Error(`DevSurface does not have permission to bind to ${host}:${port}.`, {
|
|
1724
3117
|
cause: error
|
|
1725
3118
|
});
|
|
1726
3119
|
}
|
|
1727
3120
|
return error instanceof Error ? error : new Error(String(error));
|
|
1728
3121
|
}
|
|
1729
|
-
async function
|
|
3122
|
+
async function listenOnHost(server, wss, host, port) {
|
|
1730
3123
|
await new Promise((resolve, reject) => {
|
|
1731
3124
|
let settled = false;
|
|
1732
3125
|
const cleanup = () => {
|
|
@@ -1735,17 +3128,13 @@ async function listenOnLocalHost(server, wss, port) {
|
|
|
1735
3128
|
wss.off("error", onError);
|
|
1736
3129
|
};
|
|
1737
3130
|
const onError = (error) => {
|
|
1738
|
-
if (settled)
|
|
1739
|
-
return;
|
|
1740
|
-
}
|
|
3131
|
+
if (settled) return;
|
|
1741
3132
|
settled = true;
|
|
1742
3133
|
cleanup();
|
|
1743
|
-
reject(toListenError(error, port));
|
|
3134
|
+
reject(toListenError(error, host, port));
|
|
1744
3135
|
};
|
|
1745
3136
|
const onListening = () => {
|
|
1746
|
-
if (settled)
|
|
1747
|
-
return;
|
|
1748
|
-
}
|
|
3137
|
+
if (settled) return;
|
|
1749
3138
|
settled = true;
|
|
1750
3139
|
cleanup();
|
|
1751
3140
|
resolve();
|
|
@@ -1753,7 +3142,7 @@ async function listenOnLocalHost(server, wss, port) {
|
|
|
1753
3142
|
wss.once("error", onError);
|
|
1754
3143
|
server.once("error", onError);
|
|
1755
3144
|
server.once("listening", onListening);
|
|
1756
|
-
server.listen(port,
|
|
3145
|
+
server.listen(port, host);
|
|
1757
3146
|
});
|
|
1758
3147
|
}
|
|
1759
3148
|
async function closeWebSocketServer(wss) {
|
|
@@ -1775,15 +3164,13 @@ async function closeHttpServer(server) {
|
|
|
1775
3164
|
});
|
|
1776
3165
|
});
|
|
1777
3166
|
}
|
|
1778
|
-
async function
|
|
1779
|
-
const app = new Hono();
|
|
1780
|
-
registerApiRoutes(app, options);
|
|
3167
|
+
async function mountWebUi(app) {
|
|
1781
3168
|
const webDistDir = await findWebDistDir();
|
|
1782
3169
|
if (webDistDir !== null) {
|
|
1783
3170
|
app.use("/assets/*", serveStatic({ root: webDistDir }));
|
|
1784
3171
|
app.get("/favicon.svg", serveStatic({ root: webDistDir }));
|
|
1785
3172
|
app.get("*", async (context) => {
|
|
1786
|
-
const html = await
|
|
3173
|
+
const html = await fs19.readFile(path15.join(webDistDir, "index.html"), "utf8");
|
|
1787
3174
|
return context.html(html);
|
|
1788
3175
|
});
|
|
1789
3176
|
} else {
|
|
@@ -1795,44 +3182,97 @@ async function createApp(options) {
|
|
|
1795
3182
|
)
|
|
1796
3183
|
);
|
|
1797
3184
|
}
|
|
3185
|
+
}
|
|
3186
|
+
async function createHubApp(options) {
|
|
3187
|
+
const app = new Hono();
|
|
3188
|
+
registerHubApiRoutes(app, {
|
|
3189
|
+
hub: options.hub,
|
|
3190
|
+
mutationToken: options.mutationToken ?? createMutationToken()
|
|
3191
|
+
});
|
|
3192
|
+
await mountWebUi(app);
|
|
1798
3193
|
return app;
|
|
1799
3194
|
}
|
|
1800
|
-
async function
|
|
1801
|
-
|
|
3195
|
+
async function startHubServer(options) {
|
|
3196
|
+
const host = initializeListenHost();
|
|
1802
3197
|
const port = options.port ?? DEFAULT_PORT;
|
|
1803
|
-
const
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
3198
|
+
const hub = new Hub({ dataDir: options.dataDir });
|
|
3199
|
+
hub.attachCleanupHandlers();
|
|
3200
|
+
if (options.initialWorkspace) {
|
|
3201
|
+
await hub.registry.add(options.initialWorkspace);
|
|
3202
|
+
}
|
|
3203
|
+
const mutationToken = createMutationToken();
|
|
3204
|
+
const app = await createHubApp({ hub, mutationToken });
|
|
1809
3205
|
const server = createAdaptorServer({
|
|
1810
3206
|
fetch: app.fetch,
|
|
1811
|
-
hostname:
|
|
3207
|
+
hostname: host
|
|
1812
3208
|
});
|
|
1813
|
-
const wss =
|
|
1814
|
-
await
|
|
1815
|
-
|
|
1816
|
-
const url = `http://${HOST}:${port}`;
|
|
3209
|
+
const wss = setupHubWebSocket(server, hub);
|
|
3210
|
+
await listenOnHost(server, wss, host, port);
|
|
3211
|
+
const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}`;
|
|
1817
3212
|
if (options.openBrowser !== false) {
|
|
1818
|
-
await
|
|
3213
|
+
const entries = await hub.registry.list();
|
|
3214
|
+
const deepLink = entries.length > 0 ? `${url}/?workspace=${entries[0].id}` : url;
|
|
3215
|
+
await open_default(deepLink);
|
|
1819
3216
|
}
|
|
3217
|
+
const dummyProcessManager = new ProcessManager();
|
|
1820
3218
|
return {
|
|
1821
3219
|
url,
|
|
1822
3220
|
port,
|
|
1823
|
-
|
|
3221
|
+
host,
|
|
3222
|
+
hub,
|
|
3223
|
+
processManager: dummyProcessManager,
|
|
1824
3224
|
close: async () => {
|
|
1825
|
-
|
|
3225
|
+
hub.killAll();
|
|
1826
3226
|
await closeWebSocketServer(wss);
|
|
1827
3227
|
await closeHttpServer(server);
|
|
1828
3228
|
}
|
|
1829
3229
|
};
|
|
1830
3230
|
}
|
|
1831
3231
|
|
|
3232
|
+
// src/cli/hub/client.ts
|
|
3233
|
+
async function isHubRunning(port = DEFAULT_PORT) {
|
|
3234
|
+
try {
|
|
3235
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/hub/status`, {
|
|
3236
|
+
signal: AbortSignal.timeout(2e3)
|
|
3237
|
+
});
|
|
3238
|
+
return response.ok;
|
|
3239
|
+
} catch {
|
|
3240
|
+
return false;
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
async function registerWorkspaceRemotely(dirPath, port = DEFAULT_PORT) {
|
|
3244
|
+
try {
|
|
3245
|
+
const sessionResponse = await fetch(`http://127.0.0.1:${port}/api/session`, {
|
|
3246
|
+
signal: AbortSignal.timeout(2e3)
|
|
3247
|
+
});
|
|
3248
|
+
if (!sessionResponse.ok) return null;
|
|
3249
|
+
const session = await sessionResponse.json();
|
|
3250
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/workspaces`, {
|
|
3251
|
+
method: "POST",
|
|
3252
|
+
headers: {
|
|
3253
|
+
"Content-Type": "application/json",
|
|
3254
|
+
"X-DevSurface-Intent": "dashboard",
|
|
3255
|
+
"X-DevSurface-Token": session.token
|
|
3256
|
+
},
|
|
3257
|
+
body: JSON.stringify({ path: dirPath }),
|
|
3258
|
+
signal: AbortSignal.timeout(5e3)
|
|
3259
|
+
});
|
|
3260
|
+
if (!response.ok) return null;
|
|
3261
|
+
return await response.json();
|
|
3262
|
+
} catch {
|
|
3263
|
+
return null;
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
function dashboardUrl(workspaceId2, port = DEFAULT_PORT, host = DEFAULT_HOST) {
|
|
3267
|
+
const displayHost = host === "0.0.0.0" ? "127.0.0.1" : host;
|
|
3268
|
+
return `http://${displayHost}:${port}/?workspace=${workspaceId2}`;
|
|
3269
|
+
}
|
|
3270
|
+
|
|
1832
3271
|
// src/cli/commands/start.ts
|
|
1833
3272
|
async function startCommand(options) {
|
|
1834
3273
|
const cwd = options.cwd ?? process.cwd();
|
|
1835
|
-
|
|
3274
|
+
const port = options.port ?? 4567;
|
|
3275
|
+
console.log(pc5.bold(`DevSurface v${DEV_SURFACE_VERSION}`));
|
|
1836
3276
|
console.log("Scanning project...\n");
|
|
1837
3277
|
const scan = await scanProject(cwd);
|
|
1838
3278
|
printScanResult(scan);
|
|
@@ -1844,13 +3284,87 @@ async function startCommand(options) {
|
|
|
1844
3284
|
console.log(` ${marker} ${item.title}`);
|
|
1845
3285
|
}
|
|
1846
3286
|
}
|
|
1847
|
-
|
|
1848
|
-
|
|
3287
|
+
if (await isHubRunning(port)) {
|
|
3288
|
+
console.log("\nHub already running. Registering workspace...");
|
|
3289
|
+
const registered = await registerWorkspaceRemotely(cwd, port);
|
|
3290
|
+
if (registered) {
|
|
3291
|
+
const url = dashboardUrl(registered.id, port);
|
|
3292
|
+
console.log(`Workspace ${pc5.cyan(registered.name)} attached.`);
|
|
3293
|
+
console.log(`Dashboard -> ${pc5.cyan(url)}`);
|
|
3294
|
+
if (options.openBrowser !== false) {
|
|
3295
|
+
await open_default(url);
|
|
3296
|
+
}
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
3299
|
+
console.log("Could not register with running hub. Starting a new instance...");
|
|
3300
|
+
}
|
|
3301
|
+
const server = await startHubServer({
|
|
3302
|
+
port,
|
|
3303
|
+
openBrowser: options.openBrowser,
|
|
3304
|
+
initialWorkspace: cwd
|
|
3305
|
+
});
|
|
3306
|
+
console.log(`
|
|
3307
|
+
Dashboard running at -> ${pc5.cyan(server.url)}`);
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
// src/cli/commands/serve.ts
|
|
3311
|
+
import pc6 from "picocolors";
|
|
3312
|
+
async function serveCommand(options) {
|
|
3313
|
+
console.log(pc6.bold(`DevSurface Hub v${DEV_SURFACE_VERSION}`));
|
|
3314
|
+
console.log("Starting hub server...\n");
|
|
3315
|
+
const server = await startHubServer({
|
|
1849
3316
|
port: options.port,
|
|
1850
3317
|
openBrowser: options.openBrowser
|
|
1851
3318
|
});
|
|
3319
|
+
const summaries = await server.hub.listSummaries();
|
|
3320
|
+
if (summaries.length > 0) {
|
|
3321
|
+
console.log(`Registered workspaces: ${summaries.length}`);
|
|
3322
|
+
for (const ws of summaries) {
|
|
3323
|
+
console.log(` ${pc6.cyan(ws.name)} -> ${ws.path}`);
|
|
3324
|
+
}
|
|
3325
|
+
} else {
|
|
3326
|
+
console.log(
|
|
3327
|
+
"No workspaces registered yet. Use `devsurface workspace add` or `npx devsurface` inside a project."
|
|
3328
|
+
);
|
|
3329
|
+
}
|
|
1852
3330
|
console.log(`
|
|
1853
|
-
|
|
3331
|
+
Hub running at -> ${pc6.cyan(server.url)}`);
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
// src/cli/commands/workspace.ts
|
|
3335
|
+
import path16 from "path";
|
|
3336
|
+
import pc7 from "picocolors";
|
|
3337
|
+
async function workspaceAddCommand(dirPath) {
|
|
3338
|
+
const registry = new WorkspaceRegistry();
|
|
3339
|
+
const target = path16.resolve(dirPath ?? process.cwd());
|
|
3340
|
+
const entry = await registry.add(target);
|
|
3341
|
+
console.log(`Added workspace ${pc7.cyan(entry.name)} (${entry.id}) -> ${entry.path}`);
|
|
3342
|
+
}
|
|
3343
|
+
async function workspaceListCommand() {
|
|
3344
|
+
const registry = new WorkspaceRegistry();
|
|
3345
|
+
const entries = await registry.list();
|
|
3346
|
+
if (entries.length === 0) {
|
|
3347
|
+
console.log(
|
|
3348
|
+
"No workspaces registered. Run `devsurface workspace add` or `npx devsurface` inside a project."
|
|
3349
|
+
);
|
|
3350
|
+
return;
|
|
3351
|
+
}
|
|
3352
|
+
console.log(`${entries.length} workspace${entries.length === 1 ? "" : "s"}:
|
|
3353
|
+
`);
|
|
3354
|
+
for (const entry of entries) {
|
|
3355
|
+
console.log(` ${pc7.cyan(entry.name)} (${entry.id})`);
|
|
3356
|
+
console.log(` ${entry.path}`);
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
async function workspaceRemoveCommand(id) {
|
|
3360
|
+
const registry = new WorkspaceRegistry();
|
|
3361
|
+
const removed = await registry.remove(id);
|
|
3362
|
+
if (removed) {
|
|
3363
|
+
console.log(`Removed workspace ${pc7.cyan(id)}.`);
|
|
3364
|
+
} else {
|
|
3365
|
+
console.error(`Workspace "${id}" not found.`);
|
|
3366
|
+
process.exitCode = 1;
|
|
3367
|
+
}
|
|
1854
3368
|
}
|
|
1855
3369
|
|
|
1856
3370
|
// src/cli/index.ts
|
|
@@ -1869,7 +3383,7 @@ function handle(command) {
|
|
|
1869
3383
|
process.exitCode = 1;
|
|
1870
3384
|
});
|
|
1871
3385
|
}
|
|
1872
|
-
program.name("devsurface").description("Turn any Node.js repository into a local developer control panel.").version(
|
|
3386
|
+
program.name("devsurface").description("Turn any Node.js repository into a local developer control panel.").version(DEV_SURFACE_VERSION).option("-p, --port <port>", "dashboard port", toPort, 4567).option("--no-open", "do not open the browser automatically").action((options) => {
|
|
1873
3387
|
handle(
|
|
1874
3388
|
startCommand({
|
|
1875
3389
|
cwd: process.cwd(),
|
|
@@ -1878,6 +3392,24 @@ program.name("devsurface").description("Turn any Node.js repository into a local
|
|
|
1878
3392
|
})
|
|
1879
3393
|
);
|
|
1880
3394
|
});
|
|
3395
|
+
program.command("serve").description("Start the DevSurface hub server (multi-workspace mode).").option("-p, --port <port>", "hub port", toPort, 4567).option("--no-open", "do not open the browser automatically").action((options) => {
|
|
3396
|
+
handle(
|
|
3397
|
+
serveCommand({
|
|
3398
|
+
port: options.port,
|
|
3399
|
+
openBrowser: options.open
|
|
3400
|
+
})
|
|
3401
|
+
);
|
|
3402
|
+
});
|
|
3403
|
+
var workspace = program.command("workspace").description("Manage registered workspaces.");
|
|
3404
|
+
workspace.command("add [path]").description("Register a project directory with the hub.").action((dirPath) => {
|
|
3405
|
+
handle(workspaceAddCommand(dirPath));
|
|
3406
|
+
});
|
|
3407
|
+
workspace.command("list").description("List all registered workspaces.").action(() => {
|
|
3408
|
+
handle(workspaceListCommand());
|
|
3409
|
+
});
|
|
3410
|
+
workspace.command("remove <id>").description("Remove a workspace from the hub registry.").action((id) => {
|
|
3411
|
+
handle(workspaceRemoveCommand(id));
|
|
3412
|
+
});
|
|
1881
3413
|
program.command("scan").description("Print detected project info.").action(() => {
|
|
1882
3414
|
handle(scanCommand(process.cwd()));
|
|
1883
3415
|
});
|