devsurface 0.7.1 → 0.8.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
@@ -1087,6 +1087,141 @@ async function detectPorts(ports) {
1087
1087
  );
1088
1088
  }
1089
1089
 
1090
+ // src/core/scanner/portOwner.ts
1091
+ import spawn2 from "cross-spawn";
1092
+ var LOOKUP_TIMEOUT_MS = 3e3;
1093
+ function captureCommand(command, args) {
1094
+ return new Promise((resolve) => {
1095
+ let settled = false;
1096
+ const finish = (value) => {
1097
+ if (!settled) {
1098
+ settled = true;
1099
+ resolve(value);
1100
+ }
1101
+ };
1102
+ const child = spawn2(command, args, {
1103
+ stdio: ["ignore", "pipe", "ignore"],
1104
+ windowsHide: true
1105
+ });
1106
+ const chunks = [];
1107
+ const timer = setTimeout(() => {
1108
+ child.kill();
1109
+ finish(null);
1110
+ }, LOOKUP_TIMEOUT_MS);
1111
+ child.stdout?.on("data", (chunk) => {
1112
+ chunks.push(chunk);
1113
+ });
1114
+ child.on("error", () => {
1115
+ clearTimeout(timer);
1116
+ finish(null);
1117
+ });
1118
+ child.on("close", (code) => {
1119
+ clearTimeout(timer);
1120
+ finish(code === 0 ? Buffer.concat(chunks).toString("utf8") : null);
1121
+ });
1122
+ });
1123
+ }
1124
+ function parseNetstatListeners(output) {
1125
+ const owners = /* @__PURE__ */ new Map();
1126
+ for (const line of output.split(/\r?\n/)) {
1127
+ const columns = line.trim().split(/\s+/);
1128
+ if (columns.length < 5 || columns[0].toUpperCase() !== "TCP") {
1129
+ continue;
1130
+ }
1131
+ if (columns[3].toUpperCase() !== "LISTENING") {
1132
+ continue;
1133
+ }
1134
+ const portMatch = /[.:](\d+)$/.exec(columns[1]);
1135
+ const pid = Number(columns[4]);
1136
+ if (portMatch === null || !Number.isInteger(pid) || pid <= 0) {
1137
+ continue;
1138
+ }
1139
+ const port = Number(portMatch[1]);
1140
+ if (!owners.has(port)) {
1141
+ owners.set(port, pid);
1142
+ }
1143
+ }
1144
+ return owners;
1145
+ }
1146
+ function parseTasklistName(output) {
1147
+ const line = output.split(/\r?\n/).find((candidate) => candidate.trim().startsWith('"'));
1148
+ if (line === void 0) {
1149
+ return null;
1150
+ }
1151
+ const match = /^"([^"]+)"/.exec(line.trim());
1152
+ return match === null ? null : match[1];
1153
+ }
1154
+ function parseLsofOwner(output) {
1155
+ let pid = null;
1156
+ let name = null;
1157
+ for (const line of output.split(/\r?\n/)) {
1158
+ if (line.startsWith("p") && pid === null) {
1159
+ const value = Number(line.slice(1));
1160
+ if (Number.isInteger(value) && value > 0) {
1161
+ pid = value;
1162
+ }
1163
+ } else if (line.startsWith("c") && name === null) {
1164
+ name = line.slice(1) || null;
1165
+ }
1166
+ if (pid !== null && name !== null) {
1167
+ break;
1168
+ }
1169
+ }
1170
+ return pid === null ? null : { pid, name };
1171
+ }
1172
+ async function findOwnersWindows(ports) {
1173
+ const owners = /* @__PURE__ */ new Map();
1174
+ const netstat = await captureCommand("netstat", ["-ano", "-p", "tcp"]);
1175
+ if (netstat === null) {
1176
+ return owners;
1177
+ }
1178
+ const listeners = parseNetstatListeners(netstat);
1179
+ const nameCache = /* @__PURE__ */ new Map();
1180
+ for (const port of ports) {
1181
+ const pid = listeners.get(port);
1182
+ if (pid === void 0) {
1183
+ continue;
1184
+ }
1185
+ if (!nameCache.has(pid)) {
1186
+ const tasklist = await captureCommand("tasklist", [
1187
+ "/FI",
1188
+ `PID eq ${pid}`,
1189
+ "/FO",
1190
+ "CSV",
1191
+ "/NH"
1192
+ ]);
1193
+ nameCache.set(pid, tasklist === null ? null : parseTasklistName(tasklist));
1194
+ }
1195
+ owners.set(port, { pid, name: nameCache.get(pid) ?? null });
1196
+ }
1197
+ return owners;
1198
+ }
1199
+ async function findOwnersUnix(ports) {
1200
+ const owners = /* @__PURE__ */ new Map();
1201
+ for (const port of ports) {
1202
+ const output = await captureCommand("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-FpcL"]);
1203
+ if (output === null) {
1204
+ continue;
1205
+ }
1206
+ const owner = parseLsofOwner(output);
1207
+ if (owner !== null) {
1208
+ owners.set(port, owner);
1209
+ }
1210
+ }
1211
+ return owners;
1212
+ }
1213
+ async function findPortOwners(ports) {
1214
+ const unique = Array.from(new Set(ports)).slice(0, 16);
1215
+ if (unique.length === 0) {
1216
+ return /* @__PURE__ */ new Map();
1217
+ }
1218
+ try {
1219
+ return process.platform === "win32" ? await findOwnersWindows(unique) : await findOwnersUnix(unique);
1220
+ } catch {
1221
+ return /* @__PURE__ */ new Map();
1222
+ }
1223
+ }
1224
+
1090
1225
  // src/core/scanner/presets.ts
1091
1226
  import { promises as fs9 } from "fs";
1092
1227
  import path9 from "path";
@@ -1381,6 +1516,15 @@ async function scanProject(root = process.cwd()) {
1381
1516
  findFirstFile(resolvedRoot, ["README.md", "README"]),
1382
1517
  findFirstFile(resolvedRoot, ["LICENSE", "LICENSE.md", "COPYING"])
1383
1518
  ]);
1519
+ const busyPorts = (ports ?? []).filter((probe) => probe.inUse).map((probe) => probe.port);
1520
+ if (busyPorts.length > 0) {
1521
+ const owners = await findPortOwners(busyPorts);
1522
+ for (const probe of ports ?? []) {
1523
+ if (probe.inUse) {
1524
+ probe.owner = owners.get(probe.port) ?? null;
1525
+ }
1526
+ }
1527
+ }
1384
1528
  return {
1385
1529
  root: resolvedRoot,
1386
1530
  projectName: config?.config.name ?? packageJson?.data.name ?? path10.basename(resolvedRoot),
@@ -1813,11 +1957,127 @@ async function onboardCommand(cwd = process.cwd()) {
1813
1957
  }
1814
1958
  }
1815
1959
 
1816
- // src/cli/commands/run.ts
1960
+ // src/cli/commands/passport.ts
1961
+ import { promises as fs13 } from "fs";
1962
+ import path13 from "path";
1817
1963
  import pc4 from "picocolors";
1818
1964
 
1965
+ // src/core/explain/index.ts
1966
+ var NAME_INTENTS = [
1967
+ {
1968
+ keys: ["dev", "develop", "serve", "watch", "start:dev"],
1969
+ explanation: "Starts the development server so you can preview the app in your browser while you work."
1970
+ },
1971
+ { keys: ["start"], explanation: "Starts the application." },
1972
+ {
1973
+ keys: ["build", "compile", "bundle", "dist"],
1974
+ explanation: "Builds the app into optimized files ready for production."
1975
+ },
1976
+ {
1977
+ keys: ["preview"],
1978
+ explanation: "Runs the finished production build locally so you can preview it."
1979
+ },
1980
+ {
1981
+ keys: ["test", "tests", "spec", "unit"],
1982
+ explanation: "Runs the automated tests to check the code still works."
1983
+ },
1984
+ {
1985
+ keys: ["e2e", "integration"],
1986
+ explanation: "Runs end-to-end tests that drive the app like a real user."
1987
+ },
1988
+ {
1989
+ keys: ["lint"],
1990
+ explanation: "Checks the code for style problems and common mistakes."
1991
+ },
1992
+ {
1993
+ keys: ["format", "fmt", "prettier"],
1994
+ explanation: "Automatically reformats the code to a consistent style."
1995
+ },
1996
+ {
1997
+ keys: ["typecheck", "tsc", "types"],
1998
+ explanation: "Checks the code for type errors."
1999
+ },
2000
+ {
2001
+ keys: ["migrate", "migration", "migrations"],
2002
+ explanation: "Applies pending database changes (migrations)."
2003
+ },
2004
+ { keys: ["seed"], explanation: "Fills the database with starter sample data." },
2005
+ {
2006
+ keys: ["clean"],
2007
+ explanation: "Deletes generated files and leftover build output."
2008
+ },
2009
+ {
2010
+ keys: ["deploy", "release", "publish"],
2011
+ explanation: "Publishes or deploys the project \u2014 double-check before running this one."
2012
+ },
2013
+ {
2014
+ keys: ["install", "setup", "bootstrap"],
2015
+ explanation: "Installs the tools and packages the project needs."
2016
+ },
2017
+ {
2018
+ keys: ["storybook"],
2019
+ explanation: "Starts Storybook to preview UI components on their own."
2020
+ },
2021
+ { keys: ["docs"], explanation: "Builds or serves the project documentation." }
2022
+ ];
2023
+ var TOOL_INTENTS = [
2024
+ {
2025
+ test: /nodemon|ts-node-dev/,
2026
+ explanation: "Runs the app and restarts it automatically whenever you change a file."
2027
+ },
2028
+ {
2029
+ test: /vite\s+build|webpack|rollup|esbuild|\btsup\b|\bparcel\b/,
2030
+ explanation: "Builds the app into optimized files ready for production."
2031
+ },
2032
+ {
2033
+ test: /next\s+dev|vite|remix\s+dev|astro\s+dev/,
2034
+ explanation: "Starts the development server so you can preview the app in your browser."
2035
+ },
2036
+ {
2037
+ test: /playwright|cypress/,
2038
+ explanation: "Runs end-to-end tests that drive the app like a real user."
2039
+ },
2040
+ {
2041
+ test: /vitest|\bjest\b|mocha|\bava\b|pytest|\bgo\s+test\b/,
2042
+ explanation: "Runs the automated tests to check the code works."
2043
+ },
2044
+ {
2045
+ test: /eslint|biome\s+lint|ruff|flake8/,
2046
+ explanation: "Checks the code for style problems and common mistakes."
2047
+ },
2048
+ {
2049
+ test: /prettier|biome\s+format|black\b/,
2050
+ explanation: "Reformats the code to a consistent style."
2051
+ },
2052
+ { test: /tsc\b/, explanation: "Compiles and type-checks the TypeScript code." },
2053
+ { test: /prisma/, explanation: "Manages the database schema and data with Prisma." },
2054
+ {
2055
+ test: /docker[-\s]compose|\bdocker\b/,
2056
+ explanation: "Starts or manages the project\u2019s Docker containers."
2057
+ },
2058
+ { test: /\bgo\s+run\b/, explanation: "Runs the Go program." },
2059
+ { test: /\bgo\s+build\b/, explanation: "Builds the Go program into an executable." },
2060
+ {
2061
+ test: /uvicorn|flask\s+run|manage\.py\s+runserver/,
2062
+ explanation: "Starts the development server so you can preview the app in your browser."
2063
+ }
2064
+ ];
2065
+ function explainScript(name, command = "") {
2066
+ const baseName = name.toLowerCase().split(/[:/]/)[0];
2067
+ const intent = NAME_INTENTS.find((entry) => entry.keys.includes(baseName));
2068
+ if (intent !== void 0) {
2069
+ return intent.explanation;
2070
+ }
2071
+ const lowerCommand = command.toLowerCase();
2072
+ const tool = TOOL_INTENTS.find((entry) => entry.test.test(lowerCommand));
2073
+ if (tool !== void 0) {
2074
+ return tool.explanation;
2075
+ }
2076
+ return `Runs the project\u2019s \u201C${name}\u201D command.`;
2077
+ }
2078
+
1819
2079
  // src/core/process/runner.ts
1820
- import spawn2 from "cross-spawn";
2080
+ import spawn3 from "cross-spawn";
1821
2081
 
1822
2082
  // src/core/security/dangerousCommand.ts
1823
2083
  var DANGEROUS_COMMAND = /\b(rm\s+-rf|docker\s+volume\s+rm|drop\s+database|prisma\s+migrate\s+reset|git\s+clean\s+-fdx?)\b/i;
@@ -1981,7 +2241,7 @@ async function runPackageScriptToTerminal(options) {
1981
2241
  return 1;
1982
2242
  }
1983
2243
  return await new Promise((resolve) => {
1984
- const child = spawn2(runCommand2.command, runCommand2.args, {
2244
+ const child = spawn3(runCommand2.command, runCommand2.args, {
1985
2245
  cwd: options.cwd,
1986
2246
  stdio: "inherit",
1987
2247
  windowsHide: true
@@ -2000,7 +2260,7 @@ async function runConfiguredCommandToTerminal(options) {
2000
2260
  return 1;
2001
2261
  }
2002
2262
  return await new Promise((resolve) => {
2003
- const child = spawn2(resolvedCommand.command, resolvedCommand.args, {
2263
+ const child = spawn3(resolvedCommand.command, resolvedCommand.args, {
2004
2264
  cwd: options.cwd,
2005
2265
  stdio: "inherit",
2006
2266
  windowsHide: true
@@ -2014,7 +2274,656 @@ async function runConfiguredCommandToTerminal(options) {
2014
2274
  });
2015
2275
  }
2016
2276
 
2277
+ // src/core/passport/index.ts
2278
+ function escapeHtml(value) {
2279
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
2280
+ }
2281
+ var LANGUAGE_LABELS = {
2282
+ node: "JavaScript / TypeScript",
2283
+ python: "Python",
2284
+ go: "Go",
2285
+ java: "Java"
2286
+ };
2287
+ var STACK_ROLES = [
2288
+ { names: ["next"], role: "Web framework" },
2289
+ { names: ["react", "react-dom"], role: "User interface library" },
2290
+ { names: ["vue"], role: "User interface library" },
2291
+ { names: ["svelte"], role: "User interface library" },
2292
+ { names: ["express"], role: "Web server framework" },
2293
+ { names: ["fastify"], role: "Web server framework" },
2294
+ { names: ["hono"], role: "Web server framework" },
2295
+ { names: ["@nestjs/core"], role: "Web server framework" },
2296
+ { names: ["@remix-run/react"], role: "Web framework" },
2297
+ { names: ["prisma", "@prisma/client"], role: "Database toolkit" },
2298
+ { names: ["mongoose"], role: "MongoDB database layer" },
2299
+ { names: ["pg"], role: "PostgreSQL database driver" },
2300
+ { names: ["redis", "ioredis"], role: "Redis cache client" },
2301
+ { names: ["typescript"], role: "Typed JavaScript" },
2302
+ { names: ["vite"], role: "Build tool and dev server" },
2303
+ { names: ["webpack"], role: "Build tool" },
2304
+ { names: ["tailwindcss"], role: "CSS styling framework" },
2305
+ { names: ["vitest", "jest", "mocha"], role: "Test runner" },
2306
+ { names: ["playwright", "@playwright/test", "cypress"], role: "Browser testing" },
2307
+ { names: ["eslint"], role: "Code quality checks" },
2308
+ { names: ["prettier"], role: "Code formatting" },
2309
+ { names: ["commander", "yargs"], role: "Command-line framework" },
2310
+ { names: ["ws", "socket.io"], role: "Real-time connections" },
2311
+ { names: ["zod"], role: "Data validation" },
2312
+ { names: ["axios"], role: "HTTP requests" },
2313
+ { names: ["dotenv"], role: "Loads .env settings" }
2314
+ ];
2315
+ function describeProject(scan) {
2316
+ const configured = scan.config?.config.description;
2317
+ if (typeof configured === "string" && configured.trim().length > 0) {
2318
+ return configured.trim();
2319
+ }
2320
+ const packageDescription = scan.packageJson?.data.description;
2321
+ if (typeof packageDescription === "string" && packageDescription.trim().length > 0) {
2322
+ return packageDescription.trim();
2323
+ }
2324
+ const parts = [];
2325
+ if (scan.framework !== null) {
2326
+ parts.push(`built with ${scan.framework.type}`);
2327
+ }
2328
+ const languages = scan.language.detected.map((language) => LANGUAGE_LABELS[language] ?? language).join(", ");
2329
+ if (languages.length > 0) {
2330
+ parts.push(`written in ${languages}`);
2331
+ }
2332
+ if (scan.docker !== null && scan.docker.composeFiles.length > 0) {
2333
+ parts.push("with Docker services");
2334
+ }
2335
+ if (parts.length === 0) {
2336
+ return "A software project.";
2337
+ }
2338
+ return `A software project ${parts.join(", ")}.`;
2339
+ }
2340
+ function startCommand(scan) {
2341
+ if (scan.scripts.dev !== void 0) {
2342
+ return getPackageRunCommand(scan.packageManager, "dev").displayCommand;
2343
+ }
2344
+ if (scan.scripts.start !== void 0) {
2345
+ return getPackageRunCommand(scan.packageManager, "start").displayCommand;
2346
+ }
2347
+ const configured = { ...scan.presetCommands, ...scan.config?.config.commands ?? {} };
2348
+ for (const name of ["dev", "start", "serve"]) {
2349
+ if (configured[name] !== void 0) {
2350
+ return configured[name];
2351
+ }
2352
+ }
2353
+ return null;
2354
+ }
2355
+ function commandForStep(step, scan) {
2356
+ const action = step.action;
2357
+ if (action === void 0) {
2358
+ return null;
2359
+ }
2360
+ if (action.kind === "install") {
2361
+ return getPackageInstallCommand(scan.packageManager).displayCommand;
2362
+ }
2363
+ if (action.kind === "env-copy") {
2364
+ return "cp .env.example .env";
2365
+ }
2366
+ if (action.kind === "run-script" && action.target !== void 0) {
2367
+ return getPackageRunCommand(scan.packageManager, action.target).displayCommand;
2368
+ }
2369
+ if (action.kind === "run-command" && action.target !== void 0) {
2370
+ const configured = { ...scan.presetCommands, ...scan.config?.config.commands ?? {} };
2371
+ return configured[action.target] ?? null;
2372
+ }
2373
+ if (action.kind === "docker") {
2374
+ return "docker compose up -d";
2375
+ }
2376
+ return null;
2377
+ }
2378
+ function statusBadge(status) {
2379
+ if (status === "done") {
2380
+ return '<span class="badge badge-done">Done</span>';
2381
+ }
2382
+ if (status === "todo") {
2383
+ return '<span class="badge badge-todo">To do</span>';
2384
+ }
2385
+ return '<span class="badge badge-manual">Needs you</span>';
2386
+ }
2387
+ function severityBadge(severity) {
2388
+ if (severity === "error") {
2389
+ return '<span class="badge badge-error">Error</span>';
2390
+ }
2391
+ if (severity === "warning") {
2392
+ return '<span class="badge badge-todo">Warning</span>';
2393
+ }
2394
+ return '<span class="badge badge-manual">Info</span>';
2395
+ }
2396
+ function heroBadges(scan) {
2397
+ const badges = [];
2398
+ if (scan.framework !== null) {
2399
+ badges.push(scan.framework.type);
2400
+ }
2401
+ for (const language of scan.language.detected) {
2402
+ badges.push(LANGUAGE_LABELS[language] ?? language);
2403
+ }
2404
+ if (scan.packageManager !== null) {
2405
+ badges.push(scan.packageManager);
2406
+ }
2407
+ if (scan.git?.branch != null) {
2408
+ badges.push(`branch: ${scan.git.branch}`);
2409
+ }
2410
+ return badges.map((badge) => `<span class="badge badge-hero">${escapeHtml(badge)}</span>`).join("\n ");
2411
+ }
2412
+ function commandRow(command, note) {
2413
+ const escaped = escapeHtml(command);
2414
+ return ` <div class="recipe-row">
2415
+ <div class="recipe-note">${escapeHtml(note)}</div>
2416
+ <div class="recipe-command">
2417
+ <code>${escaped}</code>
2418
+ <button type="button" class="copy" data-copy="${escaped}">Copy</button>
2419
+ </div>
2420
+ </div>`;
2421
+ }
2422
+ function quickStartSection(scan) {
2423
+ const rows = [];
2424
+ if (scan.language.detected.includes("node") && scan.packageJson !== null) {
2425
+ rows.push(
2426
+ commandRow(
2427
+ getPackageInstallCommand(scan.packageManager).displayCommand,
2428
+ "Install the packages the project needs"
2429
+ )
2430
+ );
2431
+ }
2432
+ if (scan.env?.hasExample) {
2433
+ rows.push(
2434
+ commandRow("cp .env.example .env", "Create your local settings file (Windows: use copy)")
2435
+ );
2436
+ }
2437
+ if (scan.docker !== null && scan.docker.composeFiles.length > 0) {
2438
+ rows.push(commandRow("docker compose up -d", "Start the background services"));
2439
+ }
2440
+ const start = startCommand(scan);
2441
+ if (start !== null) {
2442
+ rows.push(commandRow(start, "Start the app"));
2443
+ }
2444
+ if (rows.length === 0) {
2445
+ return "";
2446
+ }
2447
+ return ` <section id="quick-start">
2448
+ <h2>Quick start</h2>
2449
+ <p class="muted">On a fresh machine, run these in a terminal from the project folder, top to bottom.</p>
2450
+ ${rows.join("\n")}
2451
+ </section>`;
2452
+ }
2453
+ function requirementsSection(scan) {
2454
+ const requirements = [];
2455
+ if (scan.language.detected.includes("node")) {
2456
+ const nodeRange = scan.packageJson?.data.engines?.node;
2457
+ requirements.push({
2458
+ name: "Node.js",
2459
+ detail: typeof nodeRange === "string" && nodeRange.trim().length > 0 ? `version ${nodeRange.trim()}` : "any recent LTS version"
2460
+ });
2461
+ if (scan.packageManager !== null && scan.packageManager !== "npm") {
2462
+ requirements.push({
2463
+ name: scan.packageManager,
2464
+ detail: "the package manager this project uses"
2465
+ });
2466
+ }
2467
+ }
2468
+ if (scan.language.detected.includes("python")) {
2469
+ requirements.push({ name: "Python", detail: "a recent Python 3 version" });
2470
+ }
2471
+ if (scan.language.detected.includes("go")) {
2472
+ requirements.push({ name: "Go", detail: "a recent Go toolchain" });
2473
+ }
2474
+ if (scan.language.detected.includes("java")) {
2475
+ requirements.push({ name: "Java", detail: "a JDK matching the build files" });
2476
+ }
2477
+ if (scan.docker !== null && scan.docker.composeFiles.length > 0) {
2478
+ requirements.push({
2479
+ name: "Docker Desktop",
2480
+ detail: "runs the background services \u2014 start it before the quick start"
2481
+ });
2482
+ }
2483
+ if (requirements.length === 0) {
2484
+ return "";
2485
+ }
2486
+ const items = requirements.map(
2487
+ (requirement) => ` <li><strong>${escapeHtml(requirement.name)}</strong> \u2014 ${escapeHtml(requirement.detail)}</li>`
2488
+ ).join("\n");
2489
+ return ` <section id="requirements">
2490
+ <h2>What you need installed</h2>
2491
+ <ul class="plain-list">
2492
+ ${items}
2493
+ </ul>
2494
+ </section>`;
2495
+ }
2496
+ function stepsSection(plan, scan) {
2497
+ if (plan.steps.length === 0) {
2498
+ return '<p class="muted">Nothing special to set up. Open the project and start exploring.</p>';
2499
+ }
2500
+ const items = plan.steps.map((step) => {
2501
+ const command = commandForStep(step, scan);
2502
+ const commandHtml = command !== null && step.status !== "done" ? `
2503
+ <div class="recipe-command"><code>${escapeHtml(command)}</code><button type="button" class="copy" data-copy="${escapeHtml(command)}">Copy</button></div>` : "";
2504
+ return ` <li class="step step-${step.status}">
2505
+ <div class="step-heading">${statusBadge(step.status)}<strong>${escapeHtml(step.title)}</strong></div>
2506
+ <p>${escapeHtml(step.description)}</p>${commandHtml}
2507
+ </li>`;
2508
+ }).join("\n");
2509
+ return `<ol class="steps">
2510
+ ${items}
2511
+ </ol>`;
2512
+ }
2513
+ function stackSection(scan) {
2514
+ const data = scan.packageJson?.data;
2515
+ if (data === void 0 || data === null) {
2516
+ return "";
2517
+ }
2518
+ const all = { ...data.dependencies, ...data.devDependencies };
2519
+ const names = Object.keys(all);
2520
+ if (names.length === 0) {
2521
+ return "";
2522
+ }
2523
+ const seenRoles = /* @__PURE__ */ new Set();
2524
+ const highlights = [];
2525
+ for (const entry of STACK_ROLES) {
2526
+ const found = entry.names.find((name) => all[name] !== void 0);
2527
+ if (found !== void 0 && !seenRoles.has(entry.role)) {
2528
+ seenRoles.add(entry.role);
2529
+ highlights.push({ name: found, role: entry.role });
2530
+ }
2531
+ if (highlights.length >= 8) {
2532
+ break;
2533
+ }
2534
+ }
2535
+ if (highlights.length === 0) {
2536
+ return "";
2537
+ }
2538
+ const remaining = names.length - highlights.length;
2539
+ const cells = highlights.map(
2540
+ (highlight) => ` <div class="stack-cell">
2541
+ <code>${escapeHtml(highlight.name)}</code>
2542
+ <span>${escapeHtml(highlight.role)}</span>
2543
+ </div>`
2544
+ ).join("\n");
2545
+ const more = remaining > 0 ? `
2546
+ <p class="muted">\u2026plus ${remaining} more package${remaining === 1 ? "" : "s"} doing supporting work.</p>` : "";
2547
+ return ` <section id="stack">
2548
+ <h2>The tech stack, translated</h2>
2549
+ <div class="stack-grid">
2550
+ ${cells}
2551
+ </div>${more}
2552
+ </section>`;
2553
+ }
2554
+ function scriptsSection(scan) {
2555
+ const entries = Object.entries(scan.scripts);
2556
+ if (entries.length === 0) {
2557
+ return '<p class="muted">No package scripts detected.</p>';
2558
+ }
2559
+ const rows = entries.map(
2560
+ ([name, command]) => ` <tr>
2561
+ <td><code>${escapeHtml(name)}</code></td>
2562
+ <td>${escapeHtml(explainScript(name, command))}</td>
2563
+ <td class="raw"><code>${escapeHtml(command)}</code></td>
2564
+ </tr>`
2565
+ ).join("\n");
2566
+ return `<table>
2567
+ <thead><tr><th>Command</th><th>What it does</th><th>Exactly what runs</th></tr></thead>
2568
+ <tbody>
2569
+ ${rows}
2570
+ </tbody>
2571
+ </table>`;
2572
+ }
2573
+ function envSection(scan) {
2574
+ const env = scan.env;
2575
+ if (env === null || !env.hasExample && !env.hasLocal) {
2576
+ return '<p class="muted">This project does not use a .env settings file.</p>';
2577
+ }
2578
+ if (env.keys.length === 0 && env.exampleKeys.length === 0) {
2579
+ return '<p class="muted">Environment files exist but declare no keys.</p>';
2580
+ }
2581
+ const keys = env.keys.length > 0 ? env.keys.map((key) => ({
2582
+ key: key.key,
2583
+ state: key.present ? key.empty ? "empty" : "set" : "missing"
2584
+ })) : env.exampleKeys.map((key) => ({ key, state: "missing" }));
2585
+ const rows = keys.map((entry) => {
2586
+ const badge = entry.state === "set" ? '<span class="badge badge-done">Set</span>' : entry.state === "empty" ? '<span class="badge badge-todo">Empty</span>' : '<span class="badge badge-error">Missing</span>';
2587
+ return ` <tr><td><code>${escapeHtml(entry.key)}</code></td><td>${badge}</td></tr>`;
2588
+ }).join("\n");
2589
+ return `<p class="muted">Only key names are shown. Values never leave your machine.</p>
2590
+ <table>
2591
+ <thead><tr><th>Setting</th><th>Status</th></tr></thead>
2592
+ <tbody>
2593
+ ${rows}
2594
+ </tbody>
2595
+ </table>`;
2596
+ }
2597
+ function portsSection(scan) {
2598
+ if (scan.ports.length === 0) {
2599
+ return '<p class="muted">No specific network ports detected.</p>';
2600
+ }
2601
+ const items = scan.ports.map((probe) => {
2602
+ const badge = probe.inUse ? '<span class="badge badge-todo">Busy right now</span>' : '<span class="badge badge-done">Free</span>';
2603
+ return ` <li><code>${probe.port}</code> ${badge}</li>`;
2604
+ }).join("\n");
2605
+ return `<p class="muted">The app expects these ports. \u201CBusy\u201D means another program is using it.</p>
2606
+ <ul class="plain-list">
2607
+ ${items}
2608
+ </ul>`;
2609
+ }
2610
+ function dockerSection(scan) {
2611
+ const docker = scan.docker;
2612
+ if (docker === null || docker.composeFiles.length === 0) {
2613
+ return "";
2614
+ }
2615
+ const services = docker.services.length > 0 ? `<ul class="plain-list">
2616
+ ${docker.services.map((service) => ` <li><code>${escapeHtml(service.name)}</code></li>`).join("\n")}
2617
+ </ul>` : '<p class="muted">Service list is available when Docker is running.</p>';
2618
+ return ` <section id="services">
2619
+ <h2>Background services (Docker)</h2>
2620
+ <p class="muted">This project uses Docker to run helper services (like databases) in the background.</p>
2621
+ ${services}
2622
+ </section>`;
2623
+ }
2624
+ function healthSection(warnings) {
2625
+ if (warnings.length === 0) {
2626
+ return '<p class="muted">No setup problems detected. Looking good.</p>';
2627
+ }
2628
+ const items = warnings.map(
2629
+ (warning2) => ` <li>
2630
+ <div class="step-heading">${severityBadge(warning2.severity)}<strong>${escapeHtml(warning2.title)}</strong></div>
2631
+ <p>${escapeHtml(warning2.message)}</p>
2632
+ </li>`
2633
+ ).join("\n");
2634
+ return `<ul class="steps">
2635
+ ${items}
2636
+ </ul>`;
2637
+ }
2638
+ function troubleshootingSection(scan) {
2639
+ const tips = [];
2640
+ if (scan.language.detected.includes("node")) {
2641
+ const manager = scan.packageManager ?? "npm";
2642
+ tips.push({
2643
+ symptom: `\u201Ccommand not found\u201D when running ${manager}`,
2644
+ fix: manager === "npm" ? "Node.js is not installed (or not on your PATH). Install the LTS version from the official Node.js site, then reopen your terminal." : `Install Node.js first, then install ${manager} (it is a separate tool). Reopen your terminal afterwards.`
2645
+ });
2646
+ }
2647
+ if (scan.ports.length > 0) {
2648
+ tips.push({
2649
+ symptom: "The app says a port is already in use (EADDRINUSE)",
2650
+ fix: "Another program is using that port \u2014 often an old copy of this same app. Close other dev servers or restart your computer, then try again."
2651
+ });
2652
+ }
2653
+ if (scan.env?.hasExample) {
2654
+ tips.push({
2655
+ symptom: "The app starts, then crashes or complains about missing configuration",
2656
+ fix: "Open the .env file and fill in any empty values. The \u201CSettings this project needs\u201D list above shows which keys must be set."
2657
+ });
2658
+ }
2659
+ if (scan.docker !== null && scan.docker.composeFiles.length > 0) {
2660
+ tips.push({
2661
+ symptom: "Docker commands fail or hang",
2662
+ fix: "Docker Desktop is probably not running. Start it, wait for the whale icon to settle, then run the command again."
2663
+ });
2664
+ }
2665
+ tips.push({
2666
+ symptom: "Something else is wrong",
2667
+ fix: "Run \u201Cnpx devsurface\u201D in the project folder \u2014 it opens a live dashboard that checks your setup and pinpoints what is missing."
2668
+ });
2669
+ const items = tips.map(
2670
+ (tip) => ` <li>
2671
+ <strong>${escapeHtml(tip.symptom)}</strong>
2672
+ <p>${escapeHtml(tip.fix)}</p>
2673
+ </li>`
2674
+ ).join("\n");
2675
+ return ` <section id="troubleshooting">
2676
+ <h2>If something goes wrong</h2>
2677
+ <ul class="steps">
2678
+ ${items}
2679
+ </ul>
2680
+ </section>`;
2681
+ }
2682
+ function glossarySection(scan) {
2683
+ const terms = [
2684
+ [
2685
+ "Terminal",
2686
+ "The text window where you type commands. On Windows use PowerShell; on Mac use Terminal."
2687
+ ],
2688
+ ["Command", "A line of text you type into the terminal and run by pressing Enter."],
2689
+ [
2690
+ "Dependency",
2691
+ "A ready-made package of code this project reuses instead of writing from scratch."
2692
+ ],
2693
+ ["Port", "A numbered door on your computer that a running app listens on, like 3000 or 8080."],
2694
+ [
2695
+ "localhost",
2696
+ "Your own computer. http://localhost:3000 means \u201Cthe app running on my machine, door 3000\u201D."
2697
+ ]
2698
+ ];
2699
+ if (scan.env !== null && (scan.env.hasExample || scan.env.hasLocal)) {
2700
+ terms.splice(3, 0, [
2701
+ ".env file",
2702
+ "A private settings file with keys and secret values. It stays on your machine and is never shared."
2703
+ ]);
2704
+ }
2705
+ const cells = terms.map(
2706
+ ([term, definition]) => ` <div class="stack-cell">
2707
+ <strong>${escapeHtml(term)}</strong>
2708
+ <span>${escapeHtml(definition)}</span>
2709
+ </div>`
2710
+ ).join("\n");
2711
+ return ` <section id="glossary">
2712
+ <h2>Words you will meet</h2>
2713
+ <div class="stack-grid glossary-grid">
2714
+ ${cells}
2715
+ </div>
2716
+ </section>`;
2717
+ }
2718
+ var PASSPORT_CSS = `
2719
+ :root {
2720
+ color-scheme: light;
2721
+ --bg: #F0EEE6; --card: #FAF9F5; --ink: #141413; --muted: #73706A;
2722
+ --line: #E3DFD3; --accent: #CC785C; --accent-deep: #B15D41;
2723
+ --done: #3D7A4E; --warn: #96690F; --bad: #B4392A;
2724
+ }
2725
+ * { box-sizing: border-box; }
2726
+ body {
2727
+ margin: 0; padding: 40px 18px 24px; background: var(--bg); color: var(--ink);
2728
+ font: 16px/1.62 "Styrene A", "Segoe UI", system-ui, -apple-system, sans-serif;
2729
+ }
2730
+ main { max-width: 880px; margin: 0 auto; }
2731
+ h1, h2 {
2732
+ font-family: "Tiempos Headline", Georgia, "Times New Roman", serif;
2733
+ font-weight: 500; letter-spacing: -0.01em;
2734
+ }
2735
+ h1 { margin: 6px 0 10px; font-size: 40px; line-height: 1.15; }
2736
+ h2 { margin: 0 0 14px; font-size: 24px; }
2737
+ p { margin: 6px 0; }
2738
+ .muted { color: var(--muted); }
2739
+ .kicker {
2740
+ margin: 0; color: var(--accent-deep); font-size: 13px; font-weight: 700;
2741
+ letter-spacing: 0.14em; text-transform: uppercase;
2742
+ }
2743
+ .lede { font-size: 18.5px; max-width: 60ch; }
2744
+ nav.toc { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 22px; }
2745
+ nav.toc a {
2746
+ color: var(--ink); text-decoration: none; font-size: 13px; font-weight: 600;
2747
+ border: 1px solid var(--line); border-radius: 999px; padding: 6px 14px; background: var(--card);
2748
+ }
2749
+ nav.toc a:hover { border-color: var(--accent); color: var(--accent-deep); }
2750
+ header.hero, section {
2751
+ background: var(--card); border: 1px solid var(--line); border-radius: 14px;
2752
+ padding: 28px 32px; margin-bottom: 20px;
2753
+ }
2754
+ .badges { margin-top: 14px; display: flex; flex-wrap: wrap; gap: 8px; }
2755
+ .badge {
2756
+ display: inline-block; border-radius: 999px; padding: 2px 11px;
2757
+ font-size: 12.5px; font-weight: 600; border: 1px solid var(--line); background: var(--bg);
2758
+ }
2759
+ .badge-hero { color: var(--accent-deep); border-color: #E5C8BA; background: #F7EDE7; }
2760
+ .badge-done { color: var(--done); border-color: #C4D9C9; background: #EDF4EE; }
2761
+ .badge-todo { color: var(--warn); border-color: #E4D2A8; background: #F8F1DE; }
2762
+ .badge-manual { color: var(--accent-deep); border-color: #E5C8BA; background: #F7EDE7; }
2763
+ .badge-error { color: var(--bad); border-color: #E7C2BB; background: #F9ECE9; }
2764
+ .readiness { margin-top: 18px; }
2765
+ .readiness-track { height: 10px; border-radius: 999px; background: #E6E2D6; overflow: hidden; }
2766
+ .readiness-fill { height: 100%; border-radius: 999px; background: var(--accent); }
2767
+ ol.steps, ul.steps { margin: 0; padding: 0 0 0 2px; list-style: none; }
2768
+ .steps li { border-top: 1px solid var(--line); padding: 14px 2px; }
2769
+ .steps li:first-child { border-top: none; padding-top: 4px; }
2770
+ .step-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 2px; }
2771
+ .recipe-row { border-top: 1px solid var(--line); padding: 12px 0; }
2772
+ .recipe-row:first-of-type { border-top: none; }
2773
+ .recipe-note { font-size: 14px; color: var(--muted); margin-bottom: 6px; }
2774
+ .recipe-command {
2775
+ display: flex; align-items: center; gap: 10px; padding: 10px 14px; margin-top: 8px;
2776
+ border-radius: 9px; background: #191817; color: #F5F1E8; overflow-x: auto;
2777
+ }
2778
+ .recipe-command code { flex: 1 1 auto; white-space: pre; }
2779
+ button.copy {
2780
+ flex: 0 0 auto; border: 1px solid #4A4742; border-radius: 6px; cursor: pointer;
2781
+ background: transparent; color: #D8D2C4; font-size: 12px; font-weight: 600; padding: 4px 10px;
2782
+ }
2783
+ button.copy:hover { border-color: var(--accent); color: #F5F1E8; }
2784
+ code { font: 13.5px/1.5 ui-monospace, Consolas, "Cascadia Mono", monospace; }
2785
+ table { width: 100%; border-collapse: collapse; }
2786
+ th, td { text-align: left; padding: 9px 10px; border-top: 1px solid var(--line); vertical-align: top; }
2787
+ thead th { border-top: none; font-size: 13px; color: var(--muted); font-weight: 600; }
2788
+ td.raw { overflow-wrap: anywhere; }
2789
+ ul.plain-list { margin: 8px 0 0; padding-left: 4px; list-style: none; }
2790
+ ul.plain-list li { padding: 5px 0; }
2791
+ .stack-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; }
2792
+ .stack-cell {
2793
+ border: 1px solid var(--line); border-radius: 10px; background: var(--bg);
2794
+ padding: 12px 14px; display: flex; flex-direction: column; gap: 4px;
2795
+ }
2796
+ .stack-cell span { color: var(--muted); font-size: 13.5px; }
2797
+ .glossary-grid { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
2798
+ footer { text-align: center; color: var(--muted); font-size: 13px; padding: 10px 0 26px; }
2799
+ @media print {
2800
+ body { background: #fff; padding: 0; }
2801
+ header.hero, section { border: none; padding: 12px 0; break-inside: avoid; }
2802
+ nav.toc, button.copy { display: none; }
2803
+ }
2804
+ `;
2805
+ var COPY_SCRIPT = `
2806
+ document.addEventListener('click', function (event) {
2807
+ var button = event.target.closest('button.copy');
2808
+ if (!button || !navigator.clipboard) return;
2809
+ navigator.clipboard.writeText(button.getAttribute('data-copy') || '').then(function () {
2810
+ var original = button.textContent;
2811
+ button.textContent = 'Copied!';
2812
+ setTimeout(function () { button.textContent = original; }, 1600);
2813
+ });
2814
+ });
2815
+ `;
2816
+ function renderPassportHtml(options) {
2817
+ const { scan, warnings, plan, version } = options;
2818
+ const generatedAt = options.generatedAt ?? /* @__PURE__ */ new Date();
2819
+ const name = escapeHtml(scan.projectName);
2820
+ const generatedOn = generatedAt.toISOString().slice(0, 10);
2821
+ const quickStart = quickStartSection(scan);
2822
+ const requirements = requirementsSection(scan);
2823
+ const stack = stackSection(scan);
2824
+ const docker = dockerSection(scan);
2825
+ const readinessLabel = plan.ready ? "Ready to run" : `${plan.readiness}% ready \u2014 ${escapeHtml(plan.summary)}`;
2826
+ const tocEntries = [];
2827
+ if (quickStart !== "") tocEntries.push(["#quick-start", "Quick start"]);
2828
+ if (requirements !== "") tocEntries.push(["#requirements", "What you need"]);
2829
+ tocEntries.push(["#setup", "Setup steps"]);
2830
+ if (stack !== "") tocEntries.push(["#stack", "Tech stack"]);
2831
+ tocEntries.push(["#commands", "Commands"]);
2832
+ tocEntries.push(["#settings", "Settings"]);
2833
+ tocEntries.push(["#troubleshooting", "Troubleshooting"]);
2834
+ tocEntries.push(["#glossary", "Glossary"]);
2835
+ const toc = tocEntries.map(([href, label]) => ` <a href="${href}">${label}</a>`).join("\n");
2836
+ return `<!doctype html>
2837
+ <html lang="en">
2838
+ <head>
2839
+ <meta charset="utf-8">
2840
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2841
+ <meta name="color-scheme" content="light">
2842
+ <title>${name} \u2014 Project Passport</title>
2843
+ <style>${PASSPORT_CSS}</style>
2844
+ </head>
2845
+ <body>
2846
+ <main>
2847
+ <header class="hero">
2848
+ <p class="kicker">Project Passport</p>
2849
+ <h1>${name}</h1>
2850
+ <p class="lede">${escapeHtml(describeProject(scan))}</p>
2851
+ <div class="badges">
2852
+ ${heroBadges(scan)}
2853
+ </div>
2854
+ <div class="readiness">
2855
+ <p class="muted">${readinessLabel}</p>
2856
+ <div class="readiness-track"><div class="readiness-fill" style="width:${plan.readiness}%"></div></div>
2857
+ </div>
2858
+ </header>
2859
+
2860
+ <nav class="toc">
2861
+ ${toc}
2862
+ </nav>
2863
+
2864
+ ${quickStart}
2865
+ ${requirements}
2866
+ <section id="setup">
2867
+ <h2>Setup, step by step</h2>
2868
+ <p class="muted">The detailed version of the quick start. Anything marked \u201CDone\u201D was already true when this passport was generated.</p>
2869
+ ${stepsSection(plan, scan)}
2870
+ </section>
2871
+
2872
+ ${stack}
2873
+ <section id="commands">
2874
+ <h2>Every command, explained</h2>
2875
+ ${scriptsSection(scan)}
2876
+ </section>
2877
+
2878
+ <section id="settings">
2879
+ <h2>Settings this project needs</h2>
2880
+ ${envSection(scan)}
2881
+ </section>
2882
+
2883
+ <section id="ports">
2884
+ <h2>Network ports</h2>
2885
+ ${portsSection(scan)}
2886
+ </section>
2887
+
2888
+ ${docker}
2889
+ <section id="health">
2890
+ <h2>Health check</h2>
2891
+ ${healthSection(warnings)}
2892
+ </section>
2893
+
2894
+ ${troubleshootingSection(scan)}
2895
+ ${glossarySection(scan)}
2896
+ <footer>
2897
+ Generated by DevSurface v${escapeHtml(version)} on ${generatedOn} \xB7
2898
+ Everything was collected locally. No secrets, tokens, or .env values are included.
2899
+ </footer>
2900
+ </main>
2901
+ <script>${COPY_SCRIPT}</script>
2902
+ </body>
2903
+ </html>
2904
+ `;
2905
+ }
2906
+
2907
+ // src/version.ts
2908
+ var DEV_SURFACE_VERSION = "0.7.1";
2909
+
2910
+ // src/cli/commands/passport.ts
2911
+ async function passportCommand(cwd = process.cwd(), outFile) {
2912
+ const scan = await scanProject(cwd);
2913
+ const warnings = await runDoctor(cwd, scan);
2914
+ const plan = buildOnboardingPlan(scan, warnings);
2915
+ const html = renderPassportHtml({ scan, warnings, plan, version: DEV_SURFACE_VERSION });
2916
+ const target = path13.resolve(cwd, outFile ?? "devsurface-passport.html");
2917
+ await fs13.writeFile(target, html, "utf8");
2918
+ console.log(pc4.bold(`Passport created for ${safeDisplayText(scan.projectName)}`));
2919
+ console.log(`Saved to ${safeDisplayText(target)}`);
2920
+ console.log(
2921
+ pc4.dim("Open it in any browser or share it \u2014 it works offline and contains no secrets.")
2922
+ );
2923
+ }
2924
+
2017
2925
  // src/cli/commands/run.ts
2926
+ import pc5 from "picocolors";
2018
2927
  async function runCommand(script, cwd = process.cwd()) {
2019
2928
  const scan = await scanProject(cwd);
2020
2929
  if (scan.scripts[script] !== void 0) {
@@ -2029,7 +2938,7 @@ async function runCommand(script, cwd = process.cwd()) {
2029
2938
  const configuredCommand = scan.config?.config.commands?.[script] ?? scan.presetCommands[script];
2030
2939
  if (configuredCommand !== void 0) {
2031
2940
  if (isDangerousCommand(configuredCommand)) {
2032
- console.error(pc4.red(`Refusing to run dangerous command "${safeDisplayText(script)}".`));
2941
+ console.error(pc5.red(`Refusing to run dangerous command "${safeDisplayText(script)}".`));
2033
2942
  process.exitCode = 1;
2034
2943
  return;
2035
2944
  }
@@ -2046,17 +2955,17 @@ async function runCommand(script, cwd = process.cwd()) {
2046
2955
  ...Object.keys(scan.presetCommands)
2047
2956
  ];
2048
2957
  const hint = available.length > 0 ? ` Available commands: ${safeDisplayList(available)}.` : "";
2049
- console.error(pc4.red(`Command "${safeDisplayText(script)}" was not found.${hint}`));
2958
+ console.error(pc5.red(`Command "${safeDisplayText(script)}" was not found.${hint}`));
2050
2959
  process.exitCode = 1;
2051
2960
  }
2052
2961
 
2053
2962
  // src/cli/commands/scan.ts
2054
- import pc5 from "picocolors";
2963
+ import pc6 from "picocolors";
2055
2964
  function formatList(values) {
2056
2965
  return safeDisplayList(values);
2057
2966
  }
2058
2967
  function printScanResult(scan) {
2059
- console.log(pc5.bold(`Project: ${safeDisplayText(scan.projectName)}`));
2968
+ console.log(pc6.bold(`Project: ${safeDisplayText(scan.projectName)}`));
2060
2969
  console.log(`Language: ${formatList(scan.language.detected) || "unknown"}`);
2061
2970
  console.log(`Type: ${safeDisplayText(scan.framework?.type ?? "Unknown")}`);
2062
2971
  console.log(`Manager: ${safeDisplayText(scan.packageManager ?? "unknown")}`);
@@ -2083,35 +2992,35 @@ async function scanCommand(cwd = process.cwd()) {
2083
2992
  }
2084
2993
 
2085
2994
  // src/cli/commands/start.ts
2086
- import pc6 from "picocolors";
2995
+ import pc7 from "picocolors";
2087
2996
 
2088
2997
  // node_modules/open/index.js
2089
2998
  import process7 from "process";
2090
2999
  import { Buffer as Buffer2 } from "buffer";
2091
- import path13 from "path";
3000
+ import path14 from "path";
2092
3001
  import { fileURLToPath } from "url";
2093
3002
  import { promisify as promisify5 } from "util";
2094
3003
  import childProcess from "child_process";
2095
- import fs17, { constants as fsConstants2 } from "fs/promises";
3004
+ import fs18, { constants as fsConstants2 } from "fs/promises";
2096
3005
 
2097
3006
  // node_modules/wsl-utils/index.js
2098
3007
  import process3 from "process";
2099
- import fs16, { constants as fsConstants } from "fs/promises";
3008
+ import fs17, { constants as fsConstants } from "fs/promises";
2100
3009
 
2101
3010
  // node_modules/is-wsl/index.js
2102
3011
  import process2 from "process";
2103
3012
  import os2 from "os";
2104
- import fs15 from "fs";
3013
+ import fs16 from "fs";
2105
3014
 
2106
3015
  // node_modules/is-inside-container/index.js
2107
- import fs14 from "fs";
3016
+ import fs15 from "fs";
2108
3017
 
2109
3018
  // node_modules/is-docker/index.js
2110
- import fs13 from "fs";
3019
+ import fs14 from "fs";
2111
3020
  var isDockerCached;
2112
3021
  function hasDockerEnv() {
2113
3022
  try {
2114
- fs13.statSync("/.dockerenv");
3023
+ fs14.statSync("/.dockerenv");
2115
3024
  return true;
2116
3025
  } catch {
2117
3026
  return false;
@@ -2119,7 +3028,7 @@ function hasDockerEnv() {
2119
3028
  }
2120
3029
  function hasDockerCGroup() {
2121
3030
  try {
2122
- return fs13.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
3031
+ return fs14.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
2123
3032
  } catch {
2124
3033
  return false;
2125
3034
  }
@@ -2135,7 +3044,7 @@ function isDocker() {
2135
3044
  var cachedResult;
2136
3045
  var hasContainerEnv = () => {
2137
3046
  try {
2138
- fs14.statSync("/run/.containerenv");
3047
+ fs15.statSync("/run/.containerenv");
2139
3048
  return true;
2140
3049
  } catch {
2141
3050
  return false;
@@ -2160,12 +3069,12 @@ var isWsl = () => {
2160
3069
  return true;
2161
3070
  }
2162
3071
  try {
2163
- if (fs15.readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft")) {
3072
+ if (fs16.readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft")) {
2164
3073
  return !isInsideContainer();
2165
3074
  }
2166
3075
  } catch {
2167
3076
  }
2168
- if (fs15.existsSync("/proc/sys/fs/binfmt_misc/WSLInterop") || fs15.existsSync("/run/WSL")) {
3077
+ if (fs16.existsSync("/proc/sys/fs/binfmt_misc/WSLInterop") || fs16.existsSync("/run/WSL")) {
2169
3078
  return !isInsideContainer();
2170
3079
  }
2171
3080
  return false;
@@ -2183,14 +3092,14 @@ var wslDrivesMountPoint = /* @__PURE__ */ (() => {
2183
3092
  const configFilePath = "/etc/wsl.conf";
2184
3093
  let isConfigFileExists = false;
2185
3094
  try {
2186
- await fs16.access(configFilePath, fsConstants.F_OK);
3095
+ await fs17.access(configFilePath, fsConstants.F_OK);
2187
3096
  isConfigFileExists = true;
2188
3097
  } catch {
2189
3098
  }
2190
3099
  if (!isConfigFileExists) {
2191
3100
  return defaultMountPoint;
2192
3101
  }
2193
- const configContent = await fs16.readFile(configFilePath, { encoding: "utf8" });
3102
+ const configContent = await fs17.readFile(configFilePath, { encoding: "utf8" });
2194
3103
  const configMountPoint = /(?<!#.*)root\s*=\s*(?<mountPoint>.*)/g.exec(configContent);
2195
3104
  if (!configMountPoint) {
2196
3105
  return defaultMountPoint;
@@ -2344,8 +3253,8 @@ async function defaultBrowser2() {
2344
3253
 
2345
3254
  // node_modules/open/index.js
2346
3255
  var execFile5 = promisify5(childProcess.execFile);
2347
- var __dirname = path13.dirname(fileURLToPath(import.meta.url));
2348
- var localXdgOpenPath = path13.join(__dirname, "xdg-open");
3256
+ var __dirname = path14.dirname(fileURLToPath(import.meta.url));
3257
+ var localXdgOpenPath = path14.join(__dirname, "xdg-open");
2349
3258
  var { platform, arch } = process7;
2350
3259
  async function getWindowsDefaultBrowserFromWsl() {
2351
3260
  const powershellPath = await powerShellPath();
@@ -2495,7 +3404,7 @@ var baseOpen = async (options) => {
2495
3404
  const isBundled = !__dirname || __dirname === "/";
2496
3405
  let exeLocalXdgOpen = false;
2497
3406
  try {
2498
- await fs17.access(localXdgOpenPath, fsConstants2.X_OK);
3407
+ await fs18.access(localXdgOpenPath, fsConstants2.X_OK);
2499
3408
  exeLocalXdgOpen = true;
2500
3409
  } catch {
2501
3410
  }
@@ -2600,8 +3509,8 @@ defineLazyProperty(apps, "browserPrivate", () => "browserPrivate");
2600
3509
  var open_default = open;
2601
3510
 
2602
3511
  // src/server/index.ts
2603
- import { promises as fs21 } from "fs";
2604
- import path17 from "path";
3512
+ import { promises as fs23 } from "fs";
3513
+ import path19 from "path";
2605
3514
  import { fileURLToPath as fileURLToPath2 } from "url";
2606
3515
  import { createAdaptorServer } from "@hono/node-server";
2607
3516
  import { serveStatic } from "@hono/node-server/serve-static";
@@ -2609,7 +3518,7 @@ import { Hono } from "hono";
2609
3518
 
2610
3519
  // src/core/process/manager.ts
2611
3520
  import { EventEmitter } from "events";
2612
- import spawn3 from "cross-spawn";
3521
+ import spawn4 from "cross-spawn";
2613
3522
  var LOG_MESSAGE_LIMIT = 16384;
2614
3523
  var LOG_ENTRY_LIMIT = 1e3;
2615
3524
  function killChildProcessTree(child) {
@@ -2618,7 +3527,7 @@ function killChildProcessTree(child) {
2618
3527
  return;
2619
3528
  }
2620
3529
  if (process.platform === "win32") {
2621
- const result = spawn3.sync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
3530
+ const result = spawn4.sync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
2622
3531
  stdio: "ignore",
2623
3532
  windowsHide: true
2624
3533
  });
@@ -2634,7 +3543,7 @@ var ProcessManager = class extends EventEmitter {
2634
3543
  logs = [];
2635
3544
  cleanupInstalled = false;
2636
3545
  start(options) {
2637
- const child = spawn3(options.command, options.args, {
3546
+ const child = spawn4(options.command, options.args, {
2638
3547
  cwd: options.cwd,
2639
3548
  shell: false,
2640
3549
  windowsHide: true
@@ -2751,16 +3660,16 @@ var ProcessManager = class extends EventEmitter {
2751
3660
 
2752
3661
  // src/core/hub/registry.ts
2753
3662
  import { createHash } from "crypto";
2754
- import { promises as fs19 } from "fs";
3663
+ import { promises as fs20 } from "fs";
2755
3664
  import os3 from "os";
2756
- import path15 from "path";
3665
+ import path16 from "path";
2757
3666
 
2758
3667
  // src/core/hub/workspaceRoots.ts
2759
- import { promises as fs18 } from "fs";
2760
- import path14 from "path";
3668
+ import { promises as fs19 } from "fs";
3669
+ import path15 from "path";
2761
3670
  function isWithinRoot8(root, target) {
2762
- const relative = path14.relative(root, target);
2763
- return relative === "" || !relative.startsWith("..") && !path14.isAbsolute(relative);
3671
+ const relative = path15.relative(root, target);
3672
+ return relative === "" || !relative.startsWith("..") && !path15.isAbsolute(relative);
2764
3673
  }
2765
3674
  async function configuredWorkspaceRoots() {
2766
3675
  const raw = process.env.DEVSURFACE_WORKSPACE_ROOTS;
@@ -2774,7 +3683,7 @@ async function configuredWorkspaceRoots() {
2774
3683
  continue;
2775
3684
  }
2776
3685
  try {
2777
- roots.push(await fs18.realpath(path14.resolve(trimmed)));
3686
+ roots.push(await fs19.realpath(path15.resolve(trimmed)));
2778
3687
  } catch {
2779
3688
  }
2780
3689
  }
@@ -2795,16 +3704,16 @@ async function assertWithinWorkspaceRoots(targetPath) {
2795
3704
 
2796
3705
  // src/core/hub/registry.ts
2797
3706
  function workspaceId(realPath) {
2798
- const base = path15.basename(realPath).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 32) || "workspace";
3707
+ const base = path16.basename(realPath).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 32) || "workspace";
2799
3708
  const hash = createHash("sha256").update(realPath).digest("hex").slice(0, 6);
2800
3709
  return `${base}-${hash}`;
2801
3710
  }
2802
3711
  function defaultDataDir() {
2803
- return process.env.DEVSURFACE_DATA_DIR ?? path15.join(os3.homedir(), ".devsurface");
3712
+ return process.env.DEVSURFACE_DATA_DIR ?? path16.join(os3.homedir(), ".devsurface");
2804
3713
  }
2805
3714
  async function readPackageName(dirPath) {
2806
3715
  try {
2807
- const raw = JSON.parse(await fs19.readFile(path15.join(dirPath, "package.json"), "utf8"));
3716
+ const raw = JSON.parse(await fs20.readFile(path16.join(dirPath, "package.json"), "utf8"));
2808
3717
  return typeof raw?.name === "string" && raw.name.length > 0 ? raw.name : null;
2809
3718
  } catch {
2810
3719
  return null;
@@ -2815,7 +3724,7 @@ var WorkspaceRegistry = class {
2815
3724
  seeded = false;
2816
3725
  constructor(dataDir) {
2817
3726
  const dir = dataDir ?? defaultDataDir();
2818
- this.filePath = path15.join(dir, "workspaces.json");
3727
+ this.filePath = path16.join(dir, "workspaces.json");
2819
3728
  }
2820
3729
  async list() {
2821
3730
  await this.seedFromEnv();
@@ -2829,7 +3738,7 @@ var WorkspaceRegistry = class {
2829
3738
  if (existing) {
2830
3739
  return existing;
2831
3740
  }
2832
- const name = await readPackageName(realDir) ?? path15.basename(realDir);
3741
+ const name = await readPackageName(realDir) ?? path16.basename(realDir);
2833
3742
  const entry = {
2834
3743
  id: workspaceId(realDir),
2835
3744
  name,
@@ -2851,7 +3760,7 @@ var WorkspaceRegistry = class {
2851
3760
  }
2852
3761
  async findByPath(dirPath) {
2853
3762
  try {
2854
- const realDir = await fs19.realpath(path15.resolve(dirPath));
3763
+ const realDir = await fs20.realpath(path16.resolve(dirPath));
2855
3764
  const entries = await this.read();
2856
3765
  return entries.find((entry) => entry.path === realDir) ?? null;
2857
3766
  } catch {
@@ -2879,9 +3788,9 @@ var WorkspaceRegistry = class {
2879
3788
  }
2880
3789
  }
2881
3790
  async resolveDir(dirPath) {
2882
- const resolved = path15.resolve(dirPath);
2883
- const realDir = await fs19.realpath(resolved);
2884
- const stat = await fs19.stat(realDir);
3791
+ const resolved = path16.resolve(dirPath);
3792
+ const realDir = await fs20.realpath(resolved);
3793
+ const stat = await fs20.stat(realDir);
2885
3794
  if (!stat.isDirectory()) {
2886
3795
  throw new Error(`${dirPath} is not a directory.`);
2887
3796
  }
@@ -2889,7 +3798,7 @@ var WorkspaceRegistry = class {
2889
3798
  }
2890
3799
  async read() {
2891
3800
  try {
2892
- const content = await fs19.readFile(this.filePath, "utf8");
3801
+ const content = await fs20.readFile(this.filePath, "utf8");
2893
3802
  const parsed = JSON.parse(content);
2894
3803
  return Array.isArray(parsed) ? parsed : [];
2895
3804
  } catch {
@@ -2897,8 +3806,8 @@ var WorkspaceRegistry = class {
2897
3806
  }
2898
3807
  }
2899
3808
  async write(entries) {
2900
- await fs19.mkdir(path15.dirname(this.filePath), { recursive: true });
2901
- await fs19.writeFile(this.filePath, JSON.stringify(entries, null, 2) + "\n", {
3809
+ await fs20.mkdir(path16.dirname(this.filePath), { recursive: true });
3810
+ await fs20.writeFile(this.filePath, JSON.stringify(entries, null, 2) + "\n", {
2902
3811
  encoding: "utf8",
2903
3812
  mode: 384
2904
3813
  });
@@ -2982,13 +3891,92 @@ var Hub = class {
2982
3891
  };
2983
3892
 
2984
3893
  // src/server/routes/api.ts
2985
- import { constants as constants2, existsSync } from "fs";
2986
- import { promises as fs20 } from "fs";
2987
- import path16 from "path";
2988
- import spawn4 from "cross-spawn";
3894
+ import { constants as constants3, existsSync } from "fs";
3895
+ import { promises as fs22 } from "fs";
3896
+ import path18 from "path";
3897
+ import spawn5 from "cross-spawn";
2989
3898
 
2990
- // src/version.ts
2991
- var DEV_SURFACE_VERSION = "0.7.1";
3899
+ // src/core/env/write.ts
3900
+ import { constants as constants2, promises as fs21 } from "fs";
3901
+ import path17 from "path";
3902
+ function isValidEnvKey(key) {
3903
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && key.length <= 128;
3904
+ }
3905
+ function isValidEnvValue(value) {
3906
+ if (value.length > 4096) {
3907
+ return false;
3908
+ }
3909
+ for (const character of value) {
3910
+ const code = character.codePointAt(0) ?? 0;
3911
+ if (code < 32 || code === 127) {
3912
+ return false;
3913
+ }
3914
+ }
3915
+ return true;
3916
+ }
3917
+ function formatEnvLine(key, value) {
3918
+ if (value === "" || /[\s#'"\\]/.test(value)) {
3919
+ const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
3920
+ return `${key}="${escaped}"`;
3921
+ }
3922
+ return `${key}=${value}`;
3923
+ }
3924
+ function applyEnvValue(content, key, value) {
3925
+ const lines = content.split(/\r?\n/);
3926
+ const prefix = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`);
3927
+ const index = lines.findIndex((line) => prefix.test(line));
3928
+ if (index >= 0) {
3929
+ lines[index] = formatEnvLine(key, value);
3930
+ return { content: lines.join("\n"), action: "updated" };
3931
+ }
3932
+ const body = content.length === 0 || content.endsWith("\n") ? content : `${content}
3933
+ `;
3934
+ return { content: `${body}${formatEnvLine(key, value)}
3935
+ `, action: "added" };
3936
+ }
3937
+ function isWithinRoot9(root, target) {
3938
+ const relative = path17.relative(path17.resolve(root), path17.resolve(target));
3939
+ return relative === "" || !relative.startsWith("..") && !path17.isAbsolute(relative);
3940
+ }
3941
+ async function setEnvValue(options) {
3942
+ const { root, key, value } = options;
3943
+ if (!isValidEnvKey(key)) {
3944
+ return { ok: false, error: "Invalid key name." };
3945
+ }
3946
+ if (!isValidEnvValue(value)) {
3947
+ return { ok: false, error: "Value must be a single line under 4096 characters." };
3948
+ }
3949
+ const target = options.localPath ?? path17.join(root, ".env");
3950
+ if (!isWithinRoot9(root, target)) {
3951
+ return { ok: false, error: "The env file must live inside the project." };
3952
+ }
3953
+ try {
3954
+ const realParent = await fs21.realpath(path17.dirname(target));
3955
+ const realRoot = await fs21.realpath(root);
3956
+ if (!isWithinRoot9(realRoot, realParent)) {
3957
+ return { ok: false, error: "The env file must live inside the project." };
3958
+ }
3959
+ let current = "";
3960
+ try {
3961
+ current = await fs21.readFile(target, "utf8");
3962
+ } catch {
3963
+ }
3964
+ const next = applyEnvValue(current, key, value);
3965
+ const handle2 = await fs21.open(
3966
+ target,
3967
+ constants2.O_CREAT | constants2.O_WRONLY | constants2.O_TRUNC,
3968
+ 384
3969
+ );
3970
+ try {
3971
+ await handle2.writeFile(next.content, "utf8");
3972
+ } finally {
3973
+ await handle2.close();
3974
+ }
3975
+ return { ok: true, action: next.action };
3976
+ } catch {
3977
+ return { ok: false, error: "Could not write the env file." };
3978
+ }
3979
+ }
2992
3980
 
2993
3981
  // src/server/localAccess.ts
2994
3982
  var LOCAL_HOSTNAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
@@ -3174,9 +4162,9 @@ function isAllowedTerminalCommand(command) {
3174
4162
  }
3175
4163
 
3176
4164
  // src/server/routes/api.ts
3177
- function isWithinRoot9(root, target) {
3178
- const relative = path16.relative(path16.resolve(root), path16.resolve(target));
3179
- return relative === "" || !relative.startsWith("..") && !path16.isAbsolute(relative);
4165
+ function isWithinRoot10(root, target) {
4166
+ const relative = path18.relative(path18.resolve(root), path18.resolve(target));
4167
+ return relative === "" || !relative.startsWith("..") && !path18.isAbsolute(relative);
3180
4168
  }
3181
4169
  function isAllowedMutationOrigin(requestUrl, origin) {
3182
4170
  if (origin === null) {
@@ -3208,37 +4196,37 @@ function hasMutationIntent(intent) {
3208
4196
  return intent === "dashboard";
3209
4197
  }
3210
4198
  async function realPathWithinRoot(root, target) {
3211
- if (!isWithinRoot9(root, target)) {
4199
+ if (!isWithinRoot10(root, target)) {
3212
4200
  return false;
3213
4201
  }
3214
4202
  try {
3215
- const [realRoot, realTarget] = await Promise.all([fs20.realpath(root), fs20.realpath(target)]);
3216
- return isWithinRoot9(realRoot, realTarget);
4203
+ const [realRoot, realTarget] = await Promise.all([fs22.realpath(root), fs22.realpath(target)]);
4204
+ return isWithinRoot10(realRoot, realTarget);
3217
4205
  } catch {
3218
4206
  return false;
3219
4207
  }
3220
4208
  }
3221
4209
  async function writableDestinationWithinRoot(root, destination) {
3222
- if (!isWithinRoot9(root, destination)) {
4210
+ if (!isWithinRoot10(root, destination)) {
3223
4211
  return false;
3224
4212
  }
3225
4213
  try {
3226
4214
  const [realRoot, realParent] = await Promise.all([
3227
- fs20.realpath(root),
3228
- fs20.realpath(path16.dirname(destination))
4215
+ fs22.realpath(root),
4216
+ fs22.realpath(path18.dirname(destination))
3229
4217
  ]);
3230
- return isWithinRoot9(realRoot, realParent);
4218
+ return isWithinRoot10(realRoot, realParent);
3231
4219
  } catch {
3232
4220
  return false;
3233
4221
  }
3234
4222
  }
3235
4223
  async function copyFileExclusive(source, destination) {
3236
- const content = await fs20.readFile(source);
4224
+ const content = await fs22.readFile(source);
3237
4225
  let handle2 = null;
3238
4226
  try {
3239
- handle2 = await fs20.open(
4227
+ handle2 = await fs22.open(
3240
4228
  destination,
3241
- constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY,
4229
+ constants3.O_CREAT | constants3.O_EXCL | constants3.O_WRONLY,
3242
4230
  384
3243
4231
  );
3244
4232
  await handle2.writeFile(content);
@@ -3263,15 +4251,15 @@ function resolveCommandPromptExecutable() {
3263
4251
  return process.env.ComSpec ?? "cmd.exe";
3264
4252
  }
3265
4253
  function findExecutable(command) {
3266
- if (path16.isAbsolute(command)) {
4254
+ if (path18.isAbsolute(command)) {
3267
4255
  return existsSync(command) ? command : null;
3268
4256
  }
3269
4257
  const pathValue = process.env.PATH ?? "";
3270
- for (const directory of pathValue.split(path16.delimiter)) {
4258
+ for (const directory of pathValue.split(path18.delimiter)) {
3271
4259
  if (directory.length === 0) {
3272
4260
  continue;
3273
4261
  }
3274
- const candidate = path16.join(directory, command);
4262
+ const candidate = path18.join(directory, command);
3275
4263
  if (existsSync(candidate)) {
3276
4264
  return candidate;
3277
4265
  }
@@ -3279,7 +4267,7 @@ function findExecutable(command) {
3279
4267
  return null;
3280
4268
  }
3281
4269
  function launchDetached(command, args, root) {
3282
- const child = spawn4(command, args, {
4270
+ const child = spawn5(command, args, {
3283
4271
  cwd: root,
3284
4272
  detached: true,
3285
4273
  stdio: "ignore",
@@ -3354,6 +4342,33 @@ async function onboardingForRoot(root) {
3354
4342
  const warnings = await runDoctor(root, scan);
3355
4343
  return buildOnboardingPlan(scan, warnings);
3356
4344
  }
4345
+ async function passportResponse(root, context) {
4346
+ const scan = await scanProject(root);
4347
+ const warnings = await runDoctor(root, scan);
4348
+ const plan = buildOnboardingPlan(scan, warnings);
4349
+ const html = renderPassportHtml({ scan, warnings, plan, version: DEV_SURFACE_VERSION });
4350
+ if (context.req.query("download") === "1") {
4351
+ const safeName = scan.projectName.replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 60) || "project";
4352
+ context.header("Content-Disposition", `attachment; filename="${safeName}-passport.html"`);
4353
+ }
4354
+ return await context.html(html);
4355
+ }
4356
+ async function handleEnvSet(root, body) {
4357
+ if (body === null || typeof body.key !== "string" || typeof body.value !== "string") {
4358
+ return { status: 400, payload: { error: "key and value are required." } };
4359
+ }
4360
+ const scan = await scanProject(root);
4361
+ const result = await setEnvValue({
4362
+ root,
4363
+ localPath: scan.env?.localPath ?? null,
4364
+ key: body.key,
4365
+ value: body.value
4366
+ });
4367
+ if (!result.ok) {
4368
+ return { status: 400, payload: { error: result.error } };
4369
+ }
4370
+ return { status: 200, payload: { status: result.action, key: body.key } };
4371
+ }
3357
4372
  function registerWorkspaceRoutes(app, resolveWorkspace) {
3358
4373
  app.get("/api/workspaces/:id/project", async (context) => {
3359
4374
  const ws = await resolveWorkspace(context.req.param("id"));
@@ -3370,6 +4385,11 @@ function registerWorkspaceRoutes(app, resolveWorkspace) {
3370
4385
  if (!ws) return context.json({ error: "Workspace not found." }, 404);
3371
4386
  return context.json(await onboardingForRoot(ws.root));
3372
4387
  });
4388
+ app.get("/api/workspaces/:id/passport", async (context) => {
4389
+ const ws = await resolveWorkspace(context.req.param("id"));
4390
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
4391
+ return passportResponse(ws.root, context);
4392
+ });
3373
4393
  app.get("/api/workspaces/:id/processes", async (context) => {
3374
4394
  const ws = await resolveWorkspace(context.req.param("id"));
3375
4395
  if (!ws) return context.json({ error: "Workspace not found." }, 404);
@@ -3498,7 +4518,7 @@ function registerWorkspaceRoutes(app, resolveWorkspace) {
3498
4518
  app.post("/api/workspaces/:id/open/package", async (context) => {
3499
4519
  const ws = await resolveWorkspace(context.req.param("id"));
3500
4520
  if (!ws) return context.json({ error: "Workspace not found." }, 404);
3501
- const packagePath = path16.join(ws.root, "package.json");
4521
+ const packagePath = path18.join(ws.root, "package.json");
3502
4522
  if (!await realPathWithinRoot(ws.root, packagePath)) {
3503
4523
  return context.json({ error: "package.json was not found inside the project root." }, 404);
3504
4524
  }
@@ -3520,7 +4540,7 @@ function registerWorkspaceRoutes(app, resolveWorkspace) {
3520
4540
  if (examplePath === null) {
3521
4541
  return context.json({ error: ".env.example was not found." }, 404);
3522
4542
  }
3523
- const destination = localPath ?? path16.join(ws.root, scan.config?.config.env?.local ?? ".env");
4543
+ const destination = localPath ?? path18.join(ws.root, scan.config?.config.env?.local ?? ".env");
3524
4544
  if (!await realPathWithinRoot(ws.root, examplePath) || !await writableDestinationWithinRoot(ws.root, destination)) {
3525
4545
  return context.json({ error: "Refusing to copy env files outside the project root." }, 400);
3526
4546
  }
@@ -3530,6 +4550,13 @@ function registerWorkspaceRoutes(app, resolveWorkspace) {
3530
4550
  }
3531
4551
  return context.json({ copied: true });
3532
4552
  });
4553
+ app.post("/api/workspaces/:id/env/set", async (context) => {
4554
+ const ws = await resolveWorkspace(context.req.param("id"));
4555
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
4556
+ const body = await context.req.json().catch(() => null);
4557
+ const result = await handleEnvSet(ws.root, body);
4558
+ return context.json(result.payload, result.status);
4559
+ });
3533
4560
  app.delete("/api/workspaces/:id/run/:pid", async (context) => {
3534
4561
  const ws = await resolveWorkspace(context.req.param("id"));
3535
4562
  if (!ws) return context.json({ error: "Workspace not found." }, 404);
@@ -3597,6 +4624,11 @@ function registerHubApiRoutes(app, options) {
3597
4624
  if (entries.length === 0) return context.json({ error: "No workspaces registered." }, 404);
3598
4625
  return context.json(await onboardingForRoot(hub.ensure(entries[0]).root));
3599
4626
  });
4627
+ app.get("/api/passport", async (context) => {
4628
+ const entries = await hub.registry.list();
4629
+ if (entries.length === 0) return context.json({ error: "No workspaces registered." }, 404);
4630
+ return passportResponse(hub.ensure(entries[0]).root, context);
4631
+ });
3600
4632
  app.get("/api/processes", async (context) => {
3601
4633
  const entries = await hub.registry.list();
3602
4634
  if (entries.length === 0) return context.json([]);
@@ -3713,21 +4745,21 @@ function warnIfContainerRootsUnset(host) {
3713
4745
  }
3714
4746
  async function fileExists(filePath) {
3715
4747
  try {
3716
- await fs21.access(filePath);
4748
+ await fs23.access(filePath);
3717
4749
  return true;
3718
4750
  } catch {
3719
4751
  return false;
3720
4752
  }
3721
4753
  }
3722
4754
  async function findWebDistDir() {
3723
- const moduleDir = path17.dirname(fileURLToPath2(import.meta.url));
4755
+ const moduleDir = path19.dirname(fileURLToPath2(import.meta.url));
3724
4756
  const candidates = [
3725
- path17.join(moduleDir, "..", "web", "dist"),
3726
- path17.join(moduleDir, "..", "..", "src", "web", "dist"),
3727
- path17.join(moduleDir, "web", "dist")
4757
+ path19.join(moduleDir, "..", "web", "dist"),
4758
+ path19.join(moduleDir, "..", "..", "src", "web", "dist"),
4759
+ path19.join(moduleDir, "web", "dist")
3728
4760
  ];
3729
4761
  for (const candidate of candidates) {
3730
- if (await fileExists(path17.join(candidate, "index.html"))) {
4762
+ if (await fileExists(path19.join(candidate, "index.html"))) {
3731
4763
  return candidate;
3732
4764
  }
3733
4765
  }
@@ -3799,7 +4831,7 @@ async function mountWebUi(app) {
3799
4831
  app.use("/assets/*", serveStatic({ root: webDistDir }));
3800
4832
  app.get("/favicon.svg", serveStatic({ root: webDistDir }));
3801
4833
  app.get("*", async (context) => {
3802
- const html = await fs21.readFile(path17.join(webDistDir, "index.html"), "utf8");
4834
+ const html = await fs23.readFile(path19.join(webDistDir, "index.html"), "utf8");
3803
4835
  return context.html(html);
3804
4836
  });
3805
4837
  } else {
@@ -3899,10 +4931,10 @@ function dashboardUrl(workspaceId2, port = DEFAULT_PORT, host = DEFAULT_HOST) {
3899
4931
  }
3900
4932
 
3901
4933
  // src/cli/commands/start.ts
3902
- async function startCommand(options) {
4934
+ async function startCommand2(options) {
3903
4935
  const cwd = options.cwd ?? process.cwd();
3904
4936
  const port = options.port ?? 4567;
3905
- console.log(pc6.bold(`DevSurface v${DEV_SURFACE_VERSION}`));
4937
+ console.log(pc7.bold(`DevSurface v${DEV_SURFACE_VERSION}`));
3906
4938
  console.log("Scanning project...\n");
3907
4939
  const scan = await scanProject(cwd);
3908
4940
  printScanResult(scan);
@@ -3910,7 +4942,7 @@ async function startCommand(options) {
3910
4942
  if (warnings.length > 0) {
3911
4943
  console.log("\nWarnings:");
3912
4944
  for (const item of warnings) {
3913
- const marker = item.severity === "error" ? pc6.red("!") : pc6.yellow("!");
4945
+ const marker = item.severity === "error" ? pc7.red("!") : pc7.yellow("!");
3914
4946
  console.log(` ${marker} ${item.title}`);
3915
4947
  }
3916
4948
  }
@@ -3919,8 +4951,8 @@ async function startCommand(options) {
3919
4951
  const registered = await registerWorkspaceRemotely(cwd, port);
3920
4952
  if (registered) {
3921
4953
  const url = dashboardUrl(registered.id, port);
3922
- console.log(`Workspace ${pc6.cyan(registered.name)} attached.`);
3923
- console.log(`Dashboard -> ${pc6.cyan(url)}`);
4954
+ console.log(`Workspace ${pc7.cyan(registered.name)} attached.`);
4955
+ console.log(`Dashboard -> ${pc7.cyan(url)}`);
3924
4956
  if (options.openBrowser !== false) {
3925
4957
  await open_default(url);
3926
4958
  }
@@ -3934,13 +4966,13 @@ async function startCommand(options) {
3934
4966
  initialWorkspace: cwd
3935
4967
  });
3936
4968
  console.log(`
3937
- Dashboard running at -> ${pc6.cyan(server.url)}`);
4969
+ Dashboard running at -> ${pc7.cyan(server.url)}`);
3938
4970
  }
3939
4971
 
3940
4972
  // src/cli/commands/serve.ts
3941
- import pc7 from "picocolors";
4973
+ import pc8 from "picocolors";
3942
4974
  async function serveCommand(options) {
3943
- console.log(pc7.bold(`DevSurface Hub v${DEV_SURFACE_VERSION}`));
4975
+ console.log(pc8.bold(`DevSurface Hub v${DEV_SURFACE_VERSION}`));
3944
4976
  console.log("Starting hub server...\n");
3945
4977
  const server = await startHubServer({
3946
4978
  port: options.port,
@@ -3950,7 +4982,7 @@ async function serveCommand(options) {
3950
4982
  if (summaries.length > 0) {
3951
4983
  console.log(`Registered workspaces: ${summaries.length}`);
3952
4984
  for (const ws of summaries) {
3953
- console.log(` ${pc7.cyan(ws.name)} -> ${ws.path}`);
4985
+ console.log(` ${pc8.cyan(ws.name)} -> ${ws.path}`);
3954
4986
  }
3955
4987
  } else {
3956
4988
  console.log(
@@ -3958,17 +4990,17 @@ async function serveCommand(options) {
3958
4990
  );
3959
4991
  }
3960
4992
  console.log(`
3961
- Hub running at -> ${pc7.cyan(server.url)}`);
4993
+ Hub running at -> ${pc8.cyan(server.url)}`);
3962
4994
  }
3963
4995
 
3964
4996
  // src/cli/commands/workspace.ts
3965
- import path18 from "path";
3966
- import pc8 from "picocolors";
4997
+ import path20 from "path";
4998
+ import pc9 from "picocolors";
3967
4999
  async function workspaceAddCommand(dirPath) {
3968
5000
  const registry = new WorkspaceRegistry();
3969
- const target = path18.resolve(dirPath ?? process.cwd());
5001
+ const target = path20.resolve(dirPath ?? process.cwd());
3970
5002
  const entry = await registry.add(target);
3971
- console.log(`Added workspace ${pc8.cyan(entry.name)} (${entry.id}) -> ${entry.path}`);
5003
+ console.log(`Added workspace ${pc9.cyan(entry.name)} (${entry.id}) -> ${entry.path}`);
3972
5004
  }
3973
5005
  async function workspaceListCommand() {
3974
5006
  const registry = new WorkspaceRegistry();
@@ -3982,7 +5014,7 @@ async function workspaceListCommand() {
3982
5014
  console.log(`${entries.length} workspace${entries.length === 1 ? "" : "s"}:
3983
5015
  `);
3984
5016
  for (const entry of entries) {
3985
- console.log(` ${pc8.cyan(entry.name)} (${entry.id})`);
5017
+ console.log(` ${pc9.cyan(entry.name)} (${entry.id})`);
3986
5018
  console.log(` ${entry.path}`);
3987
5019
  }
3988
5020
  }
@@ -3990,7 +5022,7 @@ async function workspaceRemoveCommand(id) {
3990
5022
  const registry = new WorkspaceRegistry();
3991
5023
  const removed = await registry.remove(id);
3992
5024
  if (removed) {
3993
- console.log(`Removed workspace ${pc8.cyan(id)}.`);
5025
+ console.log(`Removed workspace ${pc9.cyan(id)}.`);
3994
5026
  } else {
3995
5027
  console.error(`Workspace "${id}" not found.`);
3996
5028
  process.exitCode = 1;
@@ -4087,9 +5119,9 @@ function handle(command) {
4087
5119
  process.exitCode = 1;
4088
5120
  });
4089
5121
  }
4090
- program.name("devsurface").description("Turn any Node.js repository into a local developer control panel.").version(DEV_SURFACE_VERSION).option("-p, --port <port>", "dashboard port", toPort, 4567).option("--no-open", "do not open the browser automatically").action((options) => {
5122
+ program.name("devsurface").description("Turn any Node.js repository into a local developer control panel.").version(DEV_SURFACE_VERSION).enablePositionalOptions().option("-p, --port <port>", "dashboard port", toPort, 4567).option("--no-open", "do not open the browser automatically").action((options) => {
4091
5123
  handle(
4092
- startCommand({
5124
+ startCommand2({
4093
5125
  cwd: process.cwd(),
4094
5126
  port: options.port,
4095
5127
  openBrowser: options.open
@@ -4123,6 +5155,9 @@ program.command("doctor").description("Print setup health warnings.").action(()
4123
5155
  program.command("onboard").description("Print a guided setup checklist with readiness score.").action(() => {
4124
5156
  handle(onboardCommand(process.cwd()));
4125
5157
  });
5158
+ program.command("passport").description("Generate a shareable HTML onboarding report (Project Passport).").option("-o, --out <file>", "output file path", "devsurface-passport.html").action((options) => {
5159
+ handle(passportCommand(process.cwd(), options.out));
5160
+ });
4126
5161
  program.command("init").description("Create a starter devsurface.config.json.").action(() => {
4127
5162
  handle(initCommand(process.cwd()));
4128
5163
  });