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/CHANGELOG.md +23 -0
- package/README.md +52 -10
- package/dist/cli/index.js +776 -189
- package/dist/cli/index.js.map +1 -1
- package/package.json +2 -2
- package/src/web/dist/assets/index-BO8glxtu.js +10 -0
- package/src/web/dist/assets/index-Bj8suDpq.css +1 -0
- package/src/web/dist/index.html +2 -2
- package/src/web/dist/assets/index-7njY8n4D.js +0 -10
- package/src/web/dist/assets/index-DvunFIw4.css +0 -1
package/dist/cli/index.js
CHANGED
|
@@ -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/
|
|
2153
|
-
import {
|
|
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
|
|
2219
|
-
const relative =
|
|
2220
|
-
return relative === "" || !relative.startsWith("..") && !
|
|
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 (!
|
|
2608
|
+
if (!isWithinRoot8(root, target)) {
|
|
2236
2609
|
return false;
|
|
2237
2610
|
}
|
|
2238
2611
|
try {
|
|
2239
|
-
const [realRoot, realTarget] = await Promise.all([
|
|
2240
|
-
return
|
|
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 (!
|
|
2619
|
+
if (!isWithinRoot8(root, destination)) {
|
|
2247
2620
|
return false;
|
|
2248
2621
|
}
|
|
2249
2622
|
try {
|
|
2250
2623
|
const [realRoot, realParent] = await Promise.all([
|
|
2251
|
-
|
|
2252
|
-
|
|
2624
|
+
fs18.realpath(root),
|
|
2625
|
+
fs18.realpath(path14.dirname(destination))
|
|
2253
2626
|
]);
|
|
2254
|
-
return
|
|
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
|
|
2633
|
+
const content = await fs18.readFile(source);
|
|
2261
2634
|
let handle2 = null;
|
|
2262
2635
|
try {
|
|
2263
|
-
handle2 = await
|
|
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 (
|
|
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(
|
|
2667
|
+
for (const directory of pathValue.split(path14.delimiter)) {
|
|
2295
2668
|
if (directory.length === 0) {
|
|
2296
2669
|
continue;
|
|
2297
2670
|
}
|
|
2298
|
-
const candidate =
|
|
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
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
});
|
|
2369
|
-
app.use("/api/*", async (context, next) => {
|
|
2370
|
-
const host = context.req.header("host") ?? new URL(context.req.url).host;
|
|
2371
|
-
if (!isAllowedLocalHostHeader(host)) {
|
|
2372
|
-
return context.json({ error: "Non-local host rejected." }, 403);
|
|
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 (
|
|
2375
|
-
|
|
2376
|
-
const secFetchSite = context.req.header("sec-fetch-site") ?? null;
|
|
2377
|
-
const intent = context.req.header("x-devsurface-intent") ?? null;
|
|
2378
|
-
const token = context.req.header("x-devsurface-token") ?? null;
|
|
2379
|
-
if (!hasMutationIntent(intent) || !hasValidMutationToken(token, options.mutationToken) || isCrossSiteFetch(secFetchSite) || !isAllowedMutationOrigin(context.req.url, origin)) {
|
|
2380
|
-
return context.json({ error: "Cross-origin mutation rejected." }, 403);
|
|
2381
|
-
}
|
|
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
|
-
|
|
2384
|
-
}
|
|
2385
|
-
|
|
2386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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 =
|
|
2467
|
-
cwd:
|
|
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
|
|
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:
|
|
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 =
|
|
2488
|
-
cwd:
|
|
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(
|
|
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(
|
|
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 =
|
|
2516
|
-
cwd:
|
|
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
|
|
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
|
|
2533
|
-
if (!
|
|
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
|
|
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
|
|
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 ??
|
|
2551
|
-
if (!await realPathWithinRoot(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
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
|
-
|
|
2616
|
-
|
|
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
|
|
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 =
|
|
3094
|
+
const moduleDir = path15.dirname(fileURLToPath2(import.meta.url));
|
|
2641
3095
|
const candidates = [
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
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(
|
|
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 ${
|
|
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 ${
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
2743
|
-
|
|
3195
|
+
async function startHubServer(options) {
|
|
3196
|
+
const host = initializeListenHost();
|
|
2744
3197
|
const port = options.port ?? DEFAULT_PORT;
|
|
2745
|
-
const
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
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:
|
|
3207
|
+
hostname: host
|
|
2754
3208
|
});
|
|
2755
|
-
const wss =
|
|
2756
|
-
await
|
|
2757
|
-
|
|
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
|
|
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
|
-
|
|
3221
|
+
host,
|
|
3222
|
+
hub,
|
|
3223
|
+
processManager: dummyProcessManager,
|
|
2766
3224
|
close: async () => {
|
|
2767
|
-
|
|
3225
|
+
hub.killAll();
|
|
2768
3226
|
await closeWebSocketServer(wss);
|
|
2769
3227
|
await closeHttpServer(server);
|
|
2770
3228
|
}
|
|
2771
3229
|
};
|
|
2772
3230
|
}
|
|
2773
3231
|
|
|
2774
|
-
// src/
|
|
2775
|
-
|
|
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
|
-
|
|
2793
|
-
|
|
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
|
-
|
|
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
|
});
|