devsurface 0.1.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 +9 -0
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1763 -0
- package/dist/cli/index.js.map +1 -0
- package/docs/devsurface-badge.svg +17 -0
- package/docs/devsurface-demo.gif +0 -0
- package/package.json +75 -0
- package/src/web/dist/assets/favicon-Doqbcjna.svg +6 -0
- package/src/web/dist/assets/index-BPOLPimA.js +10 -0
- package/src/web/dist/assets/index-Ch_lsiJZ.css +1 -0
- package/src/web/dist/index.html +14 -0
|
@@ -0,0 +1,1763 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/cli/commands/doctor.ts
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
|
|
9
|
+
// src/core/doctor/index.ts
|
|
10
|
+
import { promises as fs9 } from "fs";
|
|
11
|
+
import path9 from "path";
|
|
12
|
+
|
|
13
|
+
// src/core/scanner/index.ts
|
|
14
|
+
import { promises as fs8 } from "fs";
|
|
15
|
+
import path8 from "path";
|
|
16
|
+
|
|
17
|
+
// src/core/config/load.ts
|
|
18
|
+
import { promises as fs } from "fs";
|
|
19
|
+
import path from "path";
|
|
20
|
+
|
|
21
|
+
// src/core/config/defaults.ts
|
|
22
|
+
var CONFIG_FILE_NAME = "devsurface.config.json";
|
|
23
|
+
var defaultConfig = {
|
|
24
|
+
name: "My App",
|
|
25
|
+
description: "Local developer control panel",
|
|
26
|
+
commands: {
|
|
27
|
+
install: "npm install",
|
|
28
|
+
dev: "npm run dev",
|
|
29
|
+
build: "npm run build",
|
|
30
|
+
test: "npm test",
|
|
31
|
+
lint: "npm run lint"
|
|
32
|
+
},
|
|
33
|
+
groups: {
|
|
34
|
+
Setup: ["install"],
|
|
35
|
+
Development: ["dev"],
|
|
36
|
+
Quality: ["test", "lint"],
|
|
37
|
+
Build: ["build"]
|
|
38
|
+
},
|
|
39
|
+
ports: [3e3],
|
|
40
|
+
env: {
|
|
41
|
+
example: ".env.example",
|
|
42
|
+
local: ".env"
|
|
43
|
+
},
|
|
44
|
+
services: {
|
|
45
|
+
docker: true
|
|
46
|
+
},
|
|
47
|
+
docs: ""
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// src/core/config/load.ts
|
|
51
|
+
function isRecord(value) {
|
|
52
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
53
|
+
}
|
|
54
|
+
function toStringRecord(value, warnings, label) {
|
|
55
|
+
if (value === void 0) {
|
|
56
|
+
return void 0;
|
|
57
|
+
}
|
|
58
|
+
if (!isRecord(value)) {
|
|
59
|
+
warnings.push(`${label} must be an object.`);
|
|
60
|
+
return void 0;
|
|
61
|
+
}
|
|
62
|
+
const record = {};
|
|
63
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
64
|
+
if (typeof raw === "string") {
|
|
65
|
+
record[key] = raw;
|
|
66
|
+
} else {
|
|
67
|
+
warnings.push(`${label}.${key} must be a string.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return record;
|
|
71
|
+
}
|
|
72
|
+
function toGroups(value, warnings) {
|
|
73
|
+
if (value === void 0) {
|
|
74
|
+
return void 0;
|
|
75
|
+
}
|
|
76
|
+
if (!isRecord(value)) {
|
|
77
|
+
warnings.push("groups must be an object.");
|
|
78
|
+
return void 0;
|
|
79
|
+
}
|
|
80
|
+
const groups = {};
|
|
81
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
82
|
+
if (Array.isArray(raw) && raw.every((entry) => typeof entry === "string")) {
|
|
83
|
+
groups[key] = raw;
|
|
84
|
+
} else {
|
|
85
|
+
warnings.push(`groups.${key} must be an array of command names.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return groups;
|
|
89
|
+
}
|
|
90
|
+
function toPorts(value, warnings) {
|
|
91
|
+
if (value === void 0) {
|
|
92
|
+
return void 0;
|
|
93
|
+
}
|
|
94
|
+
if (!Array.isArray(value)) {
|
|
95
|
+
warnings.push("ports must be an array of numbers.");
|
|
96
|
+
return void 0;
|
|
97
|
+
}
|
|
98
|
+
const ports = value.filter(
|
|
99
|
+
(port) => Number.isInteger(port) && port > 0 && port < 65536
|
|
100
|
+
);
|
|
101
|
+
if (ports.length !== value.length) {
|
|
102
|
+
warnings.push("ports may only contain integers between 1 and 65535.");
|
|
103
|
+
}
|
|
104
|
+
return ports;
|
|
105
|
+
}
|
|
106
|
+
function validateConfig(raw) {
|
|
107
|
+
const warnings = [];
|
|
108
|
+
if (!isRecord(raw)) {
|
|
109
|
+
return { config: {}, warnings: ["devsurface.config.json must contain a JSON object."] };
|
|
110
|
+
}
|
|
111
|
+
const env = isRecord(raw.env) ? {
|
|
112
|
+
example: typeof raw.env.example === "string" ? raw.env.example : void 0,
|
|
113
|
+
local: typeof raw.env.local === "string" ? raw.env.local : void 0
|
|
114
|
+
} : void 0;
|
|
115
|
+
if (raw.env !== void 0 && !isRecord(raw.env)) {
|
|
116
|
+
warnings.push("env must be an object.");
|
|
117
|
+
}
|
|
118
|
+
const services = isRecord(raw.services) ? {
|
|
119
|
+
docker: typeof raw.services.docker === "boolean" ? raw.services.docker : void 0
|
|
120
|
+
} : void 0;
|
|
121
|
+
if (raw.services !== void 0 && !isRecord(raw.services)) {
|
|
122
|
+
warnings.push("services must be an object.");
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
config: {
|
|
126
|
+
name: typeof raw.name === "string" ? raw.name : void 0,
|
|
127
|
+
description: typeof raw.description === "string" ? raw.description : void 0,
|
|
128
|
+
commands: toStringRecord(raw.commands, warnings, "commands"),
|
|
129
|
+
groups: toGroups(raw.groups, warnings),
|
|
130
|
+
ports: toPorts(raw.ports, warnings),
|
|
131
|
+
env,
|
|
132
|
+
services,
|
|
133
|
+
docs: typeof raw.docs === "string" ? raw.docs : void 0
|
|
134
|
+
},
|
|
135
|
+
warnings
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
async function loadConfig(root) {
|
|
139
|
+
const configPath = path.join(root, CONFIG_FILE_NAME);
|
|
140
|
+
try {
|
|
141
|
+
const content = await fs.readFile(configPath, "utf8");
|
|
142
|
+
const parsed = JSON.parse(content);
|
|
143
|
+
const { config, warnings } = validateConfig(parsed);
|
|
144
|
+
return { path: configPath, config, warnings };
|
|
145
|
+
} catch (error) {
|
|
146
|
+
const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
147
|
+
if (code === "ENOENT") {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
if (error instanceof SyntaxError) {
|
|
151
|
+
return {
|
|
152
|
+
path: configPath,
|
|
153
|
+
config: {},
|
|
154
|
+
warnings: [`${CONFIG_FILE_NAME} contains invalid JSON.`]
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/core/scanner/docker.ts
|
|
162
|
+
import { promises as fs3 } from "fs";
|
|
163
|
+
import os from "os";
|
|
164
|
+
import path3 from "path";
|
|
165
|
+
import spawn from "cross-spawn";
|
|
166
|
+
import { parse as parseYaml } from "yaml";
|
|
167
|
+
|
|
168
|
+
// src/core/process/executable.ts
|
|
169
|
+
import { constants } from "fs";
|
|
170
|
+
import { promises as fs2 } from "fs";
|
|
171
|
+
import path2 from "path";
|
|
172
|
+
function isWithinRoot(root, target) {
|
|
173
|
+
const resolvedRoot = path2.resolve(root);
|
|
174
|
+
const resolvedTarget = path2.resolve(target);
|
|
175
|
+
const relative = path2.relative(resolvedRoot, resolvedTarget);
|
|
176
|
+
return relative === "" || !relative.startsWith("..") && !path2.isAbsolute(relative);
|
|
177
|
+
}
|
|
178
|
+
function pathEntries(pathValue) {
|
|
179
|
+
return pathValue.split(path2.delimiter).map((entry) => entry.trim().replace(/^"|"$/g, "")).filter((entry) => entry.length > 0);
|
|
180
|
+
}
|
|
181
|
+
function hasPathSeparator(command) {
|
|
182
|
+
return command.includes("/") || command.includes("\\");
|
|
183
|
+
}
|
|
184
|
+
function executableNames(command) {
|
|
185
|
+
if (process.platform !== "win32" || path2.extname(command)) {
|
|
186
|
+
return [command];
|
|
187
|
+
}
|
|
188
|
+
const extensions = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((extension) => extension.trim().toLowerCase()).filter(Boolean);
|
|
189
|
+
return extensions.map((extension) => `${command}${extension}`);
|
|
190
|
+
}
|
|
191
|
+
async function executableOutsideRoot(root, candidate) {
|
|
192
|
+
if (isWithinRoot(root, candidate)) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const [realRoot, realCandidate] = await Promise.all([
|
|
197
|
+
fs2.realpath(root),
|
|
198
|
+
fs2.realpath(candidate)
|
|
199
|
+
]);
|
|
200
|
+
if (isWithinRoot(realRoot, realCandidate)) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
await fs2.access(realCandidate, constants.X_OK);
|
|
204
|
+
return realCandidate;
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function resolveExecutableOutsideRoot(root, command) {
|
|
210
|
+
if (path2.isAbsolute(command) || hasPathSeparator(command)) {
|
|
211
|
+
return await executableOutsideRoot(root, path2.resolve(command));
|
|
212
|
+
}
|
|
213
|
+
for (const entry of pathEntries(process.env.PATH ?? "")) {
|
|
214
|
+
const directory = path2.resolve(entry);
|
|
215
|
+
if (isWithinRoot(root, directory)) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
for (const executableName of executableNames(command)) {
|
|
219
|
+
const executable = await executableOutsideRoot(root, path2.join(directory, executableName));
|
|
220
|
+
if (executable !== null) {
|
|
221
|
+
return executable;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/core/scanner/docker.ts
|
|
229
|
+
var COMPOSE_FILES = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"];
|
|
230
|
+
async function fileExists(filePath) {
|
|
231
|
+
try {
|
|
232
|
+
await fs3.access(filePath);
|
|
233
|
+
return true;
|
|
234
|
+
} catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async function getComposeFiles(root) {
|
|
239
|
+
const checks = await Promise.all(
|
|
240
|
+
COMPOSE_FILES.map(async (file) => {
|
|
241
|
+
const filePath = path3.join(root, file);
|
|
242
|
+
return await fileExists(filePath) ? filePath : null;
|
|
243
|
+
})
|
|
244
|
+
);
|
|
245
|
+
return checks.filter((filePath) => filePath !== null);
|
|
246
|
+
}
|
|
247
|
+
async function extractServices(composePath) {
|
|
248
|
+
try {
|
|
249
|
+
const content = await fs3.readFile(composePath, "utf8");
|
|
250
|
+
const parsed = parseYaml(content);
|
|
251
|
+
if (typeof parsed === "object" && parsed !== null && "services" in parsed && typeof parsed.services === "object" && parsed.services !== null) {
|
|
252
|
+
return Object.keys(parsed.services);
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
function isWithinRoot2(root, target) {
|
|
260
|
+
const relative = path3.relative(path3.resolve(root), path3.resolve(target));
|
|
261
|
+
return relative === "" || !relative.startsWith("..") && !path3.isAbsolute(relative);
|
|
262
|
+
}
|
|
263
|
+
function dockerCommandCwd(root) {
|
|
264
|
+
const candidates = [os.homedir(), os.tmpdir(), path3.parse(path3.resolve(root)).root];
|
|
265
|
+
return candidates.find((candidate) => !isWithinRoot2(root, candidate)) ?? os.homedir();
|
|
266
|
+
}
|
|
267
|
+
async function runDockerCommand(root, args, timeoutMs = 2500) {
|
|
268
|
+
const dockerExecutable = await resolveExecutableOutsideRoot(root, "docker");
|
|
269
|
+
if (dockerExecutable === null) {
|
|
270
|
+
return { code: null, stdout: "", stderr: "" };
|
|
271
|
+
}
|
|
272
|
+
return await new Promise((resolve) => {
|
|
273
|
+
const child = spawn(dockerExecutable, args, {
|
|
274
|
+
cwd: dockerCommandCwd(root),
|
|
275
|
+
windowsHide: true
|
|
276
|
+
});
|
|
277
|
+
let settled = false;
|
|
278
|
+
let stdout = "";
|
|
279
|
+
let stderr = "";
|
|
280
|
+
const timeout = setTimeout(() => {
|
|
281
|
+
settled = true;
|
|
282
|
+
child.kill();
|
|
283
|
+
resolve({ code: null, stdout, stderr });
|
|
284
|
+
}, timeoutMs);
|
|
285
|
+
child.stdout?.on("data", (chunk) => {
|
|
286
|
+
stdout += chunk.toString();
|
|
287
|
+
});
|
|
288
|
+
child.stderr?.on("data", (chunk) => {
|
|
289
|
+
stderr += chunk.toString();
|
|
290
|
+
});
|
|
291
|
+
child.on("error", () => {
|
|
292
|
+
clearTimeout(timeout);
|
|
293
|
+
if (!settled) {
|
|
294
|
+
settled = true;
|
|
295
|
+
resolve({ code: null, stdout, stderr });
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
child.on("close", (code) => {
|
|
299
|
+
clearTimeout(timeout);
|
|
300
|
+
if (!settled) {
|
|
301
|
+
settled = true;
|
|
302
|
+
resolve({ code, stdout, stderr });
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
async function isDockerRunning(root) {
|
|
308
|
+
const result = await runDockerCommand(root, ["info"]);
|
|
309
|
+
return result.code === 0;
|
|
310
|
+
}
|
|
311
|
+
function parseComposePs(output) {
|
|
312
|
+
const statuses = /* @__PURE__ */ new Map();
|
|
313
|
+
const compactOutput = output.trim();
|
|
314
|
+
if (!compactOutput) {
|
|
315
|
+
return statuses;
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
const parsed = JSON.parse(compactOutput);
|
|
319
|
+
const rows2 = Array.isArray(parsed) ? parsed : [parsed];
|
|
320
|
+
for (const row of rows2) {
|
|
321
|
+
addComposeStatusRow(statuses, row);
|
|
322
|
+
}
|
|
323
|
+
return statuses;
|
|
324
|
+
} catch {
|
|
325
|
+
}
|
|
326
|
+
const rows = compactOutput.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
327
|
+
for (const row of rows) {
|
|
328
|
+
try {
|
|
329
|
+
const parsed = JSON.parse(row);
|
|
330
|
+
addComposeStatusRow(statuses, parsed);
|
|
331
|
+
} catch {
|
|
332
|
+
return statuses;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return statuses;
|
|
336
|
+
}
|
|
337
|
+
function addComposeStatusRow(statuses, row) {
|
|
338
|
+
if (typeof row !== "object" || row === null) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const record = row;
|
|
342
|
+
if (typeof record.Service !== "string") {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const state = typeof record.State === "string" ? record.State.toLowerCase() : "";
|
|
346
|
+
statuses.set(record.Service, {
|
|
347
|
+
name: record.Service,
|
|
348
|
+
status: state === "running" ? "running" : state ? "stopped" : "unknown",
|
|
349
|
+
containerId: typeof record.ID === "string" && record.ID.length > 0 ? record.ID : null
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
async function getServiceStatuses(root, serviceNames, dockerRunning) {
|
|
353
|
+
if (serviceNames.length === 0) {
|
|
354
|
+
return [];
|
|
355
|
+
}
|
|
356
|
+
if (!dockerRunning) {
|
|
357
|
+
return serviceNames.map((service) => ({
|
|
358
|
+
name: service,
|
|
359
|
+
status: "unknown",
|
|
360
|
+
containerId: null
|
|
361
|
+
}));
|
|
362
|
+
}
|
|
363
|
+
const ps = await runDockerCommand(root, [
|
|
364
|
+
"compose",
|
|
365
|
+
"--project-directory",
|
|
366
|
+
root,
|
|
367
|
+
"ps",
|
|
368
|
+
"--format",
|
|
369
|
+
"json"
|
|
370
|
+
]);
|
|
371
|
+
const statuses = ps.code === 0 ? parseComposePs(ps.stdout) : /* @__PURE__ */ new Map();
|
|
372
|
+
return serviceNames.map(
|
|
373
|
+
(service) => statuses.get(service) ?? {
|
|
374
|
+
name: service,
|
|
375
|
+
status: "stopped",
|
|
376
|
+
containerId: null
|
|
377
|
+
}
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
async function detectDocker(root) {
|
|
381
|
+
const composeFiles = await getComposeFiles(root);
|
|
382
|
+
if (composeFiles.length === 0) {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
const serviceLists = await Promise.all(
|
|
386
|
+
composeFiles.map((composeFile) => extractServices(composeFile))
|
|
387
|
+
);
|
|
388
|
+
const serviceNames = Array.from(new Set(serviceLists.flat()));
|
|
389
|
+
const dockerRunning = await isDockerRunning(root);
|
|
390
|
+
return {
|
|
391
|
+
composeFiles,
|
|
392
|
+
services: await getServiceStatuses(root, serviceNames, dockerRunning),
|
|
393
|
+
dockerRunning
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/core/scanner/env.ts
|
|
398
|
+
import { promises as fs4 } from "fs";
|
|
399
|
+
import path4 from "path";
|
|
400
|
+
async function readIfPresent(filePath) {
|
|
401
|
+
try {
|
|
402
|
+
return await fs4.readFile(filePath, "utf8");
|
|
403
|
+
} catch {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function resolveInsideRoot(root, configuredPath) {
|
|
408
|
+
const resolvedRoot = path4.resolve(root);
|
|
409
|
+
const resolvedPath = path4.resolve(resolvedRoot, configuredPath);
|
|
410
|
+
const relative = path4.relative(resolvedRoot, resolvedPath);
|
|
411
|
+
const insideRoot = relative === "" || !relative.startsWith("..") && !path4.isAbsolute(relative);
|
|
412
|
+
return insideRoot ? resolvedPath : null;
|
|
413
|
+
}
|
|
414
|
+
async function resolveExistingInsideRoot(root, configuredPath) {
|
|
415
|
+
const candidate = resolveInsideRoot(root, configuredPath);
|
|
416
|
+
if (candidate === null) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
const [realRoot, realCandidate] = await Promise.all([
|
|
421
|
+
fs4.realpath(root),
|
|
422
|
+
fs4.realpath(candidate)
|
|
423
|
+
]);
|
|
424
|
+
const relative = path4.relative(realRoot, realCandidate);
|
|
425
|
+
const insideRoot = relative === "" || !relative.startsWith("..") && !path4.isAbsolute(relative);
|
|
426
|
+
return insideRoot ? candidate : null;
|
|
427
|
+
} catch {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function parseEnvKeys(content) {
|
|
432
|
+
const keys = [];
|
|
433
|
+
const emptyKeys = [];
|
|
434
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
435
|
+
const line = rawLine.trim();
|
|
436
|
+
if (!line || line.startsWith("#")) {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const normalized = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
440
|
+
const separator = normalized.indexOf("=");
|
|
441
|
+
if (separator <= 0) {
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
const key = normalized.slice(0, separator).trim();
|
|
445
|
+
const value = normalized.slice(separator + 1).trim();
|
|
446
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
447
|
+
keys.push(key);
|
|
448
|
+
if (value.length === 0 || value === '""' || value === "''") {
|
|
449
|
+
emptyKeys.push(key);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return { keys: Array.from(new Set(keys)), emptyKeys: Array.from(new Set(emptyKeys)) };
|
|
454
|
+
}
|
|
455
|
+
async function detectEnv(root, config) {
|
|
456
|
+
const exampleName = config?.env?.example ?? ".env.example";
|
|
457
|
+
const localName = config?.env?.local ?? ".env";
|
|
458
|
+
const [examplePath, localPath] = await Promise.all([
|
|
459
|
+
resolveExistingInsideRoot(root, exampleName),
|
|
460
|
+
resolveExistingInsideRoot(root, localName)
|
|
461
|
+
]);
|
|
462
|
+
const [exampleContent, localContent] = await Promise.all([
|
|
463
|
+
examplePath === null ? null : readIfPresent(examplePath),
|
|
464
|
+
localPath === null ? null : readIfPresent(localPath)
|
|
465
|
+
]);
|
|
466
|
+
if (exampleContent === null && localContent === null) {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
const example = exampleContent === null ? { keys: [], emptyKeys: [] } : parseEnvKeys(exampleContent);
|
|
470
|
+
const local = localContent === null ? { keys: [], emptyKeys: [] } : parseEnvKeys(localContent);
|
|
471
|
+
const localKeySet = new Set(local.keys);
|
|
472
|
+
const localEmptySet = new Set(local.emptyKeys);
|
|
473
|
+
const missingKeys = example.keys.filter((key) => !localKeySet.has(key));
|
|
474
|
+
const emptyKeys = local.keys.filter((key) => localEmptySet.has(key));
|
|
475
|
+
return {
|
|
476
|
+
examplePath: exampleContent === null ? null : examplePath,
|
|
477
|
+
localPath: localContent === null ? null : localPath,
|
|
478
|
+
hasExample: exampleContent !== null,
|
|
479
|
+
hasLocal: localContent !== null,
|
|
480
|
+
exampleKeys: example.keys,
|
|
481
|
+
localKeys: local.keys,
|
|
482
|
+
missingKeys,
|
|
483
|
+
emptyKeys,
|
|
484
|
+
keys: example.keys.map((key) => ({
|
|
485
|
+
key,
|
|
486
|
+
present: localKeySet.has(key),
|
|
487
|
+
empty: localEmptySet.has(key)
|
|
488
|
+
}))
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/core/scanner/framework.ts
|
|
493
|
+
var frameworkPackages = [
|
|
494
|
+
{ packageName: "next", label: "Next.js" },
|
|
495
|
+
{ packageName: "vite", label: "Vite" },
|
|
496
|
+
{ packageName: "express", label: "Express" },
|
|
497
|
+
{ packageName: "fastify", label: "Fastify" },
|
|
498
|
+
{ packageName: "@nestjs/core", label: "NestJS" },
|
|
499
|
+
{ packageName: "@remix-run/react", label: "Remix" },
|
|
500
|
+
{ packageName: "prisma", label: "Prisma" }
|
|
501
|
+
];
|
|
502
|
+
function detectFramework(packageJson) {
|
|
503
|
+
if (packageJson === null) {
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
const dependencies = {
|
|
507
|
+
...packageJson.data.dependencies,
|
|
508
|
+
...packageJson.data.devDependencies,
|
|
509
|
+
...packageJson.data.optionalDependencies,
|
|
510
|
+
...packageJson.data.peerDependencies
|
|
511
|
+
};
|
|
512
|
+
const detected = frameworkPackages.filter((framework) => dependencies[framework.packageName] !== void 0).map((framework) => framework.label);
|
|
513
|
+
if (detected.length === 0) {
|
|
514
|
+
return {
|
|
515
|
+
type: "Node.js",
|
|
516
|
+
detected: ["Node.js"]
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
type: ["Node.js", ...detected].join(" / "),
|
|
521
|
+
detected
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// src/core/scanner/git.ts
|
|
526
|
+
import { promises as fs5 } from "fs";
|
|
527
|
+
import path5 from "path";
|
|
528
|
+
function isWithinRoot3(root, target) {
|
|
529
|
+
const resolvedRoot = path5.resolve(root);
|
|
530
|
+
const resolvedTarget = path5.resolve(target);
|
|
531
|
+
const relative = path5.relative(resolvedRoot, resolvedTarget);
|
|
532
|
+
return relative === "" || !relative.startsWith("..") && !path5.isAbsolute(relative);
|
|
533
|
+
}
|
|
534
|
+
async function resolveGitDirectory(root) {
|
|
535
|
+
const gitPath = path5.join(root, ".git");
|
|
536
|
+
try {
|
|
537
|
+
const stat = await fs5.stat(gitPath);
|
|
538
|
+
if (stat.isDirectory()) {
|
|
539
|
+
return gitPath;
|
|
540
|
+
}
|
|
541
|
+
if (stat.isFile()) {
|
|
542
|
+
const content = await fs5.readFile(gitPath, "utf8");
|
|
543
|
+
const match = content.match(/^gitdir:\s*(.+)\s*$/m);
|
|
544
|
+
if (match) {
|
|
545
|
+
const gitDir = match[1].trim();
|
|
546
|
+
const resolvedGitDir = path5.isAbsolute(gitDir) ? path5.resolve(gitDir) : path5.resolve(root, gitDir);
|
|
547
|
+
if (!isWithinRoot3(root, resolvedGitDir)) {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
const [realRoot, realGitDir] = await Promise.all([
|
|
551
|
+
fs5.realpath(root),
|
|
552
|
+
fs5.realpath(resolvedGitDir)
|
|
553
|
+
]);
|
|
554
|
+
return isWithinRoot3(realRoot, realGitDir) ? resolvedGitDir : null;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
} catch {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
async function detectGit(root) {
|
|
563
|
+
const gitRoot = await resolveGitDirectory(root);
|
|
564
|
+
if (gitRoot === null) {
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
const head = await fs5.readFile(path5.join(gitRoot, "HEAD"), "utf8");
|
|
569
|
+
const refMatch = head.match(/^ref:\s+refs\/heads\/(.+)\s*$/);
|
|
570
|
+
return {
|
|
571
|
+
root: gitRoot,
|
|
572
|
+
branch: refMatch ? refMatch[1] : head.trim().slice(0, 12)
|
|
573
|
+
};
|
|
574
|
+
} catch {
|
|
575
|
+
return {
|
|
576
|
+
root: gitRoot,
|
|
577
|
+
branch: null
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// src/core/scanner/packageManager.ts
|
|
583
|
+
import { promises as fs6 } from "fs";
|
|
584
|
+
import path6 from "path";
|
|
585
|
+
var lockFiles = [
|
|
586
|
+
{ file: "pnpm-lock.yaml", manager: "pnpm" },
|
|
587
|
+
{ file: "yarn.lock", manager: "yarn" },
|
|
588
|
+
{ file: "bun.lockb", manager: "bun" },
|
|
589
|
+
{ file: "bun.lock", manager: "bun" },
|
|
590
|
+
{ file: "package-lock.json", manager: "npm" }
|
|
591
|
+
];
|
|
592
|
+
async function exists(filePath) {
|
|
593
|
+
try {
|
|
594
|
+
await fs6.access(filePath);
|
|
595
|
+
return true;
|
|
596
|
+
} catch {
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
async function detectPackageManager(root) {
|
|
601
|
+
for (const lockFile of lockFiles) {
|
|
602
|
+
if (await exists(path6.join(root, lockFile.file))) {
|
|
603
|
+
return lockFile.manager;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// src/core/scanner/packageJson.ts
|
|
610
|
+
import { promises as fs7 } from "fs";
|
|
611
|
+
import path7 from "path";
|
|
612
|
+
async function readPackageJson(root) {
|
|
613
|
+
const packageJsonPath = path7.join(root, "package.json");
|
|
614
|
+
try {
|
|
615
|
+
const content = await fs7.readFile(packageJsonPath, "utf8");
|
|
616
|
+
const data = JSON.parse(content);
|
|
617
|
+
return { path: packageJsonPath, data };
|
|
618
|
+
} catch {
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// src/core/scanner/ports.ts
|
|
624
|
+
import net from "net";
|
|
625
|
+
function uniquePorts(ports) {
|
|
626
|
+
return Array.from(
|
|
627
|
+
new Set(ports.filter((port) => Number.isInteger(port) && port > 0 && port < 65536))
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
function inferPortsFromScripts(scripts) {
|
|
631
|
+
const ports = [];
|
|
632
|
+
for (const command of Object.values(scripts)) {
|
|
633
|
+
const patterns = [
|
|
634
|
+
/(?:--port|-p)\s+(\d{2,5})/g,
|
|
635
|
+
/\bPORT=(\d{2,5})\b/g,
|
|
636
|
+
/localhost:(\d{2,5})/g,
|
|
637
|
+
/127\.0\.0\.1:(\d{2,5})/g
|
|
638
|
+
];
|
|
639
|
+
for (const pattern of patterns) {
|
|
640
|
+
for (const match of command.matchAll(pattern)) {
|
|
641
|
+
ports.push(Number(match[1]));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return uniquePorts(ports);
|
|
646
|
+
}
|
|
647
|
+
function defaultPortsForFramework(framework) {
|
|
648
|
+
if (framework === null) {
|
|
649
|
+
return [];
|
|
650
|
+
}
|
|
651
|
+
const ports = [];
|
|
652
|
+
if (framework.detected.includes("Next.js") || framework.detected.includes("Express")) {
|
|
653
|
+
ports.push(3e3);
|
|
654
|
+
}
|
|
655
|
+
if (framework.detected.includes("Vite")) {
|
|
656
|
+
ports.push(5173);
|
|
657
|
+
}
|
|
658
|
+
if (framework.detected.includes("Prisma")) {
|
|
659
|
+
ports.push(5555);
|
|
660
|
+
}
|
|
661
|
+
return uniquePorts(ports);
|
|
662
|
+
}
|
|
663
|
+
async function probePort(port) {
|
|
664
|
+
return await new Promise((resolve) => {
|
|
665
|
+
const server = net.createServer();
|
|
666
|
+
server.once("error", () => {
|
|
667
|
+
resolve({ port, inUse: true });
|
|
668
|
+
});
|
|
669
|
+
server.once("listening", () => {
|
|
670
|
+
server.close(() => {
|
|
671
|
+
resolve({ port, inUse: false });
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
server.listen(port, "127.0.0.1");
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
async function detectPorts(ports) {
|
|
678
|
+
const normalized = uniquePorts(ports);
|
|
679
|
+
if (normalized.length === 0) {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
return await Promise.all(normalized.map((port) => probePort(port)));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// src/core/scanner/scripts.ts
|
|
686
|
+
function extractScripts(packageJson) {
|
|
687
|
+
if (!packageJson?.data.scripts || typeof packageJson.data.scripts !== "object" || Array.isArray(packageJson.data.scripts)) {
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
return Object.fromEntries(
|
|
691
|
+
Object.entries(packageJson.data.scripts).filter((entry) => {
|
|
692
|
+
const [, command] = entry;
|
|
693
|
+
return typeof command === "string";
|
|
694
|
+
})
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// src/core/scanner/index.ts
|
|
699
|
+
async function findFirstFile(root, candidates) {
|
|
700
|
+
for (const candidate of candidates) {
|
|
701
|
+
const filePath = path8.join(root, candidate);
|
|
702
|
+
try {
|
|
703
|
+
const stat = await fs8.stat(filePath);
|
|
704
|
+
if (stat.isFile()) {
|
|
705
|
+
return { path: filePath, exists: true };
|
|
706
|
+
}
|
|
707
|
+
} catch {
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return { path: null, exists: false };
|
|
711
|
+
}
|
|
712
|
+
function configuredPorts(configPorts) {
|
|
713
|
+
return Array.isArray(configPorts) ? configPorts : [];
|
|
714
|
+
}
|
|
715
|
+
async function scanProject(root = process.cwd()) {
|
|
716
|
+
const config = await loadConfig(root);
|
|
717
|
+
const packageJson = await readPackageJson(root);
|
|
718
|
+
const scripts = extractScripts(packageJson) ?? {};
|
|
719
|
+
const framework = detectFramework(packageJson);
|
|
720
|
+
const portsToProbe = [
|
|
721
|
+
...configuredPorts(config?.config.ports),
|
|
722
|
+
...inferPortsFromScripts(scripts),
|
|
723
|
+
...defaultPortsForFramework(framework)
|
|
724
|
+
];
|
|
725
|
+
const [packageManager, env, docker, git, ports, readme, license] = await Promise.all([
|
|
726
|
+
detectPackageManager(root),
|
|
727
|
+
detectEnv(root, config?.config),
|
|
728
|
+
detectDocker(root),
|
|
729
|
+
detectGit(root),
|
|
730
|
+
detectPorts(portsToProbe),
|
|
731
|
+
findFirstFile(root, ["README.md", "README"]),
|
|
732
|
+
findFirstFile(root, ["LICENSE", "LICENSE.md", "COPYING"])
|
|
733
|
+
]);
|
|
734
|
+
return {
|
|
735
|
+
root,
|
|
736
|
+
projectName: config?.config.name ?? packageJson?.data.name ?? path8.basename(root),
|
|
737
|
+
packageJson,
|
|
738
|
+
packageManager: packageManager ?? (packageJson ? "npm" : null),
|
|
739
|
+
scripts,
|
|
740
|
+
env,
|
|
741
|
+
docker,
|
|
742
|
+
git,
|
|
743
|
+
framework,
|
|
744
|
+
ports: ports ?? [],
|
|
745
|
+
readme,
|
|
746
|
+
license,
|
|
747
|
+
config
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// src/core/doctor/index.ts
|
|
752
|
+
async function pathExists(filePath) {
|
|
753
|
+
try {
|
|
754
|
+
await fs9.access(filePath);
|
|
755
|
+
return true;
|
|
756
|
+
} catch {
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
async function readIfPresent2(filePath) {
|
|
761
|
+
if (filePath === null) {
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
try {
|
|
765
|
+
return await fs9.readFile(filePath, "utf8");
|
|
766
|
+
} catch {
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
function extractReadmeScriptReferences(readmeContent) {
|
|
771
|
+
const references = /* @__PURE__ */ new Set();
|
|
772
|
+
const commandRegexes = [
|
|
773
|
+
/\bnpm\s+run\s+([A-Za-z0-9:_-]+)/g,
|
|
774
|
+
/\bpnpm\s+run\s+([A-Za-z0-9:_-]+)/g,
|
|
775
|
+
/\bbun\s+run\s+([A-Za-z0-9:_-]+)/g,
|
|
776
|
+
/\byarn\s+run\s+([A-Za-z0-9:_-]+)/g,
|
|
777
|
+
/\bnpm\s+(test|start|build)\b/g,
|
|
778
|
+
/\bpnpm\s+(test|start|build)\b/g,
|
|
779
|
+
/\byarn\s+(test|start|build)\b/g,
|
|
780
|
+
/\bbun\s+(test|start|build)\b/g
|
|
781
|
+
];
|
|
782
|
+
for (const regex of commandRegexes) {
|
|
783
|
+
for (const match of readmeContent.matchAll(regex)) {
|
|
784
|
+
references.add(match[1]);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return Array.from(references);
|
|
788
|
+
}
|
|
789
|
+
function warning(id, severity, title, message, target) {
|
|
790
|
+
return { id, severity, title, message, target };
|
|
791
|
+
}
|
|
792
|
+
async function runDoctor(root = process.cwd(), scan) {
|
|
793
|
+
const result = scan ?? await scanProject(root);
|
|
794
|
+
const warnings = [];
|
|
795
|
+
for (const configWarning of result.config?.warnings ?? []) {
|
|
796
|
+
warnings.push(
|
|
797
|
+
warning("config-warning", "warning", "Config warning", configWarning, result.config?.path)
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
if (result.packageJson === null) {
|
|
801
|
+
warnings.push(
|
|
802
|
+
warning(
|
|
803
|
+
"missing-package-json",
|
|
804
|
+
"error",
|
|
805
|
+
"No package.json",
|
|
806
|
+
"This directory is not a Node.js project."
|
|
807
|
+
)
|
|
808
|
+
);
|
|
809
|
+
return warnings;
|
|
810
|
+
}
|
|
811
|
+
if (!await pathExists(path9.join(root, "node_modules", ".bin"))) {
|
|
812
|
+
warnings.push(
|
|
813
|
+
warning(
|
|
814
|
+
"missing-node-modules",
|
|
815
|
+
"warning",
|
|
816
|
+
"Dependencies are not installed",
|
|
817
|
+
"node_modules/.bin is missing. Run the project install command before starting scripts."
|
|
818
|
+
)
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
if (result.env?.hasExample && !result.env.hasLocal) {
|
|
822
|
+
warnings.push(
|
|
823
|
+
warning(
|
|
824
|
+
"missing-env",
|
|
825
|
+
"error",
|
|
826
|
+
".env is missing",
|
|
827
|
+
".env.example exists, but the local .env file is missing.",
|
|
828
|
+
result.env.examplePath ?? void 0
|
|
829
|
+
)
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
if (result.env && result.env.missingKeys.length > 0 && result.env.hasLocal) {
|
|
833
|
+
warnings.push(
|
|
834
|
+
warning(
|
|
835
|
+
"missing-env-keys",
|
|
836
|
+
"warning",
|
|
837
|
+
"Environment keys are missing",
|
|
838
|
+
`Missing keys: ${result.env.missingKeys.join(", ")}. Values are intentionally hidden.`
|
|
839
|
+
)
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
if (result.env && result.env.emptyKeys.length > 0) {
|
|
843
|
+
warnings.push(
|
|
844
|
+
warning(
|
|
845
|
+
"empty-env-keys",
|
|
846
|
+
"info",
|
|
847
|
+
"Environment keys are empty",
|
|
848
|
+
`Empty keys: ${result.env.emptyKeys.join(", ")}. Values are intentionally hidden.`
|
|
849
|
+
)
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
const missingReadme = !result.readme.exists;
|
|
853
|
+
if (missingReadme) {
|
|
854
|
+
warnings.push(
|
|
855
|
+
warning("missing-readme", "warning", "No README", "No README.md or README file was found.")
|
|
856
|
+
);
|
|
857
|
+
} else {
|
|
858
|
+
const readme = await readIfPresent2(result.readme.path);
|
|
859
|
+
if (readme !== null) {
|
|
860
|
+
const references = extractReadmeScriptReferences(readme);
|
|
861
|
+
const missingScripts = references.filter((script) => result.scripts[script] === void 0);
|
|
862
|
+
if (missingScripts.length > 0) {
|
|
863
|
+
warnings.push(
|
|
864
|
+
warning(
|
|
865
|
+
"readme-script-mismatch",
|
|
866
|
+
"warning",
|
|
867
|
+
"README references missing scripts",
|
|
868
|
+
`README mentions scripts not present in package.json: ${missingScripts.join(", ")}.`
|
|
869
|
+
)
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
for (const port of result.ports.filter((probe) => probe.inUse)) {
|
|
875
|
+
warnings.push(
|
|
876
|
+
warning(
|
|
877
|
+
`port-${port.port}-in-use`,
|
|
878
|
+
"error",
|
|
879
|
+
`Port ${port.port} is already in use`,
|
|
880
|
+
`Something is already bound to 127.0.0.1:${port.port}.`
|
|
881
|
+
)
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
if (result.docker && result.docker.dockerRunning === false) {
|
|
885
|
+
warnings.push(
|
|
886
|
+
warning(
|
|
887
|
+
"docker-not-running",
|
|
888
|
+
"warning",
|
|
889
|
+
"Docker Compose found but Docker is not running",
|
|
890
|
+
"A compose file exists, but the Docker daemon did not answer docker info."
|
|
891
|
+
)
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
if (result.scripts.test === void 0) {
|
|
895
|
+
warnings.push(
|
|
896
|
+
warning(
|
|
897
|
+
"missing-test-script",
|
|
898
|
+
"warning",
|
|
899
|
+
"No test script",
|
|
900
|
+
"package.json does not define a test script."
|
|
901
|
+
)
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
if (result.scripts.build === void 0) {
|
|
905
|
+
warnings.push(
|
|
906
|
+
warning(
|
|
907
|
+
"missing-build-script",
|
|
908
|
+
"warning",
|
|
909
|
+
"No build script",
|
|
910
|
+
"package.json does not define a build script."
|
|
911
|
+
)
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
if (!result.license.exists) {
|
|
915
|
+
warnings.push(warning("missing-license", "info", "No LICENSE", "No LICENSE file was found."));
|
|
916
|
+
}
|
|
917
|
+
return warnings;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// src/cli/commands/doctor.ts
|
|
921
|
+
function colorSeverity(severity) {
|
|
922
|
+
if (severity === "error") {
|
|
923
|
+
return pc.red("error");
|
|
924
|
+
}
|
|
925
|
+
if (severity === "warning") {
|
|
926
|
+
return pc.yellow("warning");
|
|
927
|
+
}
|
|
928
|
+
return pc.cyan("info");
|
|
929
|
+
}
|
|
930
|
+
async function doctorCommand(cwd = process.cwd()) {
|
|
931
|
+
const warnings = await runDoctor(cwd);
|
|
932
|
+
if (warnings.length === 0) {
|
|
933
|
+
console.log(pc.green("No health warnings found."));
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
for (const item of warnings) {
|
|
937
|
+
console.log(`${colorSeverity(item.severity)} ${pc.bold(item.title)}`);
|
|
938
|
+
console.log(` ${item.message}`);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// src/cli/commands/init.ts
|
|
943
|
+
import { promises as fs10 } from "fs";
|
|
944
|
+
import path10 from "path";
|
|
945
|
+
import pc2 from "picocolors";
|
|
946
|
+
async function initCommand(cwd = process.cwd()) {
|
|
947
|
+
const configPath = path10.join(cwd, CONFIG_FILE_NAME);
|
|
948
|
+
try {
|
|
949
|
+
await fs10.access(configPath);
|
|
950
|
+
console.log(pc2.yellow(`${CONFIG_FILE_NAME} already exists.`));
|
|
951
|
+
return;
|
|
952
|
+
} catch {
|
|
953
|
+
await fs10.writeFile(configPath, `${JSON.stringify(defaultConfig, null, 2)}
|
|
954
|
+
`, "utf8");
|
|
955
|
+
console.log(pc2.green(`Created ${CONFIG_FILE_NAME}.`));
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// src/cli/commands/run.ts
|
|
960
|
+
import pc3 from "picocolors";
|
|
961
|
+
|
|
962
|
+
// src/core/process/runner.ts
|
|
963
|
+
import spawn2 from "cross-spawn";
|
|
964
|
+
function getPackageRunCommand(packageManager, script) {
|
|
965
|
+
const manager = packageManager ?? "npm";
|
|
966
|
+
const args = ["run", script];
|
|
967
|
+
return {
|
|
968
|
+
command: manager,
|
|
969
|
+
args,
|
|
970
|
+
displayCommand: `${manager} ${args.join(" ")}`
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
async function resolvePackageRunCommand(options) {
|
|
974
|
+
const runCommand2 = getPackageRunCommand(options.packageManager, options.script);
|
|
975
|
+
const executable = await resolveExecutableOutsideRoot(options.cwd, runCommand2.command);
|
|
976
|
+
if (executable === null) {
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
return {
|
|
980
|
+
...runCommand2,
|
|
981
|
+
command: executable
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
function getPackageInstallCommand(packageManager) {
|
|
985
|
+
const manager = packageManager ?? "npm";
|
|
986
|
+
if (manager === "npm") {
|
|
987
|
+
return {
|
|
988
|
+
command: manager,
|
|
989
|
+
args: ["ci"],
|
|
990
|
+
displayCommand: "npm ci"
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
if (manager === "pnpm") {
|
|
994
|
+
return {
|
|
995
|
+
command: manager,
|
|
996
|
+
args: ["install", "--frozen-lockfile"],
|
|
997
|
+
displayCommand: "pnpm install --frozen-lockfile"
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
if (manager === "yarn") {
|
|
1001
|
+
return {
|
|
1002
|
+
command: manager,
|
|
1003
|
+
args: ["install", "--frozen-lockfile"],
|
|
1004
|
+
displayCommand: "yarn install --frozen-lockfile"
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
return {
|
|
1008
|
+
command: manager,
|
|
1009
|
+
args: ["install"],
|
|
1010
|
+
displayCommand: "bun install"
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
async function resolvePackageInstallCommand(options) {
|
|
1014
|
+
const installCommand = getPackageInstallCommand(options.packageManager);
|
|
1015
|
+
const executable = await resolveExecutableOutsideRoot(options.cwd, installCommand.command);
|
|
1016
|
+
if (executable === null) {
|
|
1017
|
+
return null;
|
|
1018
|
+
}
|
|
1019
|
+
return {
|
|
1020
|
+
...installCommand,
|
|
1021
|
+
command: executable
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
async function runPackageScriptToTerminal(options) {
|
|
1025
|
+
const runCommand2 = await resolvePackageRunCommand(options);
|
|
1026
|
+
if (runCommand2 === null) {
|
|
1027
|
+
return 1;
|
|
1028
|
+
}
|
|
1029
|
+
return await new Promise((resolve) => {
|
|
1030
|
+
const child = spawn2(runCommand2.command, runCommand2.args, {
|
|
1031
|
+
cwd: options.cwd,
|
|
1032
|
+
stdio: "inherit",
|
|
1033
|
+
windowsHide: true
|
|
1034
|
+
});
|
|
1035
|
+
child.on("error", () => {
|
|
1036
|
+
resolve(1);
|
|
1037
|
+
});
|
|
1038
|
+
child.on("close", (code) => {
|
|
1039
|
+
resolve(code ?? 1);
|
|
1040
|
+
});
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// src/cli/commands/run.ts
|
|
1045
|
+
async function runCommand(script, cwd = process.cwd()) {
|
|
1046
|
+
const scan = await scanProject(cwd);
|
|
1047
|
+
if (scan.packageJson === null) {
|
|
1048
|
+
console.error(pc3.red("No package.json was found in this directory."));
|
|
1049
|
+
process.exitCode = 1;
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
if (scan.scripts[script] === void 0) {
|
|
1053
|
+
console.error(pc3.red(`Script "${script}" was not found in package.json.`));
|
|
1054
|
+
process.exitCode = 1;
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
const exitCode = await runPackageScriptToTerminal({
|
|
1058
|
+
cwd,
|
|
1059
|
+
packageManager: scan.packageManager,
|
|
1060
|
+
script
|
|
1061
|
+
});
|
|
1062
|
+
process.exitCode = exitCode;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// src/cli/commands/scan.ts
|
|
1066
|
+
import pc4 from "picocolors";
|
|
1067
|
+
function formatList(values) {
|
|
1068
|
+
return values.length > 0 ? values.join(", ") : "none";
|
|
1069
|
+
}
|
|
1070
|
+
function printScanResult(scan) {
|
|
1071
|
+
console.log(pc4.bold(`Project: ${scan.projectName}`));
|
|
1072
|
+
console.log(`Type: ${scan.framework?.type ?? "Unknown"}`);
|
|
1073
|
+
console.log(`Manager: ${scan.packageManager ?? "unknown"}`);
|
|
1074
|
+
console.log(`Scripts: ${formatList(Object.keys(scan.scripts))}`);
|
|
1075
|
+
console.log(`Git: ${scan.git?.branch ?? "not detected"}`);
|
|
1076
|
+
console.log(`README: ${scan.readme.exists ? "found" : "missing"}`);
|
|
1077
|
+
console.log(`LICENSE: ${scan.license.exists ? "found" : "missing"}`);
|
|
1078
|
+
if (scan.env !== null) {
|
|
1079
|
+
console.log(`Env: ${scan.env.hasLocal ? ".env found" : ".env missing"}`);
|
|
1080
|
+
}
|
|
1081
|
+
if (scan.ports.length > 0) {
|
|
1082
|
+
const ports = scan.ports.map((port) => `${port.port}${port.inUse ? " in use" : " free"}`);
|
|
1083
|
+
console.log(`Ports: ${ports.join(", ")}`);
|
|
1084
|
+
}
|
|
1085
|
+
if (scan.docker !== null) {
|
|
1086
|
+
console.log(
|
|
1087
|
+
`Docker: compose found (${formatList(scan.docker.services.map((service) => service.name))})`
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
async function scanCommand(cwd = process.cwd()) {
|
|
1092
|
+
printScanResult(await scanProject(cwd));
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// src/cli/commands/start.ts
|
|
1096
|
+
import pc5 from "picocolors";
|
|
1097
|
+
|
|
1098
|
+
// src/server/index.ts
|
|
1099
|
+
import { promises as fs12 } from "fs";
|
|
1100
|
+
import path12 from "path";
|
|
1101
|
+
import { fileURLToPath } from "url";
|
|
1102
|
+
import { serve } from "@hono/node-server";
|
|
1103
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
1104
|
+
import { Hono } from "hono";
|
|
1105
|
+
import open2 from "open";
|
|
1106
|
+
|
|
1107
|
+
// src/core/process/manager.ts
|
|
1108
|
+
import { EventEmitter } from "events";
|
|
1109
|
+
import spawn3 from "cross-spawn";
|
|
1110
|
+
var ProcessManager = class extends EventEmitter {
|
|
1111
|
+
processes = /* @__PURE__ */ new Map();
|
|
1112
|
+
logs = [];
|
|
1113
|
+
cleanupInstalled = false;
|
|
1114
|
+
start(options) {
|
|
1115
|
+
const child = spawn3(options.command, options.args, {
|
|
1116
|
+
cwd: options.cwd,
|
|
1117
|
+
shell: options.shell ?? false,
|
|
1118
|
+
windowsHide: true
|
|
1119
|
+
});
|
|
1120
|
+
const pid = String(child.pid ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
1121
|
+
const record = {
|
|
1122
|
+
child,
|
|
1123
|
+
pid,
|
|
1124
|
+
script: options.script,
|
|
1125
|
+
command: options.displayCommand,
|
|
1126
|
+
status: "running",
|
|
1127
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1128
|
+
endedAt: null,
|
|
1129
|
+
exitCode: null
|
|
1130
|
+
};
|
|
1131
|
+
this.processes.set(pid, record);
|
|
1132
|
+
this.emitSystem(record, `Started ${options.displayCommand}`);
|
|
1133
|
+
child.stdout?.on("data", (chunk) => {
|
|
1134
|
+
this.emitLog(record, "stdout", chunk.toString());
|
|
1135
|
+
});
|
|
1136
|
+
child.stderr?.on("data", (chunk) => {
|
|
1137
|
+
this.emitLog(record, "stderr", chunk.toString());
|
|
1138
|
+
});
|
|
1139
|
+
child.on("error", (error) => {
|
|
1140
|
+
record.status = "failed";
|
|
1141
|
+
record.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1142
|
+
this.emitLog(record, "system", error.message);
|
|
1143
|
+
this.emit("process", this.snapshot(record));
|
|
1144
|
+
});
|
|
1145
|
+
child.on("close", (code) => {
|
|
1146
|
+
if (record.status === "stopped") {
|
|
1147
|
+
record.exitCode = code;
|
|
1148
|
+
} else {
|
|
1149
|
+
record.status = code === 0 ? "exited" : "failed";
|
|
1150
|
+
record.exitCode = code;
|
|
1151
|
+
}
|
|
1152
|
+
record.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1153
|
+
this.emitSystem(record, `Exited with code ${code ?? "unknown"}`);
|
|
1154
|
+
this.emit("process", this.snapshot(record));
|
|
1155
|
+
});
|
|
1156
|
+
this.emit("process", this.snapshot(record));
|
|
1157
|
+
return this.snapshot(record);
|
|
1158
|
+
}
|
|
1159
|
+
stop(pid) {
|
|
1160
|
+
const record = this.processes.get(pid);
|
|
1161
|
+
if (!record || record.status !== "running") {
|
|
1162
|
+
return false;
|
|
1163
|
+
}
|
|
1164
|
+
record.status = "stopped";
|
|
1165
|
+
record.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1166
|
+
record.child.kill();
|
|
1167
|
+
this.emitSystem(record, "Stopped by DevSurface");
|
|
1168
|
+
this.emit("process", this.snapshot(record));
|
|
1169
|
+
return true;
|
|
1170
|
+
}
|
|
1171
|
+
list() {
|
|
1172
|
+
return Array.from(this.processes.values()).map((record) => this.snapshot(record));
|
|
1173
|
+
}
|
|
1174
|
+
listLogs() {
|
|
1175
|
+
return [...this.logs];
|
|
1176
|
+
}
|
|
1177
|
+
killAll() {
|
|
1178
|
+
for (const record of this.processes.values()) {
|
|
1179
|
+
if (record.status === "running") {
|
|
1180
|
+
record.status = "stopped";
|
|
1181
|
+
record.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1182
|
+
record.child.kill();
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
attachCleanupHandlers() {
|
|
1187
|
+
if (this.cleanupInstalled) {
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
this.cleanupInstalled = true;
|
|
1191
|
+
process.once("exit", () => {
|
|
1192
|
+
this.killAll();
|
|
1193
|
+
});
|
|
1194
|
+
process.once("SIGINT", () => {
|
|
1195
|
+
this.killAll();
|
|
1196
|
+
process.exit(130);
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
emitLog(record, stream, message) {
|
|
1200
|
+
const event = {
|
|
1201
|
+
pid: record.pid,
|
|
1202
|
+
script: record.script,
|
|
1203
|
+
stream,
|
|
1204
|
+
message,
|
|
1205
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1206
|
+
};
|
|
1207
|
+
this.logs.push(event);
|
|
1208
|
+
if (this.logs.length > 1e3) {
|
|
1209
|
+
this.logs.splice(0, this.logs.length - 1e3);
|
|
1210
|
+
}
|
|
1211
|
+
this.emit("log", event);
|
|
1212
|
+
}
|
|
1213
|
+
emitSystem(record, message) {
|
|
1214
|
+
this.emitLog(record, "system", message);
|
|
1215
|
+
}
|
|
1216
|
+
snapshot(record) {
|
|
1217
|
+
return {
|
|
1218
|
+
pid: record.pid,
|
|
1219
|
+
script: record.script,
|
|
1220
|
+
command: record.command,
|
|
1221
|
+
status: record.status,
|
|
1222
|
+
startedAt: record.startedAt,
|
|
1223
|
+
endedAt: record.endedAt,
|
|
1224
|
+
exitCode: record.exitCode
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
// src/server/routes/api.ts
|
|
1230
|
+
import { constants as constants2, existsSync } from "fs";
|
|
1231
|
+
import { promises as fs11 } from "fs";
|
|
1232
|
+
import path11 from "path";
|
|
1233
|
+
import spawn4 from "cross-spawn";
|
|
1234
|
+
import open from "open";
|
|
1235
|
+
|
|
1236
|
+
// src/server/localAccess.ts
|
|
1237
|
+
var LOCAL_HOSTNAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
|
|
1238
|
+
function hostnameFromHostHeader(host) {
|
|
1239
|
+
const trimmed = host.trim().toLowerCase();
|
|
1240
|
+
if (trimmed.length === 0) {
|
|
1241
|
+
return null;
|
|
1242
|
+
}
|
|
1243
|
+
if (trimmed.startsWith("[")) {
|
|
1244
|
+
const end = trimmed.indexOf("]");
|
|
1245
|
+
return end > 0 ? trimmed.slice(1, end) : null;
|
|
1246
|
+
}
|
|
1247
|
+
return trimmed.split(":")[0] ?? null;
|
|
1248
|
+
}
|
|
1249
|
+
function isAllowedLocalHostHeader(host) {
|
|
1250
|
+
if (typeof host !== "string") {
|
|
1251
|
+
return false;
|
|
1252
|
+
}
|
|
1253
|
+
const hostname = hostnameFromHostHeader(host);
|
|
1254
|
+
return hostname !== null && LOCAL_HOSTNAMES.has(hostname);
|
|
1255
|
+
}
|
|
1256
|
+
function isAllowedLocalOrigin(origin) {
|
|
1257
|
+
if (origin === null) {
|
|
1258
|
+
return true;
|
|
1259
|
+
}
|
|
1260
|
+
try {
|
|
1261
|
+
const url = new URL(origin);
|
|
1262
|
+
return (url.protocol === "http:" || url.protocol === "https:") && LOCAL_HOSTNAMES.has(url.hostname.toLowerCase());
|
|
1263
|
+
} catch {
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
function isSameOrigin(requestUrl, origin) {
|
|
1268
|
+
try {
|
|
1269
|
+
return new URL(requestUrl).origin === new URL(origin).origin;
|
|
1270
|
+
} catch {
|
|
1271
|
+
return false;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// src/server/routes/api.ts
|
|
1276
|
+
function isWithinRoot4(root, target) {
|
|
1277
|
+
const relative = path11.relative(path11.resolve(root), path11.resolve(target));
|
|
1278
|
+
return relative === "" || !relative.startsWith("..") && !path11.isAbsolute(relative);
|
|
1279
|
+
}
|
|
1280
|
+
function isAllowedMutationOrigin(requestUrl, origin) {
|
|
1281
|
+
if (origin === null) {
|
|
1282
|
+
return true;
|
|
1283
|
+
}
|
|
1284
|
+
return isAllowedLocalOrigin(origin) && isSameOrigin(requestUrl, origin);
|
|
1285
|
+
}
|
|
1286
|
+
function isCrossSiteFetch(secFetchSite) {
|
|
1287
|
+
return secFetchSite === "cross-site";
|
|
1288
|
+
}
|
|
1289
|
+
function hasMutationIntent(intent) {
|
|
1290
|
+
return intent === "dashboard";
|
|
1291
|
+
}
|
|
1292
|
+
async function realPathWithinRoot(root, target) {
|
|
1293
|
+
if (!isWithinRoot4(root, target)) {
|
|
1294
|
+
return false;
|
|
1295
|
+
}
|
|
1296
|
+
try {
|
|
1297
|
+
const [realRoot, realTarget] = await Promise.all([fs11.realpath(root), fs11.realpath(target)]);
|
|
1298
|
+
return isWithinRoot4(realRoot, realTarget);
|
|
1299
|
+
} catch {
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
async function writableDestinationWithinRoot(root, destination) {
|
|
1304
|
+
if (!isWithinRoot4(root, destination)) {
|
|
1305
|
+
return false;
|
|
1306
|
+
}
|
|
1307
|
+
try {
|
|
1308
|
+
const [realRoot, realParent] = await Promise.all([
|
|
1309
|
+
fs11.realpath(root),
|
|
1310
|
+
fs11.realpath(path11.dirname(destination))
|
|
1311
|
+
]);
|
|
1312
|
+
return isWithinRoot4(realRoot, realParent);
|
|
1313
|
+
} catch {
|
|
1314
|
+
return false;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
async function copyFileExclusive(source, destination) {
|
|
1318
|
+
const content = await fs11.readFile(source);
|
|
1319
|
+
let handle2 = null;
|
|
1320
|
+
try {
|
|
1321
|
+
handle2 = await fs11.open(
|
|
1322
|
+
destination,
|
|
1323
|
+
constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY,
|
|
1324
|
+
384
|
|
1325
|
+
);
|
|
1326
|
+
await handle2.writeFile(content);
|
|
1327
|
+
return "copied";
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
1330
|
+
if (code === "EEXIST") {
|
|
1331
|
+
return "exists";
|
|
1332
|
+
}
|
|
1333
|
+
throw error;
|
|
1334
|
+
} finally {
|
|
1335
|
+
await handle2?.close();
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
function quotePowerShellString(value) {
|
|
1339
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
1340
|
+
}
|
|
1341
|
+
function quotePosixString(value) {
|
|
1342
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
1343
|
+
}
|
|
1344
|
+
function resolveCommandPromptExecutable() {
|
|
1345
|
+
return process.env.ComSpec ?? "cmd.exe";
|
|
1346
|
+
}
|
|
1347
|
+
function findExecutable(command) {
|
|
1348
|
+
if (path11.isAbsolute(command)) {
|
|
1349
|
+
return existsSync(command) ? command : null;
|
|
1350
|
+
}
|
|
1351
|
+
const pathValue = process.env.PATH ?? "";
|
|
1352
|
+
for (const directory of pathValue.split(path11.delimiter)) {
|
|
1353
|
+
if (directory.length === 0) {
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1356
|
+
const candidate = path11.join(directory, command);
|
|
1357
|
+
if (existsSync(candidate)) {
|
|
1358
|
+
return candidate;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return null;
|
|
1362
|
+
}
|
|
1363
|
+
function launchDetached(command, args, root) {
|
|
1364
|
+
const child = spawn4(command, args, {
|
|
1365
|
+
cwd: root,
|
|
1366
|
+
detached: true,
|
|
1367
|
+
stdio: "ignore",
|
|
1368
|
+
windowsHide: process.platform === "win32"
|
|
1369
|
+
});
|
|
1370
|
+
child.on("error", () => void 0);
|
|
1371
|
+
child.unref();
|
|
1372
|
+
return true;
|
|
1373
|
+
}
|
|
1374
|
+
function openTerminalAt(root) {
|
|
1375
|
+
if (process.platform === "win32") {
|
|
1376
|
+
return launchDetached(
|
|
1377
|
+
resolveCommandPromptExecutable(),
|
|
1378
|
+
[
|
|
1379
|
+
"/d",
|
|
1380
|
+
"/c",
|
|
1381
|
+
"start",
|
|
1382
|
+
'""',
|
|
1383
|
+
"/D",
|
|
1384
|
+
root,
|
|
1385
|
+
"powershell.exe",
|
|
1386
|
+
"-NoExit",
|
|
1387
|
+
"-NoLogo",
|
|
1388
|
+
"-Command",
|
|
1389
|
+
`Set-Location -LiteralPath ${quotePowerShellString(root)}`
|
|
1390
|
+
],
|
|
1391
|
+
root
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
if (process.platform === "darwin") {
|
|
1395
|
+
return launchDetached("open", ["-a", "Terminal", root], root);
|
|
1396
|
+
}
|
|
1397
|
+
const configuredTerminal = process.env.TERMINAL;
|
|
1398
|
+
if (configuredTerminal !== void 0 && findExecutable(configuredTerminal) !== null) {
|
|
1399
|
+
return launchDetached(configuredTerminal, [], root);
|
|
1400
|
+
}
|
|
1401
|
+
const linuxTerminals = [
|
|
1402
|
+
{ command: "x-terminal-emulator", args: [] },
|
|
1403
|
+
{ command: "gnome-terminal", args: ["--working-directory", root] },
|
|
1404
|
+
{ command: "konsole", args: ["--workdir", root] },
|
|
1405
|
+
{ command: "xfce4-terminal", args: ["--working-directory", root] },
|
|
1406
|
+
{
|
|
1407
|
+
command: "xterm",
|
|
1408
|
+
args: [
|
|
1409
|
+
"-e",
|
|
1410
|
+
"sh",
|
|
1411
|
+
"-lc",
|
|
1412
|
+
`cd ${quotePosixString(root)} && exec ${quotePosixString(process.env.SHELL ?? "sh")}`
|
|
1413
|
+
]
|
|
1414
|
+
}
|
|
1415
|
+
];
|
|
1416
|
+
const terminal = linuxTerminals.find((candidate) => findExecutable(candidate.command) !== null);
|
|
1417
|
+
if (terminal === void 0) {
|
|
1418
|
+
return false;
|
|
1419
|
+
}
|
|
1420
|
+
return launchDetached(terminal.command, terminal.args, root);
|
|
1421
|
+
}
|
|
1422
|
+
function registerApiRoutes(app, options) {
|
|
1423
|
+
app.use("/api/*", async (context, next) => {
|
|
1424
|
+
const host = context.req.header("host") ?? new URL(context.req.url).host;
|
|
1425
|
+
if (!isAllowedLocalHostHeader(host)) {
|
|
1426
|
+
return context.json({ error: "Non-local host rejected." }, 403);
|
|
1427
|
+
}
|
|
1428
|
+
if (context.req.method !== "GET" && context.req.method !== "HEAD") {
|
|
1429
|
+
const origin = context.req.header("origin") ?? null;
|
|
1430
|
+
const secFetchSite = context.req.header("sec-fetch-site") ?? null;
|
|
1431
|
+
const intent = context.req.header("x-devsurface-intent") ?? null;
|
|
1432
|
+
if (!hasMutationIntent(intent) || isCrossSiteFetch(secFetchSite) || !isAllowedMutationOrigin(context.req.url, origin)) {
|
|
1433
|
+
return context.json({ error: "Cross-origin mutation rejected." }, 403);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
await next();
|
|
1437
|
+
});
|
|
1438
|
+
app.get("/api/project", async (context) => {
|
|
1439
|
+
return context.json(await scanProject(options.projectRoot));
|
|
1440
|
+
});
|
|
1441
|
+
app.get("/api/health", async (context) => {
|
|
1442
|
+
return context.json(await runDoctor(options.projectRoot));
|
|
1443
|
+
});
|
|
1444
|
+
app.get("/api/processes", (context) => {
|
|
1445
|
+
return context.json(options.processManager.list());
|
|
1446
|
+
});
|
|
1447
|
+
app.post("/api/run/:script", async (context) => {
|
|
1448
|
+
const script = decodeURIComponent(context.req.param("script"));
|
|
1449
|
+
const scan = await scanProject(options.projectRoot);
|
|
1450
|
+
const packageScript = scan.scripts[script];
|
|
1451
|
+
if (packageScript === void 0) {
|
|
1452
|
+
return context.json({ error: `Script "${script}" was not found.` }, 404);
|
|
1453
|
+
}
|
|
1454
|
+
const command = await resolvePackageRunCommand({
|
|
1455
|
+
cwd: options.projectRoot,
|
|
1456
|
+
packageManager: scan.packageManager,
|
|
1457
|
+
script
|
|
1458
|
+
});
|
|
1459
|
+
if (command === null) {
|
|
1460
|
+
return context.json({ error: "Package manager executable was not found." }, 503);
|
|
1461
|
+
}
|
|
1462
|
+
const processInfo = options.processManager.start({
|
|
1463
|
+
cwd: options.projectRoot,
|
|
1464
|
+
script,
|
|
1465
|
+
command: command.command,
|
|
1466
|
+
args: command.args,
|
|
1467
|
+
displayCommand: command.displayCommand
|
|
1468
|
+
});
|
|
1469
|
+
return context.json({
|
|
1470
|
+
...processInfo,
|
|
1471
|
+
packageScript
|
|
1472
|
+
});
|
|
1473
|
+
});
|
|
1474
|
+
app.post("/api/install", async (context) => {
|
|
1475
|
+
const scan = await scanProject(options.projectRoot);
|
|
1476
|
+
const command = await resolvePackageInstallCommand({
|
|
1477
|
+
cwd: options.projectRoot,
|
|
1478
|
+
packageManager: scan.packageManager
|
|
1479
|
+
});
|
|
1480
|
+
if (command === null) {
|
|
1481
|
+
return context.json({ error: "Package manager executable was not found." }, 503);
|
|
1482
|
+
}
|
|
1483
|
+
const processInfo = options.processManager.start({
|
|
1484
|
+
cwd: options.projectRoot,
|
|
1485
|
+
script: "install",
|
|
1486
|
+
command: command.command,
|
|
1487
|
+
args: command.args,
|
|
1488
|
+
displayCommand: command.displayCommand
|
|
1489
|
+
});
|
|
1490
|
+
return context.json(processInfo);
|
|
1491
|
+
});
|
|
1492
|
+
app.post("/api/commands/:name", async (context) => {
|
|
1493
|
+
const name = decodeURIComponent(context.req.param("name"));
|
|
1494
|
+
const scan = await scanProject(options.projectRoot);
|
|
1495
|
+
const configuredCommand = scan.config?.config.commands?.[name] ?? null;
|
|
1496
|
+
if (configuredCommand === null) {
|
|
1497
|
+
return context.json({ error: `Configured command "${name}" was not found.` }, 404);
|
|
1498
|
+
}
|
|
1499
|
+
const processInfo = options.processManager.start({
|
|
1500
|
+
cwd: options.projectRoot,
|
|
1501
|
+
script: name,
|
|
1502
|
+
command: configuredCommand,
|
|
1503
|
+
args: [],
|
|
1504
|
+
displayCommand: configuredCommand,
|
|
1505
|
+
shell: true
|
|
1506
|
+
});
|
|
1507
|
+
return context.json({
|
|
1508
|
+
...processInfo,
|
|
1509
|
+
configuredCommand
|
|
1510
|
+
});
|
|
1511
|
+
});
|
|
1512
|
+
app.post("/api/open/folder", async (context) => {
|
|
1513
|
+
await open(options.projectRoot);
|
|
1514
|
+
return context.json({ opened: true, target: "folder" });
|
|
1515
|
+
});
|
|
1516
|
+
app.post("/api/open/package", async (context) => {
|
|
1517
|
+
const packagePath = path11.join(options.projectRoot, "package.json");
|
|
1518
|
+
if (!await realPathWithinRoot(options.projectRoot, packagePath)) {
|
|
1519
|
+
return context.json({ error: "package.json was not found inside the project root." }, 404);
|
|
1520
|
+
}
|
|
1521
|
+
await open(packagePath);
|
|
1522
|
+
return context.json({ opened: true, target: "package" });
|
|
1523
|
+
});
|
|
1524
|
+
app.post("/api/open/terminal", (context) => {
|
|
1525
|
+
const opened = openTerminalAt(options.projectRoot);
|
|
1526
|
+
return context.json({ opened, target: "terminal" }, opened ? 200 : 501);
|
|
1527
|
+
});
|
|
1528
|
+
app.post("/api/env/copy", async (context) => {
|
|
1529
|
+
const scan = await scanProject(options.projectRoot);
|
|
1530
|
+
const examplePath = scan.env?.examplePath ?? null;
|
|
1531
|
+
const localPath = scan.env?.localPath ?? null;
|
|
1532
|
+
if (examplePath === null) {
|
|
1533
|
+
return context.json({ error: ".env.example was not found." }, 404);
|
|
1534
|
+
}
|
|
1535
|
+
const destination = localPath ?? path11.join(options.projectRoot, scan.config?.config.env?.local ?? ".env");
|
|
1536
|
+
if (!await realPathWithinRoot(options.projectRoot, examplePath) || !await writableDestinationWithinRoot(options.projectRoot, destination)) {
|
|
1537
|
+
return context.json({ error: "Refusing to copy env files outside the project root." }, 400);
|
|
1538
|
+
}
|
|
1539
|
+
const copyResult = await copyFileExclusive(examplePath, destination);
|
|
1540
|
+
if (copyResult === "exists") {
|
|
1541
|
+
return context.json({ error: ".env already exists." }, 409);
|
|
1542
|
+
}
|
|
1543
|
+
return context.json({ copied: true });
|
|
1544
|
+
});
|
|
1545
|
+
app.delete("/api/run/:pid", (context) => {
|
|
1546
|
+
const pid = decodeURIComponent(context.req.param("pid"));
|
|
1547
|
+
const stopped = options.processManager.stop(pid);
|
|
1548
|
+
return context.json({ stopped });
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// src/server/routes/ws.ts
|
|
1553
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
1554
|
+
function isAllowedWebSocketRequest(request) {
|
|
1555
|
+
const origin = request.headers.origin;
|
|
1556
|
+
const host = request.headers.host;
|
|
1557
|
+
const secFetchSite = request.headers["sec-fetch-site"];
|
|
1558
|
+
if (typeof host !== "string" || !isAllowedLocalHostHeader(host)) {
|
|
1559
|
+
return false;
|
|
1560
|
+
}
|
|
1561
|
+
if (secFetchSite === "cross-site") {
|
|
1562
|
+
return false;
|
|
1563
|
+
}
|
|
1564
|
+
if (typeof origin !== "string") {
|
|
1565
|
+
return true;
|
|
1566
|
+
}
|
|
1567
|
+
if (!isAllowedLocalOrigin(origin)) {
|
|
1568
|
+
return false;
|
|
1569
|
+
}
|
|
1570
|
+
try {
|
|
1571
|
+
return new URL(origin).host.toLowerCase() === host.toLowerCase();
|
|
1572
|
+
} catch {
|
|
1573
|
+
return false;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
function setupWebSocket(server, processManager) {
|
|
1577
|
+
const wss = new WebSocketServer({
|
|
1578
|
+
server,
|
|
1579
|
+
path: "/ws",
|
|
1580
|
+
verifyClient: (info) => isAllowedWebSocketRequest(info.req)
|
|
1581
|
+
});
|
|
1582
|
+
function broadcast(payload) {
|
|
1583
|
+
const serialized = JSON.stringify(payload);
|
|
1584
|
+
for (const client of wss.clients) {
|
|
1585
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
1586
|
+
client.send(serialized);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
processManager.on("log", (event) => {
|
|
1591
|
+
broadcast({ type: "log", event });
|
|
1592
|
+
});
|
|
1593
|
+
processManager.on("process", (processInfo) => {
|
|
1594
|
+
broadcast({ type: "process", process: processInfo });
|
|
1595
|
+
});
|
|
1596
|
+
wss.on("connection", (socket) => {
|
|
1597
|
+
socket.send(
|
|
1598
|
+
JSON.stringify({
|
|
1599
|
+
type: "hello",
|
|
1600
|
+
processes: processManager.list(),
|
|
1601
|
+
logs: processManager.listLogs()
|
|
1602
|
+
})
|
|
1603
|
+
);
|
|
1604
|
+
});
|
|
1605
|
+
return wss;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// src/server/index.ts
|
|
1609
|
+
var HOST = "127.0.0.1";
|
|
1610
|
+
var DEFAULT_PORT = 4567;
|
|
1611
|
+
function assertLocalHost(host) {
|
|
1612
|
+
if (host !== HOST) {
|
|
1613
|
+
throw new Error("DevSurface must bind only to 127.0.0.1.");
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
async function fileExists2(filePath) {
|
|
1617
|
+
try {
|
|
1618
|
+
await fs12.access(filePath);
|
|
1619
|
+
return true;
|
|
1620
|
+
} catch {
|
|
1621
|
+
return false;
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
async function findWebDistDir() {
|
|
1625
|
+
const moduleDir = path12.dirname(fileURLToPath(import.meta.url));
|
|
1626
|
+
const candidates = [
|
|
1627
|
+
path12.join(moduleDir, "..", "web", "dist"),
|
|
1628
|
+
path12.join(moduleDir, "..", "..", "src", "web", "dist"),
|
|
1629
|
+
path12.join(moduleDir, "web", "dist")
|
|
1630
|
+
];
|
|
1631
|
+
for (const candidate of candidates) {
|
|
1632
|
+
if (await fileExists2(path12.join(candidate, "index.html"))) {
|
|
1633
|
+
return candidate;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
return null;
|
|
1637
|
+
}
|
|
1638
|
+
async function createApp(options) {
|
|
1639
|
+
const app = new Hono();
|
|
1640
|
+
registerApiRoutes(app, options);
|
|
1641
|
+
const webDistDir = await findWebDistDir();
|
|
1642
|
+
if (webDistDir !== null) {
|
|
1643
|
+
app.use("/assets/*", serveStatic({ root: webDistDir }));
|
|
1644
|
+
app.get("/favicon.svg", serveStatic({ root: webDistDir }));
|
|
1645
|
+
app.get("*", async (context) => {
|
|
1646
|
+
const html = await fs12.readFile(path12.join(webDistDir, "index.html"), "utf8");
|
|
1647
|
+
return context.html(html);
|
|
1648
|
+
});
|
|
1649
|
+
} else {
|
|
1650
|
+
app.get(
|
|
1651
|
+
"*",
|
|
1652
|
+
(context) => context.html(
|
|
1653
|
+
"<!doctype html><title>DevSurface</title><main><h1>DevSurface</h1><p>Run npm run build:web to build the dashboard.</p></main>",
|
|
1654
|
+
503
|
|
1655
|
+
)
|
|
1656
|
+
);
|
|
1657
|
+
}
|
|
1658
|
+
return app;
|
|
1659
|
+
}
|
|
1660
|
+
async function startDevSurfaceServer(options) {
|
|
1661
|
+
assertLocalHost(HOST);
|
|
1662
|
+
const port = options.port ?? DEFAULT_PORT;
|
|
1663
|
+
const processManager = new ProcessManager();
|
|
1664
|
+
processManager.attachCleanupHandlers();
|
|
1665
|
+
const app = await createApp({
|
|
1666
|
+
projectRoot: options.projectRoot,
|
|
1667
|
+
processManager
|
|
1668
|
+
});
|
|
1669
|
+
const server = serve({
|
|
1670
|
+
fetch: app.fetch,
|
|
1671
|
+
port,
|
|
1672
|
+
hostname: HOST
|
|
1673
|
+
});
|
|
1674
|
+
const wss = setupWebSocket(server, processManager);
|
|
1675
|
+
const url = `http://${HOST}:${port}`;
|
|
1676
|
+
if (options.openBrowser !== false) {
|
|
1677
|
+
await open2(url);
|
|
1678
|
+
}
|
|
1679
|
+
return {
|
|
1680
|
+
url,
|
|
1681
|
+
port,
|
|
1682
|
+
processManager,
|
|
1683
|
+
close: async () => {
|
|
1684
|
+
processManager.killAll();
|
|
1685
|
+
await new Promise((resolve) => {
|
|
1686
|
+
wss.close(() => resolve());
|
|
1687
|
+
});
|
|
1688
|
+
await new Promise((resolve, reject) => {
|
|
1689
|
+
server.close((error) => {
|
|
1690
|
+
if (error) {
|
|
1691
|
+
reject(error);
|
|
1692
|
+
} else {
|
|
1693
|
+
resolve();
|
|
1694
|
+
}
|
|
1695
|
+
});
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// src/cli/commands/start.ts
|
|
1702
|
+
async function startCommand(options) {
|
|
1703
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1704
|
+
console.log(pc5.bold(`DevSurface v0.1.0`));
|
|
1705
|
+
console.log("Scanning project...\n");
|
|
1706
|
+
const scan = await scanProject(cwd);
|
|
1707
|
+
printScanResult(scan);
|
|
1708
|
+
const warnings = await runDoctor(cwd, scan);
|
|
1709
|
+
if (warnings.length > 0) {
|
|
1710
|
+
console.log("\nWarnings:");
|
|
1711
|
+
for (const item of warnings) {
|
|
1712
|
+
const marker = item.severity === "error" ? pc5.red("!") : pc5.yellow("!");
|
|
1713
|
+
console.log(` ${marker} ${item.title}`);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
const server = await startDevSurfaceServer({
|
|
1717
|
+
projectRoot: cwd,
|
|
1718
|
+
port: options.port,
|
|
1719
|
+
openBrowser: options.openBrowser
|
|
1720
|
+
});
|
|
1721
|
+
console.log(`
|
|
1722
|
+
Dashboard running at -> ${pc5.cyan(server.url)}`);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// src/cli/index.ts
|
|
1726
|
+
var program = new Command();
|
|
1727
|
+
function toPort(value) {
|
|
1728
|
+
const port = Number(value);
|
|
1729
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
1730
|
+
throw new Error("Port must be an integer between 1 and 65535.");
|
|
1731
|
+
}
|
|
1732
|
+
return port;
|
|
1733
|
+
}
|
|
1734
|
+
function handle(command) {
|
|
1735
|
+
command.catch((error) => {
|
|
1736
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1737
|
+
console.error(message);
|
|
1738
|
+
process.exitCode = 1;
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
program.name("devsurface").description("Turn any Node.js repository into a local developer control panel.").version("0.1.0").option("-p, --port <port>", "dashboard port", toPort, 4567).option("--no-open", "do not open the browser automatically").action((options) => {
|
|
1742
|
+
handle(
|
|
1743
|
+
startCommand({
|
|
1744
|
+
cwd: process.cwd(),
|
|
1745
|
+
port: options.port,
|
|
1746
|
+
openBrowser: options.open
|
|
1747
|
+
})
|
|
1748
|
+
);
|
|
1749
|
+
});
|
|
1750
|
+
program.command("scan").description("Print detected project info.").action(() => {
|
|
1751
|
+
handle(scanCommand(process.cwd()));
|
|
1752
|
+
});
|
|
1753
|
+
program.command("doctor").description("Print setup health warnings.").action(() => {
|
|
1754
|
+
handle(doctorCommand(process.cwd()));
|
|
1755
|
+
});
|
|
1756
|
+
program.command("init").description("Create a starter devsurface.config.json.").action(() => {
|
|
1757
|
+
handle(initCommand(process.cwd()));
|
|
1758
|
+
});
|
|
1759
|
+
program.command("run").argument("<script>", "package.json script to run").description("Run a package script and stream logs.").action((script) => {
|
|
1760
|
+
handle(runCommand(script, process.cwd()));
|
|
1761
|
+
});
|
|
1762
|
+
await program.parseAsync(process.argv);
|
|
1763
|
+
//# sourceMappingURL=index.js.map
|