devsurface 0.4.0 → 0.6.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";
@@ -75,6 +75,12 @@ var defaultConfig = {
75
75
  services: {
76
76
  docker: true
77
77
  },
78
+ setupGuide: [
79
+ "Copy .env.example to .env",
80
+ "Fill in required environment values",
81
+ "Install dependencies",
82
+ "Start the dev server"
83
+ ],
78
84
  docs: ""
79
85
  };
80
86
 
@@ -123,6 +129,33 @@ function toGroups(value, warnings) {
123
129
  }
124
130
  return groups;
125
131
  }
132
+ var MAX_SETUP_GUIDE_STEPS = 24;
133
+ var MAX_SETUP_GUIDE_STEP_LENGTH = 200;
134
+ function toSetupGuide(value, warnings) {
135
+ if (value === void 0) {
136
+ return void 0;
137
+ }
138
+ if (!Array.isArray(value)) {
139
+ warnings.push("setupGuide must be an array of strings.");
140
+ return void 0;
141
+ }
142
+ const steps = [];
143
+ for (const entry of value) {
144
+ if (typeof entry !== "string") {
145
+ warnings.push("setupGuide entries must be strings.");
146
+ continue;
147
+ }
148
+ const trimmed = entry.trim();
149
+ if (trimmed.length === 0) {
150
+ continue;
151
+ }
152
+ steps.push(trimmed.slice(0, MAX_SETUP_GUIDE_STEP_LENGTH));
153
+ }
154
+ if (steps.length > MAX_SETUP_GUIDE_STEPS) {
155
+ warnings.push(`setupGuide may contain at most ${MAX_SETUP_GUIDE_STEPS} steps.`);
156
+ }
157
+ return steps.slice(0, MAX_SETUP_GUIDE_STEPS);
158
+ }
126
159
  function toPorts(value, warnings) {
127
160
  if (value === void 0) {
128
161
  return void 0;
@@ -177,6 +210,7 @@ function validateConfig(raw) {
177
210
  ports: toPorts(raw.ports, warnings),
178
211
  env,
179
212
  services,
213
+ setupGuide: toSetupGuide(raw.setupGuide ?? raw.setup_guide, warnings),
180
214
  docs
181
215
  },
182
216
  warnings
@@ -836,9 +870,64 @@ async function detectGit(root) {
836
870
  }
837
871
  }
838
872
 
839
- // src/core/scanner/packageManager.ts
873
+ // src/core/scanner/language.ts
840
874
  import { promises as fs6 } from "fs";
841
875
  import path6 from "path";
876
+ var languageFiles = [
877
+ { language: "python", candidates: ["requirements.txt", "pyproject.toml", "Pipfile"] },
878
+ { language: "go", candidates: ["go.mod"] },
879
+ { language: "java", candidates: ["pom.xml", "build.gradle", "build.gradle.kts"] }
880
+ ];
881
+ function isWithinRoot5(root, target) {
882
+ const relative = path6.relative(path6.resolve(root), path6.resolve(target));
883
+ return relative === "" || !relative.startsWith("..") && !path6.isAbsolute(relative);
884
+ }
885
+ async function safeFile(root, candidate) {
886
+ const filePath = path6.join(root, candidate);
887
+ try {
888
+ const [realRoot, stat, realPath] = await Promise.all([
889
+ fs6.realpath(root),
890
+ fs6.stat(filePath),
891
+ fs6.realpath(filePath)
892
+ ]);
893
+ if (stat.isFile() && isWithinRoot5(realRoot, realPath)) {
894
+ return realPath;
895
+ }
896
+ } catch {
897
+ return null;
898
+ }
899
+ return null;
900
+ }
901
+ async function detectProjectLanguage(root, packageJson) {
902
+ const detected = [];
903
+ const files = [];
904
+ if (packageJson !== null) {
905
+ detected.push("node");
906
+ files.push(packageJson.path);
907
+ }
908
+ for (const definition of languageFiles) {
909
+ let found = false;
910
+ for (const candidate of definition.candidates) {
911
+ const file = await safeFile(root, candidate);
912
+ if (file !== null) {
913
+ found = true;
914
+ files.push(file);
915
+ }
916
+ }
917
+ if (found) {
918
+ detected.push(definition.language);
919
+ }
920
+ }
921
+ return {
922
+ primary: detected[0] ?? null,
923
+ detected,
924
+ files
925
+ };
926
+ }
927
+
928
+ // src/core/scanner/packageManager.ts
929
+ import { promises as fs7 } from "fs";
930
+ import path7 from "path";
842
931
  var lockFiles = [
843
932
  { file: "pnpm-lock.yaml", manager: "pnpm" },
844
933
  { file: "yarn.lock", manager: "yarn" },
@@ -848,7 +937,7 @@ var lockFiles = [
848
937
  ];
849
938
  async function exists(filePath) {
850
939
  try {
851
- await fs6.access(filePath);
940
+ await fs7.access(filePath);
852
941
  return true;
853
942
  } catch {
854
943
  return false;
@@ -856,7 +945,7 @@ async function exists(filePath) {
856
945
  }
857
946
  async function detectPackageManager(root) {
858
947
  for (const lockFile of lockFiles) {
859
- if (await exists(path6.join(root, lockFile.file))) {
948
+ if (await exists(path7.join(root, lockFile.file))) {
860
949
  return lockFile.manager;
861
950
  }
862
951
  }
@@ -864,23 +953,23 @@ async function detectPackageManager(root) {
864
953
  }
865
954
 
866
955
  // 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);
956
+ import { promises as fs8 } from "fs";
957
+ import path8 from "path";
958
+ function isWithinRoot6(root, target) {
959
+ const relative = path8.relative(root, target);
960
+ return relative === "" || !relative.startsWith("..") && !path8.isAbsolute(relative);
872
961
  }
873
962
  async function readPackageJson(root) {
874
- const packageJsonPath = path7.join(root, "package.json");
963
+ const packageJsonPath = path8.join(root, "package.json");
875
964
  try {
876
965
  const [realRoot, realPackageJsonPath] = await Promise.all([
877
- fs7.realpath(root),
878
- fs7.realpath(packageJsonPath)
966
+ fs8.realpath(root),
967
+ fs8.realpath(packageJsonPath)
879
968
  ]);
880
- if (!isWithinRoot5(realRoot, realPackageJsonPath)) {
969
+ if (!isWithinRoot6(realRoot, realPackageJsonPath)) {
881
970
  return null;
882
971
  }
883
- const content = await fs7.readFile(realPackageJsonPath, "utf8");
972
+ const content = await fs8.readFile(realPackageJsonPath, "utf8");
884
973
  const data = JSON.parse(content);
885
974
  return { path: realPackageJsonPath, data };
886
975
  } catch {
@@ -966,6 +1055,235 @@ async function detectPorts(ports) {
966
1055
  );
967
1056
  }
968
1057
 
1058
+ // src/core/scanner/presets.ts
1059
+ import { promises as fs9 } from "fs";
1060
+ import path9 from "path";
1061
+ function dependencyNames(packageJson) {
1062
+ const data = packageJson?.data;
1063
+ return new Set(
1064
+ Object.keys({
1065
+ ...data?.dependencies,
1066
+ ...data?.devDependencies,
1067
+ ...data?.optionalDependencies,
1068
+ ...data?.peerDependencies
1069
+ })
1070
+ );
1071
+ }
1072
+ function hasAnyDependency(dependencies, names) {
1073
+ return names.some((name) => dependencies.has(name));
1074
+ }
1075
+ async function readIfPresent2(root, candidate) {
1076
+ const filePath = path9.join(root, candidate);
1077
+ try {
1078
+ const [realRoot, realPath] = await Promise.all([fs9.realpath(root), fs9.realpath(filePath)]);
1079
+ const relative = path9.relative(realRoot, realPath);
1080
+ if (relative.startsWith("..") || path9.isAbsolute(relative)) {
1081
+ return null;
1082
+ }
1083
+ return await fs9.readFile(realPath, "utf8");
1084
+ } catch {
1085
+ return null;
1086
+ }
1087
+ }
1088
+ function completePreset(draft) {
1089
+ return {
1090
+ name: draft.name,
1091
+ label: draft.label,
1092
+ commands: draft.commands ?? {},
1093
+ groups: draft.groups ?? {},
1094
+ ports: draft.ports ?? []
1095
+ };
1096
+ }
1097
+ function nodePresets(framework, packageJson) {
1098
+ const detected = new Set(framework?.detected ?? []);
1099
+ const dependencies = dependencyNames(packageJson);
1100
+ const presets = [];
1101
+ if (detected.has("Next.js")) {
1102
+ presets.push({
1103
+ name: "next",
1104
+ label: "Next.js",
1105
+ commands: {
1106
+ "next:dev": "next dev",
1107
+ "next:build": "next build",
1108
+ "next:start": "next start"
1109
+ },
1110
+ groups: {
1111
+ "Next.js": ["next:dev", "next:build", "next:start"]
1112
+ },
1113
+ ports: [3e3]
1114
+ });
1115
+ }
1116
+ if (detected.has("Vite")) {
1117
+ presets.push({
1118
+ name: "vite",
1119
+ label: "Vite",
1120
+ commands: {
1121
+ "vite:dev": "vite --host 127.0.0.1",
1122
+ "vite:build": "vite build",
1123
+ "vite:preview": "vite preview --host 127.0.0.1"
1124
+ },
1125
+ groups: {
1126
+ Vite: ["vite:dev", "vite:build", "vite:preview"]
1127
+ },
1128
+ ports: [5173, 4173]
1129
+ });
1130
+ }
1131
+ if (detected.has("NestJS")) {
1132
+ presets.push({
1133
+ name: "nestjs",
1134
+ label: "NestJS",
1135
+ commands: {
1136
+ "nest:start": "nest start --watch",
1137
+ "nest:build": "nest build"
1138
+ },
1139
+ groups: {
1140
+ NestJS: ["nest:start", "nest:build"]
1141
+ },
1142
+ ports: [3e3]
1143
+ });
1144
+ }
1145
+ if (detected.has("Remix")) {
1146
+ presets.push({
1147
+ name: "remix",
1148
+ label: "Remix",
1149
+ commands: {
1150
+ "remix:dev": "remix vite:dev",
1151
+ "remix:build": "remix vite:build"
1152
+ },
1153
+ groups: {
1154
+ Remix: ["remix:dev", "remix:build"]
1155
+ },
1156
+ ports: [5173]
1157
+ });
1158
+ }
1159
+ if (detected.has("Express") || detected.has("Fastify")) {
1160
+ presets.push({
1161
+ name: detected.has("Fastify") ? "fastify" : "express",
1162
+ label: detected.has("Fastify") ? "Fastify" : "Express",
1163
+ ports: [3e3]
1164
+ });
1165
+ }
1166
+ if (detected.has("Prisma") || hasAnyDependency(dependencies, ["prisma", "@prisma/client"])) {
1167
+ presets.push({
1168
+ name: "prisma",
1169
+ label: "Prisma",
1170
+ commands: {
1171
+ "prisma:migrate": "prisma migrate dev",
1172
+ "prisma:studio": "prisma studio"
1173
+ },
1174
+ groups: {
1175
+ Database: ["prisma:migrate", "prisma:studio"]
1176
+ },
1177
+ ports: [5555]
1178
+ });
1179
+ }
1180
+ return presets.map(completePreset);
1181
+ }
1182
+ async function pythonPresets(root, language) {
1183
+ if (!language.detected.includes("python")) {
1184
+ return [];
1185
+ }
1186
+ const [requirements, pyproject, pipfile] = await Promise.all([
1187
+ readIfPresent2(root, "requirements.txt"),
1188
+ readIfPresent2(root, "pyproject.toml"),
1189
+ readIfPresent2(root, "Pipfile")
1190
+ ]);
1191
+ const manifest = [requirements, pyproject, pipfile].filter(Boolean).join("\n").toLowerCase();
1192
+ const commands = {};
1193
+ const groups = {};
1194
+ const ports = [];
1195
+ if (requirements !== null) {
1196
+ commands["python:install"] = "python -m pip install -r requirements.txt";
1197
+ groups.Setup = ["python:install"];
1198
+ }
1199
+ if (manifest.includes("uvicorn") || manifest.includes("fastapi")) {
1200
+ commands["python:dev"] = "uvicorn main:app --reload --host 127.0.0.1";
1201
+ groups.Python = [...groups.Python ?? [], "python:dev"];
1202
+ ports.push(8e3);
1203
+ }
1204
+ if (manifest.includes("flask")) {
1205
+ commands["flask:dev"] = "flask --app app run --host 127.0.0.1";
1206
+ groups.Python = [...groups.Python ?? [], "flask:dev"];
1207
+ ports.push(5e3);
1208
+ }
1209
+ if (manifest.includes("django") || await readIfPresent2(root, "manage.py") !== null) {
1210
+ commands["django:dev"] = "python manage.py runserver 127.0.0.1:8000";
1211
+ commands["django:migrate"] = "python manage.py migrate";
1212
+ groups.Python = [...groups.Python ?? [], "django:dev", "django:migrate"];
1213
+ ports.push(8e3);
1214
+ }
1215
+ return [
1216
+ completePreset({
1217
+ name: "python",
1218
+ label: "Python",
1219
+ commands,
1220
+ groups,
1221
+ ports
1222
+ })
1223
+ ];
1224
+ }
1225
+ function goPresets(language) {
1226
+ if (!language.detected.includes("go")) {
1227
+ return [];
1228
+ }
1229
+ return [
1230
+ completePreset({
1231
+ name: "go",
1232
+ label: "Go",
1233
+ commands: {
1234
+ "go:run": "go run .",
1235
+ "go:build": "go build ./...",
1236
+ "go:test": "go test ./..."
1237
+ },
1238
+ groups: {
1239
+ Go: ["go:run", "go:build", "go:test"]
1240
+ }
1241
+ })
1242
+ ];
1243
+ }
1244
+ async function javaPresets(language) {
1245
+ if (!language.detected.includes("java")) {
1246
+ return [];
1247
+ }
1248
+ const hasMaven = language.files.some((file) => path9.basename(file) === "pom.xml");
1249
+ const hasGradle = language.files.some((file) => path9.basename(file).startsWith("build.gradle"));
1250
+ const commands = {};
1251
+ const groups = {};
1252
+ if (hasMaven) {
1253
+ commands["maven:test"] = "mvn test";
1254
+ commands["maven:package"] = "mvn package";
1255
+ groups.Maven = ["maven:test", "maven:package"];
1256
+ }
1257
+ if (hasGradle) {
1258
+ commands["gradle:test"] = "gradle test";
1259
+ commands["gradle:build"] = "gradle build";
1260
+ groups.Gradle = ["gradle:test", "gradle:build"];
1261
+ }
1262
+ return [completePreset({ name: "java", label: "Java", commands, groups })];
1263
+ }
1264
+ async function detectPresets(options) {
1265
+ return [
1266
+ ...nodePresets(options.framework, options.packageJson),
1267
+ ...await pythonPresets(options.root, options.language),
1268
+ ...goPresets(options.language),
1269
+ ...await javaPresets(options.language)
1270
+ ].filter(
1271
+ (preset) => Object.keys(preset.commands).length > 0 || Object.keys(preset.groups).length > 0 || preset.ports.length > 0
1272
+ );
1273
+ }
1274
+ function mergePresetCommands(presets) {
1275
+ return Object.assign({}, ...presets.map((preset) => preset.commands));
1276
+ }
1277
+ function mergePresetGroups(presets) {
1278
+ const groups = {};
1279
+ for (const preset of presets) {
1280
+ for (const [group, commands] of Object.entries(preset.groups)) {
1281
+ groups[group] = [...groups[group] ?? [], ...commands];
1282
+ }
1283
+ }
1284
+ return groups;
1285
+ }
1286
+
969
1287
  // src/core/scanner/scripts.ts
970
1288
  function extractScripts(packageJson) {
971
1289
  if (!packageJson?.data.scripts || typeof packageJson.data.scripts !== "object" || Array.isArray(packageJson.data.scripts)) {
@@ -980,17 +1298,17 @@ function extractScripts(packageJson) {
980
1298
  }
981
1299
 
982
1300
  // 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);
1301
+ function isWithinRoot7(root, target) {
1302
+ const relative = path10.relative(path10.resolve(root), path10.resolve(target));
1303
+ return relative === "" || !relative.startsWith("..") && !path10.isAbsolute(relative);
986
1304
  }
987
1305
  async function findFirstFile(root, candidates) {
988
- const resolvedRoot = await fs8.realpath(root).catch(() => path8.resolve(root));
1306
+ const resolvedRoot = await fs10.realpath(root).catch(() => path10.resolve(root));
989
1307
  for (const candidate of candidates) {
990
- const filePath = path8.join(root, candidate);
1308
+ const filePath = path10.join(root, candidate);
991
1309
  try {
992
- const [stat, realPath] = await Promise.all([fs8.stat(filePath), fs8.realpath(filePath)]);
993
- if (stat.isFile() && isWithinRoot6(resolvedRoot, realPath)) {
1310
+ const [stat, realPath] = await Promise.all([fs10.stat(filePath), fs10.realpath(filePath)]);
1311
+ if (stat.isFile() && isWithinRoot7(resolvedRoot, realPath)) {
994
1312
  return { path: realPath, exists: true };
995
1313
  }
996
1314
  } catch {
@@ -1002,15 +1320,25 @@ function configuredPorts(configPorts) {
1002
1320
  return Array.isArray(configPorts) ? configPorts : [];
1003
1321
  }
1004
1322
  async function scanProject(root = process.cwd()) {
1005
- const resolvedRoot = await fs8.realpath(root).catch(() => path8.resolve(root));
1323
+ const resolvedRoot = await fs10.realpath(root).catch(() => path10.resolve(root));
1006
1324
  const config = await loadConfig(resolvedRoot);
1007
1325
  const packageJson = await readPackageJson(resolvedRoot);
1008
1326
  const scripts = extractScripts(packageJson) ?? {};
1009
1327
  const framework = detectFramework(packageJson);
1328
+ const language = await detectProjectLanguage(resolvedRoot, packageJson);
1329
+ const presets = await detectPresets({
1330
+ root: resolvedRoot,
1331
+ packageJson,
1332
+ framework,
1333
+ language
1334
+ });
1335
+ const presetCommands = mergePresetCommands(presets);
1336
+ const presetGroups = mergePresetGroups(presets);
1010
1337
  const portsToProbe = [
1011
1338
  ...configuredPorts(config?.config.ports),
1012
1339
  ...inferPortsFromScripts(scripts),
1013
- ...defaultPortsForFramework(framework)
1340
+ ...defaultPortsForFramework(framework),
1341
+ ...presets.flatMap((preset) => preset.ports)
1014
1342
  ];
1015
1343
  const [packageManager, env, docker, git, ports, readme, license] = await Promise.all([
1016
1344
  detectPackageManager(resolvedRoot),
@@ -1023,14 +1351,18 @@ async function scanProject(root = process.cwd()) {
1023
1351
  ]);
1024
1352
  return {
1025
1353
  root: resolvedRoot,
1026
- projectName: config?.config.name ?? packageJson?.data.name ?? path8.basename(resolvedRoot),
1354
+ projectName: config?.config.name ?? packageJson?.data.name ?? path10.basename(resolvedRoot),
1027
1355
  packageJson,
1028
1356
  packageManager: packageManager ?? (packageJson ? "npm" : null),
1357
+ language,
1029
1358
  scripts,
1030
1359
  env,
1031
1360
  docker,
1032
1361
  git,
1033
1362
  framework,
1363
+ presets,
1364
+ presetCommands,
1365
+ presetGroups,
1034
1366
  ports: ports ?? [],
1035
1367
  readme,
1036
1368
  license,
@@ -1041,18 +1373,18 @@ async function scanProject(root = process.cwd()) {
1041
1373
  // src/core/doctor/index.ts
1042
1374
  async function pathExists(filePath) {
1043
1375
  try {
1044
- await fs9.access(filePath);
1376
+ await fs11.access(filePath);
1045
1377
  return true;
1046
1378
  } catch {
1047
1379
  return false;
1048
1380
  }
1049
1381
  }
1050
- async function readIfPresent2(filePath) {
1382
+ async function readIfPresent3(filePath) {
1051
1383
  if (filePath === null) {
1052
1384
  return null;
1053
1385
  }
1054
1386
  try {
1055
- return await fs9.readFile(filePath, "utf8");
1387
+ return await fs11.readFile(filePath, "utf8");
1056
1388
  } catch {
1057
1389
  return null;
1058
1390
  }
@@ -1068,7 +1400,9 @@ async function runDoctor(root = process.cwd(), scan) {
1068
1400
  warning("config-warning", "warning", "Config warning", configWarning, result.config?.path)
1069
1401
  );
1070
1402
  }
1071
- if (result.packageJson === null) {
1403
+ const isNodeProject = result.language.detected.includes("node");
1404
+ const hasKnownProjectLanguage = result.language.detected.length > 0;
1405
+ if (result.packageJson === null && !hasKnownProjectLanguage) {
1072
1406
  warnings.push(
1073
1407
  warning(
1074
1408
  "missing-package-json",
@@ -1079,7 +1413,7 @@ async function runDoctor(root = process.cwd(), scan) {
1079
1413
  );
1080
1414
  return warnings;
1081
1415
  }
1082
- if (!await pathExists(path9.join(root, "node_modules", ".bin"))) {
1416
+ if (isNodeProject && !await pathExists(path11.join(root, "node_modules", ".bin"))) {
1083
1417
  warnings.push(
1084
1418
  warning(
1085
1419
  "missing-node-modules",
@@ -1126,7 +1460,7 @@ async function runDoctor(root = process.cwd(), scan) {
1126
1460
  warning("missing-readme", "warning", "No README", "No README.md or README file was found.")
1127
1461
  );
1128
1462
  } else {
1129
- const readme = await readIfPresent2(result.readme.path);
1463
+ const readme = await readIfPresent3(result.readme.path);
1130
1464
  if (readme !== null) {
1131
1465
  const references = extractScriptReferences(readme);
1132
1466
  const missingScripts = references.filter((script) => result.scripts[script] === void 0);
@@ -1162,7 +1496,7 @@ async function runDoctor(root = process.cwd(), scan) {
1162
1496
  )
1163
1497
  );
1164
1498
  }
1165
- if (result.scripts.test === void 0) {
1499
+ if (isNodeProject && result.scripts.test === void 0) {
1166
1500
  warnings.push(
1167
1501
  warning(
1168
1502
  "missing-test-script",
@@ -1172,7 +1506,7 @@ async function runDoctor(root = process.cwd(), scan) {
1172
1506
  )
1173
1507
  );
1174
1508
  }
1175
- if (result.scripts.build === void 0) {
1509
+ if (isNodeProject && result.scripts.build === void 0) {
1176
1510
  warnings.push(
1177
1511
  warning(
1178
1512
  "missing-build-script",
@@ -1233,25 +1567,204 @@ async function doctorCommand(cwd = process.cwd()) {
1233
1567
  }
1234
1568
 
1235
1569
  // src/cli/commands/init.ts
1236
- import { promises as fs10 } from "fs";
1237
- import path10 from "path";
1570
+ import { promises as fs12 } from "fs";
1571
+ import path12 from "path";
1238
1572
  import pc2 from "picocolors";
1239
1573
  async function initCommand(cwd = process.cwd()) {
1240
- const configPath = path10.join(cwd, CONFIG_FILE_NAME);
1574
+ const configPath = path12.join(cwd, CONFIG_FILE_NAME);
1241
1575
  try {
1242
- await fs10.access(configPath);
1576
+ await fs12.access(configPath);
1243
1577
  console.log(pc2.yellow(`${CONFIG_FILE_NAME} already exists.`));
1244
1578
  return;
1245
1579
  } catch {
1246
- await fs10.writeFile(configPath, `${JSON.stringify(defaultConfig, null, 2)}
1580
+ await fs12.writeFile(configPath, `${JSON.stringify(defaultConfig, null, 2)}
1247
1581
  `, "utf8");
1248
1582
  console.log(pc2.green(`Created ${CONFIG_FILE_NAME}.`));
1249
1583
  }
1250
1584
  }
1251
1585
 
1252
- // src/cli/commands/run.ts
1586
+ // src/cli/commands/onboard.ts
1253
1587
  import pc3 from "picocolors";
1254
1588
 
1589
+ // src/core/onboarding/index.ts
1590
+ function hasWarning(warnings, id) {
1591
+ return warnings.some((warning2) => warning2.id === id);
1592
+ }
1593
+ function pickStartAction(scan) {
1594
+ if (scan.scripts.dev !== void 0) {
1595
+ return { kind: "run-script", label: "Start dev server", target: "dev" };
1596
+ }
1597
+ if (scan.scripts.start !== void 0) {
1598
+ return { kind: "run-script", label: "Start app", target: "start" };
1599
+ }
1600
+ const configuredCommands = {
1601
+ ...scan.presetCommands,
1602
+ ...scan.config?.config.commands
1603
+ };
1604
+ for (const name of ["dev", "start", "serve"]) {
1605
+ if (configuredCommands[name] !== void 0) {
1606
+ return { kind: "run-command", label: `Run ${name}`, target: name };
1607
+ }
1608
+ }
1609
+ return null;
1610
+ }
1611
+ function dockerStep(scan) {
1612
+ const docker = scan.docker;
1613
+ if (docker === null) {
1614
+ return null;
1615
+ }
1616
+ const runningServices = docker.services.filter((service) => service.status === "running");
1617
+ const allRunning = docker.services.length > 0 && runningServices.length === docker.services.length;
1618
+ if (docker.daemonStatus !== "running") {
1619
+ return {
1620
+ id: "docker-start",
1621
+ title: "Start Docker services",
1622
+ description: docker.message ?? "A Docker Compose file was found, but the Docker engine is not running.",
1623
+ status: "manual",
1624
+ blocking: false,
1625
+ action: { kind: "docker", label: "Open Services" }
1626
+ };
1627
+ }
1628
+ if (docker.services.length === 0 || allRunning) {
1629
+ return {
1630
+ id: "docker-start",
1631
+ title: "Start Docker services",
1632
+ description: docker.services.length === 0 ? "Docker is running. No Compose services need to be started." : "All Docker Compose services are running.",
1633
+ status: "done",
1634
+ blocking: false
1635
+ };
1636
+ }
1637
+ return {
1638
+ id: "docker-start",
1639
+ title: "Start Docker services",
1640
+ description: `${runningServices.length}/${docker.services.length} Compose services running. Start the rest in Services.`,
1641
+ status: "todo",
1642
+ blocking: false,
1643
+ action: { kind: "docker", label: "Open Services" }
1644
+ };
1645
+ }
1646
+ function buildOnboardingPlan(scan, warnings) {
1647
+ const steps = [];
1648
+ const isNodeProject = scan.language.detected.includes("node");
1649
+ if (isNodeProject && scan.packageJson !== null) {
1650
+ const needsInstall = hasWarning(warnings, "missing-node-modules");
1651
+ steps.push({
1652
+ id: "install-dependencies",
1653
+ title: "Install dependencies",
1654
+ description: needsInstall ? "node_modules is missing. Install dependencies before running scripts." : "Dependencies are installed.",
1655
+ status: needsInstall ? "todo" : "done",
1656
+ blocking: true,
1657
+ action: needsInstall ? { kind: "install", label: "Install" } : void 0
1658
+ });
1659
+ }
1660
+ if (scan.env?.hasExample) {
1661
+ const missingLocal = !scan.env.hasLocal;
1662
+ steps.push({
1663
+ id: "create-env",
1664
+ title: "Create .env file",
1665
+ description: missingLocal ? ".env.example exists but the local .env file is missing." : ".env is present.",
1666
+ status: missingLocal ? "todo" : "done",
1667
+ blocking: true,
1668
+ action: missingLocal ? { kind: "env-copy", label: "Copy .env" } : void 0
1669
+ });
1670
+ if (scan.env.hasLocal) {
1671
+ const unset = [.../* @__PURE__ */ new Set([...scan.env.missingKeys, ...scan.env.emptyKeys])];
1672
+ steps.push({
1673
+ id: "fill-env",
1674
+ title: "Fill in environment values",
1675
+ description: unset.length > 0 ? `Set values for: ${unset.join(", ")}. Values are intentionally hidden.` : "All environment keys from the example are present.",
1676
+ status: unset.length > 0 ? "manual" : "done",
1677
+ blocking: true
1678
+ });
1679
+ }
1680
+ }
1681
+ const docker = dockerStep(scan);
1682
+ if (docker !== null) {
1683
+ steps.push(docker);
1684
+ }
1685
+ const portsInUse = scan.ports.filter((probe) => probe.inUse);
1686
+ if (portsInUse.length > 0) {
1687
+ steps.push({
1688
+ id: "free-ports",
1689
+ title: "Resolve port conflicts",
1690
+ description: `Already in use: ${portsInUse.map((probe) => probe.port).join(", ")}. Stop the conflicting process or change the port.`,
1691
+ status: "manual",
1692
+ blocking: false
1693
+ });
1694
+ }
1695
+ for (const [index, entry] of (scan.config?.config.setupGuide ?? []).entries()) {
1696
+ steps.push({
1697
+ id: `guide-${index}`,
1698
+ title: entry,
1699
+ description: "From the project setup guide.",
1700
+ status: "manual",
1701
+ blocking: false
1702
+ });
1703
+ }
1704
+ const docs = scan.config?.config.docs;
1705
+ if (typeof docs === "string" && docs.length > 0 && isSafeHttpUrl(docs)) {
1706
+ steps.push({
1707
+ id: "read-docs",
1708
+ title: "Read the project docs",
1709
+ description: docs,
1710
+ status: "manual",
1711
+ blocking: false,
1712
+ action: { kind: "open-docs", label: "Open docs", target: docs }
1713
+ });
1714
+ }
1715
+ const startAction = pickStartAction(scan);
1716
+ if (startAction !== null) {
1717
+ steps.push({
1718
+ id: "start-app",
1719
+ title: "Start the app",
1720
+ description: "Run the development server once setup is complete.",
1721
+ status: "todo",
1722
+ blocking: false,
1723
+ action: startAction
1724
+ });
1725
+ }
1726
+ const blocking = steps.filter((step) => step.blocking);
1727
+ const blockingDone = blocking.filter((step) => step.status === "done");
1728
+ const readiness = blocking.length === 0 ? 100 : Math.round(blockingDone.length / blocking.length * 100);
1729
+ const ready = readiness === 100;
1730
+ const remaining = blocking.length - blockingDone.length;
1731
+ const summary = ready ? "Project is ready to run." : `${remaining} setup step${remaining === 1 ? "" : "s"} remaining before the project is ready.`;
1732
+ return { steps, readiness, ready, summary };
1733
+ }
1734
+
1735
+ // src/cli/commands/onboard.ts
1736
+ function statusGlyph(status) {
1737
+ if (status === "done") {
1738
+ return pc3.green("[x]");
1739
+ }
1740
+ if (status === "todo") {
1741
+ return pc3.yellow("[ ]");
1742
+ }
1743
+ return pc3.cyan("[~]");
1744
+ }
1745
+ async function onboardCommand(cwd = process.cwd()) {
1746
+ const scan = await scanProject(cwd);
1747
+ const warnings = await runDoctor(cwd, scan);
1748
+ const plan = buildOnboardingPlan(scan, warnings);
1749
+ console.log(pc3.bold(`Onboarding ${safeDisplayText(scan.projectName)}`));
1750
+ console.log(`${plan.readiness}% ready \u2014 ${safeDisplayText(plan.summary)}`);
1751
+ console.log("");
1752
+ if (plan.steps.length === 0) {
1753
+ console.log(pc3.green("No onboarding steps detected."));
1754
+ return;
1755
+ }
1756
+ for (const step of plan.steps) {
1757
+ console.log(`${statusGlyph(step.status)} ${pc3.bold(safeDisplayText(step.title))}`);
1758
+ console.log(` ${safeDisplayText(step.description)}`);
1759
+ if (step.action && step.status !== "done") {
1760
+ console.log(pc3.dim(` \u2192 ${safeDisplayText(step.action.label)}`));
1761
+ }
1762
+ }
1763
+ }
1764
+
1765
+ // src/cli/commands/run.ts
1766
+ import pc4 from "picocolors";
1767
+
1255
1768
  // src/core/process/runner.ts
1256
1769
  import spawn2 from "cross-spawn";
1257
1770
 
@@ -1430,38 +1943,74 @@ async function runPackageScriptToTerminal(options) {
1430
1943
  });
1431
1944
  });
1432
1945
  }
1946
+ async function runConfiguredCommandToTerminal(options) {
1947
+ const resolvedCommand = await resolveConfiguredCommand(options.cwd, options.command);
1948
+ if (resolvedCommand === null) {
1949
+ return 1;
1950
+ }
1951
+ return await new Promise((resolve) => {
1952
+ const child = spawn2(resolvedCommand.command, resolvedCommand.args, {
1953
+ cwd: options.cwd,
1954
+ stdio: "inherit",
1955
+ windowsHide: true
1956
+ });
1957
+ child.on("error", () => {
1958
+ resolve(1);
1959
+ });
1960
+ child.on("close", (code) => {
1961
+ resolve(code ?? 1);
1962
+ });
1963
+ });
1964
+ }
1433
1965
 
1434
1966
  // src/cli/commands/run.ts
1435
1967
  async function runCommand(script, cwd = process.cwd()) {
1436
1968
  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;
1969
+ if (scan.scripts[script] !== void 0) {
1970
+ const exitCode = await runPackageScriptToTerminal({
1971
+ cwd,
1972
+ packageManager: scan.packageManager,
1973
+ script
1974
+ });
1975
+ process.exitCode = exitCode;
1440
1976
  return;
1441
1977
  }
1442
- if (scan.scripts[script] === void 0) {
1443
- console.error(pc3.red(`Script "${script}" was not found in package.json.`));
1444
- process.exitCode = 1;
1978
+ const configuredCommand = scan.config?.config.commands?.[script] ?? scan.presetCommands[script];
1979
+ if (configuredCommand !== void 0) {
1980
+ if (isDangerousCommand(configuredCommand)) {
1981
+ console.error(pc4.red(`Refusing to run dangerous command "${safeDisplayText(script)}".`));
1982
+ process.exitCode = 1;
1983
+ return;
1984
+ }
1985
+ const exitCode = await runConfiguredCommandToTerminal({
1986
+ cwd,
1987
+ command: configuredCommand
1988
+ });
1989
+ process.exitCode = exitCode;
1445
1990
  return;
1446
1991
  }
1447
- const exitCode = await runPackageScriptToTerminal({
1448
- cwd,
1449
- packageManager: scan.packageManager,
1450
- script
1451
- });
1452
- process.exitCode = exitCode;
1992
+ const available = [
1993
+ ...Object.keys(scan.scripts),
1994
+ ...Object.keys(scan.config?.config.commands ?? {}),
1995
+ ...Object.keys(scan.presetCommands)
1996
+ ];
1997
+ const hint = available.length > 0 ? ` Available commands: ${safeDisplayList(available)}.` : "";
1998
+ console.error(pc4.red(`Command "${safeDisplayText(script)}" was not found.${hint}`));
1999
+ process.exitCode = 1;
1453
2000
  }
1454
2001
 
1455
2002
  // src/cli/commands/scan.ts
1456
- import pc4 from "picocolors";
2003
+ import pc5 from "picocolors";
1457
2004
  function formatList(values) {
1458
2005
  return safeDisplayList(values);
1459
2006
  }
1460
2007
  function printScanResult(scan) {
1461
- console.log(pc4.bold(`Project: ${safeDisplayText(scan.projectName)}`));
2008
+ console.log(pc5.bold(`Project: ${safeDisplayText(scan.projectName)}`));
2009
+ console.log(`Language: ${formatList(scan.language.detected) || "unknown"}`);
1462
2010
  console.log(`Type: ${safeDisplayText(scan.framework?.type ?? "Unknown")}`);
1463
2011
  console.log(`Manager: ${safeDisplayText(scan.packageManager ?? "unknown")}`);
1464
2012
  console.log(`Scripts: ${formatList(Object.keys(scan.scripts))}`);
2013
+ console.log(`Presets: ${formatList(scan.presets.map((preset) => preset.label)) || "none"}`);
1465
2014
  console.log(`Git: ${safeDisplayText(scan.git?.branch ?? "not detected")}`);
1466
2015
  console.log(`README: ${scan.readme.exists ? "found" : "missing"}`);
1467
2016
  console.log(`LICENSE: ${scan.license.exists ? "found" : "missing"}`);
@@ -1483,35 +2032,35 @@ async function scanCommand(cwd = process.cwd()) {
1483
2032
  }
1484
2033
 
1485
2034
  // src/cli/commands/start.ts
1486
- import pc5 from "picocolors";
2035
+ import pc6 from "picocolors";
1487
2036
 
1488
2037
  // node_modules/open/index.js
1489
2038
  import process7 from "process";
1490
2039
  import { Buffer as Buffer2 } from "buffer";
1491
- import path11 from "path";
2040
+ import path13 from "path";
1492
2041
  import { fileURLToPath } from "url";
1493
2042
  import { promisify as promisify5 } from "util";
1494
2043
  import childProcess from "child_process";
1495
- import fs15, { constants as fsConstants2 } from "fs/promises";
2044
+ import fs17, { constants as fsConstants2 } from "fs/promises";
1496
2045
 
1497
2046
  // node_modules/wsl-utils/index.js
1498
2047
  import process3 from "process";
1499
- import fs14, { constants as fsConstants } from "fs/promises";
2048
+ import fs16, { constants as fsConstants } from "fs/promises";
1500
2049
 
1501
2050
  // node_modules/is-wsl/index.js
1502
2051
  import process2 from "process";
1503
2052
  import os2 from "os";
1504
- import fs13 from "fs";
2053
+ import fs15 from "fs";
1505
2054
 
1506
2055
  // node_modules/is-inside-container/index.js
1507
- import fs12 from "fs";
2056
+ import fs14 from "fs";
1508
2057
 
1509
2058
  // node_modules/is-docker/index.js
1510
- import fs11 from "fs";
2059
+ import fs13 from "fs";
1511
2060
  var isDockerCached;
1512
2061
  function hasDockerEnv() {
1513
2062
  try {
1514
- fs11.statSync("/.dockerenv");
2063
+ fs13.statSync("/.dockerenv");
1515
2064
  return true;
1516
2065
  } catch {
1517
2066
  return false;
@@ -1519,7 +2068,7 @@ function hasDockerEnv() {
1519
2068
  }
1520
2069
  function hasDockerCGroup() {
1521
2070
  try {
1522
- return fs11.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
2071
+ return fs13.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
1523
2072
  } catch {
1524
2073
  return false;
1525
2074
  }
@@ -1535,7 +2084,7 @@ function isDocker() {
1535
2084
  var cachedResult;
1536
2085
  var hasContainerEnv = () => {
1537
2086
  try {
1538
- fs12.statSync("/run/.containerenv");
2087
+ fs14.statSync("/run/.containerenv");
1539
2088
  return true;
1540
2089
  } catch {
1541
2090
  return false;
@@ -1560,12 +2109,12 @@ var isWsl = () => {
1560
2109
  return true;
1561
2110
  }
1562
2111
  try {
1563
- if (fs13.readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft")) {
2112
+ if (fs15.readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft")) {
1564
2113
  return !isInsideContainer();
1565
2114
  }
1566
2115
  } catch {
1567
2116
  }
1568
- if (fs13.existsSync("/proc/sys/fs/binfmt_misc/WSLInterop") || fs13.existsSync("/run/WSL")) {
2117
+ if (fs15.existsSync("/proc/sys/fs/binfmt_misc/WSLInterop") || fs15.existsSync("/run/WSL")) {
1569
2118
  return !isInsideContainer();
1570
2119
  }
1571
2120
  return false;
@@ -1583,14 +2132,14 @@ var wslDrivesMountPoint = /* @__PURE__ */ (() => {
1583
2132
  const configFilePath = "/etc/wsl.conf";
1584
2133
  let isConfigFileExists = false;
1585
2134
  try {
1586
- await fs14.access(configFilePath, fsConstants.F_OK);
2135
+ await fs16.access(configFilePath, fsConstants.F_OK);
1587
2136
  isConfigFileExists = true;
1588
2137
  } catch {
1589
2138
  }
1590
2139
  if (!isConfigFileExists) {
1591
2140
  return defaultMountPoint;
1592
2141
  }
1593
- const configContent = await fs14.readFile(configFilePath, { encoding: "utf8" });
2142
+ const configContent = await fs16.readFile(configFilePath, { encoding: "utf8" });
1594
2143
  const configMountPoint = /(?<!#.*)root\s*=\s*(?<mountPoint>.*)/g.exec(configContent);
1595
2144
  if (!configMountPoint) {
1596
2145
  return defaultMountPoint;
@@ -1744,8 +2293,8 @@ async function defaultBrowser2() {
1744
2293
 
1745
2294
  // node_modules/open/index.js
1746
2295
  var execFile5 = promisify5(childProcess.execFile);
1747
- var __dirname = path11.dirname(fileURLToPath(import.meta.url));
1748
- var localXdgOpenPath = path11.join(__dirname, "xdg-open");
2296
+ var __dirname = path13.dirname(fileURLToPath(import.meta.url));
2297
+ var localXdgOpenPath = path13.join(__dirname, "xdg-open");
1749
2298
  var { platform, arch } = process7;
1750
2299
  async function getWindowsDefaultBrowserFromWsl() {
1751
2300
  const powershellPath = await powerShellPath();
@@ -1895,7 +2444,7 @@ var baseOpen = async (options) => {
1895
2444
  const isBundled = !__dirname || __dirname === "/";
1896
2445
  let exeLocalXdgOpen = false;
1897
2446
  try {
1898
- await fs15.access(localXdgOpenPath, fsConstants2.X_OK);
2447
+ await fs17.access(localXdgOpenPath, fsConstants2.X_OK);
1899
2448
  exeLocalXdgOpen = true;
1900
2449
  } catch {
1901
2450
  }
@@ -2000,8 +2549,8 @@ defineLazyProperty(apps, "browserPrivate", () => "browserPrivate");
2000
2549
  var open_default = open;
2001
2550
 
2002
2551
  // src/server/index.ts
2003
- import { promises as fs19 } from "fs";
2004
- import path15 from "path";
2552
+ import { promises as fs21 } from "fs";
2553
+ import path17 from "path";
2005
2554
  import { fileURLToPath as fileURLToPath2 } from "url";
2006
2555
  import { createAdaptorServer } from "@hono/node-server";
2007
2556
  import { serveStatic } from "@hono/node-server/serve-static";
@@ -2151,16 +2700,16 @@ var ProcessManager = class extends EventEmitter {
2151
2700
 
2152
2701
  // src/core/hub/registry.ts
2153
2702
  import { createHash } from "crypto";
2154
- import { promises as fs17 } from "fs";
2703
+ import { promises as fs19 } from "fs";
2155
2704
  import os3 from "os";
2156
- import path13 from "path";
2705
+ import path15 from "path";
2157
2706
 
2158
2707
  // src/core/hub/workspaceRoots.ts
2159
- import { promises as fs16 } from "fs";
2160
- import path12 from "path";
2161
- function isWithinRoot7(root, target) {
2162
- const relative = path12.relative(root, target);
2163
- return relative === "" || !relative.startsWith("..") && !path12.isAbsolute(relative);
2708
+ import { promises as fs18 } from "fs";
2709
+ import path14 from "path";
2710
+ function isWithinRoot8(root, target) {
2711
+ const relative = path14.relative(root, target);
2712
+ return relative === "" || !relative.startsWith("..") && !path14.isAbsolute(relative);
2164
2713
  }
2165
2714
  async function configuredWorkspaceRoots() {
2166
2715
  const raw = process.env.DEVSURFACE_WORKSPACE_ROOTS;
@@ -2174,7 +2723,7 @@ async function configuredWorkspaceRoots() {
2174
2723
  continue;
2175
2724
  }
2176
2725
  try {
2177
- roots.push(await fs16.realpath(path12.resolve(trimmed)));
2726
+ roots.push(await fs18.realpath(path14.resolve(trimmed)));
2178
2727
  } catch {
2179
2728
  }
2180
2729
  }
@@ -2186,7 +2735,7 @@ async function assertWithinWorkspaceRoots(targetPath) {
2186
2735
  return;
2187
2736
  }
2188
2737
  for (const root of roots) {
2189
- if (isWithinRoot7(root, targetPath)) {
2738
+ if (isWithinRoot8(root, targetPath)) {
2190
2739
  return;
2191
2740
  }
2192
2741
  }
@@ -2195,16 +2744,16 @@ async function assertWithinWorkspaceRoots(targetPath) {
2195
2744
 
2196
2745
  // src/core/hub/registry.ts
2197
2746
  function workspaceId(realPath) {
2198
- const base = path13.basename(realPath).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 32) || "workspace";
2747
+ const base = path15.basename(realPath).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 32) || "workspace";
2199
2748
  const hash = createHash("sha256").update(realPath).digest("hex").slice(0, 6);
2200
2749
  return `${base}-${hash}`;
2201
2750
  }
2202
2751
  function defaultDataDir() {
2203
- return process.env.DEVSURFACE_DATA_DIR ?? path13.join(os3.homedir(), ".devsurface");
2752
+ return process.env.DEVSURFACE_DATA_DIR ?? path15.join(os3.homedir(), ".devsurface");
2204
2753
  }
2205
2754
  async function readPackageName(dirPath) {
2206
2755
  try {
2207
- const raw = JSON.parse(await fs17.readFile(path13.join(dirPath, "package.json"), "utf8"));
2756
+ const raw = JSON.parse(await fs19.readFile(path15.join(dirPath, "package.json"), "utf8"));
2208
2757
  return typeof raw?.name === "string" && raw.name.length > 0 ? raw.name : null;
2209
2758
  } catch {
2210
2759
  return null;
@@ -2215,7 +2764,7 @@ var WorkspaceRegistry = class {
2215
2764
  seeded = false;
2216
2765
  constructor(dataDir) {
2217
2766
  const dir = dataDir ?? defaultDataDir();
2218
- this.filePath = path13.join(dir, "workspaces.json");
2767
+ this.filePath = path15.join(dir, "workspaces.json");
2219
2768
  }
2220
2769
  async list() {
2221
2770
  await this.seedFromEnv();
@@ -2229,7 +2778,7 @@ var WorkspaceRegistry = class {
2229
2778
  if (existing) {
2230
2779
  return existing;
2231
2780
  }
2232
- const name = await readPackageName(realDir) ?? path13.basename(realDir);
2781
+ const name = await readPackageName(realDir) ?? path15.basename(realDir);
2233
2782
  const entry = {
2234
2783
  id: workspaceId(realDir),
2235
2784
  name,
@@ -2251,7 +2800,7 @@ var WorkspaceRegistry = class {
2251
2800
  }
2252
2801
  async findByPath(dirPath) {
2253
2802
  try {
2254
- const realDir = await fs17.realpath(path13.resolve(dirPath));
2803
+ const realDir = await fs19.realpath(path15.resolve(dirPath));
2255
2804
  const entries = await this.read();
2256
2805
  return entries.find((entry) => entry.path === realDir) ?? null;
2257
2806
  } catch {
@@ -2279,9 +2828,9 @@ var WorkspaceRegistry = class {
2279
2828
  }
2280
2829
  }
2281
2830
  async resolveDir(dirPath) {
2282
- const resolved = path13.resolve(dirPath);
2283
- const realDir = await fs17.realpath(resolved);
2284
- const stat = await fs17.stat(realDir);
2831
+ const resolved = path15.resolve(dirPath);
2832
+ const realDir = await fs19.realpath(resolved);
2833
+ const stat = await fs19.stat(realDir);
2285
2834
  if (!stat.isDirectory()) {
2286
2835
  throw new Error(`${dirPath} is not a directory.`);
2287
2836
  }
@@ -2289,7 +2838,7 @@ var WorkspaceRegistry = class {
2289
2838
  }
2290
2839
  async read() {
2291
2840
  try {
2292
- const content = await fs17.readFile(this.filePath, "utf8");
2841
+ const content = await fs19.readFile(this.filePath, "utf8");
2293
2842
  const parsed = JSON.parse(content);
2294
2843
  return Array.isArray(parsed) ? parsed : [];
2295
2844
  } catch {
@@ -2297,8 +2846,11 @@ var WorkspaceRegistry = class {
2297
2846
  }
2298
2847
  }
2299
2848
  async write(entries) {
2300
- await fs17.mkdir(path13.dirname(this.filePath), { recursive: true });
2301
- await fs17.writeFile(this.filePath, JSON.stringify(entries, null, 2) + "\n", "utf8");
2849
+ await fs19.mkdir(path15.dirname(this.filePath), { recursive: true });
2850
+ await fs19.writeFile(this.filePath, JSON.stringify(entries, null, 2) + "\n", {
2851
+ encoding: "utf8",
2852
+ mode: 384
2853
+ });
2302
2854
  }
2303
2855
  async seedFromEnv() {
2304
2856
  if (this.seeded) {
@@ -2380,12 +2932,12 @@ var Hub = class {
2380
2932
 
2381
2933
  // src/server/routes/api.ts
2382
2934
  import { constants as constants2, existsSync } from "fs";
2383
- import { promises as fs18 } from "fs";
2384
- import path14 from "path";
2935
+ import { promises as fs20 } from "fs";
2936
+ import path16 from "path";
2385
2937
  import spawn4 from "cross-spawn";
2386
2938
 
2387
2939
  // src/version.ts
2388
- var DEV_SURFACE_VERSION = "0.4.0";
2940
+ var DEV_SURFACE_VERSION = "0.6.0";
2389
2941
 
2390
2942
  // src/server/localAccess.ts
2391
2943
  var LOCAL_HOSTNAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
@@ -2571,9 +3123,9 @@ function isAllowedTerminalCommand(command) {
2571
3123
  }
2572
3124
 
2573
3125
  // src/server/routes/api.ts
2574
- function isWithinRoot8(root, target) {
2575
- const relative = path14.relative(path14.resolve(root), path14.resolve(target));
2576
- return relative === "" || !relative.startsWith("..") && !path14.isAbsolute(relative);
3126
+ function isWithinRoot9(root, target) {
3127
+ const relative = path16.relative(path16.resolve(root), path16.resolve(target));
3128
+ return relative === "" || !relative.startsWith("..") && !path16.isAbsolute(relative);
2577
3129
  }
2578
3130
  function isAllowedMutationOrigin(requestUrl, origin) {
2579
3131
  if (origin === null) {
@@ -2605,35 +3157,35 @@ function hasMutationIntent(intent) {
2605
3157
  return intent === "dashboard";
2606
3158
  }
2607
3159
  async function realPathWithinRoot(root, target) {
2608
- if (!isWithinRoot8(root, target)) {
3160
+ if (!isWithinRoot9(root, target)) {
2609
3161
  return false;
2610
3162
  }
2611
3163
  try {
2612
- const [realRoot, realTarget] = await Promise.all([fs18.realpath(root), fs18.realpath(target)]);
2613
- return isWithinRoot8(realRoot, realTarget);
3164
+ const [realRoot, realTarget] = await Promise.all([fs20.realpath(root), fs20.realpath(target)]);
3165
+ return isWithinRoot9(realRoot, realTarget);
2614
3166
  } catch {
2615
3167
  return false;
2616
3168
  }
2617
3169
  }
2618
3170
  async function writableDestinationWithinRoot(root, destination) {
2619
- if (!isWithinRoot8(root, destination)) {
3171
+ if (!isWithinRoot9(root, destination)) {
2620
3172
  return false;
2621
3173
  }
2622
3174
  try {
2623
3175
  const [realRoot, realParent] = await Promise.all([
2624
- fs18.realpath(root),
2625
- fs18.realpath(path14.dirname(destination))
3176
+ fs20.realpath(root),
3177
+ fs20.realpath(path16.dirname(destination))
2626
3178
  ]);
2627
- return isWithinRoot8(realRoot, realParent);
3179
+ return isWithinRoot9(realRoot, realParent);
2628
3180
  } catch {
2629
3181
  return false;
2630
3182
  }
2631
3183
  }
2632
3184
  async function copyFileExclusive(source, destination) {
2633
- const content = await fs18.readFile(source);
3185
+ const content = await fs20.readFile(source);
2634
3186
  let handle2 = null;
2635
3187
  try {
2636
- handle2 = await fs18.open(
3188
+ handle2 = await fs20.open(
2637
3189
  destination,
2638
3190
  constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY,
2639
3191
  384
@@ -2660,15 +3212,15 @@ function resolveCommandPromptExecutable() {
2660
3212
  return process.env.ComSpec ?? "cmd.exe";
2661
3213
  }
2662
3214
  function findExecutable(command) {
2663
- if (path14.isAbsolute(command)) {
3215
+ if (path16.isAbsolute(command)) {
2664
3216
  return existsSync(command) ? command : null;
2665
3217
  }
2666
3218
  const pathValue = process.env.PATH ?? "";
2667
- for (const directory of pathValue.split(path14.delimiter)) {
3219
+ for (const directory of pathValue.split(path16.delimiter)) {
2668
3220
  if (directory.length === 0) {
2669
3221
  continue;
2670
3222
  }
2671
- const candidate = path14.join(directory, command);
3223
+ const candidate = path16.join(directory, command);
2672
3224
  if (existsSync(candidate)) {
2673
3225
  return candidate;
2674
3226
  }
@@ -2746,6 +3298,11 @@ function handleDockerError(error, context) {
2746
3298
  }
2747
3299
  throw error;
2748
3300
  }
3301
+ async function onboardingForRoot(root) {
3302
+ const scan = await scanProject(root);
3303
+ const warnings = await runDoctor(root, scan);
3304
+ return buildOnboardingPlan(scan, warnings);
3305
+ }
2749
3306
  function registerWorkspaceRoutes(app, resolveWorkspace) {
2750
3307
  app.get("/api/workspaces/:id/project", async (context) => {
2751
3308
  const ws = await resolveWorkspace(context.req.param("id"));
@@ -2757,6 +3314,11 @@ function registerWorkspaceRoutes(app, resolveWorkspace) {
2757
3314
  if (!ws) return context.json({ error: "Workspace not found." }, 404);
2758
3315
  return context.json(await runDoctor(ws.root));
2759
3316
  });
3317
+ app.get("/api/workspaces/:id/onboarding", async (context) => {
3318
+ const ws = await resolveWorkspace(context.req.param("id"));
3319
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
3320
+ return context.json(await onboardingForRoot(ws.root));
3321
+ });
2760
3322
  app.get("/api/workspaces/:id/processes", async (context) => {
2761
3323
  const ws = await resolveWorkspace(context.req.param("id"));
2762
3324
  if (!ws) return context.json({ error: "Workspace not found." }, 404);
@@ -2851,7 +3413,7 @@ function registerWorkspaceRoutes(app, resolveWorkspace) {
2851
3413
  if (!ws) return context.json({ error: "Workspace not found." }, 404);
2852
3414
  const name = decodeURIComponent(context.req.param("name"));
2853
3415
  const scan = await scanProject(ws.root);
2854
- const configuredCommand = scan.config?.config.commands?.[name] ?? null;
3416
+ const configuredCommand = scan.config?.config.commands?.[name] ?? scan.presetCommands[name] ?? null;
2855
3417
  if (configuredCommand === null) {
2856
3418
  return context.json({ error: `Configured command "${name}" was not found.` }, 404);
2857
3419
  }
@@ -2885,7 +3447,7 @@ function registerWorkspaceRoutes(app, resolveWorkspace) {
2885
3447
  app.post("/api/workspaces/:id/open/package", async (context) => {
2886
3448
  const ws = await resolveWorkspace(context.req.param("id"));
2887
3449
  if (!ws) return context.json({ error: "Workspace not found." }, 404);
2888
- const packagePath = path14.join(ws.root, "package.json");
3450
+ const packagePath = path16.join(ws.root, "package.json");
2889
3451
  if (!await realPathWithinRoot(ws.root, packagePath)) {
2890
3452
  return context.json({ error: "package.json was not found inside the project root." }, 404);
2891
3453
  }
@@ -2907,7 +3469,7 @@ function registerWorkspaceRoutes(app, resolveWorkspace) {
2907
3469
  if (examplePath === null) {
2908
3470
  return context.json({ error: ".env.example was not found." }, 404);
2909
3471
  }
2910
- const destination = localPath ?? path14.join(ws.root, scan.config?.config.env?.local ?? ".env");
3472
+ const destination = localPath ?? path16.join(ws.root, scan.config?.config.env?.local ?? ".env");
2911
3473
  if (!await realPathWithinRoot(ws.root, examplePath) || !await writableDestinationWithinRoot(ws.root, destination)) {
2912
3474
  return context.json({ error: "Refusing to copy env files outside the project root." }, 400);
2913
3475
  }
@@ -2979,6 +3541,11 @@ function registerHubApiRoutes(app, options) {
2979
3541
  if (entries.length === 0) return context.json({ error: "No workspaces registered." }, 404);
2980
3542
  return context.json(await runDoctor(hub.ensure(entries[0]).root));
2981
3543
  });
3544
+ app.get("/api/onboarding", async (context) => {
3545
+ const entries = await hub.registry.list();
3546
+ if (entries.length === 0) return context.json({ error: "No workspaces registered." }, 404);
3547
+ return context.json(await onboardingForRoot(hub.ensure(entries[0]).root));
3548
+ });
2982
3549
  app.get("/api/processes", async (context) => {
2983
3550
  const entries = await hub.registry.list();
2984
3551
  if (entries.length === 0) return context.json([]);
@@ -3082,23 +3649,34 @@ function setupHubWebSocket(server, hub) {
3082
3649
  }
3083
3650
 
3084
3651
  // src/server/index.ts
3652
+ function warnIfContainerRootsUnset(host) {
3653
+ if (host !== "0.0.0.0" && host !== "::") {
3654
+ return;
3655
+ }
3656
+ if (process.env.DEVSURFACE_WORKSPACE_ROOTS?.trim()) {
3657
+ return;
3658
+ }
3659
+ console.warn(
3660
+ "Warning: DEVSURFACE_WORKSPACE_ROOTS is unset in container mode. Any loopback or private-network client can register arbitrary directories as workspaces. Set DEVSURFACE_WORKSPACE_ROOTS to restrict workspace registration."
3661
+ );
3662
+ }
3085
3663
  async function fileExists(filePath) {
3086
3664
  try {
3087
- await fs19.access(filePath);
3665
+ await fs21.access(filePath);
3088
3666
  return true;
3089
3667
  } catch {
3090
3668
  return false;
3091
3669
  }
3092
3670
  }
3093
3671
  async function findWebDistDir() {
3094
- const moduleDir = path15.dirname(fileURLToPath2(import.meta.url));
3672
+ const moduleDir = path17.dirname(fileURLToPath2(import.meta.url));
3095
3673
  const candidates = [
3096
- path15.join(moduleDir, "..", "web", "dist"),
3097
- path15.join(moduleDir, "..", "..", "src", "web", "dist"),
3098
- path15.join(moduleDir, "web", "dist")
3674
+ path17.join(moduleDir, "..", "web", "dist"),
3675
+ path17.join(moduleDir, "..", "..", "src", "web", "dist"),
3676
+ path17.join(moduleDir, "web", "dist")
3099
3677
  ];
3100
3678
  for (const candidate of candidates) {
3101
- if (await fileExists(path15.join(candidate, "index.html"))) {
3679
+ if (await fileExists(path17.join(candidate, "index.html"))) {
3102
3680
  return candidate;
3103
3681
  }
3104
3682
  }
@@ -3170,7 +3748,7 @@ async function mountWebUi(app) {
3170
3748
  app.use("/assets/*", serveStatic({ root: webDistDir }));
3171
3749
  app.get("/favicon.svg", serveStatic({ root: webDistDir }));
3172
3750
  app.get("*", async (context) => {
3173
- const html = await fs19.readFile(path15.join(webDistDir, "index.html"), "utf8");
3751
+ const html = await fs21.readFile(path17.join(webDistDir, "index.html"), "utf8");
3174
3752
  return context.html(html);
3175
3753
  });
3176
3754
  } else {
@@ -3197,6 +3775,7 @@ async function startHubServer(options) {
3197
3775
  const port = options.port ?? DEFAULT_PORT;
3198
3776
  const hub = new Hub({ dataDir: options.dataDir });
3199
3777
  hub.attachCleanupHandlers();
3778
+ warnIfContainerRootsUnset(host);
3200
3779
  if (options.initialWorkspace) {
3201
3780
  await hub.registry.add(options.initialWorkspace);
3202
3781
  }
@@ -3272,7 +3851,7 @@ function dashboardUrl(workspaceId2, port = DEFAULT_PORT, host = DEFAULT_HOST) {
3272
3851
  async function startCommand(options) {
3273
3852
  const cwd = options.cwd ?? process.cwd();
3274
3853
  const port = options.port ?? 4567;
3275
- console.log(pc5.bold(`DevSurface v${DEV_SURFACE_VERSION}`));
3854
+ console.log(pc6.bold(`DevSurface v${DEV_SURFACE_VERSION}`));
3276
3855
  console.log("Scanning project...\n");
3277
3856
  const scan = await scanProject(cwd);
3278
3857
  printScanResult(scan);
@@ -3280,7 +3859,7 @@ async function startCommand(options) {
3280
3859
  if (warnings.length > 0) {
3281
3860
  console.log("\nWarnings:");
3282
3861
  for (const item of warnings) {
3283
- const marker = item.severity === "error" ? pc5.red("!") : pc5.yellow("!");
3862
+ const marker = item.severity === "error" ? pc6.red("!") : pc6.yellow("!");
3284
3863
  console.log(` ${marker} ${item.title}`);
3285
3864
  }
3286
3865
  }
@@ -3289,8 +3868,8 @@ async function startCommand(options) {
3289
3868
  const registered = await registerWorkspaceRemotely(cwd, port);
3290
3869
  if (registered) {
3291
3870
  const url = dashboardUrl(registered.id, port);
3292
- console.log(`Workspace ${pc5.cyan(registered.name)} attached.`);
3293
- console.log(`Dashboard -> ${pc5.cyan(url)}`);
3871
+ console.log(`Workspace ${pc6.cyan(registered.name)} attached.`);
3872
+ console.log(`Dashboard -> ${pc6.cyan(url)}`);
3294
3873
  if (options.openBrowser !== false) {
3295
3874
  await open_default(url);
3296
3875
  }
@@ -3304,13 +3883,13 @@ async function startCommand(options) {
3304
3883
  initialWorkspace: cwd
3305
3884
  });
3306
3885
  console.log(`
3307
- Dashboard running at -> ${pc5.cyan(server.url)}`);
3886
+ Dashboard running at -> ${pc6.cyan(server.url)}`);
3308
3887
  }
3309
3888
 
3310
3889
  // src/cli/commands/serve.ts
3311
- import pc6 from "picocolors";
3890
+ import pc7 from "picocolors";
3312
3891
  async function serveCommand(options) {
3313
- console.log(pc6.bold(`DevSurface Hub v${DEV_SURFACE_VERSION}`));
3892
+ console.log(pc7.bold(`DevSurface Hub v${DEV_SURFACE_VERSION}`));
3314
3893
  console.log("Starting hub server...\n");
3315
3894
  const server = await startHubServer({
3316
3895
  port: options.port,
@@ -3320,7 +3899,7 @@ async function serveCommand(options) {
3320
3899
  if (summaries.length > 0) {
3321
3900
  console.log(`Registered workspaces: ${summaries.length}`);
3322
3901
  for (const ws of summaries) {
3323
- console.log(` ${pc6.cyan(ws.name)} -> ${ws.path}`);
3902
+ console.log(` ${pc7.cyan(ws.name)} -> ${ws.path}`);
3324
3903
  }
3325
3904
  } else {
3326
3905
  console.log(
@@ -3328,17 +3907,17 @@ async function serveCommand(options) {
3328
3907
  );
3329
3908
  }
3330
3909
  console.log(`
3331
- Hub running at -> ${pc6.cyan(server.url)}`);
3910
+ Hub running at -> ${pc7.cyan(server.url)}`);
3332
3911
  }
3333
3912
 
3334
3913
  // src/cli/commands/workspace.ts
3335
- import path16 from "path";
3336
- import pc7 from "picocolors";
3914
+ import path18 from "path";
3915
+ import pc8 from "picocolors";
3337
3916
  async function workspaceAddCommand(dirPath) {
3338
3917
  const registry = new WorkspaceRegistry();
3339
- const target = path16.resolve(dirPath ?? process.cwd());
3918
+ const target = path18.resolve(dirPath ?? process.cwd());
3340
3919
  const entry = await registry.add(target);
3341
- console.log(`Added workspace ${pc7.cyan(entry.name)} (${entry.id}) -> ${entry.path}`);
3920
+ console.log(`Added workspace ${pc8.cyan(entry.name)} (${entry.id}) -> ${entry.path}`);
3342
3921
  }
3343
3922
  async function workspaceListCommand() {
3344
3923
  const registry = new WorkspaceRegistry();
@@ -3352,7 +3931,7 @@ async function workspaceListCommand() {
3352
3931
  console.log(`${entries.length} workspace${entries.length === 1 ? "" : "s"}:
3353
3932
  `);
3354
3933
  for (const entry of entries) {
3355
- console.log(` ${pc7.cyan(entry.name)} (${entry.id})`);
3934
+ console.log(` ${pc8.cyan(entry.name)} (${entry.id})`);
3356
3935
  console.log(` ${entry.path}`);
3357
3936
  }
3358
3937
  }
@@ -3360,13 +3939,85 @@ async function workspaceRemoveCommand(id) {
3360
3939
  const registry = new WorkspaceRegistry();
3361
3940
  const removed = await registry.remove(id);
3362
3941
  if (removed) {
3363
- console.log(`Removed workspace ${pc7.cyan(id)}.`);
3942
+ console.log(`Removed workspace ${pc8.cyan(id)}.`);
3364
3943
  } else {
3365
3944
  console.error(`Workspace "${id}" not found.`);
3366
3945
  process.exitCode = 1;
3367
3946
  }
3368
3947
  }
3369
3948
 
3949
+ // src/cli/updateCheck.ts
3950
+ var REGISTRY_LATEST_URL = "https://registry.npmjs.org/devsurface/latest";
3951
+ var UPDATE_CHECK_TIMEOUT_MS = 900;
3952
+ function parseVersion(version) {
3953
+ const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
3954
+ if (!match) {
3955
+ return null;
3956
+ }
3957
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
3958
+ }
3959
+ function isNewerVersion(latestVersion, currentVersion) {
3960
+ const latest = parseVersion(latestVersion);
3961
+ const current = parseVersion(currentVersion);
3962
+ if (latest === null || current === null) {
3963
+ return false;
3964
+ }
3965
+ for (let index = 0; index < latest.length; index += 1) {
3966
+ if (latest[index] > current[index]) {
3967
+ return true;
3968
+ }
3969
+ if (latest[index] < current[index]) {
3970
+ return false;
3971
+ }
3972
+ }
3973
+ return false;
3974
+ }
3975
+ function formatUpdateNotice(info) {
3976
+ return `Update available: v${info.latestVersion}
3977
+ Run: npx devsurface@latest`;
3978
+ }
3979
+ function shouldCheckForUpdates() {
3980
+ return process.env.DEVSURFACE_UPDATE_CHECK !== "0" && process.env.CI !== "true";
3981
+ }
3982
+ async function checkForUpdate(currentVersion, fetchImpl = fetch) {
3983
+ if (!shouldCheckForUpdates()) {
3984
+ return null;
3985
+ }
3986
+ const controller = new AbortController();
3987
+ const timeout = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
3988
+ try {
3989
+ const response = await fetchImpl(REGISTRY_LATEST_URL, {
3990
+ headers: {
3991
+ accept: "application/json"
3992
+ },
3993
+ signal: controller.signal
3994
+ });
3995
+ if (!response.ok) {
3996
+ return null;
3997
+ }
3998
+ const body = await response.json();
3999
+ const latestVersion = typeof body.version === "string" ? body.version : null;
4000
+ if (latestVersion === null || !isNewerVersion(latestVersion, currentVersion)) {
4001
+ return null;
4002
+ }
4003
+ return {
4004
+ currentVersion,
4005
+ latestVersion
4006
+ };
4007
+ } catch {
4008
+ return null;
4009
+ } finally {
4010
+ clearTimeout(timeout);
4011
+ }
4012
+ }
4013
+ async function printUpdateNotice(currentVersion) {
4014
+ const update = await checkForUpdate(currentVersion);
4015
+ if (update !== null) {
4016
+ console.log(`
4017
+ ${formatUpdateNotice(update)}`);
4018
+ }
4019
+ }
4020
+
3370
4021
  // src/cli/index.ts
3371
4022
  var program = new Command();
3372
4023
  function toPort(value) {
@@ -3377,7 +4028,9 @@ function toPort(value) {
3377
4028
  return port;
3378
4029
  }
3379
4030
  function handle(command) {
3380
- command.catch((error) => {
4031
+ command.then(async () => {
4032
+ await printUpdateNotice(DEV_SURFACE_VERSION);
4033
+ }).catch((error) => {
3381
4034
  const message = error instanceof Error ? error.message : String(error);
3382
4035
  console.error(message);
3383
4036
  process.exitCode = 1;
@@ -3416,6 +4069,9 @@ program.command("scan").description("Print detected project info.").action(() =>
3416
4069
  program.command("doctor").description("Print setup health warnings.").action(() => {
3417
4070
  handle(doctorCommand(process.cwd()));
3418
4071
  });
4072
+ program.command("onboard").description("Print a guided setup checklist with readiness score.").action(() => {
4073
+ handle(onboardCommand(process.cwd()));
4074
+ });
3419
4075
  program.command("init").description("Create a starter devsurface.config.json.").action(() => {
3420
4076
  handle(initCommand(process.cwd()));
3421
4077
  });