devsurface 0.3.0 → 0.5.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
@@ -7,8 +7,8 @@ import { Command } from "commander";
7
7
  import pc from "picocolors";
8
8
 
9
9
  // src/core/doctor/index.ts
10
- import { promises as fs9 } from "fs";
11
- import path9 from "path";
10
+ import { promises as fs11 } from "fs";
11
+ import path11 from "path";
12
12
 
13
13
  // src/core/documentation.ts
14
14
  function extractScriptReferences(content) {
@@ -32,8 +32,8 @@ function extractScriptReferences(content) {
32
32
  }
33
33
 
34
34
  // src/core/scanner/index.ts
35
- import { promises as fs8 } from "fs";
36
- import path8 from "path";
35
+ import { promises as fs10 } from "fs";
36
+ import path10 from "path";
37
37
 
38
38
  // src/core/config/load.ts
39
39
  import { promises as fs } from "fs";
@@ -836,9 +836,64 @@ async function detectGit(root) {
836
836
  }
837
837
  }
838
838
 
839
- // src/core/scanner/packageManager.ts
839
+ // src/core/scanner/language.ts
840
840
  import { promises as fs6 } from "fs";
841
841
  import path6 from "path";
842
+ var languageFiles = [
843
+ { language: "python", candidates: ["requirements.txt", "pyproject.toml", "Pipfile"] },
844
+ { language: "go", candidates: ["go.mod"] },
845
+ { language: "java", candidates: ["pom.xml", "build.gradle", "build.gradle.kts"] }
846
+ ];
847
+ function isWithinRoot5(root, target) {
848
+ const relative = path6.relative(path6.resolve(root), path6.resolve(target));
849
+ return relative === "" || !relative.startsWith("..") && !path6.isAbsolute(relative);
850
+ }
851
+ async function safeFile(root, candidate) {
852
+ const filePath = path6.join(root, candidate);
853
+ try {
854
+ const [realRoot, stat, realPath] = await Promise.all([
855
+ fs6.realpath(root),
856
+ fs6.stat(filePath),
857
+ fs6.realpath(filePath)
858
+ ]);
859
+ if (stat.isFile() && isWithinRoot5(realRoot, realPath)) {
860
+ return realPath;
861
+ }
862
+ } catch {
863
+ return null;
864
+ }
865
+ return null;
866
+ }
867
+ async function detectProjectLanguage(root, packageJson) {
868
+ const detected = [];
869
+ const files = [];
870
+ if (packageJson !== null) {
871
+ detected.push("node");
872
+ files.push(packageJson.path);
873
+ }
874
+ for (const definition of languageFiles) {
875
+ let found = false;
876
+ for (const candidate of definition.candidates) {
877
+ const file = await safeFile(root, candidate);
878
+ if (file !== null) {
879
+ found = true;
880
+ files.push(file);
881
+ }
882
+ }
883
+ if (found) {
884
+ detected.push(definition.language);
885
+ }
886
+ }
887
+ return {
888
+ primary: detected[0] ?? null,
889
+ detected,
890
+ files
891
+ };
892
+ }
893
+
894
+ // src/core/scanner/packageManager.ts
895
+ import { promises as fs7 } from "fs";
896
+ import path7 from "path";
842
897
  var lockFiles = [
843
898
  { file: "pnpm-lock.yaml", manager: "pnpm" },
844
899
  { file: "yarn.lock", manager: "yarn" },
@@ -848,7 +903,7 @@ var lockFiles = [
848
903
  ];
849
904
  async function exists(filePath) {
850
905
  try {
851
- await fs6.access(filePath);
906
+ await fs7.access(filePath);
852
907
  return true;
853
908
  } catch {
854
909
  return false;
@@ -856,7 +911,7 @@ async function exists(filePath) {
856
911
  }
857
912
  async function detectPackageManager(root) {
858
913
  for (const lockFile of lockFiles) {
859
- if (await exists(path6.join(root, lockFile.file))) {
914
+ if (await exists(path7.join(root, lockFile.file))) {
860
915
  return lockFile.manager;
861
916
  }
862
917
  }
@@ -864,23 +919,23 @@ async function detectPackageManager(root) {
864
919
  }
865
920
 
866
921
  // src/core/scanner/packageJson.ts
867
- import { promises as fs7 } from "fs";
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);
922
+ import { promises as fs8 } from "fs";
923
+ import path8 from "path";
924
+ function isWithinRoot6(root, target) {
925
+ const relative = path8.relative(root, target);
926
+ return relative === "" || !relative.startsWith("..") && !path8.isAbsolute(relative);
872
927
  }
873
928
  async function readPackageJson(root) {
874
- const packageJsonPath = path7.join(root, "package.json");
929
+ const packageJsonPath = path8.join(root, "package.json");
875
930
  try {
876
931
  const [realRoot, realPackageJsonPath] = await Promise.all([
877
- fs7.realpath(root),
878
- fs7.realpath(packageJsonPath)
932
+ fs8.realpath(root),
933
+ fs8.realpath(packageJsonPath)
879
934
  ]);
880
- if (!isWithinRoot5(realRoot, realPackageJsonPath)) {
935
+ if (!isWithinRoot6(realRoot, realPackageJsonPath)) {
881
936
  return null;
882
937
  }
883
- const content = await fs7.readFile(realPackageJsonPath, "utf8");
938
+ const content = await fs8.readFile(realPackageJsonPath, "utf8");
884
939
  const data = JSON.parse(content);
885
940
  return { path: realPackageJsonPath, data };
886
941
  } catch {
@@ -966,6 +1021,235 @@ async function detectPorts(ports) {
966
1021
  );
967
1022
  }
968
1023
 
1024
+ // src/core/scanner/presets.ts
1025
+ import { promises as fs9 } from "fs";
1026
+ import path9 from "path";
1027
+ function dependencyNames(packageJson) {
1028
+ const data = packageJson?.data;
1029
+ return new Set(
1030
+ Object.keys({
1031
+ ...data?.dependencies,
1032
+ ...data?.devDependencies,
1033
+ ...data?.optionalDependencies,
1034
+ ...data?.peerDependencies
1035
+ })
1036
+ );
1037
+ }
1038
+ function hasAnyDependency(dependencies, names) {
1039
+ return names.some((name) => dependencies.has(name));
1040
+ }
1041
+ async function readIfPresent2(root, candidate) {
1042
+ const filePath = path9.join(root, candidate);
1043
+ try {
1044
+ const [realRoot, realPath] = await Promise.all([fs9.realpath(root), fs9.realpath(filePath)]);
1045
+ const relative = path9.relative(realRoot, realPath);
1046
+ if (relative.startsWith("..") || path9.isAbsolute(relative)) {
1047
+ return null;
1048
+ }
1049
+ return await fs9.readFile(realPath, "utf8");
1050
+ } catch {
1051
+ return null;
1052
+ }
1053
+ }
1054
+ function completePreset(draft) {
1055
+ return {
1056
+ name: draft.name,
1057
+ label: draft.label,
1058
+ commands: draft.commands ?? {},
1059
+ groups: draft.groups ?? {},
1060
+ ports: draft.ports ?? []
1061
+ };
1062
+ }
1063
+ function nodePresets(framework, packageJson) {
1064
+ const detected = new Set(framework?.detected ?? []);
1065
+ const dependencies = dependencyNames(packageJson);
1066
+ const presets = [];
1067
+ if (detected.has("Next.js")) {
1068
+ presets.push({
1069
+ name: "next",
1070
+ label: "Next.js",
1071
+ commands: {
1072
+ "next:dev": "next dev",
1073
+ "next:build": "next build",
1074
+ "next:start": "next start"
1075
+ },
1076
+ groups: {
1077
+ "Next.js": ["next:dev", "next:build", "next:start"]
1078
+ },
1079
+ ports: [3e3]
1080
+ });
1081
+ }
1082
+ if (detected.has("Vite")) {
1083
+ presets.push({
1084
+ name: "vite",
1085
+ label: "Vite",
1086
+ commands: {
1087
+ "vite:dev": "vite --host 127.0.0.1",
1088
+ "vite:build": "vite build",
1089
+ "vite:preview": "vite preview --host 127.0.0.1"
1090
+ },
1091
+ groups: {
1092
+ Vite: ["vite:dev", "vite:build", "vite:preview"]
1093
+ },
1094
+ ports: [5173, 4173]
1095
+ });
1096
+ }
1097
+ if (detected.has("NestJS")) {
1098
+ presets.push({
1099
+ name: "nestjs",
1100
+ label: "NestJS",
1101
+ commands: {
1102
+ "nest:start": "nest start --watch",
1103
+ "nest:build": "nest build"
1104
+ },
1105
+ groups: {
1106
+ NestJS: ["nest:start", "nest:build"]
1107
+ },
1108
+ ports: [3e3]
1109
+ });
1110
+ }
1111
+ if (detected.has("Remix")) {
1112
+ presets.push({
1113
+ name: "remix",
1114
+ label: "Remix",
1115
+ commands: {
1116
+ "remix:dev": "remix vite:dev",
1117
+ "remix:build": "remix vite:build"
1118
+ },
1119
+ groups: {
1120
+ Remix: ["remix:dev", "remix:build"]
1121
+ },
1122
+ ports: [5173]
1123
+ });
1124
+ }
1125
+ if (detected.has("Express") || detected.has("Fastify")) {
1126
+ presets.push({
1127
+ name: detected.has("Fastify") ? "fastify" : "express",
1128
+ label: detected.has("Fastify") ? "Fastify" : "Express",
1129
+ ports: [3e3]
1130
+ });
1131
+ }
1132
+ if (detected.has("Prisma") || hasAnyDependency(dependencies, ["prisma", "@prisma/client"])) {
1133
+ presets.push({
1134
+ name: "prisma",
1135
+ label: "Prisma",
1136
+ commands: {
1137
+ "prisma:migrate": "prisma migrate dev",
1138
+ "prisma:studio": "prisma studio"
1139
+ },
1140
+ groups: {
1141
+ Database: ["prisma:migrate", "prisma:studio"]
1142
+ },
1143
+ ports: [5555]
1144
+ });
1145
+ }
1146
+ return presets.map(completePreset);
1147
+ }
1148
+ async function pythonPresets(root, language) {
1149
+ if (!language.detected.includes("python")) {
1150
+ return [];
1151
+ }
1152
+ const [requirements, pyproject, pipfile] = await Promise.all([
1153
+ readIfPresent2(root, "requirements.txt"),
1154
+ readIfPresent2(root, "pyproject.toml"),
1155
+ readIfPresent2(root, "Pipfile")
1156
+ ]);
1157
+ const manifest = [requirements, pyproject, pipfile].filter(Boolean).join("\n").toLowerCase();
1158
+ const commands = {};
1159
+ const groups = {};
1160
+ const ports = [];
1161
+ if (requirements !== null) {
1162
+ commands["python:install"] = "python -m pip install -r requirements.txt";
1163
+ groups.Setup = ["python:install"];
1164
+ }
1165
+ if (manifest.includes("uvicorn") || manifest.includes("fastapi")) {
1166
+ commands["python:dev"] = "uvicorn main:app --reload --host 127.0.0.1";
1167
+ groups.Python = [...groups.Python ?? [], "python:dev"];
1168
+ ports.push(8e3);
1169
+ }
1170
+ if (manifest.includes("flask")) {
1171
+ commands["flask:dev"] = "flask --app app run --host 127.0.0.1";
1172
+ groups.Python = [...groups.Python ?? [], "flask:dev"];
1173
+ ports.push(5e3);
1174
+ }
1175
+ if (manifest.includes("django") || await readIfPresent2(root, "manage.py") !== null) {
1176
+ commands["django:dev"] = "python manage.py runserver 127.0.0.1:8000";
1177
+ commands["django:migrate"] = "python manage.py migrate";
1178
+ groups.Python = [...groups.Python ?? [], "django:dev", "django:migrate"];
1179
+ ports.push(8e3);
1180
+ }
1181
+ return [
1182
+ completePreset({
1183
+ name: "python",
1184
+ label: "Python",
1185
+ commands,
1186
+ groups,
1187
+ ports
1188
+ })
1189
+ ];
1190
+ }
1191
+ function goPresets(language) {
1192
+ if (!language.detected.includes("go")) {
1193
+ return [];
1194
+ }
1195
+ return [
1196
+ completePreset({
1197
+ name: "go",
1198
+ label: "Go",
1199
+ commands: {
1200
+ "go:run": "go run .",
1201
+ "go:build": "go build ./...",
1202
+ "go:test": "go test ./..."
1203
+ },
1204
+ groups: {
1205
+ Go: ["go:run", "go:build", "go:test"]
1206
+ }
1207
+ })
1208
+ ];
1209
+ }
1210
+ async function javaPresets(language) {
1211
+ if (!language.detected.includes("java")) {
1212
+ return [];
1213
+ }
1214
+ const hasMaven = language.files.some((file) => path9.basename(file) === "pom.xml");
1215
+ const hasGradle = language.files.some((file) => path9.basename(file).startsWith("build.gradle"));
1216
+ const commands = {};
1217
+ const groups = {};
1218
+ if (hasMaven) {
1219
+ commands["maven:test"] = "mvn test";
1220
+ commands["maven:package"] = "mvn package";
1221
+ groups.Maven = ["maven:test", "maven:package"];
1222
+ }
1223
+ if (hasGradle) {
1224
+ commands["gradle:test"] = "gradle test";
1225
+ commands["gradle:build"] = "gradle build";
1226
+ groups.Gradle = ["gradle:test", "gradle:build"];
1227
+ }
1228
+ return [completePreset({ name: "java", label: "Java", commands, groups })];
1229
+ }
1230
+ async function detectPresets(options) {
1231
+ return [
1232
+ ...nodePresets(options.framework, options.packageJson),
1233
+ ...await pythonPresets(options.root, options.language),
1234
+ ...goPresets(options.language),
1235
+ ...await javaPresets(options.language)
1236
+ ].filter(
1237
+ (preset) => Object.keys(preset.commands).length > 0 || Object.keys(preset.groups).length > 0 || preset.ports.length > 0
1238
+ );
1239
+ }
1240
+ function mergePresetCommands(presets) {
1241
+ return Object.assign({}, ...presets.map((preset) => preset.commands));
1242
+ }
1243
+ function mergePresetGroups(presets) {
1244
+ const groups = {};
1245
+ for (const preset of presets) {
1246
+ for (const [group, commands] of Object.entries(preset.groups)) {
1247
+ groups[group] = [...groups[group] ?? [], ...commands];
1248
+ }
1249
+ }
1250
+ return groups;
1251
+ }
1252
+
969
1253
  // src/core/scanner/scripts.ts
970
1254
  function extractScripts(packageJson) {
971
1255
  if (!packageJson?.data.scripts || typeof packageJson.data.scripts !== "object" || Array.isArray(packageJson.data.scripts)) {
@@ -980,17 +1264,17 @@ function extractScripts(packageJson) {
980
1264
  }
981
1265
 
982
1266
  // 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);
1267
+ function isWithinRoot7(root, target) {
1268
+ const relative = path10.relative(path10.resolve(root), path10.resolve(target));
1269
+ return relative === "" || !relative.startsWith("..") && !path10.isAbsolute(relative);
986
1270
  }
987
1271
  async function findFirstFile(root, candidates) {
988
- const resolvedRoot = await fs8.realpath(root).catch(() => path8.resolve(root));
1272
+ const resolvedRoot = await fs10.realpath(root).catch(() => path10.resolve(root));
989
1273
  for (const candidate of candidates) {
990
- const filePath = path8.join(root, candidate);
1274
+ const filePath = path10.join(root, candidate);
991
1275
  try {
992
- const [stat, realPath] = await Promise.all([fs8.stat(filePath), fs8.realpath(filePath)]);
993
- if (stat.isFile() && isWithinRoot6(resolvedRoot, realPath)) {
1276
+ const [stat, realPath] = await Promise.all([fs10.stat(filePath), fs10.realpath(filePath)]);
1277
+ if (stat.isFile() && isWithinRoot7(resolvedRoot, realPath)) {
994
1278
  return { path: realPath, exists: true };
995
1279
  }
996
1280
  } catch {
@@ -1002,15 +1286,25 @@ function configuredPorts(configPorts) {
1002
1286
  return Array.isArray(configPorts) ? configPorts : [];
1003
1287
  }
1004
1288
  async function scanProject(root = process.cwd()) {
1005
- const resolvedRoot = await fs8.realpath(root).catch(() => path8.resolve(root));
1289
+ const resolvedRoot = await fs10.realpath(root).catch(() => path10.resolve(root));
1006
1290
  const config = await loadConfig(resolvedRoot);
1007
1291
  const packageJson = await readPackageJson(resolvedRoot);
1008
1292
  const scripts = extractScripts(packageJson) ?? {};
1009
1293
  const framework = detectFramework(packageJson);
1294
+ const language = await detectProjectLanguage(resolvedRoot, packageJson);
1295
+ const presets = await detectPresets({
1296
+ root: resolvedRoot,
1297
+ packageJson,
1298
+ framework,
1299
+ language
1300
+ });
1301
+ const presetCommands = mergePresetCommands(presets);
1302
+ const presetGroups = mergePresetGroups(presets);
1010
1303
  const portsToProbe = [
1011
1304
  ...configuredPorts(config?.config.ports),
1012
1305
  ...inferPortsFromScripts(scripts),
1013
- ...defaultPortsForFramework(framework)
1306
+ ...defaultPortsForFramework(framework),
1307
+ ...presets.flatMap((preset) => preset.ports)
1014
1308
  ];
1015
1309
  const [packageManager, env, docker, git, ports, readme, license] = await Promise.all([
1016
1310
  detectPackageManager(resolvedRoot),
@@ -1023,14 +1317,18 @@ async function scanProject(root = process.cwd()) {
1023
1317
  ]);
1024
1318
  return {
1025
1319
  root: resolvedRoot,
1026
- projectName: config?.config.name ?? packageJson?.data.name ?? path8.basename(resolvedRoot),
1320
+ projectName: config?.config.name ?? packageJson?.data.name ?? path10.basename(resolvedRoot),
1027
1321
  packageJson,
1028
1322
  packageManager: packageManager ?? (packageJson ? "npm" : null),
1323
+ language,
1029
1324
  scripts,
1030
1325
  env,
1031
1326
  docker,
1032
1327
  git,
1033
1328
  framework,
1329
+ presets,
1330
+ presetCommands,
1331
+ presetGroups,
1034
1332
  ports: ports ?? [],
1035
1333
  readme,
1036
1334
  license,
@@ -1041,18 +1339,18 @@ async function scanProject(root = process.cwd()) {
1041
1339
  // src/core/doctor/index.ts
1042
1340
  async function pathExists(filePath) {
1043
1341
  try {
1044
- await fs9.access(filePath);
1342
+ await fs11.access(filePath);
1045
1343
  return true;
1046
1344
  } catch {
1047
1345
  return false;
1048
1346
  }
1049
1347
  }
1050
- async function readIfPresent2(filePath) {
1348
+ async function readIfPresent3(filePath) {
1051
1349
  if (filePath === null) {
1052
1350
  return null;
1053
1351
  }
1054
1352
  try {
1055
- return await fs9.readFile(filePath, "utf8");
1353
+ return await fs11.readFile(filePath, "utf8");
1056
1354
  } catch {
1057
1355
  return null;
1058
1356
  }
@@ -1068,7 +1366,9 @@ async function runDoctor(root = process.cwd(), scan) {
1068
1366
  warning("config-warning", "warning", "Config warning", configWarning, result.config?.path)
1069
1367
  );
1070
1368
  }
1071
- if (result.packageJson === null) {
1369
+ const isNodeProject = result.language.detected.includes("node");
1370
+ const hasKnownProjectLanguage = result.language.detected.length > 0;
1371
+ if (result.packageJson === null && !hasKnownProjectLanguage) {
1072
1372
  warnings.push(
1073
1373
  warning(
1074
1374
  "missing-package-json",
@@ -1079,7 +1379,7 @@ async function runDoctor(root = process.cwd(), scan) {
1079
1379
  );
1080
1380
  return warnings;
1081
1381
  }
1082
- if (!await pathExists(path9.join(root, "node_modules", ".bin"))) {
1382
+ if (isNodeProject && !await pathExists(path11.join(root, "node_modules", ".bin"))) {
1083
1383
  warnings.push(
1084
1384
  warning(
1085
1385
  "missing-node-modules",
@@ -1126,7 +1426,7 @@ async function runDoctor(root = process.cwd(), scan) {
1126
1426
  warning("missing-readme", "warning", "No README", "No README.md or README file was found.")
1127
1427
  );
1128
1428
  } else {
1129
- const readme = await readIfPresent2(result.readme.path);
1429
+ const readme = await readIfPresent3(result.readme.path);
1130
1430
  if (readme !== null) {
1131
1431
  const references = extractScriptReferences(readme);
1132
1432
  const missingScripts = references.filter((script) => result.scripts[script] === void 0);
@@ -1162,7 +1462,7 @@ async function runDoctor(root = process.cwd(), scan) {
1162
1462
  )
1163
1463
  );
1164
1464
  }
1165
- if (result.scripts.test === void 0) {
1465
+ if (isNodeProject && result.scripts.test === void 0) {
1166
1466
  warnings.push(
1167
1467
  warning(
1168
1468
  "missing-test-script",
@@ -1172,7 +1472,7 @@ async function runDoctor(root = process.cwd(), scan) {
1172
1472
  )
1173
1473
  );
1174
1474
  }
1175
- if (result.scripts.build === void 0) {
1475
+ if (isNodeProject && result.scripts.build === void 0) {
1176
1476
  warnings.push(
1177
1477
  warning(
1178
1478
  "missing-build-script",
@@ -1233,17 +1533,17 @@ async function doctorCommand(cwd = process.cwd()) {
1233
1533
  }
1234
1534
 
1235
1535
  // src/cli/commands/init.ts
1236
- import { promises as fs10 } from "fs";
1237
- import path10 from "path";
1536
+ import { promises as fs12 } from "fs";
1537
+ import path12 from "path";
1238
1538
  import pc2 from "picocolors";
1239
1539
  async function initCommand(cwd = process.cwd()) {
1240
- const configPath = path10.join(cwd, CONFIG_FILE_NAME);
1540
+ const configPath = path12.join(cwd, CONFIG_FILE_NAME);
1241
1541
  try {
1242
- await fs10.access(configPath);
1542
+ await fs12.access(configPath);
1243
1543
  console.log(pc2.yellow(`${CONFIG_FILE_NAME} already exists.`));
1244
1544
  return;
1245
1545
  } catch {
1246
- await fs10.writeFile(configPath, `${JSON.stringify(defaultConfig, null, 2)}
1546
+ await fs12.writeFile(configPath, `${JSON.stringify(defaultConfig, null, 2)}
1247
1547
  `, "utf8");
1248
1548
  console.log(pc2.green(`Created ${CONFIG_FILE_NAME}.`));
1249
1549
  }
@@ -1430,26 +1730,60 @@ async function runPackageScriptToTerminal(options) {
1430
1730
  });
1431
1731
  });
1432
1732
  }
1733
+ async function runConfiguredCommandToTerminal(options) {
1734
+ const resolvedCommand = await resolveConfiguredCommand(options.cwd, options.command);
1735
+ if (resolvedCommand === null) {
1736
+ return 1;
1737
+ }
1738
+ return await new Promise((resolve) => {
1739
+ const child = spawn2(resolvedCommand.command, resolvedCommand.args, {
1740
+ cwd: options.cwd,
1741
+ stdio: "inherit",
1742
+ windowsHide: true
1743
+ });
1744
+ child.on("error", () => {
1745
+ resolve(1);
1746
+ });
1747
+ child.on("close", (code) => {
1748
+ resolve(code ?? 1);
1749
+ });
1750
+ });
1751
+ }
1433
1752
 
1434
1753
  // src/cli/commands/run.ts
1435
1754
  async function runCommand(script, cwd = process.cwd()) {
1436
1755
  const scan = await scanProject(cwd);
1437
- if (scan.packageJson === null) {
1438
- console.error(pc3.red("No package.json was found in this directory."));
1439
- process.exitCode = 1;
1756
+ if (scan.scripts[script] !== void 0) {
1757
+ const exitCode = await runPackageScriptToTerminal({
1758
+ cwd,
1759
+ packageManager: scan.packageManager,
1760
+ script
1761
+ });
1762
+ process.exitCode = exitCode;
1440
1763
  return;
1441
1764
  }
1442
- if (scan.scripts[script] === void 0) {
1443
- console.error(pc3.red(`Script "${script}" was not found in package.json.`));
1444
- process.exitCode = 1;
1765
+ const configuredCommand = scan.config?.config.commands?.[script] ?? scan.presetCommands[script];
1766
+ if (configuredCommand !== void 0) {
1767
+ if (isDangerousCommand(configuredCommand)) {
1768
+ console.error(pc3.red(`Refusing to run dangerous command "${safeDisplayText(script)}".`));
1769
+ process.exitCode = 1;
1770
+ return;
1771
+ }
1772
+ const exitCode = await runConfiguredCommandToTerminal({
1773
+ cwd,
1774
+ command: configuredCommand
1775
+ });
1776
+ process.exitCode = exitCode;
1445
1777
  return;
1446
1778
  }
1447
- const exitCode = await runPackageScriptToTerminal({
1448
- cwd,
1449
- packageManager: scan.packageManager,
1450
- script
1451
- });
1452
- process.exitCode = exitCode;
1779
+ const available = [
1780
+ ...Object.keys(scan.scripts),
1781
+ ...Object.keys(scan.config?.config.commands ?? {}),
1782
+ ...Object.keys(scan.presetCommands)
1783
+ ];
1784
+ const hint = available.length > 0 ? ` Available commands: ${safeDisplayList(available)}.` : "";
1785
+ console.error(pc3.red(`Command "${safeDisplayText(script)}" was not found.${hint}`));
1786
+ process.exitCode = 1;
1453
1787
  }
1454
1788
 
1455
1789
  // src/cli/commands/scan.ts
@@ -1459,9 +1793,11 @@ function formatList(values) {
1459
1793
  }
1460
1794
  function printScanResult(scan) {
1461
1795
  console.log(pc4.bold(`Project: ${safeDisplayText(scan.projectName)}`));
1796
+ console.log(`Language: ${formatList(scan.language.detected) || "unknown"}`);
1462
1797
  console.log(`Type: ${safeDisplayText(scan.framework?.type ?? "Unknown")}`);
1463
1798
  console.log(`Manager: ${safeDisplayText(scan.packageManager ?? "unknown")}`);
1464
1799
  console.log(`Scripts: ${formatList(Object.keys(scan.scripts))}`);
1800
+ console.log(`Presets: ${formatList(scan.presets.map((preset) => preset.label)) || "none"}`);
1465
1801
  console.log(`Git: ${safeDisplayText(scan.git?.branch ?? "not detected")}`);
1466
1802
  console.log(`README: ${scan.readme.exists ? "found" : "missing"}`);
1467
1803
  console.log(`LICENSE: ${scan.license.exists ? "found" : "missing"}`);
@@ -1485,41 +1821,33 @@ async function scanCommand(cwd = process.cwd()) {
1485
1821
  // src/cli/commands/start.ts
1486
1822
  import pc5 from "picocolors";
1487
1823
 
1488
- // src/server/index.ts
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";
1493
- import { serveStatic } from "@hono/node-server/serve-static";
1494
- import { Hono } from "hono";
1495
-
1496
1824
  // node_modules/open/index.js
1497
1825
  import process7 from "process";
1498
1826
  import { Buffer as Buffer2 } from "buffer";
1499
- import path11 from "path";
1827
+ import path13 from "path";
1500
1828
  import { fileURLToPath } from "url";
1501
1829
  import { promisify as promisify5 } from "util";
1502
1830
  import childProcess from "child_process";
1503
- import fs15, { constants as fsConstants2 } from "fs/promises";
1831
+ import fs17, { constants as fsConstants2 } from "fs/promises";
1504
1832
 
1505
1833
  // node_modules/wsl-utils/index.js
1506
1834
  import process3 from "process";
1507
- import fs14, { constants as fsConstants } from "fs/promises";
1835
+ import fs16, { constants as fsConstants } from "fs/promises";
1508
1836
 
1509
1837
  // node_modules/is-wsl/index.js
1510
1838
  import process2 from "process";
1511
1839
  import os2 from "os";
1512
- import fs13 from "fs";
1840
+ import fs15 from "fs";
1513
1841
 
1514
1842
  // node_modules/is-inside-container/index.js
1515
- import fs12 from "fs";
1843
+ import fs14 from "fs";
1516
1844
 
1517
1845
  // node_modules/is-docker/index.js
1518
- import fs11 from "fs";
1846
+ import fs13 from "fs";
1519
1847
  var isDockerCached;
1520
1848
  function hasDockerEnv() {
1521
1849
  try {
1522
- fs11.statSync("/.dockerenv");
1850
+ fs13.statSync("/.dockerenv");
1523
1851
  return true;
1524
1852
  } catch {
1525
1853
  return false;
@@ -1527,7 +1855,7 @@ function hasDockerEnv() {
1527
1855
  }
1528
1856
  function hasDockerCGroup() {
1529
1857
  try {
1530
- return fs11.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
1858
+ return fs13.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
1531
1859
  } catch {
1532
1860
  return false;
1533
1861
  }
@@ -1543,7 +1871,7 @@ function isDocker() {
1543
1871
  var cachedResult;
1544
1872
  var hasContainerEnv = () => {
1545
1873
  try {
1546
- fs12.statSync("/run/.containerenv");
1874
+ fs14.statSync("/run/.containerenv");
1547
1875
  return true;
1548
1876
  } catch {
1549
1877
  return false;
@@ -1568,12 +1896,12 @@ var isWsl = () => {
1568
1896
  return true;
1569
1897
  }
1570
1898
  try {
1571
- if (fs13.readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft")) {
1899
+ if (fs15.readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft")) {
1572
1900
  return !isInsideContainer();
1573
1901
  }
1574
1902
  } catch {
1575
1903
  }
1576
- if (fs13.existsSync("/proc/sys/fs/binfmt_misc/WSLInterop") || fs13.existsSync("/run/WSL")) {
1904
+ if (fs15.existsSync("/proc/sys/fs/binfmt_misc/WSLInterop") || fs15.existsSync("/run/WSL")) {
1577
1905
  return !isInsideContainer();
1578
1906
  }
1579
1907
  return false;
@@ -1591,14 +1919,14 @@ var wslDrivesMountPoint = /* @__PURE__ */ (() => {
1591
1919
  const configFilePath = "/etc/wsl.conf";
1592
1920
  let isConfigFileExists = false;
1593
1921
  try {
1594
- await fs14.access(configFilePath, fsConstants.F_OK);
1922
+ await fs16.access(configFilePath, fsConstants.F_OK);
1595
1923
  isConfigFileExists = true;
1596
1924
  } catch {
1597
1925
  }
1598
1926
  if (!isConfigFileExists) {
1599
1927
  return defaultMountPoint;
1600
1928
  }
1601
- const configContent = await fs14.readFile(configFilePath, { encoding: "utf8" });
1929
+ const configContent = await fs16.readFile(configFilePath, { encoding: "utf8" });
1602
1930
  const configMountPoint = /(?<!#.*)root\s*=\s*(?<mountPoint>.*)/g.exec(configContent);
1603
1931
  if (!configMountPoint) {
1604
1932
  return defaultMountPoint;
@@ -1752,8 +2080,8 @@ async function defaultBrowser2() {
1752
2080
 
1753
2081
  // node_modules/open/index.js
1754
2082
  var execFile5 = promisify5(childProcess.execFile);
1755
- var __dirname = path11.dirname(fileURLToPath(import.meta.url));
1756
- var localXdgOpenPath = path11.join(__dirname, "xdg-open");
2083
+ var __dirname = path13.dirname(fileURLToPath(import.meta.url));
2084
+ var localXdgOpenPath = path13.join(__dirname, "xdg-open");
1757
2085
  var { platform, arch } = process7;
1758
2086
  async function getWindowsDefaultBrowserFromWsl() {
1759
2087
  const powershellPath = await powerShellPath();
@@ -1903,7 +2231,7 @@ var baseOpen = async (options) => {
1903
2231
  const isBundled = !__dirname || __dirname === "/";
1904
2232
  let exeLocalXdgOpen = false;
1905
2233
  try {
1906
- await fs15.access(localXdgOpenPath, fsConstants2.X_OK);
2234
+ await fs17.access(localXdgOpenPath, fsConstants2.X_OK);
1907
2235
  exeLocalXdgOpen = true;
1908
2236
  } catch {
1909
2237
  }
@@ -2007,6 +2335,14 @@ defineLazyProperty(apps, "browser", () => "browser");
2007
2335
  defineLazyProperty(apps, "browserPrivate", () => "browserPrivate");
2008
2336
  var open_default = open;
2009
2337
 
2338
+ // src/server/index.ts
2339
+ import { promises as fs21 } from "fs";
2340
+ import path17 from "path";
2341
+ import { fileURLToPath as fileURLToPath2 } from "url";
2342
+ import { createAdaptorServer } from "@hono/node-server";
2343
+ import { serveStatic } from "@hono/node-server/serve-static";
2344
+ import { Hono } from "hono";
2345
+
2010
2346
  // src/core/process/manager.ts
2011
2347
  import { EventEmitter } from "events";
2012
2348
  import spawn3 from "cross-spawn";
@@ -2149,12 +2485,244 @@ var ProcessManager = class extends EventEmitter {
2149
2485
  }
2150
2486
  };
2151
2487
 
2488
+ // src/core/hub/registry.ts
2489
+ import { createHash } from "crypto";
2490
+ import { promises as fs19 } from "fs";
2491
+ import os3 from "os";
2492
+ import path15 from "path";
2493
+
2494
+ // src/core/hub/workspaceRoots.ts
2495
+ import { promises as fs18 } from "fs";
2496
+ import path14 from "path";
2497
+ function isWithinRoot8(root, target) {
2498
+ const relative = path14.relative(root, target);
2499
+ return relative === "" || !relative.startsWith("..") && !path14.isAbsolute(relative);
2500
+ }
2501
+ async function configuredWorkspaceRoots() {
2502
+ const raw = process.env.DEVSURFACE_WORKSPACE_ROOTS;
2503
+ if (!raw) {
2504
+ return [];
2505
+ }
2506
+ const roots = [];
2507
+ for (const entry of raw.split(",")) {
2508
+ const trimmed = entry.trim();
2509
+ if (!trimmed) {
2510
+ continue;
2511
+ }
2512
+ try {
2513
+ roots.push(await fs18.realpath(path14.resolve(trimmed)));
2514
+ } catch {
2515
+ }
2516
+ }
2517
+ return roots;
2518
+ }
2519
+ async function assertWithinWorkspaceRoots(targetPath) {
2520
+ const roots = await configuredWorkspaceRoots();
2521
+ if (roots.length === 0) {
2522
+ return;
2523
+ }
2524
+ for (const root of roots) {
2525
+ if (isWithinRoot8(root, targetPath)) {
2526
+ return;
2527
+ }
2528
+ }
2529
+ throw new Error("Path must be inside a configured workspace root.");
2530
+ }
2531
+
2532
+ // src/core/hub/registry.ts
2533
+ function workspaceId(realPath) {
2534
+ const base = path15.basename(realPath).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 32) || "workspace";
2535
+ const hash = createHash("sha256").update(realPath).digest("hex").slice(0, 6);
2536
+ return `${base}-${hash}`;
2537
+ }
2538
+ function defaultDataDir() {
2539
+ return process.env.DEVSURFACE_DATA_DIR ?? path15.join(os3.homedir(), ".devsurface");
2540
+ }
2541
+ async function readPackageName(dirPath) {
2542
+ try {
2543
+ const raw = JSON.parse(await fs19.readFile(path15.join(dirPath, "package.json"), "utf8"));
2544
+ return typeof raw?.name === "string" && raw.name.length > 0 ? raw.name : null;
2545
+ } catch {
2546
+ return null;
2547
+ }
2548
+ }
2549
+ var WorkspaceRegistry = class {
2550
+ filePath;
2551
+ seeded = false;
2552
+ constructor(dataDir) {
2553
+ const dir = dataDir ?? defaultDataDir();
2554
+ this.filePath = path15.join(dir, "workspaces.json");
2555
+ }
2556
+ async list() {
2557
+ await this.seedFromEnv();
2558
+ return await this.read();
2559
+ }
2560
+ async add(dirPath) {
2561
+ const realDir = await this.resolveDir(dirPath);
2562
+ await assertWithinWorkspaceRoots(realDir);
2563
+ const entries = await this.read();
2564
+ const existing = entries.find((entry2) => entry2.path === realDir);
2565
+ if (existing) {
2566
+ return existing;
2567
+ }
2568
+ const name = await readPackageName(realDir) ?? path15.basename(realDir);
2569
+ const entry = {
2570
+ id: workspaceId(realDir),
2571
+ name,
2572
+ path: realDir,
2573
+ addedAt: (/* @__PURE__ */ new Date()).toISOString()
2574
+ };
2575
+ entries.push(entry);
2576
+ await this.write(entries);
2577
+ return entry;
2578
+ }
2579
+ async remove(id) {
2580
+ const entries = await this.read();
2581
+ const filtered = entries.filter((entry) => entry.id !== id);
2582
+ if (filtered.length === entries.length) {
2583
+ return false;
2584
+ }
2585
+ await this.write(filtered);
2586
+ return true;
2587
+ }
2588
+ async findByPath(dirPath) {
2589
+ try {
2590
+ const realDir = await fs19.realpath(path15.resolve(dirPath));
2591
+ const entries = await this.read();
2592
+ return entries.find((entry) => entry.path === realDir) ?? null;
2593
+ } catch {
2594
+ return null;
2595
+ }
2596
+ }
2597
+ async resolve(id) {
2598
+ const entries = await this.read();
2599
+ const entry = entries.find((item) => item.id === id);
2600
+ if (!entry) {
2601
+ return null;
2602
+ }
2603
+ try {
2604
+ const realDir = await this.resolveDir(entry.path);
2605
+ await assertWithinWorkspaceRoots(realDir);
2606
+ if (realDir !== entry.path) {
2607
+ const updated = { ...entry, path: realDir };
2608
+ await this.write(entries.map((item) => item.id === id ? updated : item));
2609
+ return updated;
2610
+ }
2611
+ return entry;
2612
+ } catch {
2613
+ await this.remove(id);
2614
+ return null;
2615
+ }
2616
+ }
2617
+ async resolveDir(dirPath) {
2618
+ const resolved = path15.resolve(dirPath);
2619
+ const realDir = await fs19.realpath(resolved);
2620
+ const stat = await fs19.stat(realDir);
2621
+ if (!stat.isDirectory()) {
2622
+ throw new Error(`${dirPath} is not a directory.`);
2623
+ }
2624
+ return realDir;
2625
+ }
2626
+ async read() {
2627
+ try {
2628
+ const content = await fs19.readFile(this.filePath, "utf8");
2629
+ const parsed = JSON.parse(content);
2630
+ return Array.isArray(parsed) ? parsed : [];
2631
+ } catch {
2632
+ return [];
2633
+ }
2634
+ }
2635
+ async write(entries) {
2636
+ await fs19.mkdir(path15.dirname(this.filePath), { recursive: true });
2637
+ await fs19.writeFile(this.filePath, JSON.stringify(entries, null, 2) + "\n", "utf8");
2638
+ }
2639
+ async seedFromEnv() {
2640
+ if (this.seeded) {
2641
+ return;
2642
+ }
2643
+ this.seeded = true;
2644
+ const seedValue = process.env.DEVSURFACE_WORKSPACES;
2645
+ if (!seedValue) {
2646
+ return;
2647
+ }
2648
+ const paths = seedValue.split(",").map((p) => p.trim()).filter(Boolean);
2649
+ for (const p of paths) {
2650
+ try {
2651
+ await this.add(p);
2652
+ } catch {
2653
+ }
2654
+ }
2655
+ }
2656
+ };
2657
+
2658
+ // src/core/hub/runtime.ts
2659
+ var Hub = class {
2660
+ registry;
2661
+ runtimes = /* @__PURE__ */ new Map();
2662
+ cleanupInstalled = false;
2663
+ constructor(options) {
2664
+ this.registry = new WorkspaceRegistry(options?.dataDir);
2665
+ }
2666
+ get(id) {
2667
+ return this.runtimes.get(id) ?? null;
2668
+ }
2669
+ ensure(entry) {
2670
+ const existing = this.runtimes.get(entry.id);
2671
+ if (existing) {
2672
+ return existing;
2673
+ }
2674
+ const runtime = {
2675
+ id: entry.id,
2676
+ root: entry.path,
2677
+ processManager: new ProcessManager(),
2678
+ dockerController: new DockerComposeController(entry.path)
2679
+ };
2680
+ this.runtimes.set(entry.id, runtime);
2681
+ return runtime;
2682
+ }
2683
+ async listSummaries() {
2684
+ const entries = await this.registry.list();
2685
+ return entries.map((entry) => {
2686
+ const runtime = this.runtimes.get(entry.id);
2687
+ const running = runtime ? runtime.processManager.list().filter((p) => p.status === "running").length : 0;
2688
+ return {
2689
+ id: entry.id,
2690
+ name: entry.name,
2691
+ path: entry.path,
2692
+ addedAt: entry.addedAt,
2693
+ runningProcesses: running
2694
+ };
2695
+ });
2696
+ }
2697
+ killAll() {
2698
+ for (const runtime of this.runtimes.values()) {
2699
+ runtime.processManager.killAll();
2700
+ }
2701
+ }
2702
+ attachCleanupHandlers() {
2703
+ if (this.cleanupInstalled) {
2704
+ return;
2705
+ }
2706
+ this.cleanupInstalled = true;
2707
+ process.once("exit", () => {
2708
+ this.killAll();
2709
+ });
2710
+ process.once("SIGINT", () => {
2711
+ this.killAll();
2712
+ process.exit(130);
2713
+ });
2714
+ }
2715
+ };
2716
+
2152
2717
  // src/server/routes/api.ts
2153
2718
  import { constants as constants2, existsSync } from "fs";
2154
- import { promises as fs16 } from "fs";
2155
- import path12 from "path";
2719
+ import { promises as fs20 } from "fs";
2720
+ import path16 from "path";
2156
2721
  import spawn4 from "cross-spawn";
2157
2722
 
2723
+ // src/version.ts
2724
+ var DEV_SURFACE_VERSION = "0.5.0";
2725
+
2158
2726
  // src/server/localAccess.ts
2159
2727
  var LOCAL_HOSTNAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
2160
2728
  function hostnameFromHostHeader(host) {
@@ -2194,6 +2762,130 @@ function isSameOrigin(requestUrl, origin) {
2194
2762
  }
2195
2763
  }
2196
2764
 
2765
+ // src/server/listenConfig.ts
2766
+ var DEFAULT_HOST = "127.0.0.1";
2767
+ var DEFAULT_PORT = 4567;
2768
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
2769
+ var CONTAINER_HOSTS = /* @__PURE__ */ new Set(["0.0.0.0", "::"]);
2770
+ function resolveHost() {
2771
+ const envHost = process.env.DEVSURFACE_HOST;
2772
+ if (!envHost) {
2773
+ return DEFAULT_HOST;
2774
+ }
2775
+ if (LOOPBACK_HOSTS.has(envHost)) {
2776
+ return envHost;
2777
+ }
2778
+ if (CONTAINER_HOSTS.has(envHost) && process.env.DEVSURFACE_CONTAINER === "true") {
2779
+ return envHost;
2780
+ }
2781
+ if (CONTAINER_HOSTS.has(envHost)) {
2782
+ throw new Error(
2783
+ "All-interface DevSurface binding is only allowed when DEVSURFACE_CONTAINER=true. DevSurface binds to 127.0.0.1 on bare metal."
2784
+ );
2785
+ }
2786
+ throw new Error("DEVSURFACE_HOST must be a loopback host, or 0.0.0.0 inside a container.");
2787
+ }
2788
+ var listenHost = DEFAULT_HOST;
2789
+ function setListenHost(host) {
2790
+ listenHost = host;
2791
+ }
2792
+ function getListenHost() {
2793
+ return listenHost;
2794
+ }
2795
+ function normalizeRemoteAddress(raw) {
2796
+ if (typeof raw !== "string" || raw.length === 0) {
2797
+ return null;
2798
+ }
2799
+ if (raw.startsWith("::ffff:")) {
2800
+ return raw.slice("::ffff:".length);
2801
+ }
2802
+ return raw;
2803
+ }
2804
+ function isLoopbackRemoteAddress(raw) {
2805
+ const address = normalizeRemoteAddress(raw);
2806
+ if (!address) {
2807
+ return false;
2808
+ }
2809
+ if (address === "::1" || address === "127.0.0.1") {
2810
+ return true;
2811
+ }
2812
+ return address.startsWith("127.");
2813
+ }
2814
+ function parseIpv4(address) {
2815
+ const parts = address.split(".");
2816
+ if (parts.length !== 4) {
2817
+ return null;
2818
+ }
2819
+ const octets = parts.map((part) => Number(part));
2820
+ if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
2821
+ return null;
2822
+ }
2823
+ return octets;
2824
+ }
2825
+ function isPrivateRemoteAddress(raw) {
2826
+ const address = normalizeRemoteAddress(raw);
2827
+ if (!address) {
2828
+ return false;
2829
+ }
2830
+ if (isLoopbackRemoteAddress(address)) {
2831
+ return true;
2832
+ }
2833
+ if (address.startsWith("fe80:")) {
2834
+ return true;
2835
+ }
2836
+ const ipv4 = parseIpv4(address);
2837
+ if (!ipv4) {
2838
+ return false;
2839
+ }
2840
+ const [a, b] = ipv4;
2841
+ if (a === 10) {
2842
+ return true;
2843
+ }
2844
+ if (a === 192 && b === 168) {
2845
+ return true;
2846
+ }
2847
+ if (a === 172 && b >= 16 && b <= 31) {
2848
+ return true;
2849
+ }
2850
+ return false;
2851
+ }
2852
+ function isAllowedRemoteAddress(raw, host) {
2853
+ if (host === "0.0.0.0" || host === "::") {
2854
+ return isPrivateRemoteAddress(raw);
2855
+ }
2856
+ return isLoopbackRemoteAddress(raw);
2857
+ }
2858
+ function isAllowedClientConnection(raw, host = getListenHost()) {
2859
+ if (raw === void 0) {
2860
+ return true;
2861
+ }
2862
+ return isAllowedRemoteAddress(raw, host);
2863
+ }
2864
+ function initializeListenHost() {
2865
+ const host = resolveHost();
2866
+ setListenHost(host);
2867
+ return host;
2868
+ }
2869
+
2870
+ // src/server/accessControl.ts
2871
+ function remoteAddressFromRequest(request) {
2872
+ return request?.socket?.remoteAddress;
2873
+ }
2874
+ function createApiAccessMiddleware() {
2875
+ return async (context, next) => {
2876
+ const host = context.req.header("host") ?? new URL(context.req.url).host;
2877
+ if (!isAllowedLocalHostHeader(host)) {
2878
+ return context.json({ error: "Non-local host rejected." }, 403);
2879
+ }
2880
+ const env = context.env;
2881
+ const remoteAddress = remoteAddressFromRequest(env?.incoming);
2882
+ if (!isAllowedClientConnection(remoteAddress, getListenHost())) {
2883
+ return context.json({ error: "Remote client rejected." }, 403);
2884
+ }
2885
+ await next();
2886
+ };
2887
+ }
2888
+
2197
2889
  // src/server/mutationToken.ts
2198
2890
  import { randomBytes, timingSafeEqual } from "crypto";
2199
2891
  function createMutationToken() {
@@ -2215,9 +2907,9 @@ function isAllowedTerminalCommand(command) {
2215
2907
  }
2216
2908
 
2217
2909
  // src/server/routes/api.ts
2218
- function isWithinRoot7(root, target) {
2219
- const relative = path12.relative(path12.resolve(root), path12.resolve(target));
2220
- return relative === "" || !relative.startsWith("..") && !path12.isAbsolute(relative);
2910
+ function isWithinRoot9(root, target) {
2911
+ const relative = path16.relative(path16.resolve(root), path16.resolve(target));
2912
+ return relative === "" || !relative.startsWith("..") && !path16.isAbsolute(relative);
2221
2913
  }
2222
2914
  function isAllowedMutationOrigin(requestUrl, origin) {
2223
2915
  if (origin === null) {
@@ -2225,6 +2917,23 @@ function isAllowedMutationOrigin(requestUrl, origin) {
2225
2917
  }
2226
2918
  return isAllowedLocalOrigin(origin) && isSameOrigin(requestUrl, origin);
2227
2919
  }
2920
+ function registerMutationGuard(app, mutationToken) {
2921
+ app.use("/api/*", createApiAccessMiddleware());
2922
+ app.use("/api/*", async (context, next) => {
2923
+ if (context.req.method === "GET" || context.req.method === "HEAD") {
2924
+ await next();
2925
+ return;
2926
+ }
2927
+ const origin = context.req.header("origin") ?? null;
2928
+ const secFetchSite = context.req.header("sec-fetch-site") ?? null;
2929
+ const intent = context.req.header("x-devsurface-intent") ?? null;
2930
+ const token = context.req.header("x-devsurface-token") ?? null;
2931
+ if (!hasMutationIntent(intent) || !hasValidMutationToken(token, mutationToken) || isCrossSiteFetch(secFetchSite) || !isAllowedMutationOrigin(context.req.url, origin)) {
2932
+ return context.json({ error: "Cross-origin mutation rejected." }, 403);
2933
+ }
2934
+ await next();
2935
+ });
2936
+ }
2228
2937
  function isCrossSiteFetch(secFetchSite) {
2229
2938
  return secFetchSite === "cross-site";
2230
2939
  }
@@ -2232,35 +2941,35 @@ function hasMutationIntent(intent) {
2232
2941
  return intent === "dashboard";
2233
2942
  }
2234
2943
  async function realPathWithinRoot(root, target) {
2235
- if (!isWithinRoot7(root, target)) {
2944
+ if (!isWithinRoot9(root, target)) {
2236
2945
  return false;
2237
2946
  }
2238
2947
  try {
2239
- const [realRoot, realTarget] = await Promise.all([fs16.realpath(root), fs16.realpath(target)]);
2240
- return isWithinRoot7(realRoot, realTarget);
2948
+ const [realRoot, realTarget] = await Promise.all([fs20.realpath(root), fs20.realpath(target)]);
2949
+ return isWithinRoot9(realRoot, realTarget);
2241
2950
  } catch {
2242
2951
  return false;
2243
2952
  }
2244
2953
  }
2245
2954
  async function writableDestinationWithinRoot(root, destination) {
2246
- if (!isWithinRoot7(root, destination)) {
2955
+ if (!isWithinRoot9(root, destination)) {
2247
2956
  return false;
2248
2957
  }
2249
2958
  try {
2250
2959
  const [realRoot, realParent] = await Promise.all([
2251
- fs16.realpath(root),
2252
- fs16.realpath(path12.dirname(destination))
2960
+ fs20.realpath(root),
2961
+ fs20.realpath(path16.dirname(destination))
2253
2962
  ]);
2254
- return isWithinRoot7(realRoot, realParent);
2963
+ return isWithinRoot9(realRoot, realParent);
2255
2964
  } catch {
2256
2965
  return false;
2257
2966
  }
2258
2967
  }
2259
2968
  async function copyFileExclusive(source, destination) {
2260
- const content = await fs16.readFile(source);
2969
+ const content = await fs20.readFile(source);
2261
2970
  let handle2 = null;
2262
2971
  try {
2263
- handle2 = await fs16.open(
2972
+ handle2 = await fs20.open(
2264
2973
  destination,
2265
2974
  constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY,
2266
2975
  384
@@ -2287,15 +2996,15 @@ function resolveCommandPromptExecutable() {
2287
2996
  return process.env.ComSpec ?? "cmd.exe";
2288
2997
  }
2289
2998
  function findExecutable(command) {
2290
- if (path12.isAbsolute(command)) {
2999
+ if (path16.isAbsolute(command)) {
2291
3000
  return existsSync(command) ? command : null;
2292
3001
  }
2293
3002
  const pathValue = process.env.PATH ?? "";
2294
- for (const directory of pathValue.split(path12.delimiter)) {
3003
+ for (const directory of pathValue.split(path16.delimiter)) {
2295
3004
  if (directory.length === 0) {
2296
3005
  continue;
2297
3006
  }
2298
- const candidate = path12.join(directory, command);
3007
+ const candidate = path16.join(directory, command);
2299
3008
  if (existsSync(candidate)) {
2300
3009
  return candidate;
2301
3010
  }
@@ -2361,93 +3070,74 @@ function openTerminalAt(root) {
2361
3070
  }
2362
3071
  return launchDetached(terminal.command, terminal.args, root);
2363
3072
  }
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
- });
2369
- app.use("/api/*", async (context, next) => {
2370
- const host = context.req.header("host") ?? new URL(context.req.url).host;
2371
- if (!isAllowedLocalHostHeader(host)) {
2372
- return context.json({ error: "Non-local host rejected." }, 403);
3073
+ function handleDockerError(error, context) {
3074
+ if (error instanceof DockerOperationError) {
3075
+ if (error.code === "compose-not-found" || error.code === "service-not-found") {
3076
+ return context.json({ error: error.message, code: error.code }, 404);
2373
3077
  }
2374
- if (context.req.method !== "GET" && context.req.method !== "HEAD") {
2375
- const origin = context.req.header("origin") ?? null;
2376
- const secFetchSite = context.req.header("sec-fetch-site") ?? null;
2377
- const intent = context.req.header("x-devsurface-intent") ?? null;
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)) {
2380
- return context.json({ error: "Cross-origin mutation rejected." }, 403);
2381
- }
3078
+ if (error.code === "docker-not-installed" || error.code === "docker-not-running") {
3079
+ return context.json({ error: error.message, code: error.code }, 503);
2382
3080
  }
2383
- await next();
2384
- });
2385
- app.get("/api/project", async (context) => {
2386
- return context.json(await scanProject(options.projectRoot));
3081
+ return context.json({ error: error.message, code: error.code }, 502);
3082
+ }
3083
+ throw error;
3084
+ }
3085
+ function registerWorkspaceRoutes(app, resolveWorkspace) {
3086
+ app.get("/api/workspaces/:id/project", async (context) => {
3087
+ const ws = await resolveWorkspace(context.req.param("id"));
3088
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
3089
+ return context.json(await scanProject(ws.root));
2387
3090
  });
2388
- app.get("/api/health", async (context) => {
2389
- return context.json(await runDoctor(options.projectRoot));
3091
+ app.get("/api/workspaces/:id/health", async (context) => {
3092
+ const ws = await resolveWorkspace(context.req.param("id"));
3093
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
3094
+ return context.json(await runDoctor(ws.root));
2390
3095
  });
2391
- app.get("/api/processes", (context) => {
2392
- return context.json(options.processManager.list());
3096
+ app.get("/api/workspaces/:id/processes", async (context) => {
3097
+ const ws = await resolveWorkspace(context.req.param("id"));
3098
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
3099
+ return context.json(ws.processManager.list());
2393
3100
  });
2394
- app.get("/api/logs", (context) => {
2395
- return context.json(options.processManager.listLogs());
3101
+ app.get("/api/workspaces/:id/logs", async (context) => {
3102
+ const ws = await resolveWorkspace(context.req.param("id"));
3103
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
3104
+ return context.json(ws.processManager.listLogs());
2396
3105
  });
2397
- app.get("/api/docker/:service/logs", async (context) => {
3106
+ app.get("/api/workspaces/:id/docker/:service/logs", async (context) => {
3107
+ const ws = await resolveWorkspace(context.req.param("id"));
3108
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2398
3109
  const service = decodeURIComponent(context.req.param("service"));
2399
3110
  try {
2400
- return context.json(await dockerController.logs(service));
3111
+ return context.json(await ws.dockerController.logs(service));
2401
3112
  } 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;
3113
+ return handleDockerError(error, context);
2412
3114
  }
2413
3115
  });
2414
- app.post("/api/docker/:service/start", async (context) => {
3116
+ app.post("/api/workspaces/:id/docker/:service/start", async (context) => {
3117
+ const ws = await resolveWorkspace(context.req.param("id"));
3118
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2415
3119
  const service = decodeURIComponent(context.req.param("service"));
2416
3120
  try {
2417
- return context.json(await dockerController.start(service));
3121
+ return context.json(await ws.dockerController.start(service));
2418
3122
  } 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;
3123
+ return handleDockerError(error, context);
2429
3124
  }
2430
3125
  });
2431
- app.post("/api/docker/:service/stop", async (context) => {
3126
+ app.post("/api/workspaces/:id/docker/:service/stop", async (context) => {
3127
+ const ws = await resolveWorkspace(context.req.param("id"));
3128
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2432
3129
  const service = decodeURIComponent(context.req.param("service"));
2433
3130
  try {
2434
- return context.json(await dockerController.stop(service));
3131
+ return context.json(await ws.dockerController.stop(service));
2435
3132
  } 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;
3133
+ return handleDockerError(error, context);
2446
3134
  }
2447
3135
  });
2448
- app.post("/api/run/:script", async (context) => {
3136
+ app.post("/api/workspaces/:id/run/:script", async (context) => {
3137
+ const ws = await resolveWorkspace(context.req.param("id"));
3138
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2449
3139
  const script = decodeURIComponent(context.req.param("script"));
2450
- const scan = await scanProject(options.projectRoot);
3140
+ const scan = await scanProject(ws.root);
2451
3141
  const packageScript = scan.scripts[script];
2452
3142
  if (packageScript === void 0) {
2453
3143
  return context.json({ error: `Script "${script}" was not found.` }, 404);
@@ -2456,36 +3146,35 @@ function registerApiRoutes(app, options) {
2456
3146
  return context.json({ error: "Refusing to run dangerous script." }, 403);
2457
3147
  }
2458
3148
  const command = await resolvePackageRunCommand({
2459
- cwd: options.projectRoot,
3149
+ cwd: ws.root,
2460
3150
  packageManager: scan.packageManager,
2461
3151
  script
2462
3152
  });
2463
3153
  if (command === null) {
2464
3154
  return context.json({ error: "Package manager executable was not found." }, 503);
2465
3155
  }
2466
- const processInfo = options.processManager.start({
2467
- cwd: options.projectRoot,
3156
+ const processInfo = ws.processManager.start({
3157
+ cwd: ws.root,
2468
3158
  script,
2469
3159
  command: command.command,
2470
3160
  args: command.args,
2471
3161
  displayCommand: command.displayCommand
2472
3162
  });
2473
- return context.json({
2474
- ...processInfo,
2475
- packageScript
2476
- });
3163
+ return context.json({ ...processInfo, packageScript });
2477
3164
  });
2478
- app.post("/api/install", async (context) => {
2479
- const scan = await scanProject(options.projectRoot);
3165
+ app.post("/api/workspaces/:id/install", async (context) => {
3166
+ const ws = await resolveWorkspace(context.req.param("id"));
3167
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
3168
+ const scan = await scanProject(ws.root);
2480
3169
  const command = await resolvePackageInstallCommand({
2481
- cwd: options.projectRoot,
3170
+ cwd: ws.root,
2482
3171
  packageManager: scan.packageManager
2483
3172
  });
2484
3173
  if (command === null) {
2485
3174
  return context.json({ error: "Package manager executable was not found." }, 503);
2486
3175
  }
2487
- const processInfo = options.processManager.start({
2488
- cwd: options.projectRoot,
3176
+ const processInfo = ws.processManager.start({
3177
+ cwd: ws.root,
2489
3178
  script: "install",
2490
3179
  command: command.command,
2491
3180
  args: command.args,
@@ -2493,17 +3182,19 @@ function registerApiRoutes(app, options) {
2493
3182
  });
2494
3183
  return context.json(processInfo);
2495
3184
  });
2496
- app.post("/api/commands/:name", async (context) => {
3185
+ app.post("/api/workspaces/:id/commands/:name", async (context) => {
3186
+ const ws = await resolveWorkspace(context.req.param("id"));
3187
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2497
3188
  const name = decodeURIComponent(context.req.param("name"));
2498
- const scan = await scanProject(options.projectRoot);
2499
- const configuredCommand = scan.config?.config.commands?.[name] ?? null;
3189
+ const scan = await scanProject(ws.root);
3190
+ const configuredCommand = scan.config?.config.commands?.[name] ?? scan.presetCommands[name] ?? null;
2500
3191
  if (configuredCommand === null) {
2501
3192
  return context.json({ error: `Configured command "${name}" was not found.` }, 404);
2502
3193
  }
2503
3194
  if (isDangerousCommand(configuredCommand)) {
2504
3195
  return context.json({ error: "Refusing to run dangerous command." }, 403);
2505
3196
  }
2506
- const resolvedCommand = await resolveConfiguredCommand(options.projectRoot, configuredCommand);
3197
+ const resolvedCommand = await resolveConfiguredCommand(ws.root, configuredCommand);
2507
3198
  if (resolvedCommand === null) {
2508
3199
  return context.json(
2509
3200
  {
@@ -2512,43 +3203,48 @@ function registerApiRoutes(app, options) {
2512
3203
  400
2513
3204
  );
2514
3205
  }
2515
- const processInfo = options.processManager.start({
2516
- cwd: options.projectRoot,
3206
+ const processInfo = ws.processManager.start({
3207
+ cwd: ws.root,
2517
3208
  script: name,
2518
3209
  command: resolvedCommand.command,
2519
3210
  args: resolvedCommand.args,
2520
3211
  displayCommand: resolvedCommand.displayCommand
2521
3212
  });
2522
- return context.json({
2523
- ...processInfo,
2524
- configuredCommand
2525
- });
3213
+ return context.json({ ...processInfo, configuredCommand });
2526
3214
  });
2527
- app.post("/api/open/folder", async (context) => {
2528
- await open_default(options.projectRoot);
3215
+ app.post("/api/workspaces/:id/open/folder", async (context) => {
3216
+ const ws = await resolveWorkspace(context.req.param("id"));
3217
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
3218
+ await open_default(ws.root);
2529
3219
  return context.json({ opened: true, target: "folder" });
2530
3220
  });
2531
- app.post("/api/open/package", async (context) => {
2532
- const packagePath = path12.join(options.projectRoot, "package.json");
2533
- if (!await realPathWithinRoot(options.projectRoot, packagePath)) {
3221
+ app.post("/api/workspaces/:id/open/package", async (context) => {
3222
+ const ws = await resolveWorkspace(context.req.param("id"));
3223
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
3224
+ const packagePath = path16.join(ws.root, "package.json");
3225
+ if (!await realPathWithinRoot(ws.root, packagePath)) {
2534
3226
  return context.json({ error: "package.json was not found inside the project root." }, 404);
2535
3227
  }
2536
3228
  await open_default(packagePath);
2537
3229
  return context.json({ opened: true, target: "package" });
2538
3230
  });
2539
- app.post("/api/open/terminal", (context) => {
2540
- const opened = openTerminalAt(options.projectRoot);
3231
+ app.post("/api/workspaces/:id/open/terminal", async (context) => {
3232
+ const ws = await resolveWorkspace(context.req.param("id"));
3233
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
3234
+ const opened = openTerminalAt(ws.root);
2541
3235
  return context.json({ opened, target: "terminal" }, opened ? 200 : 501);
2542
3236
  });
2543
- app.post("/api/env/copy", async (context) => {
2544
- const scan = await scanProject(options.projectRoot);
3237
+ app.post("/api/workspaces/:id/env/copy", async (context) => {
3238
+ const ws = await resolveWorkspace(context.req.param("id"));
3239
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
3240
+ const scan = await scanProject(ws.root);
2545
3241
  const examplePath = scan.env?.examplePath ?? null;
2546
3242
  const localPath = scan.env?.localPath ?? null;
2547
3243
  if (examplePath === null) {
2548
3244
  return context.json({ error: ".env.example was not found." }, 404);
2549
3245
  }
2550
- const destination = localPath ?? path12.join(options.projectRoot, scan.config?.config.env?.local ?? ".env");
2551
- if (!await realPathWithinRoot(options.projectRoot, examplePath) || !await writableDestinationWithinRoot(options.projectRoot, destination)) {
3246
+ const destination = localPath ?? path16.join(ws.root, scan.config?.config.env?.local ?? ".env");
3247
+ if (!await realPathWithinRoot(ws.root, examplePath) || !await writableDestinationWithinRoot(ws.root, destination)) {
2552
3248
  return context.json({ error: "Refusing to copy env files outside the project root." }, 400);
2553
3249
  }
2554
3250
  const copyResult = await copyFileExclusive(examplePath, destination);
@@ -2557,12 +3253,79 @@ function registerApiRoutes(app, options) {
2557
3253
  }
2558
3254
  return context.json({ copied: true });
2559
3255
  });
2560
- app.delete("/api/run/:pid", (context) => {
3256
+ app.delete("/api/workspaces/:id/run/:pid", async (context) => {
3257
+ const ws = await resolveWorkspace(context.req.param("id"));
3258
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2561
3259
  const pid = decodeURIComponent(context.req.param("pid"));
2562
- const stopped = options.processManager.stop(pid);
3260
+ const stopped = ws.processManager.stop(pid);
2563
3261
  return context.json({ stopped });
2564
3262
  });
2565
3263
  }
3264
+ function registerHubApiRoutes(app, options) {
3265
+ const { hub } = options;
3266
+ registerMutationGuard(app, options.mutationToken);
3267
+ async function resolveWorkspace(id) {
3268
+ const entry = await hub.registry.resolve(id);
3269
+ if (!entry) return null;
3270
+ const runtime = hub.ensure(entry);
3271
+ return {
3272
+ root: runtime.root,
3273
+ processManager: runtime.processManager,
3274
+ dockerController: runtime.dockerController
3275
+ };
3276
+ }
3277
+ app.get("/api/session", (context) => {
3278
+ return context.json({ token: options.mutationToken });
3279
+ });
3280
+ app.get("/api/hub/status", (context) => {
3281
+ return context.json({ status: "running", version: DEV_SURFACE_VERSION });
3282
+ });
3283
+ app.get("/api/workspaces", async (context) => {
3284
+ return context.json(await hub.listSummaries());
3285
+ });
3286
+ app.post("/api/workspaces", async (context) => {
3287
+ const body = await context.req.json().catch(() => null);
3288
+ if (!body?.path) {
3289
+ return context.json({ error: "path is required." }, 400);
3290
+ }
3291
+ try {
3292
+ const entry = await hub.registry.add(body.path);
3293
+ return context.json(entry, 201);
3294
+ } catch (error) {
3295
+ return context.json({ error: error instanceof Error ? error.message : "Invalid path." }, 400);
3296
+ }
3297
+ });
3298
+ app.delete("/api/workspaces/:id", async (context) => {
3299
+ const id = context.req.param("id");
3300
+ const runtime = hub.get(id);
3301
+ if (runtime) {
3302
+ runtime.processManager.killAll();
3303
+ }
3304
+ const removed = await hub.registry.remove(id);
3305
+ return context.json({ removed }, removed ? 200 : 404);
3306
+ });
3307
+ registerWorkspaceRoutes(app, resolveWorkspace);
3308
+ app.get("/api/project", async (context) => {
3309
+ const entries = await hub.registry.list();
3310
+ if (entries.length === 0) return context.json({ error: "No workspaces registered." }, 404);
3311
+ return context.json(await scanProject(hub.ensure(entries[0]).root));
3312
+ });
3313
+ app.get("/api/health", async (context) => {
3314
+ const entries = await hub.registry.list();
3315
+ if (entries.length === 0) return context.json({ error: "No workspaces registered." }, 404);
3316
+ return context.json(await runDoctor(hub.ensure(entries[0]).root));
3317
+ });
3318
+ app.get("/api/processes", async (context) => {
3319
+ const entries = await hub.registry.list();
3320
+ if (entries.length === 0) return context.json([]);
3321
+ return context.json(hub.ensure(entries[0]).processManager.list());
3322
+ });
3323
+ app.get("/api/logs", async (context) => {
3324
+ const entries = await hub.registry.list();
3325
+ if (entries.length === 0) return context.json([]);
3326
+ return context.json(hub.ensure(entries[0]).processManager.listLogs());
3327
+ });
3328
+ }
2566
3329
 
2567
3330
  // src/server/routes/ws.ts
2568
3331
  import { WebSocket, WebSocketServer } from "ws";
@@ -2570,6 +3333,9 @@ function isAllowedWebSocketRequest(request) {
2570
3333
  const origin = request.headers.origin;
2571
3334
  const host = request.headers.host;
2572
3335
  const secFetchSite = request.headers["sec-fetch-site"];
3336
+ if (!isAllowedClientConnection(remoteAddressFromRequest(request), getListenHost())) {
3337
+ return false;
3338
+ }
2573
3339
  if (typeof host !== "string" || !isAllowedLocalHostHeader(host)) {
2574
3340
  return false;
2575
3341
  }
@@ -2588,32 +3354,63 @@ function isAllowedWebSocketRequest(request) {
2588
3354
  return false;
2589
3355
  }
2590
3356
  }
2591
- function setupWebSocket(server, processManager) {
3357
+ function workspaceIdFromUrl(url) {
3358
+ if (!url) return null;
3359
+ try {
3360
+ const parsed = new URL(url, "http://localhost");
3361
+ return parsed.searchParams.get("workspace");
3362
+ } catch {
3363
+ return null;
3364
+ }
3365
+ }
3366
+ function setupHubWebSocket(server, hub) {
2592
3367
  const wss = new WebSocketServer({
2593
3368
  server,
2594
3369
  path: "/ws",
2595
3370
  verifyClient: (info) => isAllowedWebSocketRequest(info.req)
2596
3371
  });
2597
- function broadcast(payload) {
3372
+ const clientWorkspaces = /* @__PURE__ */ new WeakMap();
3373
+ const attachedManagers = /* @__PURE__ */ new Set();
3374
+ function attachManager(workspaceId2, processManager) {
3375
+ if (attachedManagers.has(workspaceId2)) {
3376
+ return;
3377
+ }
3378
+ attachedManagers.add(workspaceId2);
3379
+ processManager.on("log", (event) => {
3380
+ broadcastToWorkspace(workspaceId2, { type: "log", event });
3381
+ });
3382
+ processManager.on("process", (processInfo) => {
3383
+ broadcastToWorkspace(workspaceId2, { type: "process", process: processInfo });
3384
+ });
3385
+ }
3386
+ function broadcastToWorkspace(workspaceId2, payload) {
2598
3387
  const serialized = JSON.stringify(payload);
2599
3388
  for (const client of wss.clients) {
2600
- if (client.readyState === WebSocket.OPEN) {
3389
+ if (client.readyState === WebSocket.OPEN && clientWorkspaces.get(client) === workspaceId2) {
2601
3390
  client.send(serialized);
2602
3391
  }
2603
3392
  }
2604
3393
  }
2605
- processManager.on("log", (event) => {
2606
- broadcast({ type: "log", event });
2607
- });
2608
- processManager.on("process", (processInfo) => {
2609
- broadcast({ type: "process", process: processInfo });
2610
- });
2611
- wss.on("connection", (socket) => {
3394
+ wss.on("connection", async (socket, request) => {
3395
+ const workspaceId2 = workspaceIdFromUrl(request.url);
3396
+ if (!workspaceId2) {
3397
+ socket.close(4e3, "Missing workspace query parameter.");
3398
+ return;
3399
+ }
3400
+ const entry = await hub.registry.resolve(workspaceId2);
3401
+ if (!entry) {
3402
+ socket.close(4004, "Workspace not found.");
3403
+ return;
3404
+ }
3405
+ const runtime = hub.ensure(entry);
3406
+ clientWorkspaces.set(socket, workspaceId2);
3407
+ attachManager(workspaceId2, runtime.processManager);
2612
3408
  socket.send(
2613
3409
  JSON.stringify({
2614
3410
  type: "hello",
2615
- processes: processManager.list(),
2616
- logs: processManager.listLogs()
3411
+ workspace: workspaceId2,
3412
+ processes: runtime.processManager.list(),
3413
+ logs: runtime.processManager.listLogs()
2617
3414
  })
2618
3415
  );
2619
3416
  });
@@ -2621,51 +3418,44 @@ function setupWebSocket(server, processManager) {
2621
3418
  }
2622
3419
 
2623
3420
  // src/server/index.ts
2624
- var HOST = "127.0.0.1";
2625
- var DEFAULT_PORT = 4567;
2626
- function assertLocalHost(host) {
2627
- if (host !== HOST) {
2628
- throw new Error("DevSurface must bind only to 127.0.0.1.");
2629
- }
2630
- }
2631
3421
  async function fileExists(filePath) {
2632
3422
  try {
2633
- await fs17.access(filePath);
3423
+ await fs21.access(filePath);
2634
3424
  return true;
2635
3425
  } catch {
2636
3426
  return false;
2637
3427
  }
2638
3428
  }
2639
3429
  async function findWebDistDir() {
2640
- const moduleDir = path13.dirname(fileURLToPath2(import.meta.url));
3430
+ const moduleDir = path17.dirname(fileURLToPath2(import.meta.url));
2641
3431
  const candidates = [
2642
- path13.join(moduleDir, "..", "web", "dist"),
2643
- path13.join(moduleDir, "..", "..", "src", "web", "dist"),
2644
- path13.join(moduleDir, "web", "dist")
3432
+ path17.join(moduleDir, "..", "web", "dist"),
3433
+ path17.join(moduleDir, "..", "..", "src", "web", "dist"),
3434
+ path17.join(moduleDir, "web", "dist")
2645
3435
  ];
2646
3436
  for (const candidate of candidates) {
2647
- if (await fileExists(path13.join(candidate, "index.html"))) {
3437
+ if (await fileExists(path17.join(candidate, "index.html"))) {
2648
3438
  return candidate;
2649
3439
  }
2650
3440
  }
2651
3441
  return null;
2652
3442
  }
2653
- function toListenError(error, port) {
3443
+ function toListenError(error, host, port) {
2654
3444
  const code = error instanceof Error ? error.code : void 0;
2655
3445
  if (code === "EADDRINUSE") {
2656
3446
  return new Error(
2657
- `Port ${port} is already in use on ${HOST}. Stop the other process or run DevSurface with --port ${port + 1}.`,
3447
+ `Port ${port} is already in use on ${host}. Stop the other process or run DevSurface with --port ${port + 1}.`,
2658
3448
  { cause: error }
2659
3449
  );
2660
3450
  }
2661
3451
  if (code === "EACCES") {
2662
- return new Error(`DevSurface does not have permission to bind to ${HOST}:${port}.`, {
3452
+ return new Error(`DevSurface does not have permission to bind to ${host}:${port}.`, {
2663
3453
  cause: error
2664
3454
  });
2665
3455
  }
2666
3456
  return error instanceof Error ? error : new Error(String(error));
2667
3457
  }
2668
- async function listenOnLocalHost(server, wss, port) {
3458
+ async function listenOnHost(server, wss, host, port) {
2669
3459
  await new Promise((resolve, reject) => {
2670
3460
  let settled = false;
2671
3461
  const cleanup = () => {
@@ -2674,17 +3464,13 @@ async function listenOnLocalHost(server, wss, port) {
2674
3464
  wss.off("error", onError);
2675
3465
  };
2676
3466
  const onError = (error) => {
2677
- if (settled) {
2678
- return;
2679
- }
3467
+ if (settled) return;
2680
3468
  settled = true;
2681
3469
  cleanup();
2682
- reject(toListenError(error, port));
3470
+ reject(toListenError(error, host, port));
2683
3471
  };
2684
3472
  const onListening = () => {
2685
- if (settled) {
2686
- return;
2687
- }
3473
+ if (settled) return;
2688
3474
  settled = true;
2689
3475
  cleanup();
2690
3476
  resolve();
@@ -2692,7 +3478,7 @@ async function listenOnLocalHost(server, wss, port) {
2692
3478
  wss.once("error", onError);
2693
3479
  server.once("error", onError);
2694
3480
  server.once("listening", onListening);
2695
- server.listen(port, HOST);
3481
+ server.listen(port, host);
2696
3482
  });
2697
3483
  }
2698
3484
  async function closeWebSocketServer(wss) {
@@ -2714,18 +3500,13 @@ async function closeHttpServer(server) {
2714
3500
  });
2715
3501
  });
2716
3502
  }
2717
- async function createApp(options) {
2718
- const app = new Hono();
2719
- registerApiRoutes(app, {
2720
- ...options,
2721
- mutationToken: options.mutationToken ?? createMutationToken()
2722
- });
3503
+ async function mountWebUi(app) {
2723
3504
  const webDistDir = await findWebDistDir();
2724
3505
  if (webDistDir !== null) {
2725
3506
  app.use("/assets/*", serveStatic({ root: webDistDir }));
2726
3507
  app.get("/favicon.svg", serveStatic({ root: webDistDir }));
2727
3508
  app.get("*", async (context) => {
2728
- const html = await fs17.readFile(path13.join(webDistDir, "index.html"), "utf8");
3509
+ const html = await fs21.readFile(path17.join(webDistDir, "index.html"), "utf8");
2729
3510
  return context.html(html);
2730
3511
  });
2731
3512
  } else {
@@ -2737,46 +3518,96 @@ async function createApp(options) {
2737
3518
  )
2738
3519
  );
2739
3520
  }
3521
+ }
3522
+ async function createHubApp(options) {
3523
+ const app = new Hono();
3524
+ registerHubApiRoutes(app, {
3525
+ hub: options.hub,
3526
+ mutationToken: options.mutationToken ?? createMutationToken()
3527
+ });
3528
+ await mountWebUi(app);
2740
3529
  return app;
2741
3530
  }
2742
- async function startDevSurfaceServer(options) {
2743
- assertLocalHost(HOST);
3531
+ async function startHubServer(options) {
3532
+ const host = initializeListenHost();
2744
3533
  const port = options.port ?? DEFAULT_PORT;
2745
- const processManager = new ProcessManager();
2746
- processManager.attachCleanupHandlers();
2747
- const app = await createApp({
2748
- projectRoot: options.projectRoot,
2749
- processManager
2750
- });
3534
+ const hub = new Hub({ dataDir: options.dataDir });
3535
+ hub.attachCleanupHandlers();
3536
+ if (options.initialWorkspace) {
3537
+ await hub.registry.add(options.initialWorkspace);
3538
+ }
3539
+ const mutationToken = createMutationToken();
3540
+ const app = await createHubApp({ hub, mutationToken });
2751
3541
  const server = createAdaptorServer({
2752
3542
  fetch: app.fetch,
2753
- hostname: HOST
3543
+ hostname: host
2754
3544
  });
2755
- const wss = setupWebSocket(server, processManager);
2756
- await listenOnLocalHost(server, wss, port);
2757
- processManager.attachCleanupHandlers();
2758
- const url = `http://${HOST}:${port}`;
3545
+ const wss = setupHubWebSocket(server, hub);
3546
+ await listenOnHost(server, wss, host, port);
3547
+ const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}`;
2759
3548
  if (options.openBrowser !== false) {
2760
- await open_default(url);
3549
+ const entries = await hub.registry.list();
3550
+ const deepLink = entries.length > 0 ? `${url}/?workspace=${entries[0].id}` : url;
3551
+ await open_default(deepLink);
2761
3552
  }
3553
+ const dummyProcessManager = new ProcessManager();
2762
3554
  return {
2763
3555
  url,
2764
3556
  port,
2765
- processManager,
3557
+ host,
3558
+ hub,
3559
+ processManager: dummyProcessManager,
2766
3560
  close: async () => {
2767
- processManager.killAll();
3561
+ hub.killAll();
2768
3562
  await closeWebSocketServer(wss);
2769
3563
  await closeHttpServer(server);
2770
3564
  }
2771
3565
  };
2772
3566
  }
2773
3567
 
2774
- // src/version.ts
2775
- var DEV_SURFACE_VERSION = "0.3.0";
3568
+ // src/cli/hub/client.ts
3569
+ async function isHubRunning(port = DEFAULT_PORT) {
3570
+ try {
3571
+ const response = await fetch(`http://127.0.0.1:${port}/api/hub/status`, {
3572
+ signal: AbortSignal.timeout(2e3)
3573
+ });
3574
+ return response.ok;
3575
+ } catch {
3576
+ return false;
3577
+ }
3578
+ }
3579
+ async function registerWorkspaceRemotely(dirPath, port = DEFAULT_PORT) {
3580
+ try {
3581
+ const sessionResponse = await fetch(`http://127.0.0.1:${port}/api/session`, {
3582
+ signal: AbortSignal.timeout(2e3)
3583
+ });
3584
+ if (!sessionResponse.ok) return null;
3585
+ const session = await sessionResponse.json();
3586
+ const response = await fetch(`http://127.0.0.1:${port}/api/workspaces`, {
3587
+ method: "POST",
3588
+ headers: {
3589
+ "Content-Type": "application/json",
3590
+ "X-DevSurface-Intent": "dashboard",
3591
+ "X-DevSurface-Token": session.token
3592
+ },
3593
+ body: JSON.stringify({ path: dirPath }),
3594
+ signal: AbortSignal.timeout(5e3)
3595
+ });
3596
+ if (!response.ok) return null;
3597
+ return await response.json();
3598
+ } catch {
3599
+ return null;
3600
+ }
3601
+ }
3602
+ function dashboardUrl(workspaceId2, port = DEFAULT_PORT, host = DEFAULT_HOST) {
3603
+ const displayHost = host === "0.0.0.0" ? "127.0.0.1" : host;
3604
+ return `http://${displayHost}:${port}/?workspace=${workspaceId2}`;
3605
+ }
2776
3606
 
2777
3607
  // src/cli/commands/start.ts
2778
3608
  async function startCommand(options) {
2779
3609
  const cwd = options.cwd ?? process.cwd();
3610
+ const port = options.port ?? 4567;
2780
3611
  console.log(pc5.bold(`DevSurface v${DEV_SURFACE_VERSION}`));
2781
3612
  console.log("Scanning project...\n");
2782
3613
  const scan = await scanProject(cwd);
@@ -2789,13 +3620,159 @@ async function startCommand(options) {
2789
3620
  console.log(` ${marker} ${item.title}`);
2790
3621
  }
2791
3622
  }
2792
- const server = await startDevSurfaceServer({
2793
- projectRoot: cwd,
3623
+ if (await isHubRunning(port)) {
3624
+ console.log("\nHub already running. Registering workspace...");
3625
+ const registered = await registerWorkspaceRemotely(cwd, port);
3626
+ if (registered) {
3627
+ const url = dashboardUrl(registered.id, port);
3628
+ console.log(`Workspace ${pc5.cyan(registered.name)} attached.`);
3629
+ console.log(`Dashboard -> ${pc5.cyan(url)}`);
3630
+ if (options.openBrowser !== false) {
3631
+ await open_default(url);
3632
+ }
3633
+ return;
3634
+ }
3635
+ console.log("Could not register with running hub. Starting a new instance...");
3636
+ }
3637
+ const server = await startHubServer({
3638
+ port,
3639
+ openBrowser: options.openBrowser,
3640
+ initialWorkspace: cwd
3641
+ });
3642
+ console.log(`
3643
+ Dashboard running at -> ${pc5.cyan(server.url)}`);
3644
+ }
3645
+
3646
+ // src/cli/commands/serve.ts
3647
+ import pc6 from "picocolors";
3648
+ async function serveCommand(options) {
3649
+ console.log(pc6.bold(`DevSurface Hub v${DEV_SURFACE_VERSION}`));
3650
+ console.log("Starting hub server...\n");
3651
+ const server = await startHubServer({
2794
3652
  port: options.port,
2795
3653
  openBrowser: options.openBrowser
2796
3654
  });
3655
+ const summaries = await server.hub.listSummaries();
3656
+ if (summaries.length > 0) {
3657
+ console.log(`Registered workspaces: ${summaries.length}`);
3658
+ for (const ws of summaries) {
3659
+ console.log(` ${pc6.cyan(ws.name)} -> ${ws.path}`);
3660
+ }
3661
+ } else {
3662
+ console.log(
3663
+ "No workspaces registered yet. Use `devsurface workspace add` or `npx devsurface` inside a project."
3664
+ );
3665
+ }
2797
3666
  console.log(`
2798
- Dashboard running at -> ${pc5.cyan(server.url)}`);
3667
+ Hub running at -> ${pc6.cyan(server.url)}`);
3668
+ }
3669
+
3670
+ // src/cli/commands/workspace.ts
3671
+ import path18 from "path";
3672
+ import pc7 from "picocolors";
3673
+ async function workspaceAddCommand(dirPath) {
3674
+ const registry = new WorkspaceRegistry();
3675
+ const target = path18.resolve(dirPath ?? process.cwd());
3676
+ const entry = await registry.add(target);
3677
+ console.log(`Added workspace ${pc7.cyan(entry.name)} (${entry.id}) -> ${entry.path}`);
3678
+ }
3679
+ async function workspaceListCommand() {
3680
+ const registry = new WorkspaceRegistry();
3681
+ const entries = await registry.list();
3682
+ if (entries.length === 0) {
3683
+ console.log(
3684
+ "No workspaces registered. Run `devsurface workspace add` or `npx devsurface` inside a project."
3685
+ );
3686
+ return;
3687
+ }
3688
+ console.log(`${entries.length} workspace${entries.length === 1 ? "" : "s"}:
3689
+ `);
3690
+ for (const entry of entries) {
3691
+ console.log(` ${pc7.cyan(entry.name)} (${entry.id})`);
3692
+ console.log(` ${entry.path}`);
3693
+ }
3694
+ }
3695
+ async function workspaceRemoveCommand(id) {
3696
+ const registry = new WorkspaceRegistry();
3697
+ const removed = await registry.remove(id);
3698
+ if (removed) {
3699
+ console.log(`Removed workspace ${pc7.cyan(id)}.`);
3700
+ } else {
3701
+ console.error(`Workspace "${id}" not found.`);
3702
+ process.exitCode = 1;
3703
+ }
3704
+ }
3705
+
3706
+ // src/cli/updateCheck.ts
3707
+ var REGISTRY_LATEST_URL = "https://registry.npmjs.org/devsurface/latest";
3708
+ var UPDATE_CHECK_TIMEOUT_MS = 900;
3709
+ function parseVersion(version) {
3710
+ const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
3711
+ if (!match) {
3712
+ return null;
3713
+ }
3714
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
3715
+ }
3716
+ function isNewerVersion(latestVersion, currentVersion) {
3717
+ const latest = parseVersion(latestVersion);
3718
+ const current = parseVersion(currentVersion);
3719
+ if (latest === null || current === null) {
3720
+ return false;
3721
+ }
3722
+ for (let index = 0; index < latest.length; index += 1) {
3723
+ if (latest[index] > current[index]) {
3724
+ return true;
3725
+ }
3726
+ if (latest[index] < current[index]) {
3727
+ return false;
3728
+ }
3729
+ }
3730
+ return false;
3731
+ }
3732
+ function formatUpdateNotice(info) {
3733
+ return `Update available: v${info.latestVersion}
3734
+ Run: npx devsurface@latest`;
3735
+ }
3736
+ function shouldCheckForUpdates() {
3737
+ return process.env.DEVSURFACE_UPDATE_CHECK !== "0" && process.env.CI !== "true";
3738
+ }
3739
+ async function checkForUpdate(currentVersion, fetchImpl = fetch) {
3740
+ if (!shouldCheckForUpdates()) {
3741
+ return null;
3742
+ }
3743
+ const controller = new AbortController();
3744
+ const timeout = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
3745
+ try {
3746
+ const response = await fetchImpl(REGISTRY_LATEST_URL, {
3747
+ headers: {
3748
+ accept: "application/json"
3749
+ },
3750
+ signal: controller.signal
3751
+ });
3752
+ if (!response.ok) {
3753
+ return null;
3754
+ }
3755
+ const body = await response.json();
3756
+ const latestVersion = typeof body.version === "string" ? body.version : null;
3757
+ if (latestVersion === null || !isNewerVersion(latestVersion, currentVersion)) {
3758
+ return null;
3759
+ }
3760
+ return {
3761
+ currentVersion,
3762
+ latestVersion
3763
+ };
3764
+ } catch {
3765
+ return null;
3766
+ } finally {
3767
+ clearTimeout(timeout);
3768
+ }
3769
+ }
3770
+ async function printUpdateNotice(currentVersion) {
3771
+ const update = await checkForUpdate(currentVersion);
3772
+ if (update !== null) {
3773
+ console.log(`
3774
+ ${formatUpdateNotice(update)}`);
3775
+ }
2799
3776
  }
2800
3777
 
2801
3778
  // src/cli/index.ts
@@ -2808,7 +3785,9 @@ function toPort(value) {
2808
3785
  return port;
2809
3786
  }
2810
3787
  function handle(command) {
2811
- command.catch((error) => {
3788
+ command.then(async () => {
3789
+ await printUpdateNotice(DEV_SURFACE_VERSION);
3790
+ }).catch((error) => {
2812
3791
  const message = error instanceof Error ? error.message : String(error);
2813
3792
  console.error(message);
2814
3793
  process.exitCode = 1;
@@ -2823,6 +3802,24 @@ program.name("devsurface").description("Turn any Node.js repository into a local
2823
3802
  })
2824
3803
  );
2825
3804
  });
3805
+ program.command("serve").description("Start the DevSurface hub server (multi-workspace mode).").option("-p, --port <port>", "hub port", toPort, 4567).option("--no-open", "do not open the browser automatically").action((options) => {
3806
+ handle(
3807
+ serveCommand({
3808
+ port: options.port,
3809
+ openBrowser: options.open
3810
+ })
3811
+ );
3812
+ });
3813
+ var workspace = program.command("workspace").description("Manage registered workspaces.");
3814
+ workspace.command("add [path]").description("Register a project directory with the hub.").action((dirPath) => {
3815
+ handle(workspaceAddCommand(dirPath));
3816
+ });
3817
+ workspace.command("list").description("List all registered workspaces.").action(() => {
3818
+ handle(workspaceListCommand());
3819
+ });
3820
+ workspace.command("remove <id>").description("Remove a workspace from the hub registry.").action((id) => {
3821
+ handle(workspaceRemoveCommand(id));
3822
+ });
2826
3823
  program.command("scan").description("Print detected project info.").action(() => {
2827
3824
  handle(scanCommand(process.cwd()));
2828
3825
  });