devsurface 0.3.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
@@ -1485,14 +1485,6 @@ async function scanCommand(cwd = process.cwd()) {
1485
1485
  // src/cli/commands/start.ts
1486
1486
  import pc5 from "picocolors";
1487
1487
 
1488
- // src/server/index.ts
1489
- import { promises as fs17 } from "fs";
1490
- import path13 from "path";
1491
- import { fileURLToPath as fileURLToPath2 } from "url";
1492
- import { createAdaptorServer } from "@hono/node-server";
1493
- import { serveStatic } from "@hono/node-server/serve-static";
1494
- import { Hono } from "hono";
1495
-
1496
1488
  // node_modules/open/index.js
1497
1489
  import process7 from "process";
1498
1490
  import { Buffer as Buffer2 } from "buffer";
@@ -2007,6 +1999,14 @@ defineLazyProperty(apps, "browser", () => "browser");
2007
1999
  defineLazyProperty(apps, "browserPrivate", () => "browserPrivate");
2008
2000
  var open_default = open;
2009
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
2010
  // src/core/process/manager.ts
2011
2011
  import { EventEmitter } from "events";
2012
2012
  import spawn3 from "cross-spawn";
@@ -2149,12 +2149,244 @@ var ProcessManager = class extends EventEmitter {
2149
2149
  }
2150
2150
  };
2151
2151
 
2152
- // src/server/routes/api.ts
2153
- import { constants as constants2, existsSync } from "fs";
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
2154
2159
  import { promises as fs16 } from "fs";
2155
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
+
2381
+ // src/server/routes/api.ts
2382
+ import { constants as constants2, existsSync } from "fs";
2383
+ import { promises as fs18 } from "fs";
2384
+ import path14 from "path";
2156
2385
  import spawn4 from "cross-spawn";
2157
2386
 
2387
+ // src/version.ts
2388
+ var DEV_SURFACE_VERSION = "0.4.0";
2389
+
2158
2390
  // src/server/localAccess.ts
2159
2391
  var LOCAL_HOSTNAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
2160
2392
  function hostnameFromHostHeader(host) {
@@ -2194,6 +2426,130 @@ function isSameOrigin(requestUrl, origin) {
2194
2426
  }
2195
2427
  }
2196
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
+
2197
2553
  // src/server/mutationToken.ts
2198
2554
  import { randomBytes, timingSafeEqual } from "crypto";
2199
2555
  function createMutationToken() {
@@ -2215,9 +2571,9 @@ function isAllowedTerminalCommand(command) {
2215
2571
  }
2216
2572
 
2217
2573
  // src/server/routes/api.ts
2218
- function isWithinRoot7(root, target) {
2219
- const relative = path12.relative(path12.resolve(root), path12.resolve(target));
2220
- return relative === "" || !relative.startsWith("..") && !path12.isAbsolute(relative);
2574
+ function isWithinRoot8(root, target) {
2575
+ const relative = path14.relative(path14.resolve(root), path14.resolve(target));
2576
+ return relative === "" || !relative.startsWith("..") && !path14.isAbsolute(relative);
2221
2577
  }
2222
2578
  function isAllowedMutationOrigin(requestUrl, origin) {
2223
2579
  if (origin === null) {
@@ -2225,6 +2581,23 @@ function isAllowedMutationOrigin(requestUrl, origin) {
2225
2581
  }
2226
2582
  return isAllowedLocalOrigin(origin) && isSameOrigin(requestUrl, origin);
2227
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
+ }
2228
2601
  function isCrossSiteFetch(secFetchSite) {
2229
2602
  return secFetchSite === "cross-site";
2230
2603
  }
@@ -2232,35 +2605,35 @@ function hasMutationIntent(intent) {
2232
2605
  return intent === "dashboard";
2233
2606
  }
2234
2607
  async function realPathWithinRoot(root, target) {
2235
- if (!isWithinRoot7(root, target)) {
2608
+ if (!isWithinRoot8(root, target)) {
2236
2609
  return false;
2237
2610
  }
2238
2611
  try {
2239
- const [realRoot, realTarget] = await Promise.all([fs16.realpath(root), fs16.realpath(target)]);
2240
- return isWithinRoot7(realRoot, realTarget);
2612
+ const [realRoot, realTarget] = await Promise.all([fs18.realpath(root), fs18.realpath(target)]);
2613
+ return isWithinRoot8(realRoot, realTarget);
2241
2614
  } catch {
2242
2615
  return false;
2243
2616
  }
2244
2617
  }
2245
2618
  async function writableDestinationWithinRoot(root, destination) {
2246
- if (!isWithinRoot7(root, destination)) {
2619
+ if (!isWithinRoot8(root, destination)) {
2247
2620
  return false;
2248
2621
  }
2249
2622
  try {
2250
2623
  const [realRoot, realParent] = await Promise.all([
2251
- fs16.realpath(root),
2252
- fs16.realpath(path12.dirname(destination))
2624
+ fs18.realpath(root),
2625
+ fs18.realpath(path14.dirname(destination))
2253
2626
  ]);
2254
- return isWithinRoot7(realRoot, realParent);
2627
+ return isWithinRoot8(realRoot, realParent);
2255
2628
  } catch {
2256
2629
  return false;
2257
2630
  }
2258
2631
  }
2259
2632
  async function copyFileExclusive(source, destination) {
2260
- const content = await fs16.readFile(source);
2633
+ const content = await fs18.readFile(source);
2261
2634
  let handle2 = null;
2262
2635
  try {
2263
- handle2 = await fs16.open(
2636
+ handle2 = await fs18.open(
2264
2637
  destination,
2265
2638
  constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY,
2266
2639
  384
@@ -2287,15 +2660,15 @@ function resolveCommandPromptExecutable() {
2287
2660
  return process.env.ComSpec ?? "cmd.exe";
2288
2661
  }
2289
2662
  function findExecutable(command) {
2290
- if (path12.isAbsolute(command)) {
2663
+ if (path14.isAbsolute(command)) {
2291
2664
  return existsSync(command) ? command : null;
2292
2665
  }
2293
2666
  const pathValue = process.env.PATH ?? "";
2294
- for (const directory of pathValue.split(path12.delimiter)) {
2667
+ for (const directory of pathValue.split(path14.delimiter)) {
2295
2668
  if (directory.length === 0) {
2296
2669
  continue;
2297
2670
  }
2298
- const candidate = path12.join(directory, command);
2671
+ const candidate = path14.join(directory, command);
2299
2672
  if (existsSync(candidate)) {
2300
2673
  return candidate;
2301
2674
  }
@@ -2361,93 +2734,74 @@ function openTerminalAt(root) {
2361
2734
  }
2362
2735
  return launchDetached(terminal.command, terminal.args, root);
2363
2736
  }
2364
- function registerApiRoutes(app, options) {
2365
- const dockerController = options.dockerController ?? new DockerComposeController(options.projectRoot);
2366
- app.get("/api/session", (context) => {
2367
- return context.json({ token: options.mutationToken });
2368
- });
2369
- app.use("/api/*", async (context, next) => {
2370
- const host = context.req.header("host") ?? new URL(context.req.url).host;
2371
- if (!isAllowedLocalHostHeader(host)) {
2372
- return context.json({ error: "Non-local host rejected." }, 403);
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);
2373
2741
  }
2374
- if (context.req.method !== "GET" && context.req.method !== "HEAD") {
2375
- const origin = context.req.header("origin") ?? null;
2376
- const secFetchSite = context.req.header("sec-fetch-site") ?? null;
2377
- const intent = context.req.header("x-devsurface-intent") ?? null;
2378
- const token = context.req.header("x-devsurface-token") ?? null;
2379
- if (!hasMutationIntent(intent) || !hasValidMutationToken(token, options.mutationToken) || isCrossSiteFetch(secFetchSite) || !isAllowedMutationOrigin(context.req.url, origin)) {
2380
- return context.json({ error: "Cross-origin mutation rejected." }, 403);
2381
- }
2742
+ if (error.code === "docker-not-installed" || error.code === "docker-not-running") {
2743
+ return context.json({ error: error.message, code: error.code }, 503);
2382
2744
  }
2383
- await next();
2384
- });
2385
- app.get("/api/project", async (context) => {
2386
- return context.json(await scanProject(options.projectRoot));
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));
2387
2754
  });
2388
- app.get("/api/health", async (context) => {
2389
- return context.json(await runDoctor(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));
2390
2759
  });
2391
- app.get("/api/processes", (context) => {
2392
- return context.json(options.processManager.list());
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());
2393
2764
  });
2394
- app.get("/api/logs", (context) => {
2395
- return context.json(options.processManager.listLogs());
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());
2396
2769
  });
2397
- app.get("/api/docker/:service/logs", async (context) => {
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);
2398
2773
  const service = decodeURIComponent(context.req.param("service"));
2399
2774
  try {
2400
- return context.json(await dockerController.logs(service));
2775
+ return context.json(await ws.dockerController.logs(service));
2401
2776
  } catch (error) {
2402
- if (error instanceof DockerOperationError) {
2403
- if (error.code === "compose-not-found" || error.code === "service-not-found") {
2404
- return context.json({ error: error.message, code: error.code }, 404);
2405
- }
2406
- if (error.code === "docker-not-installed" || error.code === "docker-not-running") {
2407
- return context.json({ error: error.message, code: error.code }, 503);
2408
- }
2409
- return context.json({ error: error.message, code: error.code }, 502);
2410
- }
2411
- throw error;
2777
+ return handleDockerError(error, context);
2412
2778
  }
2413
2779
  });
2414
- app.post("/api/docker/:service/start", 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);
2415
2783
  const service = decodeURIComponent(context.req.param("service"));
2416
2784
  try {
2417
- return context.json(await dockerController.start(service));
2785
+ return context.json(await ws.dockerController.start(service));
2418
2786
  } catch (error) {
2419
- if (error instanceof DockerOperationError) {
2420
- if (error.code === "compose-not-found" || error.code === "service-not-found") {
2421
- return context.json({ error: error.message, code: error.code }, 404);
2422
- }
2423
- if (error.code === "docker-not-installed" || error.code === "docker-not-running") {
2424
- return context.json({ error: error.message, code: error.code }, 503);
2425
- }
2426
- return context.json({ error: error.message, code: error.code }, 502);
2427
- }
2428
- throw error;
2787
+ return handleDockerError(error, context);
2429
2788
  }
2430
2789
  });
2431
- app.post("/api/docker/:service/stop", async (context) => {
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);
2432
2793
  const service = decodeURIComponent(context.req.param("service"));
2433
2794
  try {
2434
- return context.json(await dockerController.stop(service));
2795
+ return context.json(await ws.dockerController.stop(service));
2435
2796
  } catch (error) {
2436
- if (error instanceof DockerOperationError) {
2437
- if (error.code === "compose-not-found" || error.code === "service-not-found") {
2438
- return context.json({ error: error.message, code: error.code }, 404);
2439
- }
2440
- if (error.code === "docker-not-installed" || error.code === "docker-not-running") {
2441
- return context.json({ error: error.message, code: error.code }, 503);
2442
- }
2443
- return context.json({ error: error.message, code: error.code }, 502);
2444
- }
2445
- throw error;
2797
+ return handleDockerError(error, context);
2446
2798
  }
2447
2799
  });
2448
- app.post("/api/run/:script", async (context) => {
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);
2449
2803
  const script = decodeURIComponent(context.req.param("script"));
2450
- const scan = await scanProject(options.projectRoot);
2804
+ const scan = await scanProject(ws.root);
2451
2805
  const packageScript = scan.scripts[script];
2452
2806
  if (packageScript === void 0) {
2453
2807
  return context.json({ error: `Script "${script}" was not found.` }, 404);
@@ -2456,36 +2810,35 @@ function registerApiRoutes(app, options) {
2456
2810
  return context.json({ error: "Refusing to run dangerous script." }, 403);
2457
2811
  }
2458
2812
  const command = await resolvePackageRunCommand({
2459
- cwd: options.projectRoot,
2813
+ cwd: ws.root,
2460
2814
  packageManager: scan.packageManager,
2461
2815
  script
2462
2816
  });
2463
2817
  if (command === null) {
2464
2818
  return context.json({ error: "Package manager executable was not found." }, 503);
2465
2819
  }
2466
- const processInfo = options.processManager.start({
2467
- cwd: options.projectRoot,
2820
+ const processInfo = ws.processManager.start({
2821
+ cwd: ws.root,
2468
2822
  script,
2469
2823
  command: command.command,
2470
2824
  args: command.args,
2471
2825
  displayCommand: command.displayCommand
2472
2826
  });
2473
- return context.json({
2474
- ...processInfo,
2475
- packageScript
2476
- });
2827
+ return context.json({ ...processInfo, packageScript });
2477
2828
  });
2478
- app.post("/api/install", async (context) => {
2479
- 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);
2480
2833
  const command = await resolvePackageInstallCommand({
2481
- cwd: options.projectRoot,
2834
+ cwd: ws.root,
2482
2835
  packageManager: scan.packageManager
2483
2836
  });
2484
2837
  if (command === null) {
2485
2838
  return context.json({ error: "Package manager executable was not found." }, 503);
2486
2839
  }
2487
- const processInfo = options.processManager.start({
2488
- cwd: options.projectRoot,
2840
+ const processInfo = ws.processManager.start({
2841
+ cwd: ws.root,
2489
2842
  script: "install",
2490
2843
  command: command.command,
2491
2844
  args: command.args,
@@ -2493,9 +2846,11 @@ function registerApiRoutes(app, options) {
2493
2846
  });
2494
2847
  return context.json(processInfo);
2495
2848
  });
2496
- 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);
2497
2852
  const name = decodeURIComponent(context.req.param("name"));
2498
- const scan = await scanProject(options.projectRoot);
2853
+ const scan = await scanProject(ws.root);
2499
2854
  const configuredCommand = scan.config?.config.commands?.[name] ?? null;
2500
2855
  if (configuredCommand === null) {
2501
2856
  return context.json({ error: `Configured command "${name}" was not found.` }, 404);
@@ -2503,7 +2858,7 @@ function registerApiRoutes(app, options) {
2503
2858
  if (isDangerousCommand(configuredCommand)) {
2504
2859
  return context.json({ error: "Refusing to run dangerous command." }, 403);
2505
2860
  }
2506
- const resolvedCommand = await resolveConfiguredCommand(options.projectRoot, configuredCommand);
2861
+ const resolvedCommand = await resolveConfiguredCommand(ws.root, configuredCommand);
2507
2862
  if (resolvedCommand === null) {
2508
2863
  return context.json(
2509
2864
  {
@@ -2512,43 +2867,48 @@ function registerApiRoutes(app, options) {
2512
2867
  400
2513
2868
  );
2514
2869
  }
2515
- const processInfo = options.processManager.start({
2516
- cwd: options.projectRoot,
2870
+ const processInfo = ws.processManager.start({
2871
+ cwd: ws.root,
2517
2872
  script: name,
2518
2873
  command: resolvedCommand.command,
2519
2874
  args: resolvedCommand.args,
2520
2875
  displayCommand: resolvedCommand.displayCommand
2521
2876
  });
2522
- return context.json({
2523
- ...processInfo,
2524
- configuredCommand
2525
- });
2877
+ return context.json({ ...processInfo, configuredCommand });
2526
2878
  });
2527
- app.post("/api/open/folder", async (context) => {
2528
- await open_default(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);
2529
2883
  return context.json({ opened: true, target: "folder" });
2530
2884
  });
2531
- app.post("/api/open/package", async (context) => {
2532
- const packagePath = path12.join(options.projectRoot, "package.json");
2533
- 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)) {
2534
2890
  return context.json({ error: "package.json was not found inside the project root." }, 404);
2535
2891
  }
2536
2892
  await open_default(packagePath);
2537
2893
  return context.json({ opened: true, target: "package" });
2538
2894
  });
2539
- app.post("/api/open/terminal", (context) => {
2540
- 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);
2541
2899
  return context.json({ opened, target: "terminal" }, opened ? 200 : 501);
2542
2900
  });
2543
- app.post("/api/env/copy", async (context) => {
2544
- 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);
2545
2905
  const examplePath = scan.env?.examplePath ?? null;
2546
2906
  const localPath = scan.env?.localPath ?? null;
2547
2907
  if (examplePath === null) {
2548
2908
  return context.json({ error: ".env.example was not found." }, 404);
2549
2909
  }
2550
- const destination = localPath ?? path12.join(options.projectRoot, scan.config?.config.env?.local ?? ".env");
2551
- if (!await realPathWithinRoot(options.projectRoot, examplePath) || !await writableDestinationWithinRoot(options.projectRoot, destination)) {
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)) {
2552
2912
  return context.json({ error: "Refusing to copy env files outside the project root." }, 400);
2553
2913
  }
2554
2914
  const copyResult = await copyFileExclusive(examplePath, destination);
@@ -2557,12 +2917,79 @@ function registerApiRoutes(app, options) {
2557
2917
  }
2558
2918
  return context.json({ copied: true });
2559
2919
  });
2560
- 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);
2561
2923
  const pid = decodeURIComponent(context.req.param("pid"));
2562
- const stopped = options.processManager.stop(pid);
2924
+ const stopped = ws.processManager.stop(pid);
2563
2925
  return context.json({ stopped });
2564
2926
  });
2565
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
+ }
2566
2993
 
2567
2994
  // src/server/routes/ws.ts
2568
2995
  import { WebSocket, WebSocketServer } from "ws";
@@ -2570,6 +2997,9 @@ function isAllowedWebSocketRequest(request) {
2570
2997
  const origin = request.headers.origin;
2571
2998
  const host = request.headers.host;
2572
2999
  const secFetchSite = request.headers["sec-fetch-site"];
3000
+ if (!isAllowedClientConnection(remoteAddressFromRequest(request), getListenHost())) {
3001
+ return false;
3002
+ }
2573
3003
  if (typeof host !== "string" || !isAllowedLocalHostHeader(host)) {
2574
3004
  return false;
2575
3005
  }
@@ -2588,32 +3018,63 @@ function isAllowedWebSocketRequest(request) {
2588
3018
  return false;
2589
3019
  }
2590
3020
  }
2591
- 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) {
2592
3031
  const wss = new WebSocketServer({
2593
3032
  server,
2594
3033
  path: "/ws",
2595
3034
  verifyClient: (info) => isAllowedWebSocketRequest(info.req)
2596
3035
  });
2597
- 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) {
2598
3051
  const serialized = JSON.stringify(payload);
2599
3052
  for (const client of wss.clients) {
2600
- if (client.readyState === WebSocket.OPEN) {
3053
+ if (client.readyState === WebSocket.OPEN && clientWorkspaces.get(client) === workspaceId2) {
2601
3054
  client.send(serialized);
2602
3055
  }
2603
3056
  }
2604
3057
  }
2605
- processManager.on("log", (event) => {
2606
- broadcast({ type: "log", event });
2607
- });
2608
- processManager.on("process", (processInfo) => {
2609
- broadcast({ type: "process", process: processInfo });
2610
- });
2611
- wss.on("connection", (socket) => {
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);
2612
3072
  socket.send(
2613
3073
  JSON.stringify({
2614
3074
  type: "hello",
2615
- processes: processManager.list(),
2616
- logs: processManager.listLogs()
3075
+ workspace: workspaceId2,
3076
+ processes: runtime.processManager.list(),
3077
+ logs: runtime.processManager.listLogs()
2617
3078
  })
2618
3079
  );
2619
3080
  });
@@ -2621,51 +3082,44 @@ function setupWebSocket(server, processManager) {
2621
3082
  }
2622
3083
 
2623
3084
  // src/server/index.ts
2624
- var HOST = "127.0.0.1";
2625
- var DEFAULT_PORT = 4567;
2626
- function assertLocalHost(host) {
2627
- if (host !== HOST) {
2628
- throw new Error("DevSurface must bind only to 127.0.0.1.");
2629
- }
2630
- }
2631
3085
  async function fileExists(filePath) {
2632
3086
  try {
2633
- await fs17.access(filePath);
3087
+ await fs19.access(filePath);
2634
3088
  return true;
2635
3089
  } catch {
2636
3090
  return false;
2637
3091
  }
2638
3092
  }
2639
3093
  async function findWebDistDir() {
2640
- const moduleDir = path13.dirname(fileURLToPath2(import.meta.url));
3094
+ const moduleDir = path15.dirname(fileURLToPath2(import.meta.url));
2641
3095
  const candidates = [
2642
- path13.join(moduleDir, "..", "web", "dist"),
2643
- path13.join(moduleDir, "..", "..", "src", "web", "dist"),
2644
- path13.join(moduleDir, "web", "dist")
3096
+ path15.join(moduleDir, "..", "web", "dist"),
3097
+ path15.join(moduleDir, "..", "..", "src", "web", "dist"),
3098
+ path15.join(moduleDir, "web", "dist")
2645
3099
  ];
2646
3100
  for (const candidate of candidates) {
2647
- if (await fileExists(path13.join(candidate, "index.html"))) {
3101
+ if (await fileExists(path15.join(candidate, "index.html"))) {
2648
3102
  return candidate;
2649
3103
  }
2650
3104
  }
2651
3105
  return null;
2652
3106
  }
2653
- function toListenError(error, port) {
3107
+ function toListenError(error, host, port) {
2654
3108
  const code = error instanceof Error ? error.code : void 0;
2655
3109
  if (code === "EADDRINUSE") {
2656
3110
  return new Error(
2657
- `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}.`,
2658
3112
  { cause: error }
2659
3113
  );
2660
3114
  }
2661
3115
  if (code === "EACCES") {
2662
- 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}.`, {
2663
3117
  cause: error
2664
3118
  });
2665
3119
  }
2666
3120
  return error instanceof Error ? error : new Error(String(error));
2667
3121
  }
2668
- async function listenOnLocalHost(server, wss, port) {
3122
+ async function listenOnHost(server, wss, host, port) {
2669
3123
  await new Promise((resolve, reject) => {
2670
3124
  let settled = false;
2671
3125
  const cleanup = () => {
@@ -2674,17 +3128,13 @@ async function listenOnLocalHost(server, wss, port) {
2674
3128
  wss.off("error", onError);
2675
3129
  };
2676
3130
  const onError = (error) => {
2677
- if (settled) {
2678
- return;
2679
- }
3131
+ if (settled) return;
2680
3132
  settled = true;
2681
3133
  cleanup();
2682
- reject(toListenError(error, port));
3134
+ reject(toListenError(error, host, port));
2683
3135
  };
2684
3136
  const onListening = () => {
2685
- if (settled) {
2686
- return;
2687
- }
3137
+ if (settled) return;
2688
3138
  settled = true;
2689
3139
  cleanup();
2690
3140
  resolve();
@@ -2692,7 +3142,7 @@ async function listenOnLocalHost(server, wss, port) {
2692
3142
  wss.once("error", onError);
2693
3143
  server.once("error", onError);
2694
3144
  server.once("listening", onListening);
2695
- server.listen(port, HOST);
3145
+ server.listen(port, host);
2696
3146
  });
2697
3147
  }
2698
3148
  async function closeWebSocketServer(wss) {
@@ -2714,18 +3164,13 @@ async function closeHttpServer(server) {
2714
3164
  });
2715
3165
  });
2716
3166
  }
2717
- async function createApp(options) {
2718
- const app = new Hono();
2719
- registerApiRoutes(app, {
2720
- ...options,
2721
- mutationToken: options.mutationToken ?? createMutationToken()
2722
- });
3167
+ async function mountWebUi(app) {
2723
3168
  const webDistDir = await findWebDistDir();
2724
3169
  if (webDistDir !== null) {
2725
3170
  app.use("/assets/*", serveStatic({ root: webDistDir }));
2726
3171
  app.get("/favicon.svg", serveStatic({ root: webDistDir }));
2727
3172
  app.get("*", async (context) => {
2728
- const html = await fs17.readFile(path13.join(webDistDir, "index.html"), "utf8");
3173
+ const html = await fs19.readFile(path15.join(webDistDir, "index.html"), "utf8");
2729
3174
  return context.html(html);
2730
3175
  });
2731
3176
  } else {
@@ -2737,46 +3182,96 @@ async function createApp(options) {
2737
3182
  )
2738
3183
  );
2739
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);
2740
3193
  return app;
2741
3194
  }
2742
- async function startDevSurfaceServer(options) {
2743
- assertLocalHost(HOST);
3195
+ async function startHubServer(options) {
3196
+ const host = initializeListenHost();
2744
3197
  const port = options.port ?? DEFAULT_PORT;
2745
- const processManager = new ProcessManager();
2746
- processManager.attachCleanupHandlers();
2747
- const app = await createApp({
2748
- projectRoot: options.projectRoot,
2749
- processManager
2750
- });
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 });
2751
3205
  const server = createAdaptorServer({
2752
3206
  fetch: app.fetch,
2753
- hostname: HOST
3207
+ hostname: host
2754
3208
  });
2755
- const wss = setupWebSocket(server, processManager);
2756
- await listenOnLocalHost(server, wss, port);
2757
- processManager.attachCleanupHandlers();
2758
- 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}`;
2759
3212
  if (options.openBrowser !== false) {
2760
- await open_default(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);
2761
3216
  }
3217
+ const dummyProcessManager = new ProcessManager();
2762
3218
  return {
2763
3219
  url,
2764
3220
  port,
2765
- processManager,
3221
+ host,
3222
+ hub,
3223
+ processManager: dummyProcessManager,
2766
3224
  close: async () => {
2767
- processManager.killAll();
3225
+ hub.killAll();
2768
3226
  await closeWebSocketServer(wss);
2769
3227
  await closeHttpServer(server);
2770
3228
  }
2771
3229
  };
2772
3230
  }
2773
3231
 
2774
- // src/version.ts
2775
- var DEV_SURFACE_VERSION = "0.3.0";
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
+ }
2776
3270
 
2777
3271
  // src/cli/commands/start.ts
2778
3272
  async function startCommand(options) {
2779
3273
  const cwd = options.cwd ?? process.cwd();
3274
+ const port = options.port ?? 4567;
2780
3275
  console.log(pc5.bold(`DevSurface v${DEV_SURFACE_VERSION}`));
2781
3276
  console.log("Scanning project...\n");
2782
3277
  const scan = await scanProject(cwd);
@@ -2789,13 +3284,87 @@ async function startCommand(options) {
2789
3284
  console.log(` ${marker} ${item.title}`);
2790
3285
  }
2791
3286
  }
2792
- const server = await startDevSurfaceServer({
2793
- 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({
2794
3316
  port: options.port,
2795
3317
  openBrowser: options.openBrowser
2796
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
+ }
2797
3330
  console.log(`
2798
- 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
+ }
2799
3368
  }
2800
3369
 
2801
3370
  // src/cli/index.ts
@@ -2823,6 +3392,24 @@ program.name("devsurface").description("Turn any Node.js repository into a local
2823
3392
  })
2824
3393
  );
2825
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
+ });
2826
3413
  program.command("scan").description("Print detected project info.").action(() => {
2827
3414
  handle(scanCommand(process.cwd()));
2828
3415
  });