devsurface 0.1.0 → 0.3.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/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 = {
@@ -48,6 +79,11 @@ var defaultConfig = {
48
79
  };
49
80
 
50
81
  // src/core/config/load.ts
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
+ }
51
87
  function isRecord(value) {
52
88
  return typeof value === "object" && value !== null && !Array.isArray(value);
53
89
  }
@@ -101,7 +137,10 @@ function toPorts(value, warnings) {
101
137
  if (ports.length !== value.length) {
102
138
  warnings.push("ports may only contain integers between 1 and 65535.");
103
139
  }
104
- return ports;
140
+ if (ports.length > MAX_CONFIGURED_PORTS) {
141
+ warnings.push(`ports may contain at most ${MAX_CONFIGURED_PORTS} entries.`);
142
+ }
143
+ return ports.slice(0, MAX_CONFIGURED_PORTS);
105
144
  }
106
145
  function validateConfig(raw) {
107
146
  const warnings = [];
@@ -121,6 +160,14 @@ function validateConfig(raw) {
121
160
  if (raw.services !== void 0 && !isRecord(raw.services)) {
122
161
  warnings.push("services must be an object.");
123
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
+ }
124
171
  return {
125
172
  config: {
126
173
  name: typeof raw.name === "string" ? raw.name : void 0,
@@ -130,7 +177,7 @@ function validateConfig(raw) {
130
177
  ports: toPorts(raw.ports, warnings),
131
178
  env,
132
179
  services,
133
- docs: typeof raw.docs === "string" ? raw.docs : void 0
180
+ docs
134
181
  },
135
182
  warnings
136
183
  };
@@ -138,10 +185,17 @@ function validateConfig(raw) {
138
185
  async function loadConfig(root) {
139
186
  const configPath = path.join(root, CONFIG_FILE_NAME);
140
187
  try {
141
- const content = await fs.readFile(configPath, "utf8");
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");
142
196
  const parsed = JSON.parse(content);
143
197
  const { config, warnings } = validateConfig(parsed);
144
- return { path: configPath, config, warnings };
198
+ return { path: realConfigPath, config, warnings };
145
199
  } catch (error) {
146
200
  const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
147
201
  if (code === "ENOENT") {
@@ -158,7 +212,7 @@ async function loadConfig(root) {
158
212
  }
159
213
  }
160
214
 
161
- // src/core/scanner/docker.ts
215
+ // src/core/docker/compose.ts
162
216
  import { promises as fs3 } from "fs";
163
217
  import os from "os";
164
218
  import path3 from "path";
@@ -169,7 +223,7 @@ import { parse as parseYaml } from "yaml";
169
223
  import { constants } from "fs";
170
224
  import { promises as fs2 } from "fs";
171
225
  import path2 from "path";
172
- function isWithinRoot(root, target) {
226
+ function isWithinRoot2(root, target) {
173
227
  const resolvedRoot = path2.resolve(root);
174
228
  const resolvedTarget = path2.resolve(target);
175
229
  const relative = path2.relative(resolvedRoot, resolvedTarget);
@@ -189,7 +243,7 @@ function executableNames(command) {
189
243
  return extensions.map((extension) => `${command}${extension}`);
190
244
  }
191
245
  async function executableOutsideRoot(root, candidate) {
192
- if (isWithinRoot(root, candidate)) {
246
+ if (isWithinRoot2(root, candidate)) {
193
247
  return null;
194
248
  }
195
249
  try {
@@ -197,7 +251,7 @@ async function executableOutsideRoot(root, candidate) {
197
251
  fs2.realpath(root),
198
252
  fs2.realpath(candidate)
199
253
  ]);
200
- if (isWithinRoot(realRoot, realCandidate)) {
254
+ if (isWithinRoot2(realRoot, realCandidate)) {
201
255
  return null;
202
256
  }
203
257
  await fs2.access(realCandidate, constants.X_OK);
@@ -212,7 +266,7 @@ async function resolveExecutableOutsideRoot(root, command) {
212
266
  }
213
267
  for (const entry of pathEntries(process.env.PATH ?? "")) {
214
268
  const directory = path2.resolve(entry);
215
- if (isWithinRoot(root, directory)) {
269
+ if (isWithinRoot2(root, directory)) {
216
270
  continue;
217
271
  }
218
272
  for (const executableName of executableNames(command)) {
@@ -225,50 +279,38 @@ async function resolveExecutableOutsideRoot(root, command) {
225
279
  return null;
226
280
  }
227
281
 
228
- // src/core/scanner/docker.ts
282
+ // src/core/docker/compose.ts
229
283
  var COMPOSE_FILES = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"];
230
- async function fileExists(filePath) {
231
- try {
232
- await fs3.access(filePath);
233
- return true;
234
- } catch {
235
- return false;
236
- }
237
- }
238
- async function getComposeFiles(root) {
239
- const checks = await Promise.all(
240
- COMPOSE_FILES.map(async (file) => {
241
- const filePath = path3.join(root, file);
242
- return await fileExists(filePath) ? filePath : null;
243
- })
244
- );
245
- return checks.filter((filePath) => filePath !== null);
246
- }
247
- async function extractServices(composePath) {
248
- try {
249
- const content = await fs3.readFile(composePath, "utf8");
250
- const parsed = parseYaml(content);
251
- if (typeof parsed === "object" && parsed !== null && "services" in parsed && typeof parsed.services === "object" && parsed.services !== null) {
252
- return Object.keys(parsed.services);
253
- }
254
- } catch {
255
- return [];
256
- }
257
- return [];
258
- }
259
- 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) {
260
296
  const relative = path3.relative(path3.resolve(root), path3.resolve(target));
261
297
  return relative === "" || !relative.startsWith("..") && !path3.isAbsolute(relative);
262
298
  }
263
299
  function dockerCommandCwd(root) {
264
300
  const candidates = [os.homedir(), os.tmpdir(), path3.parse(path3.resolve(root)).root];
265
- return candidates.find((candidate) => !isWithinRoot2(root, candidate)) ?? os.homedir();
301
+ return candidates.find((candidate) => !isWithinRoot3(root, candidate)) ?? os.homedir();
302
+ }
303
+ function appendBounded(current, chunk, limit) {
304
+ const combined = current + chunk;
305
+ return combined.length <= limit ? combined : combined.slice(-limit);
266
306
  }
267
- async function runDockerCommand(root, args, timeoutMs = 2500) {
307
+ var runDockerCommand = async (root, args, options = {}) => {
268
308
  const dockerExecutable = await resolveExecutableOutsideRoot(root, "docker");
269
309
  if (dockerExecutable === null) {
270
- return { code: null, stdout: "", stderr: "" };
310
+ return { code: null, stdout: "", stderr: "", error: "not-found" };
271
311
  }
312
+ const timeoutMs = options.timeoutMs ?? 5e3;
313
+ const outputLimit = options.outputLimit ?? COMMAND_OUTPUT_LIMIT;
272
314
  return await new Promise((resolve) => {
273
315
  const child = spawn(dockerExecutable, args, {
274
316
  cwd: dockerCommandCwd(root),
@@ -277,36 +319,152 @@ async function runDockerCommand(root, args, timeoutMs = 2500) {
277
319
  let settled = false;
278
320
  let stdout = "";
279
321
  let stderr = "";
280
- const timeout = setTimeout(() => {
322
+ const finish = (result) => {
323
+ if (settled) {
324
+ return;
325
+ }
281
326
  settled = true;
327
+ clearTimeout(timeout);
328
+ resolve(result);
329
+ };
330
+ const timeout = setTimeout(() => {
282
331
  child.kill();
283
- resolve({ code: null, stdout, stderr });
332
+ finish({ code: null, stdout, stderr, error: "timeout" });
284
333
  }, timeoutMs);
285
334
  child.stdout?.on("data", (chunk) => {
286
- stdout += chunk.toString();
335
+ stdout = appendBounded(stdout, chunk.toString(), outputLimit);
287
336
  });
288
337
  child.stderr?.on("data", (chunk) => {
289
- stderr += chunk.toString();
338
+ stderr = appendBounded(stderr, chunk.toString(), outputLimit);
290
339
  });
291
340
  child.on("error", () => {
292
- clearTimeout(timeout);
293
- if (!settled) {
294
- settled = true;
295
- resolve({ code: null, stdout, stderr });
296
- }
341
+ finish({ code: null, stdout, stderr, error: "spawn" });
297
342
  });
298
343
  child.on("close", (code) => {
299
- clearTimeout(timeout);
300
- if (!settled) {
301
- settled = true;
302
- resolve({ code, stdout, stderr });
303
- }
344
+ finish({ code, stdout, stderr, error: null });
304
345
  });
305
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);
306
392
  }
307
- async function isDockerRunning(root) {
308
- const result = await runDockerCommand(root, ["info"]);
309
- return result.code === 0;
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";
452
+ }
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
+ });
310
468
  }
311
469
  function parseComposePs(output) {
312
470
  const statuses = /* @__PURE__ */ new Map();
@@ -316,82 +474,181 @@ function parseComposePs(output) {
316
474
  }
317
475
  try {
318
476
  const parsed = JSON.parse(compactOutput);
319
- const rows2 = Array.isArray(parsed) ? parsed : [parsed];
320
- for (const row of rows2) {
477
+ for (const row of Array.isArray(parsed) ? parsed : [parsed]) {
321
478
  addComposeStatusRow(statuses, row);
322
479
  }
323
480
  return statuses;
324
481
  } catch {
325
482
  }
326
- const rows = compactOutput.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
327
- for (const row of rows) {
483
+ for (const line of compactOutput.split(/\r?\n/)) {
328
484
  try {
329
- const parsed = JSON.parse(row);
330
- addComposeStatusRow(statuses, parsed);
485
+ addComposeStatusRow(statuses, JSON.parse(line));
331
486
  } catch {
332
- return statuses;
487
+ return /* @__PURE__ */ new Map();
333
488
  }
334
489
  }
335
490
  return statuses;
336
491
  }
337
- function addComposeStatusRow(statuses, row) {
338
- if (typeof row !== "object" || row === null) {
339
- return;
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
+ };
340
522
  }
341
- const record = row;
342
- if (typeof record.Service !== "string") {
343
- return;
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;
344
535
  }
345
- const state = typeof record.State === "string" ? record.State.toLowerCase() : "";
346
- statuses.set(record.Service, {
347
- name: record.Service,
348
- status: state === "running" ? "running" : state ? "stopped" : "unknown",
349
- containerId: typeof record.ID === "string" && record.ID.length > 0 ? record.ID : null
350
- });
351
- }
352
- async function getServiceStatuses(root, serviceNames, dockerRunning) {
353
- if (serviceNames.length === 0) {
354
- return [];
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
+ }
355
548
  }
356
- if (!dockerRunning) {
357
- return serviceNames.map((service) => ({
358
- name: service,
359
- status: "unknown",
360
- containerId: null
361
- }));
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
+ };
362
599
  }
363
- const ps = await runDockerCommand(root, [
364
- "compose",
365
- "--project-directory",
366
- root,
367
- "ps",
368
- "--format",
369
- "json"
370
- ]);
371
- const statuses = ps.code === 0 ? parseComposePs(ps.stdout) : /* @__PURE__ */ new Map();
372
- return serviceNames.map(
373
- (service) => statuses.get(service) ?? {
374
- name: service,
375
- status: "stopped",
376
- 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));
377
611
  }
378
- );
379
- }
380
- async function detectDocker(root) {
381
- const composeFiles = await getComposeFiles(root);
382
- if (composeFiles.length === 0) {
383
- return null;
612
+ return {
613
+ service,
614
+ action,
615
+ output: cleanMessage(result.stdout || result.stderr)
616
+ };
384
617
  }
385
- const serviceLists = await Promise.all(
386
- composeFiles.map((composeFile) => extractServices(composeFile))
387
- );
388
- const serviceNames = Array.from(new Set(serviceLists.flat()));
389
- const dockerRunning = await isDockerRunning(root);
390
- return {
391
- composeFiles,
392
- services: await getServiceStatuses(root, serviceNames, dockerRunning),
393
- dockerRunning
394
- };
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();
395
652
  }
396
653
 
397
654
  // src/core/scanner/env.ts
@@ -525,7 +782,7 @@ function detectFramework(packageJson) {
525
782
  // src/core/scanner/git.ts
526
783
  import { promises as fs5 } from "fs";
527
784
  import path5 from "path";
528
- function isWithinRoot3(root, target) {
785
+ function isWithinRoot4(root, target) {
529
786
  const resolvedRoot = path5.resolve(root);
530
787
  const resolvedTarget = path5.resolve(target);
531
788
  const relative = path5.relative(resolvedRoot, resolvedTarget);
@@ -544,14 +801,14 @@ async function resolveGitDirectory(root) {
544
801
  if (match) {
545
802
  const gitDir = match[1].trim();
546
803
  const resolvedGitDir = path5.isAbsolute(gitDir) ? path5.resolve(gitDir) : path5.resolve(root, gitDir);
547
- if (!isWithinRoot3(root, resolvedGitDir)) {
804
+ if (!isWithinRoot4(root, resolvedGitDir)) {
548
805
  return null;
549
806
  }
550
807
  const [realRoot, realGitDir] = await Promise.all([
551
808
  fs5.realpath(root),
552
809
  fs5.realpath(resolvedGitDir)
553
810
  ]);
554
- return isWithinRoot3(realRoot, realGitDir) ? resolvedGitDir : null;
811
+ return isWithinRoot4(realRoot, realGitDir) ? resolvedGitDir : null;
555
812
  }
556
813
  }
557
814
  } catch {
@@ -609,12 +866,23 @@ async function detectPackageManager(root) {
609
866
  // src/core/scanner/packageJson.ts
610
867
  import { promises as fs7 } from "fs";
611
868
  import path7 from "path";
869
+ function isWithinRoot5(root, target) {
870
+ const relative = path7.relative(root, target);
871
+ return relative === "" || !relative.startsWith("..") && !path7.isAbsolute(relative);
872
+ }
612
873
  async function readPackageJson(root) {
613
874
  const packageJsonPath = path7.join(root, "package.json");
614
875
  try {
615
- const content = await fs7.readFile(packageJsonPath, "utf8");
876
+ const [realRoot, realPackageJsonPath] = await Promise.all([
877
+ fs7.realpath(root),
878
+ fs7.realpath(packageJsonPath)
879
+ ]);
880
+ if (!isWithinRoot5(realRoot, realPackageJsonPath)) {
881
+ return null;
882
+ }
883
+ const content = await fs7.readFile(realPackageJsonPath, "utf8");
616
884
  const data = JSON.parse(content);
617
- return { path: packageJsonPath, data };
885
+ return { path: realPackageJsonPath, data };
618
886
  } catch {
619
887
  return null;
620
888
  }
@@ -622,6 +890,8 @@ async function readPackageJson(root) {
622
890
 
623
891
  // src/core/scanner/ports.ts
624
892
  import net from "net";
893
+ var MAX_PORT_PROBES = 64;
894
+ var PORT_PROBE_CONCURRENCY = 16;
625
895
  function uniquePorts(ports) {
626
896
  return Array.from(
627
897
  new Set(ports.filter((port) => Number.isInteger(port) && port > 0 && port < 65536))
@@ -675,11 +945,25 @@ async function probePort(port) {
675
945
  });
676
946
  }
677
947
  async function detectPorts(ports) {
678
- const normalized = uniquePorts(ports);
948
+ const normalized = uniquePorts(ports).slice(0, MAX_PORT_PROBES);
679
949
  if (normalized.length === 0) {
680
950
  return null;
681
951
  }
682
- return await Promise.all(normalized.map((port) => probePort(port)));
952
+ const results = [];
953
+ let nextIndex = 0;
954
+ async function worker() {
955
+ while (nextIndex < normalized.length) {
956
+ const port = normalized[nextIndex];
957
+ nextIndex += 1;
958
+ results.push(await probePort(port));
959
+ }
960
+ }
961
+ await Promise.all(
962
+ Array.from({ length: Math.min(PORT_PROBE_CONCURRENCY, normalized.length) }, () => worker())
963
+ );
964
+ return results.sort(
965
+ (left, right) => normalized.indexOf(left.port) - normalized.indexOf(right.port)
966
+ );
683
967
  }
684
968
 
685
969
  // src/core/scanner/scripts.ts
@@ -696,13 +980,18 @@ function extractScripts(packageJson) {
696
980
  }
697
981
 
698
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
+ }
699
987
  async function findFirstFile(root, candidates) {
988
+ const resolvedRoot = await fs8.realpath(root).catch(() => path8.resolve(root));
700
989
  for (const candidate of candidates) {
701
990
  const filePath = path8.join(root, candidate);
702
991
  try {
703
- const stat = await fs8.stat(filePath);
704
- if (stat.isFile()) {
705
- return { path: filePath, exists: true };
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 };
706
995
  }
707
996
  } catch {
708
997
  }
@@ -713,8 +1002,9 @@ function configuredPorts(configPorts) {
713
1002
  return Array.isArray(configPorts) ? configPorts : [];
714
1003
  }
715
1004
  async function scanProject(root = process.cwd()) {
716
- const config = await loadConfig(root);
717
- const packageJson = await readPackageJson(root);
1005
+ const resolvedRoot = await fs8.realpath(root).catch(() => path8.resolve(root));
1006
+ const config = await loadConfig(resolvedRoot);
1007
+ const packageJson = await readPackageJson(resolvedRoot);
718
1008
  const scripts = extractScripts(packageJson) ?? {};
719
1009
  const framework = detectFramework(packageJson);
720
1010
  const portsToProbe = [
@@ -723,17 +1013,17 @@ async function scanProject(root = process.cwd()) {
723
1013
  ...defaultPortsForFramework(framework)
724
1014
  ];
725
1015
  const [packageManager, env, docker, git, ports, readme, license] = await Promise.all([
726
- detectPackageManager(root),
727
- detectEnv(root, config?.config),
728
- detectDocker(root),
729
- detectGit(root),
1016
+ detectPackageManager(resolvedRoot),
1017
+ detectEnv(resolvedRoot, config?.config),
1018
+ detectDocker(resolvedRoot),
1019
+ detectGit(resolvedRoot),
730
1020
  detectPorts(portsToProbe),
731
- findFirstFile(root, ["README.md", "README"]),
732
- findFirstFile(root, ["LICENSE", "LICENSE.md", "COPYING"])
1021
+ findFirstFile(resolvedRoot, ["README.md", "README"]),
1022
+ findFirstFile(resolvedRoot, ["LICENSE", "LICENSE.md", "COPYING"])
733
1023
  ]);
734
1024
  return {
735
- root,
736
- projectName: config?.config.name ?? packageJson?.data.name ?? path8.basename(root),
1025
+ root: resolvedRoot,
1026
+ projectName: config?.config.name ?? packageJson?.data.name ?? path8.basename(resolvedRoot),
737
1027
  packageJson,
738
1028
  packageManager: packageManager ?? (packageJson ? "npm" : null),
739
1029
  scripts,
@@ -767,25 +1057,6 @@ async function readIfPresent2(filePath) {
767
1057
  return null;
768
1058
  }
769
1059
  }
770
- function extractReadmeScriptReferences(readmeContent) {
771
- const references = /* @__PURE__ */ new Set();
772
- const commandRegexes = [
773
- /\bnpm\s+run\s+([A-Za-z0-9:_-]+)/g,
774
- /\bpnpm\s+run\s+([A-Za-z0-9:_-]+)/g,
775
- /\bbun\s+run\s+([A-Za-z0-9:_-]+)/g,
776
- /\byarn\s+run\s+([A-Za-z0-9:_-]+)/g,
777
- /\bnpm\s+(test|start|build)\b/g,
778
- /\bpnpm\s+(test|start|build)\b/g,
779
- /\byarn\s+(test|start|build)\b/g,
780
- /\bbun\s+(test|start|build)\b/g
781
- ];
782
- for (const regex of commandRegexes) {
783
- for (const match of readmeContent.matchAll(regex)) {
784
- references.add(match[1]);
785
- }
786
- }
787
- return Array.from(references);
788
- }
789
1060
  function warning(id, severity, title, message, target) {
790
1061
  return { id, severity, title, message, target };
791
1062
  }
@@ -857,7 +1128,7 @@ async function runDoctor(root = process.cwd(), scan) {
857
1128
  } else {
858
1129
  const readme = await readIfPresent2(result.readme.path);
859
1130
  if (readme !== null) {
860
- const references = extractReadmeScriptReferences(readme);
1131
+ const references = extractScriptReferences(readme);
861
1132
  const missingScripts = references.filter((script) => result.scripts[script] === void 0);
862
1133
  if (missingScripts.length > 0) {
863
1134
  warnings.push(
@@ -887,7 +1158,7 @@ async function runDoctor(root = process.cwd(), scan) {
887
1158
  "docker-not-running",
888
1159
  "warning",
889
1160
  "Docker Compose found but Docker is not running",
890
- "A compose file exists, but the Docker daemon did not answer docker info."
1161
+ result.docker.message ?? "A compose file exists, but Docker is not available."
891
1162
  )
892
1163
  );
893
1164
  }
@@ -911,12 +1182,34 @@ async function runDoctor(root = process.cwd(), scan) {
911
1182
  )
912
1183
  );
913
1184
  }
914
- if (!result.license.exists) {
915
- warnings.push(warning("missing-license", "info", "No LICENSE", "No LICENSE file was found."));
916
- }
917
1185
  return warnings;
918
1186
  }
919
1187
 
1188
+ // src/core/security/text.ts
1189
+ var ESC2 = String.fromCharCode(27);
1190
+ var BEL = String.fromCharCode(7);
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");
1194
+ function stripControlCharacters(value) {
1195
+ let result = "";
1196
+ for (const character of value) {
1197
+ const code = character.charCodeAt(0);
1198
+ if (code > 31 && code < 127 || code > 159) {
1199
+ result += character;
1200
+ }
1201
+ }
1202
+ return result;
1203
+ }
1204
+ function safeDisplayText(value) {
1205
+ return stripControlCharacters(
1206
+ String(value).replace(OSC_SEQUENCE, "").replace(CSI_SEQUENCE, "").replace(ESCAPE_SEQUENCE, "")
1207
+ );
1208
+ }
1209
+ function safeDisplayList(values) {
1210
+ return values.length > 0 ? values.map((value) => safeDisplayText(value)).join(", ") : "none";
1211
+ }
1212
+
920
1213
  // src/cli/commands/doctor.ts
921
1214
  function colorSeverity(severity) {
922
1215
  if (severity === "error") {
@@ -934,8 +1227,8 @@ async function doctorCommand(cwd = process.cwd()) {
934
1227
  return;
935
1228
  }
936
1229
  for (const item of warnings) {
937
- console.log(`${colorSeverity(item.severity)} ${pc.bold(item.title)}`);
938
- console.log(` ${item.message}`);
1230
+ console.log(`${colorSeverity(item.severity)} ${pc.bold(safeDisplayText(item.title))}`);
1231
+ console.log(` ${safeDisplayText(item.message)}`);
939
1232
  }
940
1233
  }
941
1234
 
@@ -961,6 +1254,14 @@ import pc3 from "picocolors";
961
1254
 
962
1255
  // src/core/process/runner.ts
963
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
964
1265
  function getPackageRunCommand(packageManager, script) {
965
1266
  const manager = packageManager ?? "npm";
966
1267
  const args = ["run", script];
@@ -1021,6 +1322,95 @@ async function resolvePackageInstallCommand(options) {
1021
1322
  command: executable
1022
1323
  };
1023
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
+ }
1024
1414
  async function runPackageScriptToTerminal(options) {
1025
1415
  const runCommand2 = await resolvePackageRunCommand(options);
1026
1416
  if (runCommand2 === null) {
@@ -1065,14 +1455,14 @@ async function runCommand(script, cwd = process.cwd()) {
1065
1455
  // src/cli/commands/scan.ts
1066
1456
  import pc4 from "picocolors";
1067
1457
  function formatList(values) {
1068
- return values.length > 0 ? values.join(", ") : "none";
1458
+ return safeDisplayList(values);
1069
1459
  }
1070
1460
  function printScanResult(scan) {
1071
- console.log(pc4.bold(`Project: ${scan.projectName}`));
1072
- console.log(`Type: ${scan.framework?.type ?? "Unknown"}`);
1073
- console.log(`Manager: ${scan.packageManager ?? "unknown"}`);
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")}`);
1074
1464
  console.log(`Scripts: ${formatList(Object.keys(scan.scripts))}`);
1075
- console.log(`Git: ${scan.git?.branch ?? "not detected"}`);
1465
+ console.log(`Git: ${safeDisplayText(scan.git?.branch ?? "not detected")}`);
1076
1466
  console.log(`README: ${scan.readme.exists ? "found" : "missing"}`);
1077
1467
  console.log(`LICENSE: ${scan.license.exists ? "found" : "missing"}`);
1078
1468
  if (scan.env !== null) {
@@ -1096,17 +1486,549 @@ async function scanCommand(cwd = process.cwd()) {
1096
1486
  import pc5 from "picocolors";
1097
1487
 
1098
1488
  // src/server/index.ts
1099
- import { promises as fs12 } from "fs";
1100
- import path12 from "path";
1101
- import { fileURLToPath } from "url";
1102
- import { serve } from "@hono/node-server";
1489
+ import { promises as fs17 } from "fs";
1490
+ import path13 from "path";
1491
+ import { fileURLToPath as fileURLToPath2 } from "url";
1492
+ import { createAdaptorServer } from "@hono/node-server";
1103
1493
  import { serveStatic } from "@hono/node-server/serve-static";
1104
1494
  import { Hono } from "hono";
1105
- import open2 from "open";
1495
+
1496
+ // node_modules/open/index.js
1497
+ import process7 from "process";
1498
+ import { Buffer as Buffer2 } from "buffer";
1499
+ import path11 from "path";
1500
+ import { fileURLToPath } from "url";
1501
+ import { promisify as promisify5 } from "util";
1502
+ import childProcess from "child_process";
1503
+ import fs15, { constants as fsConstants2 } from "fs/promises";
1504
+
1505
+ // node_modules/wsl-utils/index.js
1506
+ import process3 from "process";
1507
+ import fs14, { constants as fsConstants } from "fs/promises";
1508
+
1509
+ // node_modules/is-wsl/index.js
1510
+ import process2 from "process";
1511
+ import os2 from "os";
1512
+ import fs13 from "fs";
1513
+
1514
+ // node_modules/is-inside-container/index.js
1515
+ import fs12 from "fs";
1516
+
1517
+ // node_modules/is-docker/index.js
1518
+ import fs11 from "fs";
1519
+ var isDockerCached;
1520
+ function hasDockerEnv() {
1521
+ try {
1522
+ fs11.statSync("/.dockerenv");
1523
+ return true;
1524
+ } catch {
1525
+ return false;
1526
+ }
1527
+ }
1528
+ function hasDockerCGroup() {
1529
+ try {
1530
+ return fs11.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
1531
+ } catch {
1532
+ return false;
1533
+ }
1534
+ }
1535
+ function isDocker() {
1536
+ if (isDockerCached === void 0) {
1537
+ isDockerCached = hasDockerEnv() || hasDockerCGroup();
1538
+ }
1539
+ return isDockerCached;
1540
+ }
1541
+
1542
+ // node_modules/is-inside-container/index.js
1543
+ var cachedResult;
1544
+ var hasContainerEnv = () => {
1545
+ try {
1546
+ fs12.statSync("/run/.containerenv");
1547
+ return true;
1548
+ } catch {
1549
+ return false;
1550
+ }
1551
+ };
1552
+ function isInsideContainer() {
1553
+ if (cachedResult === void 0) {
1554
+ cachedResult = hasContainerEnv() || isDocker();
1555
+ }
1556
+ return cachedResult;
1557
+ }
1558
+
1559
+ // node_modules/is-wsl/index.js
1560
+ var isWsl = () => {
1561
+ if (process2.platform !== "linux") {
1562
+ return false;
1563
+ }
1564
+ if (os2.release().toLowerCase().includes("microsoft")) {
1565
+ if (isInsideContainer()) {
1566
+ return false;
1567
+ }
1568
+ return true;
1569
+ }
1570
+ try {
1571
+ if (fs13.readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft")) {
1572
+ return !isInsideContainer();
1573
+ }
1574
+ } catch {
1575
+ }
1576
+ if (fs13.existsSync("/proc/sys/fs/binfmt_misc/WSLInterop") || fs13.existsSync("/run/WSL")) {
1577
+ return !isInsideContainer();
1578
+ }
1579
+ return false;
1580
+ };
1581
+ var is_wsl_default = process2.env.__IS_WSL_TEST__ ? isWsl : isWsl();
1582
+
1583
+ // node_modules/wsl-utils/index.js
1584
+ var wslDrivesMountPoint = /* @__PURE__ */ (() => {
1585
+ const defaultMountPoint = "/mnt/";
1586
+ let mountPoint;
1587
+ return async function() {
1588
+ if (mountPoint) {
1589
+ return mountPoint;
1590
+ }
1591
+ const configFilePath = "/etc/wsl.conf";
1592
+ let isConfigFileExists = false;
1593
+ try {
1594
+ await fs14.access(configFilePath, fsConstants.F_OK);
1595
+ isConfigFileExists = true;
1596
+ } catch {
1597
+ }
1598
+ if (!isConfigFileExists) {
1599
+ return defaultMountPoint;
1600
+ }
1601
+ const configContent = await fs14.readFile(configFilePath, { encoding: "utf8" });
1602
+ const configMountPoint = /(?<!#.*)root\s*=\s*(?<mountPoint>.*)/g.exec(configContent);
1603
+ if (!configMountPoint) {
1604
+ return defaultMountPoint;
1605
+ }
1606
+ mountPoint = configMountPoint.groups.mountPoint.trim();
1607
+ mountPoint = mountPoint.endsWith("/") ? mountPoint : `${mountPoint}/`;
1608
+ return mountPoint;
1609
+ };
1610
+ })();
1611
+ var powerShellPathFromWsl = async () => {
1612
+ const mountPoint = await wslDrivesMountPoint();
1613
+ return `${mountPoint}c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`;
1614
+ };
1615
+ var powerShellPath = async () => {
1616
+ if (is_wsl_default) {
1617
+ return powerShellPathFromWsl();
1618
+ }
1619
+ return `${process3.env.SYSTEMROOT || process3.env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
1620
+ };
1621
+
1622
+ // node_modules/define-lazy-prop/index.js
1623
+ function defineLazyProperty(object, propertyName, valueGetter) {
1624
+ const define = (value) => Object.defineProperty(object, propertyName, { value, enumerable: true, writable: true });
1625
+ Object.defineProperty(object, propertyName, {
1626
+ configurable: true,
1627
+ enumerable: true,
1628
+ get() {
1629
+ const result = valueGetter();
1630
+ define(result);
1631
+ return result;
1632
+ },
1633
+ set(value) {
1634
+ define(value);
1635
+ }
1636
+ });
1637
+ return object;
1638
+ }
1639
+
1640
+ // node_modules/default-browser/index.js
1641
+ import { promisify as promisify4 } from "util";
1642
+ import process6 from "process";
1643
+ import { execFile as execFile4 } from "child_process";
1644
+
1645
+ // node_modules/default-browser-id/index.js
1646
+ import { promisify } from "util";
1647
+ import process4 from "process";
1648
+ import { execFile } from "child_process";
1649
+ var execFileAsync = promisify(execFile);
1650
+ async function defaultBrowserId() {
1651
+ if (process4.platform !== "darwin") {
1652
+ throw new Error("macOS only");
1653
+ }
1654
+ const { stdout } = await execFileAsync("defaults", ["read", "com.apple.LaunchServices/com.apple.launchservices.secure", "LSHandlers"]);
1655
+ const match = /LSHandlerRoleAll = "(?!-)(?<id>[^"]+?)";\s+?LSHandlerURLScheme = (?:http|https);/.exec(stdout);
1656
+ const browserId = match?.groups.id ?? "com.apple.Safari";
1657
+ if (browserId === "com.apple.safari") {
1658
+ return "com.apple.Safari";
1659
+ }
1660
+ return browserId;
1661
+ }
1662
+
1663
+ // node_modules/run-applescript/index.js
1664
+ import process5 from "process";
1665
+ import { promisify as promisify2 } from "util";
1666
+ import { execFile as execFile2, execFileSync } from "child_process";
1667
+ var execFileAsync2 = promisify2(execFile2);
1668
+ async function runAppleScript(script, { humanReadableOutput = true, signal } = {}) {
1669
+ if (process5.platform !== "darwin") {
1670
+ throw new Error("macOS only");
1671
+ }
1672
+ const outputArguments = humanReadableOutput ? [] : ["-ss"];
1673
+ const execOptions = {};
1674
+ if (signal) {
1675
+ execOptions.signal = signal;
1676
+ }
1677
+ const { stdout } = await execFileAsync2("osascript", ["-e", script, outputArguments], execOptions);
1678
+ return stdout.trim();
1679
+ }
1680
+
1681
+ // node_modules/bundle-name/index.js
1682
+ async function bundleName(bundleId) {
1683
+ return runAppleScript(`tell application "Finder" to set app_path to application file id "${bundleId}" as string
1684
+ tell application "System Events" to get value of property list item "CFBundleName" of property list file (app_path & ":Contents:Info.plist")`);
1685
+ }
1686
+
1687
+ // node_modules/default-browser/windows.js
1688
+ import { promisify as promisify3 } from "util";
1689
+ import { execFile as execFile3 } from "child_process";
1690
+ var execFileAsync3 = promisify3(execFile3);
1691
+ var windowsBrowserProgIds = {
1692
+ MSEdgeHTM: { name: "Edge", id: "com.microsoft.edge" },
1693
+ // The missing `L` is correct.
1694
+ MSEdgeBHTML: { name: "Edge Beta", id: "com.microsoft.edge.beta" },
1695
+ MSEdgeDHTML: { name: "Edge Dev", id: "com.microsoft.edge.dev" },
1696
+ AppXq0fevzme2pys62n3e0fbqa7peapykr8v: { name: "Edge", id: "com.microsoft.edge.old" },
1697
+ ChromeHTML: { name: "Chrome", id: "com.google.chrome" },
1698
+ ChromeBHTML: { name: "Chrome Beta", id: "com.google.chrome.beta" },
1699
+ ChromeDHTML: { name: "Chrome Dev", id: "com.google.chrome.dev" },
1700
+ ChromiumHTM: { name: "Chromium", id: "org.chromium.Chromium" },
1701
+ BraveHTML: { name: "Brave", id: "com.brave.Browser" },
1702
+ BraveBHTML: { name: "Brave Beta", id: "com.brave.Browser.beta" },
1703
+ BraveDHTML: { name: "Brave Dev", id: "com.brave.Browser.dev" },
1704
+ BraveSSHTM: { name: "Brave Nightly", id: "com.brave.Browser.nightly" },
1705
+ FirefoxURL: { name: "Firefox", id: "org.mozilla.firefox" },
1706
+ OperaStable: { name: "Opera", id: "com.operasoftware.Opera" },
1707
+ VivaldiHTM: { name: "Vivaldi", id: "com.vivaldi.Vivaldi" },
1708
+ "IE.HTTP": { name: "Internet Explorer", id: "com.microsoft.ie" }
1709
+ };
1710
+ var _windowsBrowserProgIdMap = new Map(Object.entries(windowsBrowserProgIds));
1711
+ var UnknownBrowserError = class extends Error {
1712
+ };
1713
+ async function defaultBrowser(_execFileAsync = execFileAsync3) {
1714
+ const { stdout } = await _execFileAsync("reg", [
1715
+ "QUERY",
1716
+ " HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice",
1717
+ "/v",
1718
+ "ProgId"
1719
+ ]);
1720
+ const match = /ProgId\s*REG_SZ\s*(?<id>\S+)/.exec(stdout);
1721
+ if (!match) {
1722
+ throw new UnknownBrowserError(`Cannot find Windows browser in stdout: ${JSON.stringify(stdout)}`);
1723
+ }
1724
+ const { id } = match.groups;
1725
+ const dotIndex = id.lastIndexOf(".");
1726
+ const hyphenIndex = id.lastIndexOf("-");
1727
+ const baseIdByDot = dotIndex === -1 ? void 0 : id.slice(0, dotIndex);
1728
+ const baseIdByHyphen = hyphenIndex === -1 ? void 0 : id.slice(0, hyphenIndex);
1729
+ return windowsBrowserProgIds[id] ?? windowsBrowserProgIds[baseIdByDot] ?? windowsBrowserProgIds[baseIdByHyphen] ?? { name: id, id };
1730
+ }
1731
+
1732
+ // node_modules/default-browser/index.js
1733
+ var execFileAsync4 = promisify4(execFile4);
1734
+ var titleize = (string) => string.toLowerCase().replaceAll(/(?:^|\s|-)\S/g, (x) => x.toUpperCase());
1735
+ async function defaultBrowser2() {
1736
+ if (process6.platform === "darwin") {
1737
+ const id = await defaultBrowserId();
1738
+ const name = await bundleName(id);
1739
+ return { name, id };
1740
+ }
1741
+ if (process6.platform === "linux") {
1742
+ const { stdout } = await execFileAsync4("xdg-mime", ["query", "default", "x-scheme-handler/http"]);
1743
+ const id = stdout.trim();
1744
+ const name = titleize(id.replace(/.desktop$/, "").replace("-", " "));
1745
+ return { name, id };
1746
+ }
1747
+ if (process6.platform === "win32") {
1748
+ return defaultBrowser();
1749
+ }
1750
+ throw new Error("Only macOS, Linux, and Windows are supported");
1751
+ }
1752
+
1753
+ // node_modules/open/index.js
1754
+ var execFile5 = promisify5(childProcess.execFile);
1755
+ var __dirname = path11.dirname(fileURLToPath(import.meta.url));
1756
+ var localXdgOpenPath = path11.join(__dirname, "xdg-open");
1757
+ var { platform, arch } = process7;
1758
+ async function getWindowsDefaultBrowserFromWsl() {
1759
+ const powershellPath = await powerShellPath();
1760
+ const rawCommand = String.raw`(Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice").ProgId`;
1761
+ const encodedCommand = Buffer2.from(rawCommand, "utf16le").toString("base64");
1762
+ const { stdout } = await execFile5(
1763
+ powershellPath,
1764
+ [
1765
+ "-NoProfile",
1766
+ "-NonInteractive",
1767
+ "-ExecutionPolicy",
1768
+ "Bypass",
1769
+ "-EncodedCommand",
1770
+ encodedCommand
1771
+ ],
1772
+ { encoding: "utf8" }
1773
+ );
1774
+ const progId = stdout.trim();
1775
+ const browserMap = {
1776
+ ChromeHTML: "com.google.chrome",
1777
+ BraveHTML: "com.brave.Browser",
1778
+ MSEdgeHTM: "com.microsoft.edge",
1779
+ FirefoxURL: "org.mozilla.firefox"
1780
+ };
1781
+ return browserMap[progId] ? { id: browserMap[progId] } : {};
1782
+ }
1783
+ var pTryEach = async (array, mapper) => {
1784
+ let latestError;
1785
+ for (const item of array) {
1786
+ try {
1787
+ return await mapper(item);
1788
+ } catch (error) {
1789
+ latestError = error;
1790
+ }
1791
+ }
1792
+ throw latestError;
1793
+ };
1794
+ var baseOpen = async (options) => {
1795
+ options = {
1796
+ wait: false,
1797
+ background: false,
1798
+ newInstance: false,
1799
+ allowNonzeroExitCode: false,
1800
+ ...options
1801
+ };
1802
+ if (Array.isArray(options.app)) {
1803
+ return pTryEach(options.app, (singleApp) => baseOpen({
1804
+ ...options,
1805
+ app: singleApp
1806
+ }));
1807
+ }
1808
+ let { name: app, arguments: appArguments = [] } = options.app ?? {};
1809
+ appArguments = [...appArguments];
1810
+ if (Array.isArray(app)) {
1811
+ return pTryEach(app, (appName) => baseOpen({
1812
+ ...options,
1813
+ app: {
1814
+ name: appName,
1815
+ arguments: appArguments
1816
+ }
1817
+ }));
1818
+ }
1819
+ if (app === "browser" || app === "browserPrivate") {
1820
+ const ids = {
1821
+ "com.google.chrome": "chrome",
1822
+ "google-chrome.desktop": "chrome",
1823
+ "com.brave.Browser": "brave",
1824
+ "org.mozilla.firefox": "firefox",
1825
+ "firefox.desktop": "firefox",
1826
+ "com.microsoft.msedge": "edge",
1827
+ "com.microsoft.edge": "edge",
1828
+ "com.microsoft.edgemac": "edge",
1829
+ "microsoft-edge.desktop": "edge"
1830
+ };
1831
+ const flags = {
1832
+ chrome: "--incognito",
1833
+ brave: "--incognito",
1834
+ firefox: "--private-window",
1835
+ edge: "--inPrivate"
1836
+ };
1837
+ const browser = is_wsl_default ? await getWindowsDefaultBrowserFromWsl() : await defaultBrowser2();
1838
+ if (browser.id in ids) {
1839
+ const browserName = ids[browser.id];
1840
+ if (app === "browserPrivate") {
1841
+ appArguments.push(flags[browserName]);
1842
+ }
1843
+ return baseOpen({
1844
+ ...options,
1845
+ app: {
1846
+ name: apps[browserName],
1847
+ arguments: appArguments
1848
+ }
1849
+ });
1850
+ }
1851
+ throw new Error(`${browser.name} is not supported as a default browser`);
1852
+ }
1853
+ let command;
1854
+ const cliArguments = [];
1855
+ const childProcessOptions = {};
1856
+ if (platform === "darwin") {
1857
+ command = "open";
1858
+ if (options.wait) {
1859
+ cliArguments.push("--wait-apps");
1860
+ }
1861
+ if (options.background) {
1862
+ cliArguments.push("--background");
1863
+ }
1864
+ if (options.newInstance) {
1865
+ cliArguments.push("--new");
1866
+ }
1867
+ if (app) {
1868
+ cliArguments.push("-a", app);
1869
+ }
1870
+ } else if (platform === "win32" || is_wsl_default && !isInsideContainer() && !app) {
1871
+ command = await powerShellPath();
1872
+ cliArguments.push(
1873
+ "-NoProfile",
1874
+ "-NonInteractive",
1875
+ "-ExecutionPolicy",
1876
+ "Bypass",
1877
+ "-EncodedCommand"
1878
+ );
1879
+ if (!is_wsl_default) {
1880
+ childProcessOptions.windowsVerbatimArguments = true;
1881
+ }
1882
+ const encodedArguments = ["Start"];
1883
+ if (options.wait) {
1884
+ encodedArguments.push("-Wait");
1885
+ }
1886
+ if (app) {
1887
+ encodedArguments.push(`"\`"${app}\`""`);
1888
+ if (options.target) {
1889
+ appArguments.push(options.target);
1890
+ }
1891
+ } else if (options.target) {
1892
+ encodedArguments.push(`"${options.target}"`);
1893
+ }
1894
+ if (appArguments.length > 0) {
1895
+ appArguments = appArguments.map((argument) => `"\`"${argument}\`""`);
1896
+ encodedArguments.push("-ArgumentList", appArguments.join(","));
1897
+ }
1898
+ options.target = Buffer2.from(encodedArguments.join(" "), "utf16le").toString("base64");
1899
+ } else {
1900
+ if (app) {
1901
+ command = app;
1902
+ } else {
1903
+ const isBundled = !__dirname || __dirname === "/";
1904
+ let exeLocalXdgOpen = false;
1905
+ try {
1906
+ await fs15.access(localXdgOpenPath, fsConstants2.X_OK);
1907
+ exeLocalXdgOpen = true;
1908
+ } catch {
1909
+ }
1910
+ const useSystemXdgOpen = process7.versions.electron ?? (platform === "android" || isBundled || !exeLocalXdgOpen);
1911
+ command = useSystemXdgOpen ? "xdg-open" : localXdgOpenPath;
1912
+ }
1913
+ if (appArguments.length > 0) {
1914
+ cliArguments.push(...appArguments);
1915
+ }
1916
+ if (!options.wait) {
1917
+ childProcessOptions.stdio = "ignore";
1918
+ childProcessOptions.detached = true;
1919
+ }
1920
+ }
1921
+ if (platform === "darwin" && appArguments.length > 0) {
1922
+ cliArguments.push("--args", ...appArguments);
1923
+ }
1924
+ if (options.target) {
1925
+ cliArguments.push(options.target);
1926
+ }
1927
+ const subprocess = childProcess.spawn(command, cliArguments, childProcessOptions);
1928
+ if (options.wait) {
1929
+ return new Promise((resolve, reject) => {
1930
+ subprocess.once("error", reject);
1931
+ subprocess.once("close", (exitCode) => {
1932
+ if (!options.allowNonzeroExitCode && exitCode > 0) {
1933
+ reject(new Error(`Exited with code ${exitCode}`));
1934
+ return;
1935
+ }
1936
+ resolve(subprocess);
1937
+ });
1938
+ });
1939
+ }
1940
+ subprocess.unref();
1941
+ return subprocess;
1942
+ };
1943
+ var open = (target, options) => {
1944
+ if (typeof target !== "string") {
1945
+ throw new TypeError("Expected a `target`");
1946
+ }
1947
+ return baseOpen({
1948
+ ...options,
1949
+ target
1950
+ });
1951
+ };
1952
+ function detectArchBinary(binary) {
1953
+ if (typeof binary === "string" || Array.isArray(binary)) {
1954
+ return binary;
1955
+ }
1956
+ const { [arch]: archBinary } = binary;
1957
+ if (!archBinary) {
1958
+ throw new Error(`${arch} is not supported`);
1959
+ }
1960
+ return archBinary;
1961
+ }
1962
+ function detectPlatformBinary({ [platform]: platformBinary }, { wsl }) {
1963
+ if (wsl && is_wsl_default) {
1964
+ return detectArchBinary(wsl);
1965
+ }
1966
+ if (!platformBinary) {
1967
+ throw new Error(`${platform} is not supported`);
1968
+ }
1969
+ return detectArchBinary(platformBinary);
1970
+ }
1971
+ var apps = {};
1972
+ defineLazyProperty(apps, "chrome", () => detectPlatformBinary({
1973
+ darwin: "google chrome",
1974
+ win32: "chrome",
1975
+ linux: ["google-chrome", "google-chrome-stable", "chromium"]
1976
+ }, {
1977
+ wsl: {
1978
+ ia32: "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
1979
+ x64: ["/mnt/c/Program Files/Google/Chrome/Application/chrome.exe", "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe"]
1980
+ }
1981
+ }));
1982
+ defineLazyProperty(apps, "brave", () => detectPlatformBinary({
1983
+ darwin: "brave browser",
1984
+ win32: "brave",
1985
+ linux: ["brave-browser", "brave"]
1986
+ }, {
1987
+ wsl: {
1988
+ ia32: "/mnt/c/Program Files (x86)/BraveSoftware/Brave-Browser/Application/brave.exe",
1989
+ x64: ["/mnt/c/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe", "/mnt/c/Program Files (x86)/BraveSoftware/Brave-Browser/Application/brave.exe"]
1990
+ }
1991
+ }));
1992
+ defineLazyProperty(apps, "firefox", () => detectPlatformBinary({
1993
+ darwin: "firefox",
1994
+ win32: String.raw`C:\Program Files\Mozilla Firefox\firefox.exe`,
1995
+ linux: "firefox"
1996
+ }, {
1997
+ wsl: "/mnt/c/Program Files/Mozilla Firefox/firefox.exe"
1998
+ }));
1999
+ defineLazyProperty(apps, "edge", () => detectPlatformBinary({
2000
+ darwin: "microsoft edge",
2001
+ win32: "msedge",
2002
+ linux: ["microsoft-edge", "microsoft-edge-dev"]
2003
+ }, {
2004
+ wsl: "/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"
2005
+ }));
2006
+ defineLazyProperty(apps, "browser", () => "browser");
2007
+ defineLazyProperty(apps, "browserPrivate", () => "browserPrivate");
2008
+ var open_default = open;
1106
2009
 
1107
2010
  // src/core/process/manager.ts
1108
2011
  import { EventEmitter } from "events";
1109
2012
  import spawn3 from "cross-spawn";
2013
+ var LOG_MESSAGE_LIMIT = 16384;
2014
+ var LOG_ENTRY_LIMIT = 1e3;
2015
+ function killChildProcessTree(child) {
2016
+ if (child.pid === void 0) {
2017
+ child.kill();
2018
+ return;
2019
+ }
2020
+ if (process.platform === "win32") {
2021
+ const result = spawn3.sync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
2022
+ stdio: "ignore",
2023
+ windowsHide: true
2024
+ });
2025
+ if (result.error) {
2026
+ child.kill();
2027
+ }
2028
+ return;
2029
+ }
2030
+ child.kill();
2031
+ }
1110
2032
  var ProcessManager = class extends EventEmitter {
1111
2033
  processes = /* @__PURE__ */ new Map();
1112
2034
  logs = [];
@@ -1114,7 +2036,7 @@ var ProcessManager = class extends EventEmitter {
1114
2036
  start(options) {
1115
2037
  const child = spawn3(options.command, options.args, {
1116
2038
  cwd: options.cwd,
1117
- shell: options.shell ?? false,
2039
+ shell: false,
1118
2040
  windowsHide: true
1119
2041
  });
1120
2042
  const pid = String(child.pid ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`);
@@ -1163,7 +2085,7 @@ var ProcessManager = class extends EventEmitter {
1163
2085
  }
1164
2086
  record.status = "stopped";
1165
2087
  record.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1166
- record.child.kill();
2088
+ killChildProcessTree(record.child);
1167
2089
  this.emitSystem(record, "Stopped by DevSurface");
1168
2090
  this.emit("process", this.snapshot(record));
1169
2091
  return true;
@@ -1179,7 +2101,7 @@ var ProcessManager = class extends EventEmitter {
1179
2101
  if (record.status === "running") {
1180
2102
  record.status = "stopped";
1181
2103
  record.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1182
- record.child.kill();
2104
+ killChildProcessTree(record.child);
1183
2105
  }
1184
2106
  }
1185
2107
  }
@@ -1197,16 +2119,17 @@ var ProcessManager = class extends EventEmitter {
1197
2119
  });
1198
2120
  }
1199
2121
  emitLog(record, stream, message) {
2122
+ const boundedMessage = message.length <= LOG_MESSAGE_LIMIT ? message : message.slice(-LOG_MESSAGE_LIMIT);
1200
2123
  const event = {
1201
2124
  pid: record.pid,
1202
2125
  script: record.script,
1203
2126
  stream,
1204
- message,
2127
+ message: boundedMessage,
1205
2128
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1206
2129
  };
1207
2130
  this.logs.push(event);
1208
- if (this.logs.length > 1e3) {
1209
- this.logs.splice(0, this.logs.length - 1e3);
2131
+ if (this.logs.length > LOG_ENTRY_LIMIT) {
2132
+ this.logs.splice(0, this.logs.length - LOG_ENTRY_LIMIT);
1210
2133
  }
1211
2134
  this.emit("log", event);
1212
2135
  }
@@ -1228,10 +2151,9 @@ var ProcessManager = class extends EventEmitter {
1228
2151
 
1229
2152
  // src/server/routes/api.ts
1230
2153
  import { constants as constants2, existsSync } from "fs";
1231
- import { promises as fs11 } from "fs";
1232
- import path11 from "path";
2154
+ import { promises as fs16 } from "fs";
2155
+ import path12 from "path";
1233
2156
  import spawn4 from "cross-spawn";
1234
- import open from "open";
1235
2157
 
1236
2158
  // src/server/localAccess.ts
1237
2159
  var LOCAL_HOSTNAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
@@ -1272,10 +2194,30 @@ function isSameOrigin(requestUrl, origin) {
1272
2194
  }
1273
2195
  }
1274
2196
 
2197
+ // src/server/mutationToken.ts
2198
+ import { randomBytes, timingSafeEqual } from "crypto";
2199
+ function createMutationToken() {
2200
+ return randomBytes(32).toString("hex");
2201
+ }
2202
+ function hasValidMutationToken(provided, expected) {
2203
+ if (typeof provided !== "string" || provided.length === 0) {
2204
+ return false;
2205
+ }
2206
+ if (provided.length !== expected.length) {
2207
+ return false;
2208
+ }
2209
+ return timingSafeEqual(Buffer.from(provided, "utf8"), Buffer.from(expected, "utf8"));
2210
+ }
2211
+
2212
+ // src/server/terminal.ts
2213
+ function isAllowedTerminalCommand(command) {
2214
+ return /^[A-Za-z0-9._+-]+$/.test(command);
2215
+ }
2216
+
1275
2217
  // src/server/routes/api.ts
1276
- function isWithinRoot4(root, target) {
1277
- const relative = path11.relative(path11.resolve(root), path11.resolve(target));
1278
- return relative === "" || !relative.startsWith("..") && !path11.isAbsolute(relative);
2218
+ function isWithinRoot7(root, target) {
2219
+ const relative = path12.relative(path12.resolve(root), path12.resolve(target));
2220
+ return relative === "" || !relative.startsWith("..") && !path12.isAbsolute(relative);
1279
2221
  }
1280
2222
  function isAllowedMutationOrigin(requestUrl, origin) {
1281
2223
  if (origin === null) {
@@ -1290,35 +2232,35 @@ function hasMutationIntent(intent) {
1290
2232
  return intent === "dashboard";
1291
2233
  }
1292
2234
  async function realPathWithinRoot(root, target) {
1293
- if (!isWithinRoot4(root, target)) {
2235
+ if (!isWithinRoot7(root, target)) {
1294
2236
  return false;
1295
2237
  }
1296
2238
  try {
1297
- const [realRoot, realTarget] = await Promise.all([fs11.realpath(root), fs11.realpath(target)]);
1298
- return isWithinRoot4(realRoot, realTarget);
2239
+ const [realRoot, realTarget] = await Promise.all([fs16.realpath(root), fs16.realpath(target)]);
2240
+ return isWithinRoot7(realRoot, realTarget);
1299
2241
  } catch {
1300
2242
  return false;
1301
2243
  }
1302
2244
  }
1303
2245
  async function writableDestinationWithinRoot(root, destination) {
1304
- if (!isWithinRoot4(root, destination)) {
2246
+ if (!isWithinRoot7(root, destination)) {
1305
2247
  return false;
1306
2248
  }
1307
2249
  try {
1308
2250
  const [realRoot, realParent] = await Promise.all([
1309
- fs11.realpath(root),
1310
- fs11.realpath(path11.dirname(destination))
2251
+ fs16.realpath(root),
2252
+ fs16.realpath(path12.dirname(destination))
1311
2253
  ]);
1312
- return isWithinRoot4(realRoot, realParent);
2254
+ return isWithinRoot7(realRoot, realParent);
1313
2255
  } catch {
1314
2256
  return false;
1315
2257
  }
1316
2258
  }
1317
2259
  async function copyFileExclusive(source, destination) {
1318
- const content = await fs11.readFile(source);
2260
+ const content = await fs16.readFile(source);
1319
2261
  let handle2 = null;
1320
2262
  try {
1321
- handle2 = await fs11.open(
2263
+ handle2 = await fs16.open(
1322
2264
  destination,
1323
2265
  constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY,
1324
2266
  384
@@ -1345,15 +2287,15 @@ function resolveCommandPromptExecutable() {
1345
2287
  return process.env.ComSpec ?? "cmd.exe";
1346
2288
  }
1347
2289
  function findExecutable(command) {
1348
- if (path11.isAbsolute(command)) {
2290
+ if (path12.isAbsolute(command)) {
1349
2291
  return existsSync(command) ? command : null;
1350
2292
  }
1351
2293
  const pathValue = process.env.PATH ?? "";
1352
- for (const directory of pathValue.split(path11.delimiter)) {
2294
+ for (const directory of pathValue.split(path12.delimiter)) {
1353
2295
  if (directory.length === 0) {
1354
2296
  continue;
1355
2297
  }
1356
- const candidate = path11.join(directory, command);
2298
+ const candidate = path12.join(directory, command);
1357
2299
  if (existsSync(candidate)) {
1358
2300
  return candidate;
1359
2301
  }
@@ -1394,8 +2336,8 @@ function openTerminalAt(root) {
1394
2336
  if (process.platform === "darwin") {
1395
2337
  return launchDetached("open", ["-a", "Terminal", root], root);
1396
2338
  }
1397
- const configuredTerminal = process.env.TERMINAL;
1398
- if (configuredTerminal !== void 0 && findExecutable(configuredTerminal) !== null) {
2339
+ const configuredTerminal = process.env.TERMINAL?.trim();
2340
+ if (configuredTerminal !== void 0 && configuredTerminal.length > 0 && isAllowedTerminalCommand(configuredTerminal) && findExecutable(configuredTerminal) !== null) {
1399
2341
  return launchDetached(configuredTerminal, [], root);
1400
2342
  }
1401
2343
  const linuxTerminals = [
@@ -1420,6 +2362,10 @@ function openTerminalAt(root) {
1420
2362
  return launchDetached(terminal.command, terminal.args, root);
1421
2363
  }
1422
2364
  function registerApiRoutes(app, options) {
2365
+ const dockerController = options.dockerController ?? new DockerComposeController(options.projectRoot);
2366
+ app.get("/api/session", (context) => {
2367
+ return context.json({ token: options.mutationToken });
2368
+ });
1423
2369
  app.use("/api/*", async (context, next) => {
1424
2370
  const host = context.req.header("host") ?? new URL(context.req.url).host;
1425
2371
  if (!isAllowedLocalHostHeader(host)) {
@@ -1429,7 +2375,8 @@ function registerApiRoutes(app, options) {
1429
2375
  const origin = context.req.header("origin") ?? null;
1430
2376
  const secFetchSite = context.req.header("sec-fetch-site") ?? null;
1431
2377
  const intent = context.req.header("x-devsurface-intent") ?? null;
1432
- if (!hasMutationIntent(intent) || isCrossSiteFetch(secFetchSite) || !isAllowedMutationOrigin(context.req.url, origin)) {
2378
+ const token = context.req.header("x-devsurface-token") ?? null;
2379
+ if (!hasMutationIntent(intent) || !hasValidMutationToken(token, options.mutationToken) || isCrossSiteFetch(secFetchSite) || !isAllowedMutationOrigin(context.req.url, origin)) {
1433
2380
  return context.json({ error: "Cross-origin mutation rejected." }, 403);
1434
2381
  }
1435
2382
  }
@@ -1444,6 +2391,60 @@ function registerApiRoutes(app, options) {
1444
2391
  app.get("/api/processes", (context) => {
1445
2392
  return context.json(options.processManager.list());
1446
2393
  });
2394
+ app.get("/api/logs", (context) => {
2395
+ return context.json(options.processManager.listLogs());
2396
+ });
2397
+ app.get("/api/docker/:service/logs", async (context) => {
2398
+ const service = decodeURIComponent(context.req.param("service"));
2399
+ try {
2400
+ return context.json(await dockerController.logs(service));
2401
+ } catch (error) {
2402
+ if (error instanceof DockerOperationError) {
2403
+ if (error.code === "compose-not-found" || error.code === "service-not-found") {
2404
+ return context.json({ error: error.message, code: error.code }, 404);
2405
+ }
2406
+ if (error.code === "docker-not-installed" || error.code === "docker-not-running") {
2407
+ return context.json({ error: error.message, code: error.code }, 503);
2408
+ }
2409
+ return context.json({ error: error.message, code: error.code }, 502);
2410
+ }
2411
+ throw error;
2412
+ }
2413
+ });
2414
+ app.post("/api/docker/:service/start", async (context) => {
2415
+ const service = decodeURIComponent(context.req.param("service"));
2416
+ try {
2417
+ return context.json(await dockerController.start(service));
2418
+ } catch (error) {
2419
+ if (error instanceof DockerOperationError) {
2420
+ if (error.code === "compose-not-found" || error.code === "service-not-found") {
2421
+ return context.json({ error: error.message, code: error.code }, 404);
2422
+ }
2423
+ if (error.code === "docker-not-installed" || error.code === "docker-not-running") {
2424
+ return context.json({ error: error.message, code: error.code }, 503);
2425
+ }
2426
+ return context.json({ error: error.message, code: error.code }, 502);
2427
+ }
2428
+ throw error;
2429
+ }
2430
+ });
2431
+ app.post("/api/docker/:service/stop", async (context) => {
2432
+ const service = decodeURIComponent(context.req.param("service"));
2433
+ try {
2434
+ return context.json(await dockerController.stop(service));
2435
+ } catch (error) {
2436
+ if (error instanceof DockerOperationError) {
2437
+ if (error.code === "compose-not-found" || error.code === "service-not-found") {
2438
+ return context.json({ error: error.message, code: error.code }, 404);
2439
+ }
2440
+ if (error.code === "docker-not-installed" || error.code === "docker-not-running") {
2441
+ return context.json({ error: error.message, code: error.code }, 503);
2442
+ }
2443
+ return context.json({ error: error.message, code: error.code }, 502);
2444
+ }
2445
+ throw error;
2446
+ }
2447
+ });
1447
2448
  app.post("/api/run/:script", async (context) => {
1448
2449
  const script = decodeURIComponent(context.req.param("script"));
1449
2450
  const scan = await scanProject(options.projectRoot);
@@ -1451,6 +2452,9 @@ function registerApiRoutes(app, options) {
1451
2452
  if (packageScript === void 0) {
1452
2453
  return context.json({ error: `Script "${script}" was not found.` }, 404);
1453
2454
  }
2455
+ if (isDangerousCommand(packageScript)) {
2456
+ return context.json({ error: "Refusing to run dangerous script." }, 403);
2457
+ }
1454
2458
  const command = await resolvePackageRunCommand({
1455
2459
  cwd: options.projectRoot,
1456
2460
  packageManager: scan.packageManager,
@@ -1496,13 +2500,24 @@ function registerApiRoutes(app, options) {
1496
2500
  if (configuredCommand === null) {
1497
2501
  return context.json({ error: `Configured command "${name}" was not found.` }, 404);
1498
2502
  }
2503
+ if (isDangerousCommand(configuredCommand)) {
2504
+ return context.json({ error: "Refusing to run dangerous command." }, 403);
2505
+ }
2506
+ const resolvedCommand = await resolveConfiguredCommand(options.projectRoot, configuredCommand);
2507
+ if (resolvedCommand === null) {
2508
+ return context.json(
2509
+ {
2510
+ error: "Configured command uses unsupported shell syntax. Use a simple executable with arguments, or move complex logic into a package.json script."
2511
+ },
2512
+ 400
2513
+ );
2514
+ }
1499
2515
  const processInfo = options.processManager.start({
1500
2516
  cwd: options.projectRoot,
1501
2517
  script: name,
1502
- command: configuredCommand,
1503
- args: [],
1504
- displayCommand: configuredCommand,
1505
- shell: true
2518
+ command: resolvedCommand.command,
2519
+ args: resolvedCommand.args,
2520
+ displayCommand: resolvedCommand.displayCommand
1506
2521
  });
1507
2522
  return context.json({
1508
2523
  ...processInfo,
@@ -1510,15 +2525,15 @@ function registerApiRoutes(app, options) {
1510
2525
  });
1511
2526
  });
1512
2527
  app.post("/api/open/folder", async (context) => {
1513
- await open(options.projectRoot);
2528
+ await open_default(options.projectRoot);
1514
2529
  return context.json({ opened: true, target: "folder" });
1515
2530
  });
1516
2531
  app.post("/api/open/package", async (context) => {
1517
- const packagePath = path11.join(options.projectRoot, "package.json");
2532
+ const packagePath = path12.join(options.projectRoot, "package.json");
1518
2533
  if (!await realPathWithinRoot(options.projectRoot, packagePath)) {
1519
2534
  return context.json({ error: "package.json was not found inside the project root." }, 404);
1520
2535
  }
1521
- await open(packagePath);
2536
+ await open_default(packagePath);
1522
2537
  return context.json({ opened: true, target: "package" });
1523
2538
  });
1524
2539
  app.post("/api/open/terminal", (context) => {
@@ -1532,7 +2547,7 @@ function registerApiRoutes(app, options) {
1532
2547
  if (examplePath === null) {
1533
2548
  return context.json({ error: ".env.example was not found." }, 404);
1534
2549
  }
1535
- const destination = localPath ?? path11.join(options.projectRoot, scan.config?.config.env?.local ?? ".env");
2550
+ const destination = localPath ?? path12.join(options.projectRoot, scan.config?.config.env?.local ?? ".env");
1536
2551
  if (!await realPathWithinRoot(options.projectRoot, examplePath) || !await writableDestinationWithinRoot(options.projectRoot, destination)) {
1537
2552
  return context.json({ error: "Refusing to copy env files outside the project root." }, 400);
1538
2553
  }
@@ -1613,37 +2628,104 @@ function assertLocalHost(host) {
1613
2628
  throw new Error("DevSurface must bind only to 127.0.0.1.");
1614
2629
  }
1615
2630
  }
1616
- async function fileExists2(filePath) {
2631
+ async function fileExists(filePath) {
1617
2632
  try {
1618
- await fs12.access(filePath);
2633
+ await fs17.access(filePath);
1619
2634
  return true;
1620
2635
  } catch {
1621
2636
  return false;
1622
2637
  }
1623
2638
  }
1624
2639
  async function findWebDistDir() {
1625
- const moduleDir = path12.dirname(fileURLToPath(import.meta.url));
2640
+ const moduleDir = path13.dirname(fileURLToPath2(import.meta.url));
1626
2641
  const candidates = [
1627
- path12.join(moduleDir, "..", "web", "dist"),
1628
- path12.join(moduleDir, "..", "..", "src", "web", "dist"),
1629
- path12.join(moduleDir, "web", "dist")
2642
+ path13.join(moduleDir, "..", "web", "dist"),
2643
+ path13.join(moduleDir, "..", "..", "src", "web", "dist"),
2644
+ path13.join(moduleDir, "web", "dist")
1630
2645
  ];
1631
2646
  for (const candidate of candidates) {
1632
- if (await fileExists2(path12.join(candidate, "index.html"))) {
2647
+ if (await fileExists(path13.join(candidate, "index.html"))) {
1633
2648
  return candidate;
1634
2649
  }
1635
2650
  }
1636
2651
  return null;
1637
2652
  }
2653
+ function toListenError(error, port) {
2654
+ const code = error instanceof Error ? error.code : void 0;
2655
+ if (code === "EADDRINUSE") {
2656
+ return new Error(
2657
+ `Port ${port} is already in use on ${HOST}. Stop the other process or run DevSurface with --port ${port + 1}.`,
2658
+ { cause: error }
2659
+ );
2660
+ }
2661
+ if (code === "EACCES") {
2662
+ return new Error(`DevSurface does not have permission to bind to ${HOST}:${port}.`, {
2663
+ cause: error
2664
+ });
2665
+ }
2666
+ return error instanceof Error ? error : new Error(String(error));
2667
+ }
2668
+ async function listenOnLocalHost(server, wss, port) {
2669
+ await new Promise((resolve, reject) => {
2670
+ let settled = false;
2671
+ const cleanup = () => {
2672
+ server.off("error", onError);
2673
+ server.off("listening", onListening);
2674
+ wss.off("error", onError);
2675
+ };
2676
+ const onError = (error) => {
2677
+ if (settled) {
2678
+ return;
2679
+ }
2680
+ settled = true;
2681
+ cleanup();
2682
+ reject(toListenError(error, port));
2683
+ };
2684
+ const onListening = () => {
2685
+ if (settled) {
2686
+ return;
2687
+ }
2688
+ settled = true;
2689
+ cleanup();
2690
+ resolve();
2691
+ };
2692
+ wss.once("error", onError);
2693
+ server.once("error", onError);
2694
+ server.once("listening", onListening);
2695
+ server.listen(port, HOST);
2696
+ });
2697
+ }
2698
+ async function closeWebSocketServer(wss) {
2699
+ await new Promise((resolve) => {
2700
+ wss.close(() => resolve());
2701
+ });
2702
+ }
2703
+ async function closeHttpServer(server) {
2704
+ if (!server.listening) {
2705
+ return;
2706
+ }
2707
+ await new Promise((resolve, reject) => {
2708
+ server.close((error) => {
2709
+ if (error) {
2710
+ reject(error);
2711
+ } else {
2712
+ resolve();
2713
+ }
2714
+ });
2715
+ });
2716
+ }
1638
2717
  async function createApp(options) {
1639
2718
  const app = new Hono();
1640
- registerApiRoutes(app, options);
2719
+ registerApiRoutes(app, {
2720
+ ...options,
2721
+ mutationToken: options.mutationToken ?? createMutationToken()
2722
+ });
1641
2723
  const webDistDir = await findWebDistDir();
1642
2724
  if (webDistDir !== null) {
1643
2725
  app.use("/assets/*", serveStatic({ root: webDistDir }));
1644
2726
  app.get("/favicon.svg", serveStatic({ root: webDistDir }));
1645
2727
  app.get("*", async (context) => {
1646
- const html = await fs12.readFile(path12.join(webDistDir, "index.html"), "utf8");
2728
+ const html = await fs17.readFile(path13.join(webDistDir, "index.html"), "utf8");
1647
2729
  return context.html(html);
1648
2730
  });
1649
2731
  } else {
@@ -1666,15 +2748,16 @@ async function startDevSurfaceServer(options) {
1666
2748
  projectRoot: options.projectRoot,
1667
2749
  processManager
1668
2750
  });
1669
- const server = serve({
2751
+ const server = createAdaptorServer({
1670
2752
  fetch: app.fetch,
1671
- port,
1672
2753
  hostname: HOST
1673
2754
  });
1674
2755
  const wss = setupWebSocket(server, processManager);
2756
+ await listenOnLocalHost(server, wss, port);
2757
+ processManager.attachCleanupHandlers();
1675
2758
  const url = `http://${HOST}:${port}`;
1676
2759
  if (options.openBrowser !== false) {
1677
- await open2(url);
2760
+ await open_default(url);
1678
2761
  }
1679
2762
  return {
1680
2763
  url,
@@ -1682,26 +2765,19 @@ async function startDevSurfaceServer(options) {
1682
2765
  processManager,
1683
2766
  close: async () => {
1684
2767
  processManager.killAll();
1685
- await new Promise((resolve) => {
1686
- wss.close(() => resolve());
1687
- });
1688
- await new Promise((resolve, reject) => {
1689
- server.close((error) => {
1690
- if (error) {
1691
- reject(error);
1692
- } else {
1693
- resolve();
1694
- }
1695
- });
1696
- });
2768
+ await closeWebSocketServer(wss);
2769
+ await closeHttpServer(server);
1697
2770
  }
1698
2771
  };
1699
2772
  }
1700
2773
 
2774
+ // src/version.ts
2775
+ var DEV_SURFACE_VERSION = "0.3.0";
2776
+
1701
2777
  // src/cli/commands/start.ts
1702
2778
  async function startCommand(options) {
1703
2779
  const cwd = options.cwd ?? process.cwd();
1704
- console.log(pc5.bold(`DevSurface v0.1.0`));
2780
+ console.log(pc5.bold(`DevSurface v${DEV_SURFACE_VERSION}`));
1705
2781
  console.log("Scanning project...\n");
1706
2782
  const scan = await scanProject(cwd);
1707
2783
  printScanResult(scan);
@@ -1738,7 +2814,7 @@ function handle(command) {
1738
2814
  process.exitCode = 1;
1739
2815
  });
1740
2816
  }
1741
- program.name("devsurface").description("Turn any Node.js repository into a local developer control panel.").version("0.1.0").option("-p, --port <port>", "dashboard port", toPort, 4567).option("--no-open", "do not open the browser automatically").action((options) => {
2817
+ 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) => {
1742
2818
  handle(
1743
2819
  startCommand({
1744
2820
  cwd: process.cwd(),