devsurface 0.1.0 → 0.2.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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - Added retained process logs through `GET /api/logs` so the dashboard can recover session output without relying only on WebSocket state.
6
+ - Added dashboard keyboard shortcuts for refresh, section navigation, settings, sidebar collapse, and drawer close.
7
+ - Added exit-code-aware process status labels in the dashboard.
8
+ - Kept dashboard settings in memory to avoid browser storage.
9
+ - Documented `bunx devsurface` as a no-install launch command.
10
+
3
11
  ## 0.1.0
4
12
 
5
13
  - Initial DevSurface MVP scaffold.
package/README.md CHANGED
@@ -1,8 +1,34 @@
1
- # DevSurface
1
+ <!-- markdownlint-disable MD033 MD041 -->
2
2
 
3
- [![DevSurface ready](docs/devsurface-badge.svg)](https://github.com/mrfandu1/devsurface)
3
+ <a id="readme-top"></a>
4
4
 
5
- Turn any Node.js repository into a local developer control panel.
5
+ <div align="center">
6
+
7
+ <h1>DevSurface</h1>
8
+
9
+ <p><strong>Turn any Node.js repository into a local developer control panel.</strong></p>
10
+
11
+ <p>
12
+ <a href="#quick-start">Quick Start</a>
13
+ &nbsp;&middot;&nbsp;
14
+ <a href="#commands">Commands</a>
15
+ &nbsp;&middot;&nbsp;
16
+ <a href="#dashboard">Dashboard</a>
17
+ &nbsp;&middot;&nbsp;
18
+ <a href="https://github.com/mrfandu1/devsurface/issues">Report an issue</a>
19
+ </p>
20
+
21
+ <p>
22
+ <a href="https://github.com/mrfandu1/devsurface">
23
+ <img alt="DevSurface ready" src="docs/devsurface-badge.svg">
24
+ </a>
25
+ <a href="LICENSE">
26
+ <img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-informational">
27
+ </a>
28
+ <img alt="Built with TypeScript" src="https://img.shields.io/badge/Built%20with-TypeScript-3178c6">
29
+ </p>
30
+
31
+ </div>
6
32
 
7
33
  DevSurface scans a project, starts a local dashboard, and shows the things contributors
8
34
  usually need before a project will run: package scripts, environment files, ports,
@@ -14,6 +40,12 @@ No config file is required.
14
40
  npx devsurface
15
41
  ```
16
42
 
43
+ With Bun:
44
+
45
+ ```bash
46
+ bunx devsurface
47
+ ```
48
+
17
49
  ![DevSurface demo](docs/devsurface-demo.gif)
18
50
 
19
51
  ## Why DevSurface
@@ -52,6 +84,13 @@ cd my-node-project
52
84
  npx devsurface
53
85
  ```
54
86
 
87
+ Or, if you use Bun:
88
+
89
+ ```bash
90
+ cd my-node-project
91
+ bunx devsurface
92
+ ```
93
+
55
94
  The dashboard opens at:
56
95
 
57
96
  ```text
@@ -63,6 +102,13 @@ terminal.
63
102
 
64
103
  ## Commands
65
104
 
105
+ Run DevSurface without installing it globally:
106
+
107
+ | Runtime | Command |
108
+ | ------- | ----------------- |
109
+ | npm | `npx devsurface` |
110
+ | Bun | `bunx devsurface` |
111
+
66
112
  | Command | Description |
67
113
  | ------------------------- | -------------------------------------------------------------------- |
68
114
  | `devsurface` | Scan the current project, start the dashboard, and open the browser. |
@@ -205,18 +251,21 @@ npm test
205
251
  npm run build
206
252
  ```
207
253
 
208
- ## Publishing
254
+ ## Contributing
209
255
 
210
- The npm package is allowlisted through `package.json#files`. The package includes the
211
- built CLI, built web UI, README, demo GIF, license, and changelog. Private notes,
212
- tests, examples, and development-only files are excluded from npm publishes.
256
+ Contributions of every kind are welcome: code, documentation, bug reports,
257
+ examples, and reviews. Start with [CONTRIBUTING.md](CONTRIBUTING.md) for the
258
+ development workflow.
213
259
 
214
- Check package contents before publishing:
260
+ ## License
215
261
 
216
- ```bash
217
- npm pack --dry-run
218
- ```
262
+ DevSurface is released under the MIT License. See [LICENSE](LICENSE) for the full
263
+ text. Copyright (c) 2026 DevSurface contributors.
219
264
 
220
- ## License
265
+ ## Contact and community
266
+
267
+ - GitHub Issues: report bugs and request features through
268
+ [GitHub Issues](https://github.com/mrfandu1/devsurface/issues).
269
+ - Security: report vulnerabilities through [SECURITY.md](SECURITY.md).
221
270
 
222
- MIT. See [LICENSE](LICENSE).
271
+ [(back to top)](#readme-top)
package/SECURITY.md ADDED
@@ -0,0 +1,9 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ Please report security issues privately by opening a GitHub security advisory or by
6
+ emailing the maintainer listed in the repository profile.
7
+
8
+ DevSurface runs local commands from the project it scans. It never binds outside
9
+ `127.0.0.1`, never sends telemetry, and never exposes `.env` values.
package/dist/cli/index.js CHANGED
@@ -48,6 +48,7 @@ var defaultConfig = {
48
48
  };
49
49
 
50
50
  // src/core/config/load.ts
51
+ var MAX_CONFIGURED_PORTS = 32;
51
52
  function isRecord(value) {
52
53
  return typeof value === "object" && value !== null && !Array.isArray(value);
53
54
  }
@@ -101,7 +102,10 @@ function toPorts(value, warnings) {
101
102
  if (ports.length !== value.length) {
102
103
  warnings.push("ports may only contain integers between 1 and 65535.");
103
104
  }
104
- return ports;
105
+ if (ports.length > MAX_CONFIGURED_PORTS) {
106
+ warnings.push(`ports may contain at most ${MAX_CONFIGURED_PORTS} entries.`);
107
+ }
108
+ return ports.slice(0, MAX_CONFIGURED_PORTS);
105
109
  }
106
110
  function validateConfig(raw) {
107
111
  const warnings = [];
@@ -609,12 +613,23 @@ async function detectPackageManager(root) {
609
613
  // src/core/scanner/packageJson.ts
610
614
  import { promises as fs7 } from "fs";
611
615
  import path7 from "path";
616
+ function isWithinRoot4(root, target) {
617
+ const relative = path7.relative(root, target);
618
+ return relative === "" || !relative.startsWith("..") && !path7.isAbsolute(relative);
619
+ }
612
620
  async function readPackageJson(root) {
613
621
  const packageJsonPath = path7.join(root, "package.json");
614
622
  try {
615
- const content = await fs7.readFile(packageJsonPath, "utf8");
623
+ const [realRoot, realPackageJsonPath] = await Promise.all([
624
+ fs7.realpath(root),
625
+ fs7.realpath(packageJsonPath)
626
+ ]);
627
+ if (!isWithinRoot4(realRoot, realPackageJsonPath)) {
628
+ return null;
629
+ }
630
+ const content = await fs7.readFile(realPackageJsonPath, "utf8");
616
631
  const data = JSON.parse(content);
617
- return { path: packageJsonPath, data };
632
+ return { path: realPackageJsonPath, data };
618
633
  } catch {
619
634
  return null;
620
635
  }
@@ -622,6 +637,8 @@ async function readPackageJson(root) {
622
637
 
623
638
  // src/core/scanner/ports.ts
624
639
  import net from "net";
640
+ var MAX_PORT_PROBES = 64;
641
+ var PORT_PROBE_CONCURRENCY = 16;
625
642
  function uniquePorts(ports) {
626
643
  return Array.from(
627
644
  new Set(ports.filter((port) => Number.isInteger(port) && port > 0 && port < 65536))
@@ -675,11 +692,25 @@ async function probePort(port) {
675
692
  });
676
693
  }
677
694
  async function detectPorts(ports) {
678
- const normalized = uniquePorts(ports);
695
+ const normalized = uniquePorts(ports).slice(0, MAX_PORT_PROBES);
679
696
  if (normalized.length === 0) {
680
697
  return null;
681
698
  }
682
- return await Promise.all(normalized.map((port) => probePort(port)));
699
+ const results = [];
700
+ let nextIndex = 0;
701
+ async function worker() {
702
+ while (nextIndex < normalized.length) {
703
+ const port = normalized[nextIndex];
704
+ nextIndex += 1;
705
+ results.push(await probePort(port));
706
+ }
707
+ }
708
+ await Promise.all(
709
+ Array.from({ length: Math.min(PORT_PROBE_CONCURRENCY, normalized.length) }, () => worker())
710
+ );
711
+ return results.sort(
712
+ (left, right) => normalized.indexOf(left.port) - normalized.indexOf(right.port)
713
+ );
683
714
  }
684
715
 
685
716
  // src/core/scanner/scripts.ts
@@ -917,6 +948,31 @@ async function runDoctor(root = process.cwd(), scan) {
917
948
  return warnings;
918
949
  }
919
950
 
951
+ // src/cli/terminal.ts
952
+ var ESC = String.fromCharCode(27);
953
+ var BEL = String.fromCharCode(7);
954
+ var OSC_SEQUENCE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, "g");
955
+ var CSI_SEQUENCE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, "g");
956
+ var ESCAPE_SEQUENCE = new RegExp(`${ESC}[@-Z\\\\-_]`, "g");
957
+ function stripControlCharacters(value) {
958
+ let result = "";
959
+ for (const character of value) {
960
+ const code = character.charCodeAt(0);
961
+ if (code > 31 && code < 127 || code > 159) {
962
+ result += character;
963
+ }
964
+ }
965
+ return result;
966
+ }
967
+ function safeTerminalText(value) {
968
+ return stripControlCharacters(
969
+ String(value).replace(OSC_SEQUENCE, "").replace(CSI_SEQUENCE, "").replace(ESCAPE_SEQUENCE, "")
970
+ );
971
+ }
972
+ function safeTerminalList(values) {
973
+ return values.length > 0 ? values.map((value) => safeTerminalText(value)).join(", ") : "none";
974
+ }
975
+
920
976
  // src/cli/commands/doctor.ts
921
977
  function colorSeverity(severity) {
922
978
  if (severity === "error") {
@@ -934,8 +990,8 @@ async function doctorCommand(cwd = process.cwd()) {
934
990
  return;
935
991
  }
936
992
  for (const item of warnings) {
937
- console.log(`${colorSeverity(item.severity)} ${pc.bold(item.title)}`);
938
- console.log(` ${item.message}`);
993
+ console.log(`${colorSeverity(item.severity)} ${pc.bold(safeTerminalText(item.title))}`);
994
+ console.log(` ${safeTerminalText(item.message)}`);
939
995
  }
940
996
  }
941
997
 
@@ -1065,14 +1121,14 @@ async function runCommand(script, cwd = process.cwd()) {
1065
1121
  // src/cli/commands/scan.ts
1066
1122
  import pc4 from "picocolors";
1067
1123
  function formatList(values) {
1068
- return values.length > 0 ? values.join(", ") : "none";
1124
+ return safeTerminalList(values);
1069
1125
  }
1070
1126
  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"}`);
1127
+ console.log(pc4.bold(`Project: ${safeTerminalText(scan.projectName)}`));
1128
+ console.log(`Type: ${safeTerminalText(scan.framework?.type ?? "Unknown")}`);
1129
+ console.log(`Manager: ${safeTerminalText(scan.packageManager ?? "unknown")}`);
1074
1130
  console.log(`Scripts: ${formatList(Object.keys(scan.scripts))}`);
1075
- console.log(`Git: ${scan.git?.branch ?? "not detected"}`);
1131
+ console.log(`Git: ${safeTerminalText(scan.git?.branch ?? "not detected")}`);
1076
1132
  console.log(`README: ${scan.readme.exists ? "found" : "missing"}`);
1077
1133
  console.log(`LICENSE: ${scan.license.exists ? "found" : "missing"}`);
1078
1134
  if (scan.env !== null) {
@@ -1099,7 +1155,7 @@ import pc5 from "picocolors";
1099
1155
  import { promises as fs12 } from "fs";
1100
1156
  import path12 from "path";
1101
1157
  import { fileURLToPath } from "url";
1102
- import { serve } from "@hono/node-server";
1158
+ import { createAdaptorServer } from "@hono/node-server";
1103
1159
  import { serveStatic } from "@hono/node-server/serve-static";
1104
1160
  import { Hono } from "hono";
1105
1161
  import open2 from "open";
@@ -1107,6 +1163,23 @@ import open2 from "open";
1107
1163
  // src/core/process/manager.ts
1108
1164
  import { EventEmitter } from "events";
1109
1165
  import spawn3 from "cross-spawn";
1166
+ function killChildProcessTree(child) {
1167
+ if (child.pid === void 0) {
1168
+ child.kill();
1169
+ return;
1170
+ }
1171
+ if (process.platform === "win32") {
1172
+ const result = spawn3.sync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
1173
+ stdio: "ignore",
1174
+ windowsHide: true
1175
+ });
1176
+ if (result.error) {
1177
+ child.kill();
1178
+ }
1179
+ return;
1180
+ }
1181
+ child.kill();
1182
+ }
1110
1183
  var ProcessManager = class extends EventEmitter {
1111
1184
  processes = /* @__PURE__ */ new Map();
1112
1185
  logs = [];
@@ -1163,7 +1236,7 @@ var ProcessManager = class extends EventEmitter {
1163
1236
  }
1164
1237
  record.status = "stopped";
1165
1238
  record.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1166
- record.child.kill();
1239
+ killChildProcessTree(record.child);
1167
1240
  this.emitSystem(record, "Stopped by DevSurface");
1168
1241
  this.emit("process", this.snapshot(record));
1169
1242
  return true;
@@ -1179,7 +1252,7 @@ var ProcessManager = class extends EventEmitter {
1179
1252
  if (record.status === "running") {
1180
1253
  record.status = "stopped";
1181
1254
  record.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1182
- record.child.kill();
1255
+ killChildProcessTree(record.child);
1183
1256
  }
1184
1257
  }
1185
1258
  }
@@ -1273,7 +1346,7 @@ function isSameOrigin(requestUrl, origin) {
1273
1346
  }
1274
1347
 
1275
1348
  // src/server/routes/api.ts
1276
- function isWithinRoot4(root, target) {
1349
+ function isWithinRoot5(root, target) {
1277
1350
  const relative = path11.relative(path11.resolve(root), path11.resolve(target));
1278
1351
  return relative === "" || !relative.startsWith("..") && !path11.isAbsolute(relative);
1279
1352
  }
@@ -1290,18 +1363,18 @@ function hasMutationIntent(intent) {
1290
1363
  return intent === "dashboard";
1291
1364
  }
1292
1365
  async function realPathWithinRoot(root, target) {
1293
- if (!isWithinRoot4(root, target)) {
1366
+ if (!isWithinRoot5(root, target)) {
1294
1367
  return false;
1295
1368
  }
1296
1369
  try {
1297
1370
  const [realRoot, realTarget] = await Promise.all([fs11.realpath(root), fs11.realpath(target)]);
1298
- return isWithinRoot4(realRoot, realTarget);
1371
+ return isWithinRoot5(realRoot, realTarget);
1299
1372
  } catch {
1300
1373
  return false;
1301
1374
  }
1302
1375
  }
1303
1376
  async function writableDestinationWithinRoot(root, destination) {
1304
- if (!isWithinRoot4(root, destination)) {
1377
+ if (!isWithinRoot5(root, destination)) {
1305
1378
  return false;
1306
1379
  }
1307
1380
  try {
@@ -1309,7 +1382,7 @@ async function writableDestinationWithinRoot(root, destination) {
1309
1382
  fs11.realpath(root),
1310
1383
  fs11.realpath(path11.dirname(destination))
1311
1384
  ]);
1312
- return isWithinRoot4(realRoot, realParent);
1385
+ return isWithinRoot5(realRoot, realParent);
1313
1386
  } catch {
1314
1387
  return false;
1315
1388
  }
@@ -1444,6 +1517,9 @@ function registerApiRoutes(app, options) {
1444
1517
  app.get("/api/processes", (context) => {
1445
1518
  return context.json(options.processManager.list());
1446
1519
  });
1520
+ app.get("/api/logs", (context) => {
1521
+ return context.json(options.processManager.listLogs());
1522
+ });
1447
1523
  app.post("/api/run/:script", async (context) => {
1448
1524
  const script = decodeURIComponent(context.req.param("script"));
1449
1525
  const scan = await scanProject(options.projectRoot);
@@ -1635,6 +1711,70 @@ async function findWebDistDir() {
1635
1711
  }
1636
1712
  return null;
1637
1713
  }
1714
+ function toListenError(error, port) {
1715
+ const code = error instanceof Error ? error.code : void 0;
1716
+ if (code === "EADDRINUSE") {
1717
+ return new Error(
1718
+ `Port ${port} is already in use on ${HOST}. Stop the other process or run DevSurface with --port ${port + 1}.`,
1719
+ { cause: error }
1720
+ );
1721
+ }
1722
+ if (code === "EACCES") {
1723
+ return new Error(`DevSurface does not have permission to bind to ${HOST}:${port}.`, {
1724
+ cause: error
1725
+ });
1726
+ }
1727
+ return error instanceof Error ? error : new Error(String(error));
1728
+ }
1729
+ async function listenOnLocalHost(server, wss, port) {
1730
+ await new Promise((resolve, reject) => {
1731
+ let settled = false;
1732
+ const cleanup = () => {
1733
+ server.off("error", onError);
1734
+ server.off("listening", onListening);
1735
+ wss.off("error", onError);
1736
+ };
1737
+ const onError = (error) => {
1738
+ if (settled) {
1739
+ return;
1740
+ }
1741
+ settled = true;
1742
+ cleanup();
1743
+ reject(toListenError(error, port));
1744
+ };
1745
+ const onListening = () => {
1746
+ if (settled) {
1747
+ return;
1748
+ }
1749
+ settled = true;
1750
+ cleanup();
1751
+ resolve();
1752
+ };
1753
+ wss.once("error", onError);
1754
+ server.once("error", onError);
1755
+ server.once("listening", onListening);
1756
+ server.listen(port, HOST);
1757
+ });
1758
+ }
1759
+ async function closeWebSocketServer(wss) {
1760
+ await new Promise((resolve) => {
1761
+ wss.close(() => resolve());
1762
+ });
1763
+ }
1764
+ async function closeHttpServer(server) {
1765
+ if (!server.listening) {
1766
+ return;
1767
+ }
1768
+ await new Promise((resolve, reject) => {
1769
+ server.close((error) => {
1770
+ if (error) {
1771
+ reject(error);
1772
+ } else {
1773
+ resolve();
1774
+ }
1775
+ });
1776
+ });
1777
+ }
1638
1778
  async function createApp(options) {
1639
1779
  const app = new Hono();
1640
1780
  registerApiRoutes(app, options);
@@ -1666,12 +1806,13 @@ async function startDevSurfaceServer(options) {
1666
1806
  projectRoot: options.projectRoot,
1667
1807
  processManager
1668
1808
  });
1669
- const server = serve({
1809
+ const server = createAdaptorServer({
1670
1810
  fetch: app.fetch,
1671
- port,
1672
1811
  hostname: HOST
1673
1812
  });
1674
1813
  const wss = setupWebSocket(server, processManager);
1814
+ await listenOnLocalHost(server, wss, port);
1815
+ processManager.attachCleanupHandlers();
1675
1816
  const url = `http://${HOST}:${port}`;
1676
1817
  if (options.openBrowser !== false) {
1677
1818
  await open2(url);
@@ -1682,18 +1823,8 @@ async function startDevSurfaceServer(options) {
1682
1823
  processManager,
1683
1824
  close: async () => {
1684
1825
  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
- });
1826
+ await closeWebSocketServer(wss);
1827
+ await closeHttpServer(server);
1697
1828
  }
1698
1829
  };
1699
1830
  }
@@ -1701,7 +1832,7 @@ async function startDevSurfaceServer(options) {
1701
1832
  // src/cli/commands/start.ts
1702
1833
  async function startCommand(options) {
1703
1834
  const cwd = options.cwd ?? process.cwd();
1704
- console.log(pc5.bold(`DevSurface v0.1.0`));
1835
+ console.log(pc5.bold(`DevSurface v0.2.0`));
1705
1836
  console.log("Scanning project...\n");
1706
1837
  const scan = await scanProject(cwd);
1707
1838
  printScanResult(scan);
@@ -1738,7 +1869,7 @@ function handle(command) {
1738
1869
  process.exitCode = 1;
1739
1870
  });
1740
1871
  }
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) => {
1872
+ program.name("devsurface").description("Turn any Node.js repository into a local developer control panel.").version("0.2.0").option("-p, --port <port>", "dashboard port", toPort, 4567).option("--no-open", "do not open the browser automatically").action((options) => {
1742
1873
  handle(
1743
1874
  startCommand({
1744
1875
  cwd: process.cwd(),