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/CHANGELOG.md +32 -0
- package/README.md +124 -16
- package/dist/cli/index.js +1256 -259
- package/dist/cli/index.js.map +1 -1
- package/package.json +22 -3
- package/src/web/dist/assets/index-Bj8suDpq.css +1 -0
- package/src/web/dist/assets/index-DOLQwdCe.js +10 -0
- package/src/web/dist/index.html +2 -2
- package/src/web/dist/assets/index-7njY8n4D.js +0 -10
- package/src/web/dist/assets/index-DvunFIw4.css +0 -1
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
|
|
11
|
-
import
|
|
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
|
|
36
|
-
import
|
|
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/
|
|
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
|
|
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(
|
|
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
|
|
868
|
-
import
|
|
869
|
-
function
|
|
870
|
-
const relative =
|
|
871
|
-
return relative === "" || !relative.startsWith("..") && !
|
|
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 =
|
|
929
|
+
const packageJsonPath = path8.join(root, "package.json");
|
|
875
930
|
try {
|
|
876
931
|
const [realRoot, realPackageJsonPath] = await Promise.all([
|
|
877
|
-
|
|
878
|
-
|
|
932
|
+
fs8.realpath(root),
|
|
933
|
+
fs8.realpath(packageJsonPath)
|
|
879
934
|
]);
|
|
880
|
-
if (!
|
|
935
|
+
if (!isWithinRoot6(realRoot, realPackageJsonPath)) {
|
|
881
936
|
return null;
|
|
882
937
|
}
|
|
883
|
-
const content = await
|
|
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
|
|
984
|
-
const relative =
|
|
985
|
-
return relative === "" || !relative.startsWith("..") && !
|
|
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
|
|
1272
|
+
const resolvedRoot = await fs10.realpath(root).catch(() => path10.resolve(root));
|
|
989
1273
|
for (const candidate of candidates) {
|
|
990
|
-
const filePath =
|
|
1274
|
+
const filePath = path10.join(root, candidate);
|
|
991
1275
|
try {
|
|
992
|
-
const [stat, realPath] = await Promise.all([
|
|
993
|
-
if (stat.isFile() &&
|
|
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
|
|
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 ??
|
|
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
|
|
1342
|
+
await fs11.access(filePath);
|
|
1045
1343
|
return true;
|
|
1046
1344
|
} catch {
|
|
1047
1345
|
return false;
|
|
1048
1346
|
}
|
|
1049
1347
|
}
|
|
1050
|
-
async function
|
|
1348
|
+
async function readIfPresent3(filePath) {
|
|
1051
1349
|
if (filePath === null) {
|
|
1052
1350
|
return null;
|
|
1053
1351
|
}
|
|
1054
1352
|
try {
|
|
1055
|
-
return await
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
1237
|
-
import
|
|
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 =
|
|
1540
|
+
const configPath = path12.join(cwd, CONFIG_FILE_NAME);
|
|
1241
1541
|
try {
|
|
1242
|
-
await
|
|
1542
|
+
await fs12.access(configPath);
|
|
1243
1543
|
console.log(pc2.yellow(`${CONFIG_FILE_NAME} already exists.`));
|
|
1244
1544
|
return;
|
|
1245
1545
|
} catch {
|
|
1246
|
-
await
|
|
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.
|
|
1438
|
-
|
|
1439
|
-
|
|
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
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
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
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1840
|
+
import fs15 from "fs";
|
|
1513
1841
|
|
|
1514
1842
|
// node_modules/is-inside-container/index.js
|
|
1515
|
-
import
|
|
1843
|
+
import fs14 from "fs";
|
|
1516
1844
|
|
|
1517
1845
|
// node_modules/is-docker/index.js
|
|
1518
|
-
import
|
|
1846
|
+
import fs13 from "fs";
|
|
1519
1847
|
var isDockerCached;
|
|
1520
1848
|
function hasDockerEnv() {
|
|
1521
1849
|
try {
|
|
1522
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1899
|
+
if (fs15.readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft")) {
|
|
1572
1900
|
return !isInsideContainer();
|
|
1573
1901
|
}
|
|
1574
1902
|
} catch {
|
|
1575
1903
|
}
|
|
1576
|
-
if (
|
|
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
|
|
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
|
|
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 =
|
|
1756
|
-
var localXdgOpenPath =
|
|
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
|
|
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
|
|
2155
|
-
import
|
|
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
|
|
2219
|
-
const relative =
|
|
2220
|
-
return relative === "" || !relative.startsWith("..") && !
|
|
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 (!
|
|
2944
|
+
if (!isWithinRoot9(root, target)) {
|
|
2236
2945
|
return false;
|
|
2237
2946
|
}
|
|
2238
2947
|
try {
|
|
2239
|
-
const [realRoot, realTarget] = await Promise.all([
|
|
2240
|
-
return
|
|
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 (!
|
|
2955
|
+
if (!isWithinRoot9(root, destination)) {
|
|
2247
2956
|
return false;
|
|
2248
2957
|
}
|
|
2249
2958
|
try {
|
|
2250
2959
|
const [realRoot, realParent] = await Promise.all([
|
|
2251
|
-
|
|
2252
|
-
|
|
2960
|
+
fs20.realpath(root),
|
|
2961
|
+
fs20.realpath(path16.dirname(destination))
|
|
2253
2962
|
]);
|
|
2254
|
-
return
|
|
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
|
|
2969
|
+
const content = await fs20.readFile(source);
|
|
2261
2970
|
let handle2 = null;
|
|
2262
2971
|
try {
|
|
2263
|
-
handle2 = await
|
|
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 (
|
|
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(
|
|
3003
|
+
for (const directory of pathValue.split(path16.delimiter)) {
|
|
2295
3004
|
if (directory.length === 0) {
|
|
2296
3005
|
continue;
|
|
2297
3006
|
}
|
|
2298
|
-
const candidate =
|
|
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
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
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 (
|
|
2375
|
-
|
|
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
|
-
|
|
2384
|
-
}
|
|
2385
|
-
|
|
2386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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 =
|
|
2467
|
-
cwd:
|
|
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
|
|
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:
|
|
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 =
|
|
2488
|
-
cwd:
|
|
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(
|
|
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(
|
|
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 =
|
|
2516
|
-
cwd:
|
|
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
|
|
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
|
|
2533
|
-
if (!
|
|
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
|
|
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
|
|
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 ??
|
|
2551
|
-
if (!await realPathWithinRoot(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
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
|
-
|
|
2616
|
-
|
|
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
|
|
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 =
|
|
3430
|
+
const moduleDir = path17.dirname(fileURLToPath2(import.meta.url));
|
|
2641
3431
|
const candidates = [
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
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(
|
|
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 ${
|
|
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 ${
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
2743
|
-
|
|
3531
|
+
async function startHubServer(options) {
|
|
3532
|
+
const host = initializeListenHost();
|
|
2744
3533
|
const port = options.port ?? DEFAULT_PORT;
|
|
2745
|
-
const
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
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:
|
|
3543
|
+
hostname: host
|
|
2754
3544
|
});
|
|
2755
|
-
const wss =
|
|
2756
|
-
await
|
|
2757
|
-
|
|
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
|
|
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
|
-
|
|
3557
|
+
host,
|
|
3558
|
+
hub,
|
|
3559
|
+
processManager: dummyProcessManager,
|
|
2766
3560
|
close: async () => {
|
|
2767
|
-
|
|
3561
|
+
hub.killAll();
|
|
2768
3562
|
await closeWebSocketServer(wss);
|
|
2769
3563
|
await closeHttpServer(server);
|
|
2770
3564
|
}
|
|
2771
3565
|
};
|
|
2772
3566
|
}
|
|
2773
3567
|
|
|
2774
|
-
// src/
|
|
2775
|
-
|
|
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
|
-
|
|
2793
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
});
|