devsurface 0.2.0 → 0.4.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
@@ -10,6 +10,27 @@ import pc from "picocolors";
10
10
  import { promises as fs9 } from "fs";
11
11
  import path9 from "path";
12
12
 
13
+ // src/core/documentation.ts
14
+ function extractScriptReferences(content) {
15
+ const references = /* @__PURE__ */ new Set();
16
+ const commandRegexes = [
17
+ /\bnpm\s+run\s+([A-Za-z0-9:_-]+)/g,
18
+ /\bpnpm\s+run\s+([A-Za-z0-9:_-]+)/g,
19
+ /\bbun\s+run\s+([A-Za-z0-9:_-]+)/g,
20
+ /\byarn\s+run\s+([A-Za-z0-9:_-]+)/g,
21
+ /\bnpm\s+(test|start|build)\b/g,
22
+ /\bpnpm\s+(test|start|build)\b/g,
23
+ /\byarn\s+(test|start|build)\b/g,
24
+ /\bbun\s+(test|start|build)\b/g
25
+ ];
26
+ for (const regex of commandRegexes) {
27
+ for (const match of content.matchAll(regex)) {
28
+ references.add(match[1]);
29
+ }
30
+ }
31
+ return Array.from(references);
32
+ }
33
+
13
34
  // src/core/scanner/index.ts
14
35
  import { promises as fs8 } from "fs";
15
36
  import path8 from "path";
@@ -18,6 +39,16 @@ import path8 from "path";
18
39
  import { promises as fs } from "fs";
19
40
  import path from "path";
20
41
 
42
+ // src/core/security/url.ts
43
+ function isSafeHttpUrl(value) {
44
+ try {
45
+ const url = new URL(value);
46
+ return url.protocol === "http:" || url.protocol === "https:";
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
21
52
  // src/core/config/defaults.ts
22
53
  var CONFIG_FILE_NAME = "devsurface.config.json";
23
54
  var defaultConfig = {
@@ -49,6 +80,10 @@ var defaultConfig = {
49
80
 
50
81
  // src/core/config/load.ts
51
82
  var MAX_CONFIGURED_PORTS = 32;
83
+ function isWithinRoot(root, target) {
84
+ const relative = path.relative(root, target);
85
+ return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
86
+ }
52
87
  function isRecord(value) {
53
88
  return typeof value === "object" && value !== null && !Array.isArray(value);
54
89
  }
@@ -125,6 +160,14 @@ function validateConfig(raw) {
125
160
  if (raw.services !== void 0 && !isRecord(raw.services)) {
126
161
  warnings.push("services must be an object.");
127
162
  }
163
+ let docs;
164
+ if (typeof raw.docs === "string" && raw.docs.length > 0) {
165
+ if (isSafeHttpUrl(raw.docs)) {
166
+ docs = raw.docs;
167
+ } else {
168
+ warnings.push("docs must be an http or https URL.");
169
+ }
170
+ }
128
171
  return {
129
172
  config: {
130
173
  name: typeof raw.name === "string" ? raw.name : void 0,
@@ -134,7 +177,7 @@ function validateConfig(raw) {
134
177
  ports: toPorts(raw.ports, warnings),
135
178
  env,
136
179
  services,
137
- docs: typeof raw.docs === "string" ? raw.docs : void 0
180
+ docs
138
181
  },
139
182
  warnings
140
183
  };
@@ -142,10 +185,17 @@ function validateConfig(raw) {
142
185
  async function loadConfig(root) {
143
186
  const configPath = path.join(root, CONFIG_FILE_NAME);
144
187
  try {
145
- const content = await fs.readFile(configPath, "utf8");
188
+ const [realRoot, realConfigPath] = await Promise.all([
189
+ fs.realpath(root),
190
+ fs.realpath(configPath)
191
+ ]);
192
+ if (!isWithinRoot(realRoot, realConfigPath)) {
193
+ return null;
194
+ }
195
+ const content = await fs.readFile(realConfigPath, "utf8");
146
196
  const parsed = JSON.parse(content);
147
197
  const { config, warnings } = validateConfig(parsed);
148
- return { path: configPath, config, warnings };
198
+ return { path: realConfigPath, config, warnings };
149
199
  } catch (error) {
150
200
  const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
151
201
  if (code === "ENOENT") {
@@ -162,7 +212,7 @@ async function loadConfig(root) {
162
212
  }
163
213
  }
164
214
 
165
- // src/core/scanner/docker.ts
215
+ // src/core/docker/compose.ts
166
216
  import { promises as fs3 } from "fs";
167
217
  import os from "os";
168
218
  import path3 from "path";
@@ -173,7 +223,7 @@ import { parse as parseYaml } from "yaml";
173
223
  import { constants } from "fs";
174
224
  import { promises as fs2 } from "fs";
175
225
  import path2 from "path";
176
- function isWithinRoot(root, target) {
226
+ function isWithinRoot2(root, target) {
177
227
  const resolvedRoot = path2.resolve(root);
178
228
  const resolvedTarget = path2.resolve(target);
179
229
  const relative = path2.relative(resolvedRoot, resolvedTarget);
@@ -193,7 +243,7 @@ function executableNames(command) {
193
243
  return extensions.map((extension) => `${command}${extension}`);
194
244
  }
195
245
  async function executableOutsideRoot(root, candidate) {
196
- if (isWithinRoot(root, candidate)) {
246
+ if (isWithinRoot2(root, candidate)) {
197
247
  return null;
198
248
  }
199
249
  try {
@@ -201,7 +251,7 @@ async function executableOutsideRoot(root, candidate) {
201
251
  fs2.realpath(root),
202
252
  fs2.realpath(candidate)
203
253
  ]);
204
- if (isWithinRoot(realRoot, realCandidate)) {
254
+ if (isWithinRoot2(realRoot, realCandidate)) {
205
255
  return null;
206
256
  }
207
257
  await fs2.access(realCandidate, constants.X_OK);
@@ -216,7 +266,7 @@ async function resolveExecutableOutsideRoot(root, command) {
216
266
  }
217
267
  for (const entry of pathEntries(process.env.PATH ?? "")) {
218
268
  const directory = path2.resolve(entry);
219
- if (isWithinRoot(root, directory)) {
269
+ if (isWithinRoot2(root, directory)) {
220
270
  continue;
221
271
  }
222
272
  for (const executableName of executableNames(command)) {
@@ -229,50 +279,38 @@ async function resolveExecutableOutsideRoot(root, command) {
229
279
  return null;
230
280
  }
231
281
 
232
- // src/core/scanner/docker.ts
282
+ // src/core/docker/compose.ts
233
283
  var COMPOSE_FILES = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"];
234
- async function fileExists(filePath) {
235
- try {
236
- await fs3.access(filePath);
237
- return true;
238
- } catch {
239
- return false;
240
- }
241
- }
242
- async function getComposeFiles(root) {
243
- const checks = await Promise.all(
244
- COMPOSE_FILES.map(async (file) => {
245
- const filePath = path3.join(root, file);
246
- return await fileExists(filePath) ? filePath : null;
247
- })
248
- );
249
- return checks.filter((filePath) => filePath !== null);
250
- }
251
- async function extractServices(composePath) {
252
- try {
253
- const content = await fs3.readFile(composePath, "utf8");
254
- const parsed = parseYaml(content);
255
- if (typeof parsed === "object" && parsed !== null && "services" in parsed && typeof parsed.services === "object" && parsed.services !== null) {
256
- return Object.keys(parsed.services);
257
- }
258
- } catch {
259
- return [];
260
- }
261
- return [];
262
- }
263
- function isWithinRoot2(root, target) {
284
+ var COMMAND_OUTPUT_LIMIT = 2e5;
285
+ var ESC = String.fromCharCode(27);
286
+ var ANSI_CSI_SEQUENCE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, "g");
287
+ var DockerOperationError = class extends Error {
288
+ constructor(code, message) {
289
+ super(message);
290
+ this.code = code;
291
+ this.name = "DockerOperationError";
292
+ }
293
+ code;
294
+ };
295
+ function isWithinRoot3(root, target) {
264
296
  const relative = path3.relative(path3.resolve(root), path3.resolve(target));
265
297
  return relative === "" || !relative.startsWith("..") && !path3.isAbsolute(relative);
266
298
  }
267
299
  function dockerCommandCwd(root) {
268
300
  const candidates = [os.homedir(), os.tmpdir(), path3.parse(path3.resolve(root)).root];
269
- return candidates.find((candidate) => !isWithinRoot2(root, candidate)) ?? os.homedir();
301
+ return candidates.find((candidate) => !isWithinRoot3(root, candidate)) ?? os.homedir();
270
302
  }
271
- async function runDockerCommand(root, args, timeoutMs = 2500) {
303
+ function appendBounded(current, chunk, limit) {
304
+ const combined = current + chunk;
305
+ return combined.length <= limit ? combined : combined.slice(-limit);
306
+ }
307
+ var runDockerCommand = async (root, args, options = {}) => {
272
308
  const dockerExecutable = await resolveExecutableOutsideRoot(root, "docker");
273
309
  if (dockerExecutable === null) {
274
- return { code: null, stdout: "", stderr: "" };
310
+ return { code: null, stdout: "", stderr: "", error: "not-found" };
275
311
  }
312
+ const timeoutMs = options.timeoutMs ?? 5e3;
313
+ const outputLimit = options.outputLimit ?? COMMAND_OUTPUT_LIMIT;
276
314
  return await new Promise((resolve) => {
277
315
  const child = spawn(dockerExecutable, args, {
278
316
  cwd: dockerCommandCwd(root),
@@ -281,36 +319,152 @@ async function runDockerCommand(root, args, timeoutMs = 2500) {
281
319
  let settled = false;
282
320
  let stdout = "";
283
321
  let stderr = "";
284
- const timeout = setTimeout(() => {
322
+ const finish = (result) => {
323
+ if (settled) {
324
+ return;
325
+ }
285
326
  settled = true;
327
+ clearTimeout(timeout);
328
+ resolve(result);
329
+ };
330
+ const timeout = setTimeout(() => {
286
331
  child.kill();
287
- resolve({ code: null, stdout, stderr });
332
+ finish({ code: null, stdout, stderr, error: "timeout" });
288
333
  }, timeoutMs);
289
334
  child.stdout?.on("data", (chunk) => {
290
- stdout += chunk.toString();
335
+ stdout = appendBounded(stdout, chunk.toString(), outputLimit);
291
336
  });
292
337
  child.stderr?.on("data", (chunk) => {
293
- stderr += chunk.toString();
338
+ stderr = appendBounded(stderr, chunk.toString(), outputLimit);
294
339
  });
295
340
  child.on("error", () => {
296
- clearTimeout(timeout);
297
- if (!settled) {
298
- settled = true;
299
- resolve({ code: null, stdout, stderr });
300
- }
341
+ finish({ code: null, stdout, stderr, error: "spawn" });
301
342
  });
302
343
  child.on("close", (code) => {
303
- clearTimeout(timeout);
304
- if (!settled) {
305
- settled = true;
306
- resolve({ code, stdout, stderr });
307
- }
344
+ finish({ code, stdout, stderr, error: null });
308
345
  });
309
346
  });
347
+ };
348
+ async function findComposeFiles(root) {
349
+ const resolvedRoot = await fs3.realpath(root).catch(() => path3.resolve(root));
350
+ const matches = [];
351
+ for (const file of COMPOSE_FILES) {
352
+ const candidate = path3.join(root, file);
353
+ try {
354
+ const [stat, realCandidate] = await Promise.all([fs3.stat(candidate), fs3.realpath(candidate)]);
355
+ if (stat.isFile() && isWithinRoot3(resolvedRoot, realCandidate)) {
356
+ matches.push(realCandidate);
357
+ }
358
+ } catch {
359
+ }
360
+ }
361
+ return matches;
362
+ }
363
+ async function serviceNamesFromFiles(composeFiles) {
364
+ const names = /* @__PURE__ */ new Set();
365
+ for (const composeFile of composeFiles) {
366
+ try {
367
+ const parsed = parseYaml(await fs3.readFile(composeFile, "utf8"));
368
+ if (typeof parsed !== "object" || parsed === null || !("services" in parsed) || typeof parsed.services !== "object" || parsed.services === null || Array.isArray(parsed.services)) {
369
+ continue;
370
+ }
371
+ for (const name of Object.keys(parsed.services)) {
372
+ if (name.length > 0) {
373
+ names.add(name);
374
+ }
375
+ }
376
+ } catch {
377
+ }
378
+ }
379
+ return Array.from(names);
380
+ }
381
+ function composeArgs(root, composeFiles, args) {
382
+ return [
383
+ "compose",
384
+ ...composeFiles.flatMap((composeFile) => ["-f", composeFile]),
385
+ "--project-directory",
386
+ root,
387
+ ...args
388
+ ];
389
+ }
390
+ function cleanMessage(value) {
391
+ return value.replace(ANSI_CSI_SEQUENCE, "").trim().slice(-1e3);
392
+ }
393
+ function resultMessage(result) {
394
+ return cleanMessage(result.stderr || result.stdout);
395
+ }
396
+ function daemonStatus(result, platform2) {
397
+ if (result.error === "not-found") {
398
+ return {
399
+ status: "not-installed",
400
+ running: false,
401
+ message: "Docker CLI was not found. Install Docker and refresh this page."
402
+ };
403
+ }
404
+ if (result.code === 0) {
405
+ return { status: "running", running: true, message: null };
406
+ }
407
+ if (platform2 === "win32" || platform2 === "darwin") {
408
+ return {
409
+ status: "stopped",
410
+ running: false,
411
+ message: "Docker is installed, but its engine is not responding. Start Docker Desktop and refresh."
412
+ };
413
+ }
414
+ const detail = resultMessage(result);
415
+ return {
416
+ status: result.error === "timeout" ? "unknown" : "stopped",
417
+ running: false,
418
+ message: detail ? `Docker is installed, but its daemon is not responding: ${detail}` : "Docker is installed, but its daemon is not responding. Start Docker and refresh."
419
+ };
420
+ }
421
+ function exitCodeFromRow(record) {
422
+ if (typeof record.ExitCode === "number") {
423
+ return record.ExitCode;
424
+ }
425
+ if (typeof record.ExitCode === "string" && /^\d+$/.test(record.ExitCode)) {
426
+ return Number(record.ExitCode);
427
+ }
428
+ if (typeof record.Status === "string") {
429
+ const match = /\bExited\s+\((\d+)\)/i.exec(record.Status);
430
+ if (match?.[1]) {
431
+ return Number(match[1]);
432
+ }
433
+ }
434
+ return null;
435
+ }
436
+ function serviceStatusFromRow(record) {
437
+ const state = typeof record.State === "string" ? record.State.toLowerCase() : "";
438
+ const exitCode = exitCodeFromRow(record);
439
+ if (state === "running") {
440
+ return "running";
441
+ }
442
+ if (exitCode !== null && exitCode > 0) {
443
+ return "error";
444
+ }
445
+ if (state === "created" || state === "exited" || state === "stopped") {
446
+ return "stopped";
447
+ }
448
+ if (state === "dead" || state === "restarting" || state === "paused") {
449
+ return "error";
450
+ }
451
+ return "unknown";
310
452
  }
311
- async function isDockerRunning(root) {
312
- const result = await runDockerCommand(root, ["info"]);
313
- return result.code === 0;
453
+ function addComposeStatusRow(statuses, row) {
454
+ if (typeof row !== "object" || row === null) {
455
+ return;
456
+ }
457
+ const record = row;
458
+ if (typeof record.Service !== "string") {
459
+ return;
460
+ }
461
+ const detail = typeof record.Status === "string" && record.Status.trim() ? record.Status.trim() : typeof record.State === "string" && record.State.trim() ? record.State.trim() : null;
462
+ statuses.set(record.Service, {
463
+ name: record.Service,
464
+ status: serviceStatusFromRow(record),
465
+ statusDetail: detail,
466
+ containerId: typeof record.ID === "string" && record.ID.length > 0 ? record.ID : null
467
+ });
314
468
  }
315
469
  function parseComposePs(output) {
316
470
  const statuses = /* @__PURE__ */ new Map();
@@ -320,82 +474,181 @@ function parseComposePs(output) {
320
474
  }
321
475
  try {
322
476
  const parsed = JSON.parse(compactOutput);
323
- const rows2 = Array.isArray(parsed) ? parsed : [parsed];
324
- for (const row of rows2) {
477
+ for (const row of Array.isArray(parsed) ? parsed : [parsed]) {
325
478
  addComposeStatusRow(statuses, row);
326
479
  }
327
480
  return statuses;
328
481
  } catch {
329
482
  }
330
- const rows = compactOutput.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
331
- for (const row of rows) {
483
+ for (const line of compactOutput.split(/\r?\n/)) {
332
484
  try {
333
- const parsed = JSON.parse(row);
334
- addComposeStatusRow(statuses, parsed);
485
+ addComposeStatusRow(statuses, JSON.parse(line));
335
486
  } catch {
336
- return statuses;
487
+ return /* @__PURE__ */ new Map();
337
488
  }
338
489
  }
339
490
  return statuses;
340
491
  }
341
- function addComposeStatusRow(statuses, row) {
342
- if (typeof row !== "object" || row === null) {
343
- return;
492
+ function unknownServices(serviceNames) {
493
+ return serviceNames.map((name) => ({
494
+ name,
495
+ status: "unknown",
496
+ statusDetail: null,
497
+ containerId: null
498
+ }));
499
+ }
500
+ function commandFailureMessage(action, result) {
501
+ const detail = resultMessage(result);
502
+ return detail ? `Docker Compose ${action} failed: ${detail}` : `Docker Compose ${action} failed.`;
503
+ }
504
+ var DockerComposeController = class {
505
+ constructor(root, options = {}) {
506
+ this.root = root;
507
+ this.runner = options.runner ?? runDockerCommand;
508
+ this.platform = options.platform ?? process.platform;
509
+ }
510
+ root;
511
+ runner;
512
+ platform;
513
+ async definition() {
514
+ const composeFiles = await findComposeFiles(this.root);
515
+ if (composeFiles.length === 0) {
516
+ return null;
517
+ }
518
+ return {
519
+ composeFiles,
520
+ serviceNames: await serviceNamesFromFiles(composeFiles)
521
+ };
344
522
  }
345
- const record = row;
346
- if (typeof record.Service !== "string") {
347
- return;
523
+ async requireService(service) {
524
+ const definition = await this.definition();
525
+ if (definition === null) {
526
+ throw new DockerOperationError("compose-not-found", "No Docker Compose file was found.");
527
+ }
528
+ if (!definition.serviceNames.includes(service)) {
529
+ throw new DockerOperationError(
530
+ "service-not-found",
531
+ `Docker Compose service "${service}" was not found.`
532
+ );
533
+ }
534
+ return definition;
348
535
  }
349
- const state = typeof record.State === "string" ? record.State.toLowerCase() : "";
350
- statuses.set(record.Service, {
351
- name: record.Service,
352
- status: state === "running" ? "running" : state ? "stopped" : "unknown",
353
- containerId: typeof record.ID === "string" && record.ID.length > 0 ? record.ID : null
354
- });
355
- }
356
- async function getServiceStatuses(root, serviceNames, dockerRunning) {
357
- if (serviceNames.length === 0) {
358
- return [];
536
+ async requireDaemon() {
537
+ const result = await this.runner(this.root, ["info"], { timeoutMs: 5e3 });
538
+ const daemon = daemonStatus(result, this.platform);
539
+ if (daemon.status === "not-installed") {
540
+ throw new DockerOperationError("docker-not-installed", daemon.message ?? "Docker not found.");
541
+ }
542
+ if (!daemon.running) {
543
+ throw new DockerOperationError(
544
+ "docker-not-running",
545
+ daemon.message ?? "Docker is not running."
546
+ );
547
+ }
359
548
  }
360
- if (!dockerRunning) {
361
- return serviceNames.map((service) => ({
362
- name: service,
363
- status: "unknown",
364
- containerId: null
365
- }));
549
+ async inspect() {
550
+ const definition = await this.definition();
551
+ if (definition === null) {
552
+ return null;
553
+ }
554
+ const infoResult = await this.runner(this.root, ["info"], { timeoutMs: 5e3 });
555
+ const daemon = daemonStatus(infoResult, this.platform);
556
+ if (!daemon.running) {
557
+ return {
558
+ composeFiles: definition.composeFiles,
559
+ services: unknownServices(definition.serviceNames),
560
+ dockerRunning: false,
561
+ daemonStatus: daemon.status,
562
+ message: daemon.message
563
+ };
564
+ }
565
+ const ps = await this.runner(
566
+ this.root,
567
+ composeArgs(this.root, definition.composeFiles, ["ps", "--all", "--format", "json"]),
568
+ { timeoutMs: 8e3 }
569
+ );
570
+ if (ps.code !== 0 || ps.error !== null) {
571
+ return {
572
+ composeFiles: definition.composeFiles,
573
+ services: definition.serviceNames.map((name) => ({
574
+ name,
575
+ status: "error",
576
+ statusDetail: null,
577
+ containerId: null
578
+ })),
579
+ dockerRunning: true,
580
+ daemonStatus: "running",
581
+ message: commandFailureMessage("status check", ps)
582
+ };
583
+ }
584
+ const statuses = parseComposePs(ps.stdout);
585
+ return {
586
+ composeFiles: definition.composeFiles,
587
+ services: definition.serviceNames.map(
588
+ (name) => statuses.get(name) ?? {
589
+ name,
590
+ status: "stopped",
591
+ statusDetail: null,
592
+ containerId: null
593
+ }
594
+ ),
595
+ dockerRunning: true,
596
+ daemonStatus: "running",
597
+ message: null
598
+ };
366
599
  }
367
- const ps = await runDockerCommand(root, [
368
- "compose",
369
- "--project-directory",
370
- root,
371
- "ps",
372
- "--format",
373
- "json"
374
- ]);
375
- const statuses = ps.code === 0 ? parseComposePs(ps.stdout) : /* @__PURE__ */ new Map();
376
- return serviceNames.map(
377
- (service) => statuses.get(service) ?? {
378
- name: service,
379
- status: "stopped",
380
- containerId: null
600
+ async action(service, action) {
601
+ const definition = await this.requireService(service);
602
+ await this.requireDaemon();
603
+ const composeCommand = action === "start" ? ["up", "-d", "--", service] : ["stop", "--", service];
604
+ const result = await this.runner(
605
+ this.root,
606
+ composeArgs(this.root, definition.composeFiles, composeCommand),
607
+ { timeoutMs: 6e4 }
608
+ );
609
+ if (result.code !== 0 || result.error !== null) {
610
+ throw new DockerOperationError("command-failed", commandFailureMessage(action, result));
381
611
  }
382
- );
383
- }
384
- async function detectDocker(root) {
385
- const composeFiles = await getComposeFiles(root);
386
- if (composeFiles.length === 0) {
387
- return null;
612
+ return {
613
+ service,
614
+ action,
615
+ output: cleanMessage(result.stdout || result.stderr)
616
+ };
388
617
  }
389
- const serviceLists = await Promise.all(
390
- composeFiles.map((composeFile) => extractServices(composeFile))
391
- );
392
- const serviceNames = Array.from(new Set(serviceLists.flat()));
393
- const dockerRunning = await isDockerRunning(root);
394
- return {
395
- composeFiles,
396
- services: await getServiceStatuses(root, serviceNames, dockerRunning),
397
- dockerRunning
398
- };
618
+ async start(service) {
619
+ return await this.action(service, "start");
620
+ }
621
+ async stop(service) {
622
+ return await this.action(service, "stop");
623
+ }
624
+ async logs(service) {
625
+ const definition = await this.requireService(service);
626
+ await this.requireDaemon();
627
+ const result = await this.runner(
628
+ this.root,
629
+ composeArgs(this.root, definition.composeFiles, [
630
+ "logs",
631
+ "--no-color",
632
+ "--tail",
633
+ "200",
634
+ "--",
635
+ service
636
+ ]),
637
+ { timeoutMs: 15e3, outputLimit: COMMAND_OUTPUT_LIMIT }
638
+ );
639
+ if (result.code !== 0 || result.error !== null) {
640
+ throw new DockerOperationError("command-failed", commandFailureMessage("logs", result));
641
+ }
642
+ return {
643
+ service,
644
+ logs: appendBounded("", `${result.stdout}${result.stderr}`, COMMAND_OUTPUT_LIMIT)
645
+ };
646
+ }
647
+ };
648
+
649
+ // src/core/scanner/docker.ts
650
+ async function detectDocker(root) {
651
+ return await new DockerComposeController(root).inspect();
399
652
  }
400
653
 
401
654
  // src/core/scanner/env.ts
@@ -529,7 +782,7 @@ function detectFramework(packageJson) {
529
782
  // src/core/scanner/git.ts
530
783
  import { promises as fs5 } from "fs";
531
784
  import path5 from "path";
532
- function isWithinRoot3(root, target) {
785
+ function isWithinRoot4(root, target) {
533
786
  const resolvedRoot = path5.resolve(root);
534
787
  const resolvedTarget = path5.resolve(target);
535
788
  const relative = path5.relative(resolvedRoot, resolvedTarget);
@@ -548,14 +801,14 @@ async function resolveGitDirectory(root) {
548
801
  if (match) {
549
802
  const gitDir = match[1].trim();
550
803
  const resolvedGitDir = path5.isAbsolute(gitDir) ? path5.resolve(gitDir) : path5.resolve(root, gitDir);
551
- if (!isWithinRoot3(root, resolvedGitDir)) {
804
+ if (!isWithinRoot4(root, resolvedGitDir)) {
552
805
  return null;
553
806
  }
554
807
  const [realRoot, realGitDir] = await Promise.all([
555
808
  fs5.realpath(root),
556
809
  fs5.realpath(resolvedGitDir)
557
810
  ]);
558
- return isWithinRoot3(realRoot, realGitDir) ? resolvedGitDir : null;
811
+ return isWithinRoot4(realRoot, realGitDir) ? resolvedGitDir : null;
559
812
  }
560
813
  }
561
814
  } catch {
@@ -613,7 +866,7 @@ async function detectPackageManager(root) {
613
866
  // src/core/scanner/packageJson.ts
614
867
  import { promises as fs7 } from "fs";
615
868
  import path7 from "path";
616
- function isWithinRoot4(root, target) {
869
+ function isWithinRoot5(root, target) {
617
870
  const relative = path7.relative(root, target);
618
871
  return relative === "" || !relative.startsWith("..") && !path7.isAbsolute(relative);
619
872
  }
@@ -624,7 +877,7 @@ async function readPackageJson(root) {
624
877
  fs7.realpath(root),
625
878
  fs7.realpath(packageJsonPath)
626
879
  ]);
627
- if (!isWithinRoot4(realRoot, realPackageJsonPath)) {
880
+ if (!isWithinRoot5(realRoot, realPackageJsonPath)) {
628
881
  return null;
629
882
  }
630
883
  const content = await fs7.readFile(realPackageJsonPath, "utf8");
@@ -727,13 +980,18 @@ function extractScripts(packageJson) {
727
980
  }
728
981
 
729
982
  // 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);
986
+ }
730
987
  async function findFirstFile(root, candidates) {
988
+ const resolvedRoot = await fs8.realpath(root).catch(() => path8.resolve(root));
731
989
  for (const candidate of candidates) {
732
990
  const filePath = path8.join(root, candidate);
733
991
  try {
734
- const stat = await fs8.stat(filePath);
735
- if (stat.isFile()) {
736
- return { path: filePath, exists: true };
992
+ const [stat, realPath] = await Promise.all([fs8.stat(filePath), fs8.realpath(filePath)]);
993
+ if (stat.isFile() && isWithinRoot6(resolvedRoot, realPath)) {
994
+ return { path: realPath, exists: true };
737
995
  }
738
996
  } catch {
739
997
  }
@@ -744,8 +1002,9 @@ function configuredPorts(configPorts) {
744
1002
  return Array.isArray(configPorts) ? configPorts : [];
745
1003
  }
746
1004
  async function scanProject(root = process.cwd()) {
747
- const config = await loadConfig(root);
748
- const packageJson = await readPackageJson(root);
1005
+ const resolvedRoot = await fs8.realpath(root).catch(() => path8.resolve(root));
1006
+ const config = await loadConfig(resolvedRoot);
1007
+ const packageJson = await readPackageJson(resolvedRoot);
749
1008
  const scripts = extractScripts(packageJson) ?? {};
750
1009
  const framework = detectFramework(packageJson);
751
1010
  const portsToProbe = [
@@ -754,17 +1013,17 @@ async function scanProject(root = process.cwd()) {
754
1013
  ...defaultPortsForFramework(framework)
755
1014
  ];
756
1015
  const [packageManager, env, docker, git, ports, readme, license] = await Promise.all([
757
- detectPackageManager(root),
758
- detectEnv(root, config?.config),
759
- detectDocker(root),
760
- detectGit(root),
1016
+ detectPackageManager(resolvedRoot),
1017
+ detectEnv(resolvedRoot, config?.config),
1018
+ detectDocker(resolvedRoot),
1019
+ detectGit(resolvedRoot),
761
1020
  detectPorts(portsToProbe),
762
- findFirstFile(root, ["README.md", "README"]),
763
- findFirstFile(root, ["LICENSE", "LICENSE.md", "COPYING"])
1021
+ findFirstFile(resolvedRoot, ["README.md", "README"]),
1022
+ findFirstFile(resolvedRoot, ["LICENSE", "LICENSE.md", "COPYING"])
764
1023
  ]);
765
1024
  return {
766
- root,
767
- projectName: config?.config.name ?? packageJson?.data.name ?? path8.basename(root),
1025
+ root: resolvedRoot,
1026
+ projectName: config?.config.name ?? packageJson?.data.name ?? path8.basename(resolvedRoot),
768
1027
  packageJson,
769
1028
  packageManager: packageManager ?? (packageJson ? "npm" : null),
770
1029
  scripts,
@@ -798,25 +1057,6 @@ async function readIfPresent2(filePath) {
798
1057
  return null;
799
1058
  }
800
1059
  }
801
- function extractReadmeScriptReferences(readmeContent) {
802
- const references = /* @__PURE__ */ new Set();
803
- const commandRegexes = [
804
- /\bnpm\s+run\s+([A-Za-z0-9:_-]+)/g,
805
- /\bpnpm\s+run\s+([A-Za-z0-9:_-]+)/g,
806
- /\bbun\s+run\s+([A-Za-z0-9:_-]+)/g,
807
- /\byarn\s+run\s+([A-Za-z0-9:_-]+)/g,
808
- /\bnpm\s+(test|start|build)\b/g,
809
- /\bpnpm\s+(test|start|build)\b/g,
810
- /\byarn\s+(test|start|build)\b/g,
811
- /\bbun\s+(test|start|build)\b/g
812
- ];
813
- for (const regex of commandRegexes) {
814
- for (const match of readmeContent.matchAll(regex)) {
815
- references.add(match[1]);
816
- }
817
- }
818
- return Array.from(references);
819
- }
820
1060
  function warning(id, severity, title, message, target) {
821
1061
  return { id, severity, title, message, target };
822
1062
  }
@@ -888,7 +1128,7 @@ async function runDoctor(root = process.cwd(), scan) {
888
1128
  } else {
889
1129
  const readme = await readIfPresent2(result.readme.path);
890
1130
  if (readme !== null) {
891
- const references = extractReadmeScriptReferences(readme);
1131
+ const references = extractScriptReferences(readme);
892
1132
  const missingScripts = references.filter((script) => result.scripts[script] === void 0);
893
1133
  if (missingScripts.length > 0) {
894
1134
  warnings.push(
@@ -918,7 +1158,7 @@ async function runDoctor(root = process.cwd(), scan) {
918
1158
  "docker-not-running",
919
1159
  "warning",
920
1160
  "Docker Compose found but Docker is not running",
921
- "A compose file exists, but the Docker daemon did not answer docker info."
1161
+ result.docker.message ?? "A compose file exists, but Docker is not available."
922
1162
  )
923
1163
  );
924
1164
  }
@@ -942,18 +1182,15 @@ async function runDoctor(root = process.cwd(), scan) {
942
1182
  )
943
1183
  );
944
1184
  }
945
- if (!result.license.exists) {
946
- warnings.push(warning("missing-license", "info", "No LICENSE", "No LICENSE file was found."));
947
- }
948
1185
  return warnings;
949
1186
  }
950
1187
 
951
- // src/cli/terminal.ts
952
- var ESC = String.fromCharCode(27);
1188
+ // src/core/security/text.ts
1189
+ var ESC2 = String.fromCharCode(27);
953
1190
  var BEL = String.fromCharCode(7);
954
- var OSC_SEQUENCE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, "g");
955
- var CSI_SEQUENCE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, "g");
956
- var ESCAPE_SEQUENCE = new RegExp(`${ESC}[@-Z\\\\-_]`, "g");
1191
+ var OSC_SEQUENCE = new RegExp(`${ESC2}\\][\\s\\S]*?(?:${BEL}|${ESC2}\\\\)`, "g");
1192
+ var CSI_SEQUENCE = new RegExp(`${ESC2}\\[[0-?]*[ -/]*[@-~]`, "g");
1193
+ var ESCAPE_SEQUENCE = new RegExp(`${ESC2}[@-Z\\\\-_]`, "g");
957
1194
  function stripControlCharacters(value) {
958
1195
  let result = "";
959
1196
  for (const character of value) {
@@ -964,13 +1201,13 @@ function stripControlCharacters(value) {
964
1201
  }
965
1202
  return result;
966
1203
  }
967
- function safeTerminalText(value) {
1204
+ function safeDisplayText(value) {
968
1205
  return stripControlCharacters(
969
1206
  String(value).replace(OSC_SEQUENCE, "").replace(CSI_SEQUENCE, "").replace(ESCAPE_SEQUENCE, "")
970
1207
  );
971
1208
  }
972
- function safeTerminalList(values) {
973
- return values.length > 0 ? values.map((value) => safeTerminalText(value)).join(", ") : "none";
1209
+ function safeDisplayList(values) {
1210
+ return values.length > 0 ? values.map((value) => safeDisplayText(value)).join(", ") : "none";
974
1211
  }
975
1212
 
976
1213
  // src/cli/commands/doctor.ts
@@ -990,8 +1227,8 @@ async function doctorCommand(cwd = process.cwd()) {
990
1227
  return;
991
1228
  }
992
1229
  for (const item of warnings) {
993
- console.log(`${colorSeverity(item.severity)} ${pc.bold(safeTerminalText(item.title))}`);
994
- console.log(` ${safeTerminalText(item.message)}`);
1230
+ console.log(`${colorSeverity(item.severity)} ${pc.bold(safeDisplayText(item.title))}`);
1231
+ console.log(` ${safeDisplayText(item.message)}`);
995
1232
  }
996
1233
  }
997
1234
 
@@ -1017,6 +1254,14 @@ import pc3 from "picocolors";
1017
1254
 
1018
1255
  // src/core/process/runner.ts
1019
1256
  import spawn2 from "cross-spawn";
1257
+
1258
+ // src/core/security/dangerousCommand.ts
1259
+ var DANGEROUS_COMMAND = /\b(rm\s+-rf|docker\s+volume\s+rm|drop\s+database|prisma\s+migrate\s+reset|git\s+clean\s+-fdx?)\b/i;
1260
+ function isDangerousCommand(command) {
1261
+ return DANGEROUS_COMMAND.test(command);
1262
+ }
1263
+
1264
+ // src/core/process/runner.ts
1020
1265
  function getPackageRunCommand(packageManager, script) {
1021
1266
  const manager = packageManager ?? "npm";
1022
1267
  const args = ["run", script];
@@ -1077,6 +1322,95 @@ async function resolvePackageInstallCommand(options) {
1077
1322
  command: executable
1078
1323
  };
1079
1324
  }
1325
+ function splitCommandLine(command) {
1326
+ const tokens = [];
1327
+ let current = "";
1328
+ let quote = null;
1329
+ for (let index = 0; index < command.length; index += 1) {
1330
+ const character = command[index] ?? "";
1331
+ if (quote !== null) {
1332
+ if (character === quote) {
1333
+ quote = null;
1334
+ } else if (character === "\\" && quote === '"') {
1335
+ const next = command[index + 1];
1336
+ if (next === '"' || next === "\\") {
1337
+ index += 1;
1338
+ current += next ?? "";
1339
+ } else {
1340
+ current += character;
1341
+ }
1342
+ } else {
1343
+ current += character;
1344
+ }
1345
+ continue;
1346
+ }
1347
+ if (character === '"' || character === "'") {
1348
+ quote = character;
1349
+ continue;
1350
+ }
1351
+ if (/\s/.test(character)) {
1352
+ if (current.length > 0) {
1353
+ tokens.push(current);
1354
+ current = "";
1355
+ }
1356
+ continue;
1357
+ }
1358
+ current += character;
1359
+ }
1360
+ if (current.length > 0) {
1361
+ tokens.push(current);
1362
+ }
1363
+ return tokens;
1364
+ }
1365
+ function containsShellMetacharacters(command) {
1366
+ let quote = null;
1367
+ for (let index = 0; index < command.length; index += 1) {
1368
+ const character = command[index] ?? "";
1369
+ if (quote !== null) {
1370
+ if (character === quote) {
1371
+ quote = null;
1372
+ }
1373
+ continue;
1374
+ }
1375
+ if (character === '"' || character === "'") {
1376
+ quote = character;
1377
+ continue;
1378
+ }
1379
+ if (character === "\n" || character === "\r") {
1380
+ return true;
1381
+ }
1382
+ if (";|&<>".includes(character)) {
1383
+ return true;
1384
+ }
1385
+ if (character === "`") {
1386
+ return true;
1387
+ }
1388
+ if (character === "$" && (command[index + 1] === "(" || command[index + 1] === "{")) {
1389
+ return true;
1390
+ }
1391
+ }
1392
+ return false;
1393
+ }
1394
+ async function resolveConfiguredCommand(cwd, command) {
1395
+ const trimmed = command.trim();
1396
+ if (trimmed.length === 0 || containsShellMetacharacters(trimmed)) {
1397
+ return null;
1398
+ }
1399
+ const tokens = splitCommandLine(trimmed);
1400
+ if (tokens.length === 0) {
1401
+ return null;
1402
+ }
1403
+ const [executableName, ...args] = tokens;
1404
+ const executable = await resolveExecutableOutsideRoot(cwd, executableName);
1405
+ if (executable === null) {
1406
+ return null;
1407
+ }
1408
+ return {
1409
+ command: executable,
1410
+ args,
1411
+ displayCommand: trimmed
1412
+ };
1413
+ }
1080
1414
  async function runPackageScriptToTerminal(options) {
1081
1415
  const runCommand2 = await resolvePackageRunCommand(options);
1082
1416
  if (runCommand2 === null) {
@@ -1121,14 +1455,14 @@ async function runCommand(script, cwd = process.cwd()) {
1121
1455
  // src/cli/commands/scan.ts
1122
1456
  import pc4 from "picocolors";
1123
1457
  function formatList(values) {
1124
- return safeTerminalList(values);
1458
+ return safeDisplayList(values);
1125
1459
  }
1126
1460
  function printScanResult(scan) {
1127
- console.log(pc4.bold(`Project: ${safeTerminalText(scan.projectName)}`));
1128
- console.log(`Type: ${safeTerminalText(scan.framework?.type ?? "Unknown")}`);
1129
- console.log(`Manager: ${safeTerminalText(scan.packageManager ?? "unknown")}`);
1461
+ console.log(pc4.bold(`Project: ${safeDisplayText(scan.projectName)}`));
1462
+ console.log(`Type: ${safeDisplayText(scan.framework?.type ?? "Unknown")}`);
1463
+ console.log(`Manager: ${safeDisplayText(scan.packageManager ?? "unknown")}`);
1130
1464
  console.log(`Scripts: ${formatList(Object.keys(scan.scripts))}`);
1131
- console.log(`Git: ${safeTerminalText(scan.git?.branch ?? "not detected")}`);
1465
+ console.log(`Git: ${safeDisplayText(scan.git?.branch ?? "not detected")}`);
1132
1466
  console.log(`README: ${scan.readme.exists ? "found" : "missing"}`);
1133
1467
  console.log(`LICENSE: ${scan.license.exists ? "found" : "missing"}`);
1134
1468
  if (scan.env !== null) {
@@ -1151,18 +1485,533 @@ async function scanCommand(cwd = process.cwd()) {
1151
1485
  // src/cli/commands/start.ts
1152
1486
  import pc5 from "picocolors";
1153
1487
 
1154
- // src/server/index.ts
1155
- import { promises as fs12 } from "fs";
1156
- import path12 from "path";
1488
+ // node_modules/open/index.js
1489
+ import process7 from "process";
1490
+ import { Buffer as Buffer2 } from "buffer";
1491
+ import path11 from "path";
1157
1492
  import { fileURLToPath } from "url";
1158
- import { createAdaptorServer } from "@hono/node-server";
1159
- import { serveStatic } from "@hono/node-server/serve-static";
1160
- import { Hono } from "hono";
1161
- import open2 from "open";
1493
+ import { promisify as promisify5 } from "util";
1494
+ import childProcess from "child_process";
1495
+ import fs15, { constants as fsConstants2 } from "fs/promises";
1162
1496
 
1163
- // src/core/process/manager.ts
1164
- import { EventEmitter } from "events";
1165
- import spawn3 from "cross-spawn";
1497
+ // node_modules/wsl-utils/index.js
1498
+ import process3 from "process";
1499
+ import fs14, { constants as fsConstants } from "fs/promises";
1500
+
1501
+ // node_modules/is-wsl/index.js
1502
+ import process2 from "process";
1503
+ import os2 from "os";
1504
+ import fs13 from "fs";
1505
+
1506
+ // node_modules/is-inside-container/index.js
1507
+ import fs12 from "fs";
1508
+
1509
+ // node_modules/is-docker/index.js
1510
+ import fs11 from "fs";
1511
+ var isDockerCached;
1512
+ function hasDockerEnv() {
1513
+ try {
1514
+ fs11.statSync("/.dockerenv");
1515
+ return true;
1516
+ } catch {
1517
+ return false;
1518
+ }
1519
+ }
1520
+ function hasDockerCGroup() {
1521
+ try {
1522
+ return fs11.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
1523
+ } catch {
1524
+ return false;
1525
+ }
1526
+ }
1527
+ function isDocker() {
1528
+ if (isDockerCached === void 0) {
1529
+ isDockerCached = hasDockerEnv() || hasDockerCGroup();
1530
+ }
1531
+ return isDockerCached;
1532
+ }
1533
+
1534
+ // node_modules/is-inside-container/index.js
1535
+ var cachedResult;
1536
+ var hasContainerEnv = () => {
1537
+ try {
1538
+ fs12.statSync("/run/.containerenv");
1539
+ return true;
1540
+ } catch {
1541
+ return false;
1542
+ }
1543
+ };
1544
+ function isInsideContainer() {
1545
+ if (cachedResult === void 0) {
1546
+ cachedResult = hasContainerEnv() || isDocker();
1547
+ }
1548
+ return cachedResult;
1549
+ }
1550
+
1551
+ // node_modules/is-wsl/index.js
1552
+ var isWsl = () => {
1553
+ if (process2.platform !== "linux") {
1554
+ return false;
1555
+ }
1556
+ if (os2.release().toLowerCase().includes("microsoft")) {
1557
+ if (isInsideContainer()) {
1558
+ return false;
1559
+ }
1560
+ return true;
1561
+ }
1562
+ try {
1563
+ if (fs13.readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft")) {
1564
+ return !isInsideContainer();
1565
+ }
1566
+ } catch {
1567
+ }
1568
+ if (fs13.existsSync("/proc/sys/fs/binfmt_misc/WSLInterop") || fs13.existsSync("/run/WSL")) {
1569
+ return !isInsideContainer();
1570
+ }
1571
+ return false;
1572
+ };
1573
+ var is_wsl_default = process2.env.__IS_WSL_TEST__ ? isWsl : isWsl();
1574
+
1575
+ // node_modules/wsl-utils/index.js
1576
+ var wslDrivesMountPoint = /* @__PURE__ */ (() => {
1577
+ const defaultMountPoint = "/mnt/";
1578
+ let mountPoint;
1579
+ return async function() {
1580
+ if (mountPoint) {
1581
+ return mountPoint;
1582
+ }
1583
+ const configFilePath = "/etc/wsl.conf";
1584
+ let isConfigFileExists = false;
1585
+ try {
1586
+ await fs14.access(configFilePath, fsConstants.F_OK);
1587
+ isConfigFileExists = true;
1588
+ } catch {
1589
+ }
1590
+ if (!isConfigFileExists) {
1591
+ return defaultMountPoint;
1592
+ }
1593
+ const configContent = await fs14.readFile(configFilePath, { encoding: "utf8" });
1594
+ const configMountPoint = /(?<!#.*)root\s*=\s*(?<mountPoint>.*)/g.exec(configContent);
1595
+ if (!configMountPoint) {
1596
+ return defaultMountPoint;
1597
+ }
1598
+ mountPoint = configMountPoint.groups.mountPoint.trim();
1599
+ mountPoint = mountPoint.endsWith("/") ? mountPoint : `${mountPoint}/`;
1600
+ return mountPoint;
1601
+ };
1602
+ })();
1603
+ var powerShellPathFromWsl = async () => {
1604
+ const mountPoint = await wslDrivesMountPoint();
1605
+ return `${mountPoint}c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`;
1606
+ };
1607
+ var powerShellPath = async () => {
1608
+ if (is_wsl_default) {
1609
+ return powerShellPathFromWsl();
1610
+ }
1611
+ return `${process3.env.SYSTEMROOT || process3.env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
1612
+ };
1613
+
1614
+ // node_modules/define-lazy-prop/index.js
1615
+ function defineLazyProperty(object, propertyName, valueGetter) {
1616
+ const define = (value) => Object.defineProperty(object, propertyName, { value, enumerable: true, writable: true });
1617
+ Object.defineProperty(object, propertyName, {
1618
+ configurable: true,
1619
+ enumerable: true,
1620
+ get() {
1621
+ const result = valueGetter();
1622
+ define(result);
1623
+ return result;
1624
+ },
1625
+ set(value) {
1626
+ define(value);
1627
+ }
1628
+ });
1629
+ return object;
1630
+ }
1631
+
1632
+ // node_modules/default-browser/index.js
1633
+ import { promisify as promisify4 } from "util";
1634
+ import process6 from "process";
1635
+ import { execFile as execFile4 } from "child_process";
1636
+
1637
+ // node_modules/default-browser-id/index.js
1638
+ import { promisify } from "util";
1639
+ import process4 from "process";
1640
+ import { execFile } from "child_process";
1641
+ var execFileAsync = promisify(execFile);
1642
+ async function defaultBrowserId() {
1643
+ if (process4.platform !== "darwin") {
1644
+ throw new Error("macOS only");
1645
+ }
1646
+ const { stdout } = await execFileAsync("defaults", ["read", "com.apple.LaunchServices/com.apple.launchservices.secure", "LSHandlers"]);
1647
+ const match = /LSHandlerRoleAll = "(?!-)(?<id>[^"]+?)";\s+?LSHandlerURLScheme = (?:http|https);/.exec(stdout);
1648
+ const browserId = match?.groups.id ?? "com.apple.Safari";
1649
+ if (browserId === "com.apple.safari") {
1650
+ return "com.apple.Safari";
1651
+ }
1652
+ return browserId;
1653
+ }
1654
+
1655
+ // node_modules/run-applescript/index.js
1656
+ import process5 from "process";
1657
+ import { promisify as promisify2 } from "util";
1658
+ import { execFile as execFile2, execFileSync } from "child_process";
1659
+ var execFileAsync2 = promisify2(execFile2);
1660
+ async function runAppleScript(script, { humanReadableOutput = true, signal } = {}) {
1661
+ if (process5.platform !== "darwin") {
1662
+ throw new Error("macOS only");
1663
+ }
1664
+ const outputArguments = humanReadableOutput ? [] : ["-ss"];
1665
+ const execOptions = {};
1666
+ if (signal) {
1667
+ execOptions.signal = signal;
1668
+ }
1669
+ const { stdout } = await execFileAsync2("osascript", ["-e", script, outputArguments], execOptions);
1670
+ return stdout.trim();
1671
+ }
1672
+
1673
+ // node_modules/bundle-name/index.js
1674
+ async function bundleName(bundleId) {
1675
+ return runAppleScript(`tell application "Finder" to set app_path to application file id "${bundleId}" as string
1676
+ tell application "System Events" to get value of property list item "CFBundleName" of property list file (app_path & ":Contents:Info.plist")`);
1677
+ }
1678
+
1679
+ // node_modules/default-browser/windows.js
1680
+ import { promisify as promisify3 } from "util";
1681
+ import { execFile as execFile3 } from "child_process";
1682
+ var execFileAsync3 = promisify3(execFile3);
1683
+ var windowsBrowserProgIds = {
1684
+ MSEdgeHTM: { name: "Edge", id: "com.microsoft.edge" },
1685
+ // The missing `L` is correct.
1686
+ MSEdgeBHTML: { name: "Edge Beta", id: "com.microsoft.edge.beta" },
1687
+ MSEdgeDHTML: { name: "Edge Dev", id: "com.microsoft.edge.dev" },
1688
+ AppXq0fevzme2pys62n3e0fbqa7peapykr8v: { name: "Edge", id: "com.microsoft.edge.old" },
1689
+ ChromeHTML: { name: "Chrome", id: "com.google.chrome" },
1690
+ ChromeBHTML: { name: "Chrome Beta", id: "com.google.chrome.beta" },
1691
+ ChromeDHTML: { name: "Chrome Dev", id: "com.google.chrome.dev" },
1692
+ ChromiumHTM: { name: "Chromium", id: "org.chromium.Chromium" },
1693
+ BraveHTML: { name: "Brave", id: "com.brave.Browser" },
1694
+ BraveBHTML: { name: "Brave Beta", id: "com.brave.Browser.beta" },
1695
+ BraveDHTML: { name: "Brave Dev", id: "com.brave.Browser.dev" },
1696
+ BraveSSHTM: { name: "Brave Nightly", id: "com.brave.Browser.nightly" },
1697
+ FirefoxURL: { name: "Firefox", id: "org.mozilla.firefox" },
1698
+ OperaStable: { name: "Opera", id: "com.operasoftware.Opera" },
1699
+ VivaldiHTM: { name: "Vivaldi", id: "com.vivaldi.Vivaldi" },
1700
+ "IE.HTTP": { name: "Internet Explorer", id: "com.microsoft.ie" }
1701
+ };
1702
+ var _windowsBrowserProgIdMap = new Map(Object.entries(windowsBrowserProgIds));
1703
+ var UnknownBrowserError = class extends Error {
1704
+ };
1705
+ async function defaultBrowser(_execFileAsync = execFileAsync3) {
1706
+ const { stdout } = await _execFileAsync("reg", [
1707
+ "QUERY",
1708
+ " HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice",
1709
+ "/v",
1710
+ "ProgId"
1711
+ ]);
1712
+ const match = /ProgId\s*REG_SZ\s*(?<id>\S+)/.exec(stdout);
1713
+ if (!match) {
1714
+ throw new UnknownBrowserError(`Cannot find Windows browser in stdout: ${JSON.stringify(stdout)}`);
1715
+ }
1716
+ const { id } = match.groups;
1717
+ const dotIndex = id.lastIndexOf(".");
1718
+ const hyphenIndex = id.lastIndexOf("-");
1719
+ const baseIdByDot = dotIndex === -1 ? void 0 : id.slice(0, dotIndex);
1720
+ const baseIdByHyphen = hyphenIndex === -1 ? void 0 : id.slice(0, hyphenIndex);
1721
+ return windowsBrowserProgIds[id] ?? windowsBrowserProgIds[baseIdByDot] ?? windowsBrowserProgIds[baseIdByHyphen] ?? { name: id, id };
1722
+ }
1723
+
1724
+ // node_modules/default-browser/index.js
1725
+ var execFileAsync4 = promisify4(execFile4);
1726
+ var titleize = (string) => string.toLowerCase().replaceAll(/(?:^|\s|-)\S/g, (x) => x.toUpperCase());
1727
+ async function defaultBrowser2() {
1728
+ if (process6.platform === "darwin") {
1729
+ const id = await defaultBrowserId();
1730
+ const name = await bundleName(id);
1731
+ return { name, id };
1732
+ }
1733
+ if (process6.platform === "linux") {
1734
+ const { stdout } = await execFileAsync4("xdg-mime", ["query", "default", "x-scheme-handler/http"]);
1735
+ const id = stdout.trim();
1736
+ const name = titleize(id.replace(/.desktop$/, "").replace("-", " "));
1737
+ return { name, id };
1738
+ }
1739
+ if (process6.platform === "win32") {
1740
+ return defaultBrowser();
1741
+ }
1742
+ throw new Error("Only macOS, Linux, and Windows are supported");
1743
+ }
1744
+
1745
+ // node_modules/open/index.js
1746
+ var execFile5 = promisify5(childProcess.execFile);
1747
+ var __dirname = path11.dirname(fileURLToPath(import.meta.url));
1748
+ var localXdgOpenPath = path11.join(__dirname, "xdg-open");
1749
+ var { platform, arch } = process7;
1750
+ async function getWindowsDefaultBrowserFromWsl() {
1751
+ const powershellPath = await powerShellPath();
1752
+ const rawCommand = String.raw`(Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice").ProgId`;
1753
+ const encodedCommand = Buffer2.from(rawCommand, "utf16le").toString("base64");
1754
+ const { stdout } = await execFile5(
1755
+ powershellPath,
1756
+ [
1757
+ "-NoProfile",
1758
+ "-NonInteractive",
1759
+ "-ExecutionPolicy",
1760
+ "Bypass",
1761
+ "-EncodedCommand",
1762
+ encodedCommand
1763
+ ],
1764
+ { encoding: "utf8" }
1765
+ );
1766
+ const progId = stdout.trim();
1767
+ const browserMap = {
1768
+ ChromeHTML: "com.google.chrome",
1769
+ BraveHTML: "com.brave.Browser",
1770
+ MSEdgeHTM: "com.microsoft.edge",
1771
+ FirefoxURL: "org.mozilla.firefox"
1772
+ };
1773
+ return browserMap[progId] ? { id: browserMap[progId] } : {};
1774
+ }
1775
+ var pTryEach = async (array, mapper) => {
1776
+ let latestError;
1777
+ for (const item of array) {
1778
+ try {
1779
+ return await mapper(item);
1780
+ } catch (error) {
1781
+ latestError = error;
1782
+ }
1783
+ }
1784
+ throw latestError;
1785
+ };
1786
+ var baseOpen = async (options) => {
1787
+ options = {
1788
+ wait: false,
1789
+ background: false,
1790
+ newInstance: false,
1791
+ allowNonzeroExitCode: false,
1792
+ ...options
1793
+ };
1794
+ if (Array.isArray(options.app)) {
1795
+ return pTryEach(options.app, (singleApp) => baseOpen({
1796
+ ...options,
1797
+ app: singleApp
1798
+ }));
1799
+ }
1800
+ let { name: app, arguments: appArguments = [] } = options.app ?? {};
1801
+ appArguments = [...appArguments];
1802
+ if (Array.isArray(app)) {
1803
+ return pTryEach(app, (appName) => baseOpen({
1804
+ ...options,
1805
+ app: {
1806
+ name: appName,
1807
+ arguments: appArguments
1808
+ }
1809
+ }));
1810
+ }
1811
+ if (app === "browser" || app === "browserPrivate") {
1812
+ const ids = {
1813
+ "com.google.chrome": "chrome",
1814
+ "google-chrome.desktop": "chrome",
1815
+ "com.brave.Browser": "brave",
1816
+ "org.mozilla.firefox": "firefox",
1817
+ "firefox.desktop": "firefox",
1818
+ "com.microsoft.msedge": "edge",
1819
+ "com.microsoft.edge": "edge",
1820
+ "com.microsoft.edgemac": "edge",
1821
+ "microsoft-edge.desktop": "edge"
1822
+ };
1823
+ const flags = {
1824
+ chrome: "--incognito",
1825
+ brave: "--incognito",
1826
+ firefox: "--private-window",
1827
+ edge: "--inPrivate"
1828
+ };
1829
+ const browser = is_wsl_default ? await getWindowsDefaultBrowserFromWsl() : await defaultBrowser2();
1830
+ if (browser.id in ids) {
1831
+ const browserName = ids[browser.id];
1832
+ if (app === "browserPrivate") {
1833
+ appArguments.push(flags[browserName]);
1834
+ }
1835
+ return baseOpen({
1836
+ ...options,
1837
+ app: {
1838
+ name: apps[browserName],
1839
+ arguments: appArguments
1840
+ }
1841
+ });
1842
+ }
1843
+ throw new Error(`${browser.name} is not supported as a default browser`);
1844
+ }
1845
+ let command;
1846
+ const cliArguments = [];
1847
+ const childProcessOptions = {};
1848
+ if (platform === "darwin") {
1849
+ command = "open";
1850
+ if (options.wait) {
1851
+ cliArguments.push("--wait-apps");
1852
+ }
1853
+ if (options.background) {
1854
+ cliArguments.push("--background");
1855
+ }
1856
+ if (options.newInstance) {
1857
+ cliArguments.push("--new");
1858
+ }
1859
+ if (app) {
1860
+ cliArguments.push("-a", app);
1861
+ }
1862
+ } else if (platform === "win32" || is_wsl_default && !isInsideContainer() && !app) {
1863
+ command = await powerShellPath();
1864
+ cliArguments.push(
1865
+ "-NoProfile",
1866
+ "-NonInteractive",
1867
+ "-ExecutionPolicy",
1868
+ "Bypass",
1869
+ "-EncodedCommand"
1870
+ );
1871
+ if (!is_wsl_default) {
1872
+ childProcessOptions.windowsVerbatimArguments = true;
1873
+ }
1874
+ const encodedArguments = ["Start"];
1875
+ if (options.wait) {
1876
+ encodedArguments.push("-Wait");
1877
+ }
1878
+ if (app) {
1879
+ encodedArguments.push(`"\`"${app}\`""`);
1880
+ if (options.target) {
1881
+ appArguments.push(options.target);
1882
+ }
1883
+ } else if (options.target) {
1884
+ encodedArguments.push(`"${options.target}"`);
1885
+ }
1886
+ if (appArguments.length > 0) {
1887
+ appArguments = appArguments.map((argument) => `"\`"${argument}\`""`);
1888
+ encodedArguments.push("-ArgumentList", appArguments.join(","));
1889
+ }
1890
+ options.target = Buffer2.from(encodedArguments.join(" "), "utf16le").toString("base64");
1891
+ } else {
1892
+ if (app) {
1893
+ command = app;
1894
+ } else {
1895
+ const isBundled = !__dirname || __dirname === "/";
1896
+ let exeLocalXdgOpen = false;
1897
+ try {
1898
+ await fs15.access(localXdgOpenPath, fsConstants2.X_OK);
1899
+ exeLocalXdgOpen = true;
1900
+ } catch {
1901
+ }
1902
+ const useSystemXdgOpen = process7.versions.electron ?? (platform === "android" || isBundled || !exeLocalXdgOpen);
1903
+ command = useSystemXdgOpen ? "xdg-open" : localXdgOpenPath;
1904
+ }
1905
+ if (appArguments.length > 0) {
1906
+ cliArguments.push(...appArguments);
1907
+ }
1908
+ if (!options.wait) {
1909
+ childProcessOptions.stdio = "ignore";
1910
+ childProcessOptions.detached = true;
1911
+ }
1912
+ }
1913
+ if (platform === "darwin" && appArguments.length > 0) {
1914
+ cliArguments.push("--args", ...appArguments);
1915
+ }
1916
+ if (options.target) {
1917
+ cliArguments.push(options.target);
1918
+ }
1919
+ const subprocess = childProcess.spawn(command, cliArguments, childProcessOptions);
1920
+ if (options.wait) {
1921
+ return new Promise((resolve, reject) => {
1922
+ subprocess.once("error", reject);
1923
+ subprocess.once("close", (exitCode) => {
1924
+ if (!options.allowNonzeroExitCode && exitCode > 0) {
1925
+ reject(new Error(`Exited with code ${exitCode}`));
1926
+ return;
1927
+ }
1928
+ resolve(subprocess);
1929
+ });
1930
+ });
1931
+ }
1932
+ subprocess.unref();
1933
+ return subprocess;
1934
+ };
1935
+ var open = (target, options) => {
1936
+ if (typeof target !== "string") {
1937
+ throw new TypeError("Expected a `target`");
1938
+ }
1939
+ return baseOpen({
1940
+ ...options,
1941
+ target
1942
+ });
1943
+ };
1944
+ function detectArchBinary(binary) {
1945
+ if (typeof binary === "string" || Array.isArray(binary)) {
1946
+ return binary;
1947
+ }
1948
+ const { [arch]: archBinary } = binary;
1949
+ if (!archBinary) {
1950
+ throw new Error(`${arch} is not supported`);
1951
+ }
1952
+ return archBinary;
1953
+ }
1954
+ function detectPlatformBinary({ [platform]: platformBinary }, { wsl }) {
1955
+ if (wsl && is_wsl_default) {
1956
+ return detectArchBinary(wsl);
1957
+ }
1958
+ if (!platformBinary) {
1959
+ throw new Error(`${platform} is not supported`);
1960
+ }
1961
+ return detectArchBinary(platformBinary);
1962
+ }
1963
+ var apps = {};
1964
+ defineLazyProperty(apps, "chrome", () => detectPlatformBinary({
1965
+ darwin: "google chrome",
1966
+ win32: "chrome",
1967
+ linux: ["google-chrome", "google-chrome-stable", "chromium"]
1968
+ }, {
1969
+ wsl: {
1970
+ ia32: "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
1971
+ x64: ["/mnt/c/Program Files/Google/Chrome/Application/chrome.exe", "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe"]
1972
+ }
1973
+ }));
1974
+ defineLazyProperty(apps, "brave", () => detectPlatformBinary({
1975
+ darwin: "brave browser",
1976
+ win32: "brave",
1977
+ linux: ["brave-browser", "brave"]
1978
+ }, {
1979
+ wsl: {
1980
+ ia32: "/mnt/c/Program Files (x86)/BraveSoftware/Brave-Browser/Application/brave.exe",
1981
+ x64: ["/mnt/c/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe", "/mnt/c/Program Files (x86)/BraveSoftware/Brave-Browser/Application/brave.exe"]
1982
+ }
1983
+ }));
1984
+ defineLazyProperty(apps, "firefox", () => detectPlatformBinary({
1985
+ darwin: "firefox",
1986
+ win32: String.raw`C:\Program Files\Mozilla Firefox\firefox.exe`,
1987
+ linux: "firefox"
1988
+ }, {
1989
+ wsl: "/mnt/c/Program Files/Mozilla Firefox/firefox.exe"
1990
+ }));
1991
+ defineLazyProperty(apps, "edge", () => detectPlatformBinary({
1992
+ darwin: "microsoft edge",
1993
+ win32: "msedge",
1994
+ linux: ["microsoft-edge", "microsoft-edge-dev"]
1995
+ }, {
1996
+ wsl: "/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"
1997
+ }));
1998
+ defineLazyProperty(apps, "browser", () => "browser");
1999
+ defineLazyProperty(apps, "browserPrivate", () => "browserPrivate");
2000
+ var open_default = open;
2001
+
2002
+ // src/server/index.ts
2003
+ import { promises as fs19 } from "fs";
2004
+ import path15 from "path";
2005
+ import { fileURLToPath as fileURLToPath2 } from "url";
2006
+ import { createAdaptorServer } from "@hono/node-server";
2007
+ import { serveStatic } from "@hono/node-server/serve-static";
2008
+ import { Hono } from "hono";
2009
+
2010
+ // src/core/process/manager.ts
2011
+ import { EventEmitter } from "events";
2012
+ import spawn3 from "cross-spawn";
2013
+ var LOG_MESSAGE_LIMIT = 16384;
2014
+ var LOG_ENTRY_LIMIT = 1e3;
1166
2015
  function killChildProcessTree(child) {
1167
2016
  if (child.pid === void 0) {
1168
2017
  child.kill();
@@ -1187,7 +2036,7 @@ var ProcessManager = class extends EventEmitter {
1187
2036
  start(options) {
1188
2037
  const child = spawn3(options.command, options.args, {
1189
2038
  cwd: options.cwd,
1190
- shell: options.shell ?? false,
2039
+ shell: false,
1191
2040
  windowsHide: true
1192
2041
  });
1193
2042
  const pid = String(child.pid ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`);
@@ -1270,16 +2119,17 @@ var ProcessManager = class extends EventEmitter {
1270
2119
  });
1271
2120
  }
1272
2121
  emitLog(record, stream, message) {
2122
+ const boundedMessage = message.length <= LOG_MESSAGE_LIMIT ? message : message.slice(-LOG_MESSAGE_LIMIT);
1273
2123
  const event = {
1274
2124
  pid: record.pid,
1275
2125
  script: record.script,
1276
2126
  stream,
1277
- message,
2127
+ message: boundedMessage,
1278
2128
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1279
2129
  };
1280
2130
  this.logs.push(event);
1281
- if (this.logs.length > 1e3) {
1282
- this.logs.splice(0, this.logs.length - 1e3);
2131
+ if (this.logs.length > LOG_ENTRY_LIMIT) {
2132
+ this.logs.splice(0, this.logs.length - LOG_ENTRY_LIMIT);
1283
2133
  }
1284
2134
  this.emit("log", event);
1285
2135
  }
@@ -1299,12 +2149,243 @@ var ProcessManager = class extends EventEmitter {
1299
2149
  }
1300
2150
  };
1301
2151
 
2152
+ // src/core/hub/registry.ts
2153
+ import { createHash } from "crypto";
2154
+ import { promises as fs17 } from "fs";
2155
+ import os3 from "os";
2156
+ import path13 from "path";
2157
+
2158
+ // 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);
2164
+ }
2165
+ async function configuredWorkspaceRoots() {
2166
+ const raw = process.env.DEVSURFACE_WORKSPACE_ROOTS;
2167
+ if (!raw) {
2168
+ return [];
2169
+ }
2170
+ const roots = [];
2171
+ for (const entry of raw.split(",")) {
2172
+ const trimmed = entry.trim();
2173
+ if (!trimmed) {
2174
+ continue;
2175
+ }
2176
+ try {
2177
+ roots.push(await fs16.realpath(path12.resolve(trimmed)));
2178
+ } catch {
2179
+ }
2180
+ }
2181
+ return roots;
2182
+ }
2183
+ async function assertWithinWorkspaceRoots(targetPath) {
2184
+ const roots = await configuredWorkspaceRoots();
2185
+ if (roots.length === 0) {
2186
+ return;
2187
+ }
2188
+ for (const root of roots) {
2189
+ if (isWithinRoot7(root, targetPath)) {
2190
+ return;
2191
+ }
2192
+ }
2193
+ throw new Error("Path must be inside a configured workspace root.");
2194
+ }
2195
+
2196
+ // src/core/hub/registry.ts
2197
+ function workspaceId(realPath) {
2198
+ const base = path13.basename(realPath).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 32) || "workspace";
2199
+ const hash = createHash("sha256").update(realPath).digest("hex").slice(0, 6);
2200
+ return `${base}-${hash}`;
2201
+ }
2202
+ function defaultDataDir() {
2203
+ return process.env.DEVSURFACE_DATA_DIR ?? path13.join(os3.homedir(), ".devsurface");
2204
+ }
2205
+ async function readPackageName(dirPath) {
2206
+ try {
2207
+ const raw = JSON.parse(await fs17.readFile(path13.join(dirPath, "package.json"), "utf8"));
2208
+ return typeof raw?.name === "string" && raw.name.length > 0 ? raw.name : null;
2209
+ } catch {
2210
+ return null;
2211
+ }
2212
+ }
2213
+ var WorkspaceRegistry = class {
2214
+ filePath;
2215
+ seeded = false;
2216
+ constructor(dataDir) {
2217
+ const dir = dataDir ?? defaultDataDir();
2218
+ this.filePath = path13.join(dir, "workspaces.json");
2219
+ }
2220
+ async list() {
2221
+ await this.seedFromEnv();
2222
+ return await this.read();
2223
+ }
2224
+ async add(dirPath) {
2225
+ const realDir = await this.resolveDir(dirPath);
2226
+ await assertWithinWorkspaceRoots(realDir);
2227
+ const entries = await this.read();
2228
+ const existing = entries.find((entry2) => entry2.path === realDir);
2229
+ if (existing) {
2230
+ return existing;
2231
+ }
2232
+ const name = await readPackageName(realDir) ?? path13.basename(realDir);
2233
+ const entry = {
2234
+ id: workspaceId(realDir),
2235
+ name,
2236
+ path: realDir,
2237
+ addedAt: (/* @__PURE__ */ new Date()).toISOString()
2238
+ };
2239
+ entries.push(entry);
2240
+ await this.write(entries);
2241
+ return entry;
2242
+ }
2243
+ async remove(id) {
2244
+ const entries = await this.read();
2245
+ const filtered = entries.filter((entry) => entry.id !== id);
2246
+ if (filtered.length === entries.length) {
2247
+ return false;
2248
+ }
2249
+ await this.write(filtered);
2250
+ return true;
2251
+ }
2252
+ async findByPath(dirPath) {
2253
+ try {
2254
+ const realDir = await fs17.realpath(path13.resolve(dirPath));
2255
+ const entries = await this.read();
2256
+ return entries.find((entry) => entry.path === realDir) ?? null;
2257
+ } catch {
2258
+ return null;
2259
+ }
2260
+ }
2261
+ async resolve(id) {
2262
+ const entries = await this.read();
2263
+ const entry = entries.find((item) => item.id === id);
2264
+ if (!entry) {
2265
+ return null;
2266
+ }
2267
+ try {
2268
+ const realDir = await this.resolveDir(entry.path);
2269
+ await assertWithinWorkspaceRoots(realDir);
2270
+ if (realDir !== entry.path) {
2271
+ const updated = { ...entry, path: realDir };
2272
+ await this.write(entries.map((item) => item.id === id ? updated : item));
2273
+ return updated;
2274
+ }
2275
+ return entry;
2276
+ } catch {
2277
+ await this.remove(id);
2278
+ return null;
2279
+ }
2280
+ }
2281
+ async resolveDir(dirPath) {
2282
+ const resolved = path13.resolve(dirPath);
2283
+ const realDir = await fs17.realpath(resolved);
2284
+ const stat = await fs17.stat(realDir);
2285
+ if (!stat.isDirectory()) {
2286
+ throw new Error(`${dirPath} is not a directory.`);
2287
+ }
2288
+ return realDir;
2289
+ }
2290
+ async read() {
2291
+ try {
2292
+ const content = await fs17.readFile(this.filePath, "utf8");
2293
+ const parsed = JSON.parse(content);
2294
+ return Array.isArray(parsed) ? parsed : [];
2295
+ } catch {
2296
+ return [];
2297
+ }
2298
+ }
2299
+ 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");
2302
+ }
2303
+ async seedFromEnv() {
2304
+ if (this.seeded) {
2305
+ return;
2306
+ }
2307
+ this.seeded = true;
2308
+ const seedValue = process.env.DEVSURFACE_WORKSPACES;
2309
+ if (!seedValue) {
2310
+ return;
2311
+ }
2312
+ const paths = seedValue.split(",").map((p) => p.trim()).filter(Boolean);
2313
+ for (const p of paths) {
2314
+ try {
2315
+ await this.add(p);
2316
+ } catch {
2317
+ }
2318
+ }
2319
+ }
2320
+ };
2321
+
2322
+ // src/core/hub/runtime.ts
2323
+ var Hub = class {
2324
+ registry;
2325
+ runtimes = /* @__PURE__ */ new Map();
2326
+ cleanupInstalled = false;
2327
+ constructor(options) {
2328
+ this.registry = new WorkspaceRegistry(options?.dataDir);
2329
+ }
2330
+ get(id) {
2331
+ return this.runtimes.get(id) ?? null;
2332
+ }
2333
+ ensure(entry) {
2334
+ const existing = this.runtimes.get(entry.id);
2335
+ if (existing) {
2336
+ return existing;
2337
+ }
2338
+ const runtime = {
2339
+ id: entry.id,
2340
+ root: entry.path,
2341
+ processManager: new ProcessManager(),
2342
+ dockerController: new DockerComposeController(entry.path)
2343
+ };
2344
+ this.runtimes.set(entry.id, runtime);
2345
+ return runtime;
2346
+ }
2347
+ async listSummaries() {
2348
+ const entries = await this.registry.list();
2349
+ return entries.map((entry) => {
2350
+ const runtime = this.runtimes.get(entry.id);
2351
+ const running = runtime ? runtime.processManager.list().filter((p) => p.status === "running").length : 0;
2352
+ return {
2353
+ id: entry.id,
2354
+ name: entry.name,
2355
+ path: entry.path,
2356
+ addedAt: entry.addedAt,
2357
+ runningProcesses: running
2358
+ };
2359
+ });
2360
+ }
2361
+ killAll() {
2362
+ for (const runtime of this.runtimes.values()) {
2363
+ runtime.processManager.killAll();
2364
+ }
2365
+ }
2366
+ attachCleanupHandlers() {
2367
+ if (this.cleanupInstalled) {
2368
+ return;
2369
+ }
2370
+ this.cleanupInstalled = true;
2371
+ process.once("exit", () => {
2372
+ this.killAll();
2373
+ });
2374
+ process.once("SIGINT", () => {
2375
+ this.killAll();
2376
+ process.exit(130);
2377
+ });
2378
+ }
2379
+ };
2380
+
1302
2381
  // src/server/routes/api.ts
1303
2382
  import { constants as constants2, existsSync } from "fs";
1304
- import { promises as fs11 } from "fs";
1305
- import path11 from "path";
2383
+ import { promises as fs18 } from "fs";
2384
+ import path14 from "path";
1306
2385
  import spawn4 from "cross-spawn";
1307
- import open from "open";
2386
+
2387
+ // src/version.ts
2388
+ var DEV_SURFACE_VERSION = "0.4.0";
1308
2389
 
1309
2390
  // src/server/localAccess.ts
1310
2391
  var LOCAL_HOSTNAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
@@ -1345,10 +2426,154 @@ function isSameOrigin(requestUrl, origin) {
1345
2426
  }
1346
2427
  }
1347
2428
 
2429
+ // src/server/listenConfig.ts
2430
+ var DEFAULT_HOST = "127.0.0.1";
2431
+ var DEFAULT_PORT = 4567;
2432
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
2433
+ var CONTAINER_HOSTS = /* @__PURE__ */ new Set(["0.0.0.0", "::"]);
2434
+ function resolveHost() {
2435
+ const envHost = process.env.DEVSURFACE_HOST;
2436
+ if (!envHost) {
2437
+ return DEFAULT_HOST;
2438
+ }
2439
+ if (LOOPBACK_HOSTS.has(envHost)) {
2440
+ return envHost;
2441
+ }
2442
+ if (CONTAINER_HOSTS.has(envHost) && process.env.DEVSURFACE_CONTAINER === "true") {
2443
+ return envHost;
2444
+ }
2445
+ if (CONTAINER_HOSTS.has(envHost)) {
2446
+ throw new Error(
2447
+ "All-interface DevSurface binding is only allowed when DEVSURFACE_CONTAINER=true. DevSurface binds to 127.0.0.1 on bare metal."
2448
+ );
2449
+ }
2450
+ throw new Error("DEVSURFACE_HOST must be a loopback host, or 0.0.0.0 inside a container.");
2451
+ }
2452
+ var listenHost = DEFAULT_HOST;
2453
+ function setListenHost(host) {
2454
+ listenHost = host;
2455
+ }
2456
+ function getListenHost() {
2457
+ return listenHost;
2458
+ }
2459
+ function normalizeRemoteAddress(raw) {
2460
+ if (typeof raw !== "string" || raw.length === 0) {
2461
+ return null;
2462
+ }
2463
+ if (raw.startsWith("::ffff:")) {
2464
+ return raw.slice("::ffff:".length);
2465
+ }
2466
+ return raw;
2467
+ }
2468
+ function isLoopbackRemoteAddress(raw) {
2469
+ const address = normalizeRemoteAddress(raw);
2470
+ if (!address) {
2471
+ return false;
2472
+ }
2473
+ if (address === "::1" || address === "127.0.0.1") {
2474
+ return true;
2475
+ }
2476
+ return address.startsWith("127.");
2477
+ }
2478
+ function parseIpv4(address) {
2479
+ const parts = address.split(".");
2480
+ if (parts.length !== 4) {
2481
+ return null;
2482
+ }
2483
+ const octets = parts.map((part) => Number(part));
2484
+ if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
2485
+ return null;
2486
+ }
2487
+ return octets;
2488
+ }
2489
+ function isPrivateRemoteAddress(raw) {
2490
+ const address = normalizeRemoteAddress(raw);
2491
+ if (!address) {
2492
+ return false;
2493
+ }
2494
+ if (isLoopbackRemoteAddress(address)) {
2495
+ return true;
2496
+ }
2497
+ if (address.startsWith("fe80:")) {
2498
+ return true;
2499
+ }
2500
+ const ipv4 = parseIpv4(address);
2501
+ if (!ipv4) {
2502
+ return false;
2503
+ }
2504
+ const [a, b] = ipv4;
2505
+ if (a === 10) {
2506
+ return true;
2507
+ }
2508
+ if (a === 192 && b === 168) {
2509
+ return true;
2510
+ }
2511
+ if (a === 172 && b >= 16 && b <= 31) {
2512
+ return true;
2513
+ }
2514
+ return false;
2515
+ }
2516
+ function isAllowedRemoteAddress(raw, host) {
2517
+ if (host === "0.0.0.0" || host === "::") {
2518
+ return isPrivateRemoteAddress(raw);
2519
+ }
2520
+ return isLoopbackRemoteAddress(raw);
2521
+ }
2522
+ function isAllowedClientConnection(raw, host = getListenHost()) {
2523
+ if (raw === void 0) {
2524
+ return true;
2525
+ }
2526
+ return isAllowedRemoteAddress(raw, host);
2527
+ }
2528
+ function initializeListenHost() {
2529
+ const host = resolveHost();
2530
+ setListenHost(host);
2531
+ return host;
2532
+ }
2533
+
2534
+ // src/server/accessControl.ts
2535
+ function remoteAddressFromRequest(request) {
2536
+ return request?.socket?.remoteAddress;
2537
+ }
2538
+ function createApiAccessMiddleware() {
2539
+ return async (context, next) => {
2540
+ const host = context.req.header("host") ?? new URL(context.req.url).host;
2541
+ if (!isAllowedLocalHostHeader(host)) {
2542
+ return context.json({ error: "Non-local host rejected." }, 403);
2543
+ }
2544
+ const env = context.env;
2545
+ const remoteAddress = remoteAddressFromRequest(env?.incoming);
2546
+ if (!isAllowedClientConnection(remoteAddress, getListenHost())) {
2547
+ return context.json({ error: "Remote client rejected." }, 403);
2548
+ }
2549
+ await next();
2550
+ };
2551
+ }
2552
+
2553
+ // src/server/mutationToken.ts
2554
+ import { randomBytes, timingSafeEqual } from "crypto";
2555
+ function createMutationToken() {
2556
+ return randomBytes(32).toString("hex");
2557
+ }
2558
+ function hasValidMutationToken(provided, expected) {
2559
+ if (typeof provided !== "string" || provided.length === 0) {
2560
+ return false;
2561
+ }
2562
+ if (provided.length !== expected.length) {
2563
+ return false;
2564
+ }
2565
+ return timingSafeEqual(Buffer.from(provided, "utf8"), Buffer.from(expected, "utf8"));
2566
+ }
2567
+
2568
+ // src/server/terminal.ts
2569
+ function isAllowedTerminalCommand(command) {
2570
+ return /^[A-Za-z0-9._+-]+$/.test(command);
2571
+ }
2572
+
1348
2573
  // src/server/routes/api.ts
1349
- function isWithinRoot5(root, target) {
1350
- const relative = path11.relative(path11.resolve(root), path11.resolve(target));
1351
- return relative === "" || !relative.startsWith("..") && !path11.isAbsolute(relative);
2574
+ function isWithinRoot8(root, target) {
2575
+ const relative = path14.relative(path14.resolve(root), path14.resolve(target));
2576
+ return relative === "" || !relative.startsWith("..") && !path14.isAbsolute(relative);
1352
2577
  }
1353
2578
  function isAllowedMutationOrigin(requestUrl, origin) {
1354
2579
  if (origin === null) {
@@ -1356,6 +2581,23 @@ function isAllowedMutationOrigin(requestUrl, origin) {
1356
2581
  }
1357
2582
  return isAllowedLocalOrigin(origin) && isSameOrigin(requestUrl, origin);
1358
2583
  }
2584
+ function registerMutationGuard(app, mutationToken) {
2585
+ app.use("/api/*", createApiAccessMiddleware());
2586
+ app.use("/api/*", async (context, next) => {
2587
+ if (context.req.method === "GET" || context.req.method === "HEAD") {
2588
+ await next();
2589
+ return;
2590
+ }
2591
+ const origin = context.req.header("origin") ?? null;
2592
+ const secFetchSite = context.req.header("sec-fetch-site") ?? null;
2593
+ const intent = context.req.header("x-devsurface-intent") ?? null;
2594
+ const token = context.req.header("x-devsurface-token") ?? null;
2595
+ if (!hasMutationIntent(intent) || !hasValidMutationToken(token, mutationToken) || isCrossSiteFetch(secFetchSite) || !isAllowedMutationOrigin(context.req.url, origin)) {
2596
+ return context.json({ error: "Cross-origin mutation rejected." }, 403);
2597
+ }
2598
+ await next();
2599
+ });
2600
+ }
1359
2601
  function isCrossSiteFetch(secFetchSite) {
1360
2602
  return secFetchSite === "cross-site";
1361
2603
  }
@@ -1363,35 +2605,35 @@ function hasMutationIntent(intent) {
1363
2605
  return intent === "dashboard";
1364
2606
  }
1365
2607
  async function realPathWithinRoot(root, target) {
1366
- if (!isWithinRoot5(root, target)) {
2608
+ if (!isWithinRoot8(root, target)) {
1367
2609
  return false;
1368
2610
  }
1369
2611
  try {
1370
- const [realRoot, realTarget] = await Promise.all([fs11.realpath(root), fs11.realpath(target)]);
1371
- return isWithinRoot5(realRoot, realTarget);
2612
+ const [realRoot, realTarget] = await Promise.all([fs18.realpath(root), fs18.realpath(target)]);
2613
+ return isWithinRoot8(realRoot, realTarget);
1372
2614
  } catch {
1373
2615
  return false;
1374
2616
  }
1375
2617
  }
1376
2618
  async function writableDestinationWithinRoot(root, destination) {
1377
- if (!isWithinRoot5(root, destination)) {
2619
+ if (!isWithinRoot8(root, destination)) {
1378
2620
  return false;
1379
2621
  }
1380
2622
  try {
1381
2623
  const [realRoot, realParent] = await Promise.all([
1382
- fs11.realpath(root),
1383
- fs11.realpath(path11.dirname(destination))
2624
+ fs18.realpath(root),
2625
+ fs18.realpath(path14.dirname(destination))
1384
2626
  ]);
1385
- return isWithinRoot5(realRoot, realParent);
2627
+ return isWithinRoot8(realRoot, realParent);
1386
2628
  } catch {
1387
2629
  return false;
1388
2630
  }
1389
2631
  }
1390
2632
  async function copyFileExclusive(source, destination) {
1391
- const content = await fs11.readFile(source);
2633
+ const content = await fs18.readFile(source);
1392
2634
  let handle2 = null;
1393
2635
  try {
1394
- handle2 = await fs11.open(
2636
+ handle2 = await fs18.open(
1395
2637
  destination,
1396
2638
  constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY,
1397
2639
  384
@@ -1418,15 +2660,15 @@ function resolveCommandPromptExecutable() {
1418
2660
  return process.env.ComSpec ?? "cmd.exe";
1419
2661
  }
1420
2662
  function findExecutable(command) {
1421
- if (path11.isAbsolute(command)) {
2663
+ if (path14.isAbsolute(command)) {
1422
2664
  return existsSync(command) ? command : null;
1423
2665
  }
1424
2666
  const pathValue = process.env.PATH ?? "";
1425
- for (const directory of pathValue.split(path11.delimiter)) {
2667
+ for (const directory of pathValue.split(path14.delimiter)) {
1426
2668
  if (directory.length === 0) {
1427
2669
  continue;
1428
2670
  }
1429
- const candidate = path11.join(directory, command);
2671
+ const candidate = path14.join(directory, command);
1430
2672
  if (existsSync(candidate)) {
1431
2673
  return candidate;
1432
2674
  }
@@ -1467,8 +2709,8 @@ function openTerminalAt(root) {
1467
2709
  if (process.platform === "darwin") {
1468
2710
  return launchDetached("open", ["-a", "Terminal", root], root);
1469
2711
  }
1470
- const configuredTerminal = process.env.TERMINAL;
1471
- if (configuredTerminal !== void 0 && findExecutable(configuredTerminal) !== null) {
2712
+ const configuredTerminal = process.env.TERMINAL?.trim();
2713
+ if (configuredTerminal !== void 0 && configuredTerminal.length > 0 && isAllowedTerminalCommand(configuredTerminal) && findExecutable(configuredTerminal) !== null) {
1472
2714
  return launchDetached(configuredTerminal, [], root);
1473
2715
  }
1474
2716
  const linuxTerminals = [
@@ -1492,72 +2734,111 @@ function openTerminalAt(root) {
1492
2734
  }
1493
2735
  return launchDetached(terminal.command, terminal.args, root);
1494
2736
  }
1495
- function registerApiRoutes(app, options) {
1496
- app.use("/api/*", async (context, next) => {
1497
- const host = context.req.header("host") ?? new URL(context.req.url).host;
1498
- if (!isAllowedLocalHostHeader(host)) {
1499
- return context.json({ error: "Non-local host rejected." }, 403);
2737
+ function handleDockerError(error, context) {
2738
+ if (error instanceof DockerOperationError) {
2739
+ if (error.code === "compose-not-found" || error.code === "service-not-found") {
2740
+ return context.json({ error: error.message, code: error.code }, 404);
1500
2741
  }
1501
- if (context.req.method !== "GET" && context.req.method !== "HEAD") {
1502
- const origin = context.req.header("origin") ?? null;
1503
- const secFetchSite = context.req.header("sec-fetch-site") ?? null;
1504
- const intent = context.req.header("x-devsurface-intent") ?? null;
1505
- if (!hasMutationIntent(intent) || isCrossSiteFetch(secFetchSite) || !isAllowedMutationOrigin(context.req.url, origin)) {
1506
- return context.json({ error: "Cross-origin mutation rejected." }, 403);
1507
- }
2742
+ if (error.code === "docker-not-installed" || error.code === "docker-not-running") {
2743
+ return context.json({ error: error.message, code: error.code }, 503);
1508
2744
  }
1509
- await next();
2745
+ return context.json({ error: error.message, code: error.code }, 502);
2746
+ }
2747
+ throw error;
2748
+ }
2749
+ function registerWorkspaceRoutes(app, resolveWorkspace) {
2750
+ app.get("/api/workspaces/:id/project", async (context) => {
2751
+ const ws = await resolveWorkspace(context.req.param("id"));
2752
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2753
+ return context.json(await scanProject(ws.root));
1510
2754
  });
1511
- app.get("/api/project", async (context) => {
1512
- return context.json(await scanProject(options.projectRoot));
2755
+ app.get("/api/workspaces/:id/health", async (context) => {
2756
+ const ws = await resolveWorkspace(context.req.param("id"));
2757
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2758
+ return context.json(await runDoctor(ws.root));
1513
2759
  });
1514
- app.get("/api/health", async (context) => {
1515
- return context.json(await runDoctor(options.projectRoot));
2760
+ app.get("/api/workspaces/:id/processes", async (context) => {
2761
+ const ws = await resolveWorkspace(context.req.param("id"));
2762
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2763
+ return context.json(ws.processManager.list());
1516
2764
  });
1517
- app.get("/api/processes", (context) => {
1518
- return context.json(options.processManager.list());
2765
+ app.get("/api/workspaces/:id/logs", async (context) => {
2766
+ const ws = await resolveWorkspace(context.req.param("id"));
2767
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2768
+ return context.json(ws.processManager.listLogs());
1519
2769
  });
1520
- app.get("/api/logs", (context) => {
1521
- return context.json(options.processManager.listLogs());
2770
+ app.get("/api/workspaces/:id/docker/:service/logs", async (context) => {
2771
+ const ws = await resolveWorkspace(context.req.param("id"));
2772
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2773
+ const service = decodeURIComponent(context.req.param("service"));
2774
+ try {
2775
+ return context.json(await ws.dockerController.logs(service));
2776
+ } catch (error) {
2777
+ return handleDockerError(error, context);
2778
+ }
1522
2779
  });
1523
- app.post("/api/run/:script", async (context) => {
2780
+ app.post("/api/workspaces/:id/docker/:service/start", async (context) => {
2781
+ const ws = await resolveWorkspace(context.req.param("id"));
2782
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2783
+ const service = decodeURIComponent(context.req.param("service"));
2784
+ try {
2785
+ return context.json(await ws.dockerController.start(service));
2786
+ } catch (error) {
2787
+ return handleDockerError(error, context);
2788
+ }
2789
+ });
2790
+ app.post("/api/workspaces/:id/docker/:service/stop", async (context) => {
2791
+ const ws = await resolveWorkspace(context.req.param("id"));
2792
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2793
+ const service = decodeURIComponent(context.req.param("service"));
2794
+ try {
2795
+ return context.json(await ws.dockerController.stop(service));
2796
+ } catch (error) {
2797
+ return handleDockerError(error, context);
2798
+ }
2799
+ });
2800
+ app.post("/api/workspaces/:id/run/:script", async (context) => {
2801
+ const ws = await resolveWorkspace(context.req.param("id"));
2802
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
1524
2803
  const script = decodeURIComponent(context.req.param("script"));
1525
- const scan = await scanProject(options.projectRoot);
2804
+ const scan = await scanProject(ws.root);
1526
2805
  const packageScript = scan.scripts[script];
1527
2806
  if (packageScript === void 0) {
1528
2807
  return context.json({ error: `Script "${script}" was not found.` }, 404);
1529
2808
  }
2809
+ if (isDangerousCommand(packageScript)) {
2810
+ return context.json({ error: "Refusing to run dangerous script." }, 403);
2811
+ }
1530
2812
  const command = await resolvePackageRunCommand({
1531
- cwd: options.projectRoot,
2813
+ cwd: ws.root,
1532
2814
  packageManager: scan.packageManager,
1533
2815
  script
1534
2816
  });
1535
2817
  if (command === null) {
1536
2818
  return context.json({ error: "Package manager executable was not found." }, 503);
1537
2819
  }
1538
- const processInfo = options.processManager.start({
1539
- cwd: options.projectRoot,
2820
+ const processInfo = ws.processManager.start({
2821
+ cwd: ws.root,
1540
2822
  script,
1541
2823
  command: command.command,
1542
2824
  args: command.args,
1543
2825
  displayCommand: command.displayCommand
1544
2826
  });
1545
- return context.json({
1546
- ...processInfo,
1547
- packageScript
1548
- });
2827
+ return context.json({ ...processInfo, packageScript });
1549
2828
  });
1550
- app.post("/api/install", async (context) => {
1551
- const scan = await scanProject(options.projectRoot);
2829
+ app.post("/api/workspaces/:id/install", async (context) => {
2830
+ const ws = await resolveWorkspace(context.req.param("id"));
2831
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2832
+ const scan = await scanProject(ws.root);
1552
2833
  const command = await resolvePackageInstallCommand({
1553
- cwd: options.projectRoot,
2834
+ cwd: ws.root,
1554
2835
  packageManager: scan.packageManager
1555
2836
  });
1556
2837
  if (command === null) {
1557
2838
  return context.json({ error: "Package manager executable was not found." }, 503);
1558
2839
  }
1559
- const processInfo = options.processManager.start({
1560
- cwd: options.projectRoot,
2840
+ const processInfo = ws.processManager.start({
2841
+ cwd: ws.root,
1561
2842
  script: "install",
1562
2843
  command: command.command,
1563
2844
  args: command.args,
@@ -1565,51 +2846,69 @@ function registerApiRoutes(app, options) {
1565
2846
  });
1566
2847
  return context.json(processInfo);
1567
2848
  });
1568
- app.post("/api/commands/:name", async (context) => {
2849
+ app.post("/api/workspaces/:id/commands/:name", async (context) => {
2850
+ const ws = await resolveWorkspace(context.req.param("id"));
2851
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
1569
2852
  const name = decodeURIComponent(context.req.param("name"));
1570
- const scan = await scanProject(options.projectRoot);
2853
+ const scan = await scanProject(ws.root);
1571
2854
  const configuredCommand = scan.config?.config.commands?.[name] ?? null;
1572
2855
  if (configuredCommand === null) {
1573
2856
  return context.json({ error: `Configured command "${name}" was not found.` }, 404);
1574
2857
  }
1575
- const processInfo = options.processManager.start({
1576
- cwd: options.projectRoot,
2858
+ if (isDangerousCommand(configuredCommand)) {
2859
+ return context.json({ error: "Refusing to run dangerous command." }, 403);
2860
+ }
2861
+ const resolvedCommand = await resolveConfiguredCommand(ws.root, configuredCommand);
2862
+ if (resolvedCommand === null) {
2863
+ return context.json(
2864
+ {
2865
+ error: "Configured command uses unsupported shell syntax. Use a simple executable with arguments, or move complex logic into a package.json script."
2866
+ },
2867
+ 400
2868
+ );
2869
+ }
2870
+ const processInfo = ws.processManager.start({
2871
+ cwd: ws.root,
1577
2872
  script: name,
1578
- command: configuredCommand,
1579
- args: [],
1580
- displayCommand: configuredCommand,
1581
- shell: true
1582
- });
1583
- return context.json({
1584
- ...processInfo,
1585
- configuredCommand
2873
+ command: resolvedCommand.command,
2874
+ args: resolvedCommand.args,
2875
+ displayCommand: resolvedCommand.displayCommand
1586
2876
  });
2877
+ return context.json({ ...processInfo, configuredCommand });
1587
2878
  });
1588
- app.post("/api/open/folder", async (context) => {
1589
- await open(options.projectRoot);
2879
+ app.post("/api/workspaces/:id/open/folder", async (context) => {
2880
+ const ws = await resolveWorkspace(context.req.param("id"));
2881
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2882
+ await open_default(ws.root);
1590
2883
  return context.json({ opened: true, target: "folder" });
1591
2884
  });
1592
- app.post("/api/open/package", async (context) => {
1593
- const packagePath = path11.join(options.projectRoot, "package.json");
1594
- if (!await realPathWithinRoot(options.projectRoot, packagePath)) {
2885
+ app.post("/api/workspaces/:id/open/package", async (context) => {
2886
+ const ws = await resolveWorkspace(context.req.param("id"));
2887
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2888
+ const packagePath = path14.join(ws.root, "package.json");
2889
+ if (!await realPathWithinRoot(ws.root, packagePath)) {
1595
2890
  return context.json({ error: "package.json was not found inside the project root." }, 404);
1596
2891
  }
1597
- await open(packagePath);
2892
+ await open_default(packagePath);
1598
2893
  return context.json({ opened: true, target: "package" });
1599
2894
  });
1600
- app.post("/api/open/terminal", (context) => {
1601
- const opened = openTerminalAt(options.projectRoot);
2895
+ app.post("/api/workspaces/:id/open/terminal", async (context) => {
2896
+ const ws = await resolveWorkspace(context.req.param("id"));
2897
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2898
+ const opened = openTerminalAt(ws.root);
1602
2899
  return context.json({ opened, target: "terminal" }, opened ? 200 : 501);
1603
2900
  });
1604
- app.post("/api/env/copy", async (context) => {
1605
- const scan = await scanProject(options.projectRoot);
2901
+ app.post("/api/workspaces/:id/env/copy", async (context) => {
2902
+ const ws = await resolveWorkspace(context.req.param("id"));
2903
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
2904
+ const scan = await scanProject(ws.root);
1606
2905
  const examplePath = scan.env?.examplePath ?? null;
1607
2906
  const localPath = scan.env?.localPath ?? null;
1608
2907
  if (examplePath === null) {
1609
2908
  return context.json({ error: ".env.example was not found." }, 404);
1610
2909
  }
1611
- const destination = localPath ?? path11.join(options.projectRoot, scan.config?.config.env?.local ?? ".env");
1612
- if (!await realPathWithinRoot(options.projectRoot, examplePath) || !await writableDestinationWithinRoot(options.projectRoot, destination)) {
2910
+ const destination = localPath ?? path14.join(ws.root, scan.config?.config.env?.local ?? ".env");
2911
+ if (!await realPathWithinRoot(ws.root, examplePath) || !await writableDestinationWithinRoot(ws.root, destination)) {
1613
2912
  return context.json({ error: "Refusing to copy env files outside the project root." }, 400);
1614
2913
  }
1615
2914
  const copyResult = await copyFileExclusive(examplePath, destination);
@@ -1618,12 +2917,79 @@ function registerApiRoutes(app, options) {
1618
2917
  }
1619
2918
  return context.json({ copied: true });
1620
2919
  });
1621
- app.delete("/api/run/:pid", (context) => {
2920
+ app.delete("/api/workspaces/:id/run/:pid", async (context) => {
2921
+ const ws = await resolveWorkspace(context.req.param("id"));
2922
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
1622
2923
  const pid = decodeURIComponent(context.req.param("pid"));
1623
- const stopped = options.processManager.stop(pid);
2924
+ const stopped = ws.processManager.stop(pid);
1624
2925
  return context.json({ stopped });
1625
2926
  });
1626
2927
  }
2928
+ function registerHubApiRoutes(app, options) {
2929
+ const { hub } = options;
2930
+ registerMutationGuard(app, options.mutationToken);
2931
+ async function resolveWorkspace(id) {
2932
+ const entry = await hub.registry.resolve(id);
2933
+ if (!entry) return null;
2934
+ const runtime = hub.ensure(entry);
2935
+ return {
2936
+ root: runtime.root,
2937
+ processManager: runtime.processManager,
2938
+ dockerController: runtime.dockerController
2939
+ };
2940
+ }
2941
+ app.get("/api/session", (context) => {
2942
+ return context.json({ token: options.mutationToken });
2943
+ });
2944
+ app.get("/api/hub/status", (context) => {
2945
+ return context.json({ status: "running", version: DEV_SURFACE_VERSION });
2946
+ });
2947
+ app.get("/api/workspaces", async (context) => {
2948
+ return context.json(await hub.listSummaries());
2949
+ });
2950
+ app.post("/api/workspaces", async (context) => {
2951
+ const body = await context.req.json().catch(() => null);
2952
+ if (!body?.path) {
2953
+ return context.json({ error: "path is required." }, 400);
2954
+ }
2955
+ try {
2956
+ const entry = await hub.registry.add(body.path);
2957
+ return context.json(entry, 201);
2958
+ } catch (error) {
2959
+ return context.json({ error: error instanceof Error ? error.message : "Invalid path." }, 400);
2960
+ }
2961
+ });
2962
+ app.delete("/api/workspaces/:id", async (context) => {
2963
+ const id = context.req.param("id");
2964
+ const runtime = hub.get(id);
2965
+ if (runtime) {
2966
+ runtime.processManager.killAll();
2967
+ }
2968
+ const removed = await hub.registry.remove(id);
2969
+ return context.json({ removed }, removed ? 200 : 404);
2970
+ });
2971
+ registerWorkspaceRoutes(app, resolveWorkspace);
2972
+ app.get("/api/project", async (context) => {
2973
+ const entries = await hub.registry.list();
2974
+ if (entries.length === 0) return context.json({ error: "No workspaces registered." }, 404);
2975
+ return context.json(await scanProject(hub.ensure(entries[0]).root));
2976
+ });
2977
+ app.get("/api/health", async (context) => {
2978
+ const entries = await hub.registry.list();
2979
+ if (entries.length === 0) return context.json({ error: "No workspaces registered." }, 404);
2980
+ return context.json(await runDoctor(hub.ensure(entries[0]).root));
2981
+ });
2982
+ app.get("/api/processes", async (context) => {
2983
+ const entries = await hub.registry.list();
2984
+ if (entries.length === 0) return context.json([]);
2985
+ return context.json(hub.ensure(entries[0]).processManager.list());
2986
+ });
2987
+ app.get("/api/logs", async (context) => {
2988
+ const entries = await hub.registry.list();
2989
+ if (entries.length === 0) return context.json([]);
2990
+ return context.json(hub.ensure(entries[0]).processManager.listLogs());
2991
+ });
2992
+ }
1627
2993
 
1628
2994
  // src/server/routes/ws.ts
1629
2995
  import { WebSocket, WebSocketServer } from "ws";
@@ -1631,6 +2997,9 @@ function isAllowedWebSocketRequest(request) {
1631
2997
  const origin = request.headers.origin;
1632
2998
  const host = request.headers.host;
1633
2999
  const secFetchSite = request.headers["sec-fetch-site"];
3000
+ if (!isAllowedClientConnection(remoteAddressFromRequest(request), getListenHost())) {
3001
+ return false;
3002
+ }
1634
3003
  if (typeof host !== "string" || !isAllowedLocalHostHeader(host)) {
1635
3004
  return false;
1636
3005
  }
@@ -1649,32 +3018,63 @@ function isAllowedWebSocketRequest(request) {
1649
3018
  return false;
1650
3019
  }
1651
3020
  }
1652
- function setupWebSocket(server, processManager) {
3021
+ function workspaceIdFromUrl(url) {
3022
+ if (!url) return null;
3023
+ try {
3024
+ const parsed = new URL(url, "http://localhost");
3025
+ return parsed.searchParams.get("workspace");
3026
+ } catch {
3027
+ return null;
3028
+ }
3029
+ }
3030
+ function setupHubWebSocket(server, hub) {
1653
3031
  const wss = new WebSocketServer({
1654
3032
  server,
1655
3033
  path: "/ws",
1656
3034
  verifyClient: (info) => isAllowedWebSocketRequest(info.req)
1657
3035
  });
1658
- function broadcast(payload) {
3036
+ const clientWorkspaces = /* @__PURE__ */ new WeakMap();
3037
+ const attachedManagers = /* @__PURE__ */ new Set();
3038
+ function attachManager(workspaceId2, processManager) {
3039
+ if (attachedManagers.has(workspaceId2)) {
3040
+ return;
3041
+ }
3042
+ attachedManagers.add(workspaceId2);
3043
+ processManager.on("log", (event) => {
3044
+ broadcastToWorkspace(workspaceId2, { type: "log", event });
3045
+ });
3046
+ processManager.on("process", (processInfo) => {
3047
+ broadcastToWorkspace(workspaceId2, { type: "process", process: processInfo });
3048
+ });
3049
+ }
3050
+ function broadcastToWorkspace(workspaceId2, payload) {
1659
3051
  const serialized = JSON.stringify(payload);
1660
3052
  for (const client of wss.clients) {
1661
- if (client.readyState === WebSocket.OPEN) {
3053
+ if (client.readyState === WebSocket.OPEN && clientWorkspaces.get(client) === workspaceId2) {
1662
3054
  client.send(serialized);
1663
3055
  }
1664
3056
  }
1665
3057
  }
1666
- processManager.on("log", (event) => {
1667
- broadcast({ type: "log", event });
1668
- });
1669
- processManager.on("process", (processInfo) => {
1670
- broadcast({ type: "process", process: processInfo });
1671
- });
1672
- wss.on("connection", (socket) => {
3058
+ wss.on("connection", async (socket, request) => {
3059
+ const workspaceId2 = workspaceIdFromUrl(request.url);
3060
+ if (!workspaceId2) {
3061
+ socket.close(4e3, "Missing workspace query parameter.");
3062
+ return;
3063
+ }
3064
+ const entry = await hub.registry.resolve(workspaceId2);
3065
+ if (!entry) {
3066
+ socket.close(4004, "Workspace not found.");
3067
+ return;
3068
+ }
3069
+ const runtime = hub.ensure(entry);
3070
+ clientWorkspaces.set(socket, workspaceId2);
3071
+ attachManager(workspaceId2, runtime.processManager);
1673
3072
  socket.send(
1674
3073
  JSON.stringify({
1675
3074
  type: "hello",
1676
- processes: processManager.list(),
1677
- logs: processManager.listLogs()
3075
+ workspace: workspaceId2,
3076
+ processes: runtime.processManager.list(),
3077
+ logs: runtime.processManager.listLogs()
1678
3078
  })
1679
3079
  );
1680
3080
  });
@@ -1682,51 +3082,44 @@ function setupWebSocket(server, processManager) {
1682
3082
  }
1683
3083
 
1684
3084
  // src/server/index.ts
1685
- var HOST = "127.0.0.1";
1686
- var DEFAULT_PORT = 4567;
1687
- function assertLocalHost(host) {
1688
- if (host !== HOST) {
1689
- throw new Error("DevSurface must bind only to 127.0.0.1.");
1690
- }
1691
- }
1692
- async function fileExists2(filePath) {
3085
+ async function fileExists(filePath) {
1693
3086
  try {
1694
- await fs12.access(filePath);
3087
+ await fs19.access(filePath);
1695
3088
  return true;
1696
3089
  } catch {
1697
3090
  return false;
1698
3091
  }
1699
3092
  }
1700
3093
  async function findWebDistDir() {
1701
- const moduleDir = path12.dirname(fileURLToPath(import.meta.url));
3094
+ const moduleDir = path15.dirname(fileURLToPath2(import.meta.url));
1702
3095
  const candidates = [
1703
- path12.join(moduleDir, "..", "web", "dist"),
1704
- path12.join(moduleDir, "..", "..", "src", "web", "dist"),
1705
- path12.join(moduleDir, "web", "dist")
3096
+ path15.join(moduleDir, "..", "web", "dist"),
3097
+ path15.join(moduleDir, "..", "..", "src", "web", "dist"),
3098
+ path15.join(moduleDir, "web", "dist")
1706
3099
  ];
1707
3100
  for (const candidate of candidates) {
1708
- if (await fileExists2(path12.join(candidate, "index.html"))) {
3101
+ if (await fileExists(path15.join(candidate, "index.html"))) {
1709
3102
  return candidate;
1710
3103
  }
1711
3104
  }
1712
3105
  return null;
1713
3106
  }
1714
- function toListenError(error, port) {
3107
+ function toListenError(error, host, port) {
1715
3108
  const code = error instanceof Error ? error.code : void 0;
1716
3109
  if (code === "EADDRINUSE") {
1717
3110
  return new Error(
1718
- `Port ${port} is already in use on ${HOST}. Stop the other process or run DevSurface with --port ${port + 1}.`,
3111
+ `Port ${port} is already in use on ${host}. Stop the other process or run DevSurface with --port ${port + 1}.`,
1719
3112
  { cause: error }
1720
3113
  );
1721
3114
  }
1722
3115
  if (code === "EACCES") {
1723
- return new Error(`DevSurface does not have permission to bind to ${HOST}:${port}.`, {
3116
+ return new Error(`DevSurface does not have permission to bind to ${host}:${port}.`, {
1724
3117
  cause: error
1725
3118
  });
1726
3119
  }
1727
3120
  return error instanceof Error ? error : new Error(String(error));
1728
3121
  }
1729
- async function listenOnLocalHost(server, wss, port) {
3122
+ async function listenOnHost(server, wss, host, port) {
1730
3123
  await new Promise((resolve, reject) => {
1731
3124
  let settled = false;
1732
3125
  const cleanup = () => {
@@ -1735,17 +3128,13 @@ async function listenOnLocalHost(server, wss, port) {
1735
3128
  wss.off("error", onError);
1736
3129
  };
1737
3130
  const onError = (error) => {
1738
- if (settled) {
1739
- return;
1740
- }
3131
+ if (settled) return;
1741
3132
  settled = true;
1742
3133
  cleanup();
1743
- reject(toListenError(error, port));
3134
+ reject(toListenError(error, host, port));
1744
3135
  };
1745
3136
  const onListening = () => {
1746
- if (settled) {
1747
- return;
1748
- }
3137
+ if (settled) return;
1749
3138
  settled = true;
1750
3139
  cleanup();
1751
3140
  resolve();
@@ -1753,7 +3142,7 @@ async function listenOnLocalHost(server, wss, port) {
1753
3142
  wss.once("error", onError);
1754
3143
  server.once("error", onError);
1755
3144
  server.once("listening", onListening);
1756
- server.listen(port, HOST);
3145
+ server.listen(port, host);
1757
3146
  });
1758
3147
  }
1759
3148
  async function closeWebSocketServer(wss) {
@@ -1775,15 +3164,13 @@ async function closeHttpServer(server) {
1775
3164
  });
1776
3165
  });
1777
3166
  }
1778
- async function createApp(options) {
1779
- const app = new Hono();
1780
- registerApiRoutes(app, options);
3167
+ async function mountWebUi(app) {
1781
3168
  const webDistDir = await findWebDistDir();
1782
3169
  if (webDistDir !== null) {
1783
3170
  app.use("/assets/*", serveStatic({ root: webDistDir }));
1784
3171
  app.get("/favicon.svg", serveStatic({ root: webDistDir }));
1785
3172
  app.get("*", async (context) => {
1786
- const html = await fs12.readFile(path12.join(webDistDir, "index.html"), "utf8");
3173
+ const html = await fs19.readFile(path15.join(webDistDir, "index.html"), "utf8");
1787
3174
  return context.html(html);
1788
3175
  });
1789
3176
  } else {
@@ -1795,44 +3182,97 @@ async function createApp(options) {
1795
3182
  )
1796
3183
  );
1797
3184
  }
3185
+ }
3186
+ async function createHubApp(options) {
3187
+ const app = new Hono();
3188
+ registerHubApiRoutes(app, {
3189
+ hub: options.hub,
3190
+ mutationToken: options.mutationToken ?? createMutationToken()
3191
+ });
3192
+ await mountWebUi(app);
1798
3193
  return app;
1799
3194
  }
1800
- async function startDevSurfaceServer(options) {
1801
- assertLocalHost(HOST);
3195
+ async function startHubServer(options) {
3196
+ const host = initializeListenHost();
1802
3197
  const port = options.port ?? DEFAULT_PORT;
1803
- const processManager = new ProcessManager();
1804
- processManager.attachCleanupHandlers();
1805
- const app = await createApp({
1806
- projectRoot: options.projectRoot,
1807
- processManager
1808
- });
3198
+ const hub = new Hub({ dataDir: options.dataDir });
3199
+ hub.attachCleanupHandlers();
3200
+ if (options.initialWorkspace) {
3201
+ await hub.registry.add(options.initialWorkspace);
3202
+ }
3203
+ const mutationToken = createMutationToken();
3204
+ const app = await createHubApp({ hub, mutationToken });
1809
3205
  const server = createAdaptorServer({
1810
3206
  fetch: app.fetch,
1811
- hostname: HOST
3207
+ hostname: host
1812
3208
  });
1813
- const wss = setupWebSocket(server, processManager);
1814
- await listenOnLocalHost(server, wss, port);
1815
- processManager.attachCleanupHandlers();
1816
- const url = `http://${HOST}:${port}`;
3209
+ const wss = setupHubWebSocket(server, hub);
3210
+ await listenOnHost(server, wss, host, port);
3211
+ const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}`;
1817
3212
  if (options.openBrowser !== false) {
1818
- await open2(url);
3213
+ const entries = await hub.registry.list();
3214
+ const deepLink = entries.length > 0 ? `${url}/?workspace=${entries[0].id}` : url;
3215
+ await open_default(deepLink);
1819
3216
  }
3217
+ const dummyProcessManager = new ProcessManager();
1820
3218
  return {
1821
3219
  url,
1822
3220
  port,
1823
- processManager,
3221
+ host,
3222
+ hub,
3223
+ processManager: dummyProcessManager,
1824
3224
  close: async () => {
1825
- processManager.killAll();
3225
+ hub.killAll();
1826
3226
  await closeWebSocketServer(wss);
1827
3227
  await closeHttpServer(server);
1828
3228
  }
1829
3229
  };
1830
3230
  }
1831
3231
 
3232
+ // src/cli/hub/client.ts
3233
+ async function isHubRunning(port = DEFAULT_PORT) {
3234
+ try {
3235
+ const response = await fetch(`http://127.0.0.1:${port}/api/hub/status`, {
3236
+ signal: AbortSignal.timeout(2e3)
3237
+ });
3238
+ return response.ok;
3239
+ } catch {
3240
+ return false;
3241
+ }
3242
+ }
3243
+ async function registerWorkspaceRemotely(dirPath, port = DEFAULT_PORT) {
3244
+ try {
3245
+ const sessionResponse = await fetch(`http://127.0.0.1:${port}/api/session`, {
3246
+ signal: AbortSignal.timeout(2e3)
3247
+ });
3248
+ if (!sessionResponse.ok) return null;
3249
+ const session = await sessionResponse.json();
3250
+ const response = await fetch(`http://127.0.0.1:${port}/api/workspaces`, {
3251
+ method: "POST",
3252
+ headers: {
3253
+ "Content-Type": "application/json",
3254
+ "X-DevSurface-Intent": "dashboard",
3255
+ "X-DevSurface-Token": session.token
3256
+ },
3257
+ body: JSON.stringify({ path: dirPath }),
3258
+ signal: AbortSignal.timeout(5e3)
3259
+ });
3260
+ if (!response.ok) return null;
3261
+ return await response.json();
3262
+ } catch {
3263
+ return null;
3264
+ }
3265
+ }
3266
+ function dashboardUrl(workspaceId2, port = DEFAULT_PORT, host = DEFAULT_HOST) {
3267
+ const displayHost = host === "0.0.0.0" ? "127.0.0.1" : host;
3268
+ return `http://${displayHost}:${port}/?workspace=${workspaceId2}`;
3269
+ }
3270
+
1832
3271
  // src/cli/commands/start.ts
1833
3272
  async function startCommand(options) {
1834
3273
  const cwd = options.cwd ?? process.cwd();
1835
- console.log(pc5.bold(`DevSurface v0.2.0`));
3274
+ const port = options.port ?? 4567;
3275
+ console.log(pc5.bold(`DevSurface v${DEV_SURFACE_VERSION}`));
1836
3276
  console.log("Scanning project...\n");
1837
3277
  const scan = await scanProject(cwd);
1838
3278
  printScanResult(scan);
@@ -1844,13 +3284,87 @@ async function startCommand(options) {
1844
3284
  console.log(` ${marker} ${item.title}`);
1845
3285
  }
1846
3286
  }
1847
- const server = await startDevSurfaceServer({
1848
- projectRoot: cwd,
3287
+ if (await isHubRunning(port)) {
3288
+ console.log("\nHub already running. Registering workspace...");
3289
+ const registered = await registerWorkspaceRemotely(cwd, port);
3290
+ if (registered) {
3291
+ const url = dashboardUrl(registered.id, port);
3292
+ console.log(`Workspace ${pc5.cyan(registered.name)} attached.`);
3293
+ console.log(`Dashboard -> ${pc5.cyan(url)}`);
3294
+ if (options.openBrowser !== false) {
3295
+ await open_default(url);
3296
+ }
3297
+ return;
3298
+ }
3299
+ console.log("Could not register with running hub. Starting a new instance...");
3300
+ }
3301
+ const server = await startHubServer({
3302
+ port,
3303
+ openBrowser: options.openBrowser,
3304
+ initialWorkspace: cwd
3305
+ });
3306
+ console.log(`
3307
+ Dashboard running at -> ${pc5.cyan(server.url)}`);
3308
+ }
3309
+
3310
+ // src/cli/commands/serve.ts
3311
+ import pc6 from "picocolors";
3312
+ async function serveCommand(options) {
3313
+ console.log(pc6.bold(`DevSurface Hub v${DEV_SURFACE_VERSION}`));
3314
+ console.log("Starting hub server...\n");
3315
+ const server = await startHubServer({
1849
3316
  port: options.port,
1850
3317
  openBrowser: options.openBrowser
1851
3318
  });
3319
+ const summaries = await server.hub.listSummaries();
3320
+ if (summaries.length > 0) {
3321
+ console.log(`Registered workspaces: ${summaries.length}`);
3322
+ for (const ws of summaries) {
3323
+ console.log(` ${pc6.cyan(ws.name)} -> ${ws.path}`);
3324
+ }
3325
+ } else {
3326
+ console.log(
3327
+ "No workspaces registered yet. Use `devsurface workspace add` or `npx devsurface` inside a project."
3328
+ );
3329
+ }
1852
3330
  console.log(`
1853
- Dashboard running at -> ${pc5.cyan(server.url)}`);
3331
+ Hub running at -> ${pc6.cyan(server.url)}`);
3332
+ }
3333
+
3334
+ // src/cli/commands/workspace.ts
3335
+ import path16 from "path";
3336
+ import pc7 from "picocolors";
3337
+ async function workspaceAddCommand(dirPath) {
3338
+ const registry = new WorkspaceRegistry();
3339
+ const target = path16.resolve(dirPath ?? process.cwd());
3340
+ const entry = await registry.add(target);
3341
+ console.log(`Added workspace ${pc7.cyan(entry.name)} (${entry.id}) -> ${entry.path}`);
3342
+ }
3343
+ async function workspaceListCommand() {
3344
+ const registry = new WorkspaceRegistry();
3345
+ const entries = await registry.list();
3346
+ if (entries.length === 0) {
3347
+ console.log(
3348
+ "No workspaces registered. Run `devsurface workspace add` or `npx devsurface` inside a project."
3349
+ );
3350
+ return;
3351
+ }
3352
+ console.log(`${entries.length} workspace${entries.length === 1 ? "" : "s"}:
3353
+ `);
3354
+ for (const entry of entries) {
3355
+ console.log(` ${pc7.cyan(entry.name)} (${entry.id})`);
3356
+ console.log(` ${entry.path}`);
3357
+ }
3358
+ }
3359
+ async function workspaceRemoveCommand(id) {
3360
+ const registry = new WorkspaceRegistry();
3361
+ const removed = await registry.remove(id);
3362
+ if (removed) {
3363
+ console.log(`Removed workspace ${pc7.cyan(id)}.`);
3364
+ } else {
3365
+ console.error(`Workspace "${id}" not found.`);
3366
+ process.exitCode = 1;
3367
+ }
1854
3368
  }
1855
3369
 
1856
3370
  // src/cli/index.ts
@@ -1869,7 +3383,7 @@ function handle(command) {
1869
3383
  process.exitCode = 1;
1870
3384
  });
1871
3385
  }
1872
- program.name("devsurface").description("Turn any Node.js repository into a local developer control panel.").version("0.2.0").option("-p, --port <port>", "dashboard port", toPort, 4567).option("--no-open", "do not open the browser automatically").action((options) => {
3386
+ program.name("devsurface").description("Turn any Node.js repository into a local developer control panel.").version(DEV_SURFACE_VERSION).option("-p, --port <port>", "dashboard port", toPort, 4567).option("--no-open", "do not open the browser automatically").action((options) => {
1873
3387
  handle(
1874
3388
  startCommand({
1875
3389
  cwd: process.cwd(),
@@ -1878,6 +3392,24 @@ program.name("devsurface").description("Turn any Node.js repository into a local
1878
3392
  })
1879
3393
  );
1880
3394
  });
3395
+ program.command("serve").description("Start the DevSurface hub server (multi-workspace mode).").option("-p, --port <port>", "hub port", toPort, 4567).option("--no-open", "do not open the browser automatically").action((options) => {
3396
+ handle(
3397
+ serveCommand({
3398
+ port: options.port,
3399
+ openBrowser: options.open
3400
+ })
3401
+ );
3402
+ });
3403
+ var workspace = program.command("workspace").description("Manage registered workspaces.");
3404
+ workspace.command("add [path]").description("Register a project directory with the hub.").action((dirPath) => {
3405
+ handle(workspaceAddCommand(dirPath));
3406
+ });
3407
+ workspace.command("list").description("List all registered workspaces.").action(() => {
3408
+ handle(workspaceListCommand());
3409
+ });
3410
+ workspace.command("remove <id>").description("Remove a workspace from the hub registry.").action((id) => {
3411
+ handle(workspaceRemoveCommand(id));
3412
+ });
1881
3413
  program.command("scan").description("Print detected project info.").action(() => {
1882
3414
  handle(scanCommand(process.cwd()));
1883
3415
  });