devsurface 0.5.0 → 0.7.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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ - Added structured `setupGuide` steps in `devsurface.config.json`: each step can be a plain string or an object with `title`, `description`, and a `command` or `script` key that turns it into a one-click action button in the Onboarding tab.
6
+ - Updated `devsurface init` to generate a richer config with grouped commands (First-time setup, Daily development, Before committing) and actionable setup steps.
7
+ - Added plain-English explanations for every package script in the dashboard, so non-technical users can see what a command like `vite` or `tsc --noEmit` actually does before running it.
8
+
9
+ ## 0.6.0
10
+
11
+ - Added guided onboarding with a setup readiness score (0–100%) computed from scan and doctor results.
12
+ - Added `devsurface onboard` CLI command that prints a colored checklist of setup steps with done/todo/manual status.
13
+ - Added `/api/onboarding` endpoint (and `/api/workspaces/:id/onboarding` for hub mode) returning the full onboarding plan.
14
+ - Added Onboarding tab in the dashboard with progress bar, per-step actions (install deps, copy `.env`, run scripts, open docs), and a compact banner on the overview when the project is not yet ready.
15
+ - Added `setupGuide` (or `setup_guide`) field in `devsurface.config.json` for maintainers to embed ordered setup instructions (max 24 steps, 200 chars each).
16
+ - Added Python, Go, and Java language support to the onboarding plan; install step is skipped for non-Node projects.
17
+ - Hardened hub workspace registry file permissions to `0o600`.
18
+ - Added a startup warning when `DEVSURFACE_WORKSPACE_ROOTS` is unset in container mode.
19
+ - Shifted keyboard shortcuts: 2=Onboarding, 3=Scripts, 8=Logs (previously 7).
20
+
3
21
  ## 0.5.0
4
22
 
5
23
  - Added framework presets for Next.js, Vite, Express, Fastify, NestJS, Remix, and Prisma.
package/README.md CHANGED
@@ -328,12 +328,16 @@ DevSurface is designed for local development.
328
328
 
329
329
  - Local dashboard servers bind to loopback hosts.
330
330
  - Container deployments use `DEVSURFACE_CONTAINER=true`.
331
- - Workspace registration can be limited with `DEVSURFACE_WORKSPACE_ROOTS`.
331
+ - Workspace registration can be limited with `DEVSURFACE_WORKSPACE_ROOTS`. In container
332
+ or shared-host deployments, set this to restrict which directories the hub will accept;
333
+ on a single-user laptop it is optional and DevSurface starts with no extra config.
332
334
  - `.env` values are never returned by scanners, API routes, CLI output, or UI panels.
333
335
  - Dashboard command runs show the exact command string first.
334
336
  - Docker service start and stop actions show the exact Compose command before running.
335
337
  - Destructive-looking configured commands, such as `rm -rf`, `docker volume rm`,
336
- database drops, and `git clean -fd`, are visibly marked before execution.
338
+ database drops, and `git clean -fd`, are visibly marked before execution. This list is a
339
+ helpful warning, not a sandbox: it flags common footguns for confirmation but does not
340
+ attempt to detect every dangerous command. Treat package scripts as code that runs as you.
337
341
  - Child processes started by DevSurface are cleaned up when the dashboard exits.
338
342
 
339
343
  ## FAQ
@@ -68,6 +68,50 @@ function toGroups(value, warnings) {
68
68
  }
69
69
  return groups;
70
70
  }
71
+ var MAX_SETUP_GUIDE_STEPS = 24;
72
+ var MAX_SETUP_GUIDE_STEP_LENGTH = 200;
73
+ function toSetupGuide(value, warnings) {
74
+ if (value === void 0) {
75
+ return void 0;
76
+ }
77
+ if (!Array.isArray(value)) {
78
+ warnings.push("setupGuide must be an array of strings or step objects.");
79
+ return void 0;
80
+ }
81
+ const steps = [];
82
+ for (const entry of value) {
83
+ if (typeof entry === "string") {
84
+ const trimmed = entry.trim();
85
+ if (trimmed.length > 0) {
86
+ steps.push(trimmed.slice(0, MAX_SETUP_GUIDE_STEP_LENGTH));
87
+ }
88
+ } else if (isRecord(entry)) {
89
+ if (typeof entry.title !== "string" || entry.title.trim().length === 0) {
90
+ warnings.push("setupGuide step objects must have a non-empty title string.");
91
+ continue;
92
+ }
93
+ const step = {
94
+ title: entry.title.trim().slice(0, MAX_SETUP_GUIDE_STEP_LENGTH)
95
+ };
96
+ if (typeof entry.description === "string" && entry.description.trim().length > 0) {
97
+ step.description = entry.description.trim().slice(0, MAX_SETUP_GUIDE_STEP_LENGTH);
98
+ }
99
+ if (typeof entry.command === "string" && entry.command.trim().length > 0) {
100
+ step.command = entry.command.trim();
101
+ }
102
+ if (typeof entry.script === "string" && entry.script.trim().length > 0) {
103
+ step.script = entry.script.trim();
104
+ }
105
+ steps.push(step);
106
+ } else {
107
+ warnings.push("setupGuide entries must be strings or step objects.");
108
+ }
109
+ }
110
+ if (steps.length > MAX_SETUP_GUIDE_STEPS) {
111
+ warnings.push(`setupGuide may contain at most ${MAX_SETUP_GUIDE_STEPS} steps.`);
112
+ }
113
+ return steps.slice(0, MAX_SETUP_GUIDE_STEPS);
114
+ }
71
115
  function toPorts(value, warnings) {
72
116
  if (value === void 0) {
73
117
  return void 0;
@@ -122,6 +166,7 @@ function validateConfig(raw) {
122
166
  ports: toPorts(raw.ports, warnings),
123
167
  env,
124
168
  services,
169
+ setupGuide: toSetupGuide(raw.setupGuide ?? raw.setup_guide, warnings),
125
170
  docs
126
171
  },
127
172
  warnings
package/dist/cli/index.js CHANGED
@@ -56,15 +56,17 @@ var defaultConfig = {
56
56
  description: "Local developer control panel",
57
57
  commands: {
58
58
  install: "npm install",
59
+ migrate: "npm run db:migrate",
60
+ seed: "npm run db:seed",
59
61
  dev: "npm run dev",
60
62
  build: "npm run build",
61
63
  test: "npm test",
62
64
  lint: "npm run lint"
63
65
  },
64
66
  groups: {
65
- Setup: ["install"],
66
- Development: ["dev"],
67
- Quality: ["test", "lint"],
67
+ "First-time setup": ["install", "migrate", "seed"],
68
+ "Daily development": ["dev"],
69
+ "Before committing": ["test", "lint"],
68
70
  Build: ["build"]
69
71
  },
70
72
  ports: [3e3],
@@ -75,6 +77,25 @@ var defaultConfig = {
75
77
  services: {
76
78
  docker: true
77
79
  },
80
+ setupGuide: [
81
+ {
82
+ title: "Install dependencies",
83
+ description: "Run the package manager install to set up node_modules.",
84
+ command: "install"
85
+ },
86
+ "Copy .env.example to .env",
87
+ "Fill in required environment values (DATABASE_URL, etc.)",
88
+ {
89
+ title: "Run database migrations",
90
+ description: "Apply the database schema to your local database.",
91
+ command: "migrate"
92
+ },
93
+ {
94
+ title: "Start the development server",
95
+ description: "Run the local dev server and open the app in a browser.",
96
+ command: "dev"
97
+ }
98
+ ],
78
99
  docs: ""
79
100
  };
80
101
 
@@ -123,6 +144,50 @@ function toGroups(value, warnings) {
123
144
  }
124
145
  return groups;
125
146
  }
147
+ var MAX_SETUP_GUIDE_STEPS = 24;
148
+ var MAX_SETUP_GUIDE_STEP_LENGTH = 200;
149
+ function toSetupGuide(value, warnings) {
150
+ if (value === void 0) {
151
+ return void 0;
152
+ }
153
+ if (!Array.isArray(value)) {
154
+ warnings.push("setupGuide must be an array of strings or step objects.");
155
+ return void 0;
156
+ }
157
+ const steps = [];
158
+ for (const entry of value) {
159
+ if (typeof entry === "string") {
160
+ const trimmed = entry.trim();
161
+ if (trimmed.length > 0) {
162
+ steps.push(trimmed.slice(0, MAX_SETUP_GUIDE_STEP_LENGTH));
163
+ }
164
+ } else if (isRecord(entry)) {
165
+ if (typeof entry.title !== "string" || entry.title.trim().length === 0) {
166
+ warnings.push("setupGuide step objects must have a non-empty title string.");
167
+ continue;
168
+ }
169
+ const step = {
170
+ title: entry.title.trim().slice(0, MAX_SETUP_GUIDE_STEP_LENGTH)
171
+ };
172
+ if (typeof entry.description === "string" && entry.description.trim().length > 0) {
173
+ step.description = entry.description.trim().slice(0, MAX_SETUP_GUIDE_STEP_LENGTH);
174
+ }
175
+ if (typeof entry.command === "string" && entry.command.trim().length > 0) {
176
+ step.command = entry.command.trim();
177
+ }
178
+ if (typeof entry.script === "string" && entry.script.trim().length > 0) {
179
+ step.script = entry.script.trim();
180
+ }
181
+ steps.push(step);
182
+ } else {
183
+ warnings.push("setupGuide entries must be strings or step objects.");
184
+ }
185
+ }
186
+ if (steps.length > MAX_SETUP_GUIDE_STEPS) {
187
+ warnings.push(`setupGuide may contain at most ${MAX_SETUP_GUIDE_STEPS} steps.`);
188
+ }
189
+ return steps.slice(0, MAX_SETUP_GUIDE_STEPS);
190
+ }
126
191
  function toPorts(value, warnings) {
127
192
  if (value === void 0) {
128
193
  return void 0;
@@ -177,6 +242,7 @@ function validateConfig(raw) {
177
242
  ports: toPorts(raw.ports, warnings),
178
243
  env,
179
244
  services,
245
+ setupGuide: toSetupGuide(raw.setupGuide ?? raw.setup_guide, warnings),
180
246
  docs
181
247
  },
182
248
  warnings
@@ -1549,9 +1615,207 @@ async function initCommand(cwd = process.cwd()) {
1549
1615
  }
1550
1616
  }
1551
1617
 
1552
- // src/cli/commands/run.ts
1618
+ // src/cli/commands/onboard.ts
1553
1619
  import pc3 from "picocolors";
1554
1620
 
1621
+ // src/core/onboarding/index.ts
1622
+ function hasWarning(warnings, id) {
1623
+ return warnings.some((warning2) => warning2.id === id);
1624
+ }
1625
+ function pickStartAction(scan) {
1626
+ if (scan.scripts.dev !== void 0) {
1627
+ return { kind: "run-script", label: "Start dev server", target: "dev" };
1628
+ }
1629
+ if (scan.scripts.start !== void 0) {
1630
+ return { kind: "run-script", label: "Start app", target: "start" };
1631
+ }
1632
+ const configuredCommands = {
1633
+ ...scan.presetCommands,
1634
+ ...scan.config?.config.commands
1635
+ };
1636
+ for (const name of ["dev", "start", "serve"]) {
1637
+ if (configuredCommands[name] !== void 0) {
1638
+ return { kind: "run-command", label: `Run ${name}`, target: name };
1639
+ }
1640
+ }
1641
+ return null;
1642
+ }
1643
+ function dockerStep(scan) {
1644
+ const docker = scan.docker;
1645
+ if (docker === null) {
1646
+ return null;
1647
+ }
1648
+ const runningServices = docker.services.filter((service) => service.status === "running");
1649
+ const allRunning = docker.services.length > 0 && runningServices.length === docker.services.length;
1650
+ if (docker.daemonStatus !== "running") {
1651
+ return {
1652
+ id: "docker-start",
1653
+ title: "Start Docker services",
1654
+ description: docker.message ?? "A Docker Compose file was found, but the Docker engine is not running.",
1655
+ status: "manual",
1656
+ blocking: false,
1657
+ action: { kind: "docker", label: "Open Services" }
1658
+ };
1659
+ }
1660
+ if (docker.services.length === 0 || allRunning) {
1661
+ return {
1662
+ id: "docker-start",
1663
+ title: "Start Docker services",
1664
+ description: docker.services.length === 0 ? "Docker is running. No Compose services need to be started." : "All Docker Compose services are running.",
1665
+ status: "done",
1666
+ blocking: false
1667
+ };
1668
+ }
1669
+ return {
1670
+ id: "docker-start",
1671
+ title: "Start Docker services",
1672
+ description: `${runningServices.length}/${docker.services.length} Compose services running. Start the rest in Services.`,
1673
+ status: "todo",
1674
+ blocking: false,
1675
+ action: { kind: "docker", label: "Open Services" }
1676
+ };
1677
+ }
1678
+ function buildOnboardingPlan(scan, warnings) {
1679
+ const steps = [];
1680
+ const isNodeProject = scan.language.detected.includes("node");
1681
+ if (isNodeProject && scan.packageJson !== null) {
1682
+ const needsInstall = hasWarning(warnings, "missing-node-modules");
1683
+ steps.push({
1684
+ id: "install-dependencies",
1685
+ title: "Install dependencies",
1686
+ description: needsInstall ? "node_modules is missing. Install dependencies before running scripts." : "Dependencies are installed.",
1687
+ status: needsInstall ? "todo" : "done",
1688
+ blocking: true,
1689
+ action: needsInstall ? { kind: "install", label: "Install" } : void 0
1690
+ });
1691
+ }
1692
+ if (scan.env?.hasExample) {
1693
+ const missingLocal = !scan.env.hasLocal;
1694
+ steps.push({
1695
+ id: "create-env",
1696
+ title: "Create .env file",
1697
+ description: missingLocal ? ".env.example exists but the local .env file is missing." : ".env is present.",
1698
+ status: missingLocal ? "todo" : "done",
1699
+ blocking: true,
1700
+ action: missingLocal ? { kind: "env-copy", label: "Copy .env" } : void 0
1701
+ });
1702
+ if (scan.env.hasLocal) {
1703
+ const unset = [.../* @__PURE__ */ new Set([...scan.env.missingKeys, ...scan.env.emptyKeys])];
1704
+ steps.push({
1705
+ id: "fill-env",
1706
+ title: "Fill in environment values",
1707
+ description: unset.length > 0 ? `Set values for: ${unset.join(", ")}. Values are intentionally hidden.` : "All environment keys from the example are present.",
1708
+ status: unset.length > 0 ? "manual" : "done",
1709
+ blocking: true
1710
+ });
1711
+ }
1712
+ }
1713
+ const docker = dockerStep(scan);
1714
+ if (docker !== null) {
1715
+ steps.push(docker);
1716
+ }
1717
+ const portsInUse = scan.ports.filter((probe) => probe.inUse);
1718
+ if (portsInUse.length > 0) {
1719
+ steps.push({
1720
+ id: "free-ports",
1721
+ title: "Resolve port conflicts",
1722
+ description: `Already in use: ${portsInUse.map((probe) => probe.port).join(", ")}. Stop the conflicting process or change the port.`,
1723
+ status: "manual",
1724
+ blocking: false
1725
+ });
1726
+ }
1727
+ const allCommands = { ...scan.presetCommands, ...scan.config?.config.commands ?? {} };
1728
+ for (const [index, entry] of (scan.config?.config.setupGuide ?? []).entries()) {
1729
+ if (typeof entry === "string") {
1730
+ steps.push({
1731
+ id: `guide-${index}`,
1732
+ title: entry,
1733
+ description: "From the project setup guide.",
1734
+ status: "manual",
1735
+ blocking: false
1736
+ });
1737
+ } else {
1738
+ const step = entry;
1739
+ let action;
1740
+ if (step.command !== void 0 && step.command in allCommands) {
1741
+ action = { kind: "run-command", label: "Run", target: step.command };
1742
+ } else if (step.script !== void 0) {
1743
+ action = { kind: "run-script", label: "Run", target: step.script };
1744
+ }
1745
+ steps.push({
1746
+ id: `guide-${index}`,
1747
+ title: step.title,
1748
+ description: step.description ?? "From the project setup guide.",
1749
+ status: action !== void 0 ? "todo" : "manual",
1750
+ blocking: false,
1751
+ action
1752
+ });
1753
+ }
1754
+ }
1755
+ const docs = scan.config?.config.docs;
1756
+ if (typeof docs === "string" && docs.length > 0 && isSafeHttpUrl(docs)) {
1757
+ steps.push({
1758
+ id: "read-docs",
1759
+ title: "Read the project docs",
1760
+ description: docs,
1761
+ status: "manual",
1762
+ blocking: false,
1763
+ action: { kind: "open-docs", label: "Open docs", target: docs }
1764
+ });
1765
+ }
1766
+ const startAction = pickStartAction(scan);
1767
+ if (startAction !== null) {
1768
+ steps.push({
1769
+ id: "start-app",
1770
+ title: "Start the app",
1771
+ description: "Run the development server once setup is complete.",
1772
+ status: "todo",
1773
+ blocking: false,
1774
+ action: startAction
1775
+ });
1776
+ }
1777
+ const blocking = steps.filter((step) => step.blocking);
1778
+ const blockingDone = blocking.filter((step) => step.status === "done");
1779
+ const readiness = blocking.length === 0 ? 100 : Math.round(blockingDone.length / blocking.length * 100);
1780
+ const ready = readiness === 100;
1781
+ const remaining = blocking.length - blockingDone.length;
1782
+ const summary = ready ? "Project is ready to run." : `${remaining} setup step${remaining === 1 ? "" : "s"} remaining before the project is ready.`;
1783
+ return { steps, readiness, ready, summary };
1784
+ }
1785
+
1786
+ // src/cli/commands/onboard.ts
1787
+ function statusGlyph(status) {
1788
+ if (status === "done") {
1789
+ return pc3.green("[x]");
1790
+ }
1791
+ if (status === "todo") {
1792
+ return pc3.yellow("[ ]");
1793
+ }
1794
+ return pc3.cyan("[~]");
1795
+ }
1796
+ async function onboardCommand(cwd = process.cwd()) {
1797
+ const scan = await scanProject(cwd);
1798
+ const warnings = await runDoctor(cwd, scan);
1799
+ const plan = buildOnboardingPlan(scan, warnings);
1800
+ console.log(pc3.bold(`Onboarding ${safeDisplayText(scan.projectName)}`));
1801
+ console.log(`${plan.readiness}% ready \u2014 ${safeDisplayText(plan.summary)}`);
1802
+ console.log("");
1803
+ if (plan.steps.length === 0) {
1804
+ console.log(pc3.green("No onboarding steps detected."));
1805
+ return;
1806
+ }
1807
+ for (const step of plan.steps) {
1808
+ console.log(`${statusGlyph(step.status)} ${pc3.bold(safeDisplayText(step.title))}`);
1809
+ console.log(` ${safeDisplayText(step.description)}`);
1810
+ if (step.action && step.status !== "done") {
1811
+ console.log(pc3.dim(` \u2192 ${safeDisplayText(step.action.label)}`));
1812
+ }
1813
+ }
1814
+ }
1815
+
1816
+ // src/cli/commands/run.ts
1817
+ import pc4 from "picocolors";
1818
+
1555
1819
  // src/core/process/runner.ts
1556
1820
  import spawn2 from "cross-spawn";
1557
1821
 
@@ -1765,7 +2029,7 @@ async function runCommand(script, cwd = process.cwd()) {
1765
2029
  const configuredCommand = scan.config?.config.commands?.[script] ?? scan.presetCommands[script];
1766
2030
  if (configuredCommand !== void 0) {
1767
2031
  if (isDangerousCommand(configuredCommand)) {
1768
- console.error(pc3.red(`Refusing to run dangerous command "${safeDisplayText(script)}".`));
2032
+ console.error(pc4.red(`Refusing to run dangerous command "${safeDisplayText(script)}".`));
1769
2033
  process.exitCode = 1;
1770
2034
  return;
1771
2035
  }
@@ -1782,17 +2046,17 @@ async function runCommand(script, cwd = process.cwd()) {
1782
2046
  ...Object.keys(scan.presetCommands)
1783
2047
  ];
1784
2048
  const hint = available.length > 0 ? ` Available commands: ${safeDisplayList(available)}.` : "";
1785
- console.error(pc3.red(`Command "${safeDisplayText(script)}" was not found.${hint}`));
2049
+ console.error(pc4.red(`Command "${safeDisplayText(script)}" was not found.${hint}`));
1786
2050
  process.exitCode = 1;
1787
2051
  }
1788
2052
 
1789
2053
  // src/cli/commands/scan.ts
1790
- import pc4 from "picocolors";
2054
+ import pc5 from "picocolors";
1791
2055
  function formatList(values) {
1792
2056
  return safeDisplayList(values);
1793
2057
  }
1794
2058
  function printScanResult(scan) {
1795
- console.log(pc4.bold(`Project: ${safeDisplayText(scan.projectName)}`));
2059
+ console.log(pc5.bold(`Project: ${safeDisplayText(scan.projectName)}`));
1796
2060
  console.log(`Language: ${formatList(scan.language.detected) || "unknown"}`);
1797
2061
  console.log(`Type: ${safeDisplayText(scan.framework?.type ?? "Unknown")}`);
1798
2062
  console.log(`Manager: ${safeDisplayText(scan.packageManager ?? "unknown")}`);
@@ -1819,7 +2083,7 @@ async function scanCommand(cwd = process.cwd()) {
1819
2083
  }
1820
2084
 
1821
2085
  // src/cli/commands/start.ts
1822
- import pc5 from "picocolors";
2086
+ import pc6 from "picocolors";
1823
2087
 
1824
2088
  // node_modules/open/index.js
1825
2089
  import process7 from "process";
@@ -2634,7 +2898,10 @@ var WorkspaceRegistry = class {
2634
2898
  }
2635
2899
  async write(entries) {
2636
2900
  await fs19.mkdir(path15.dirname(this.filePath), { recursive: true });
2637
- await fs19.writeFile(this.filePath, JSON.stringify(entries, null, 2) + "\n", "utf8");
2901
+ await fs19.writeFile(this.filePath, JSON.stringify(entries, null, 2) + "\n", {
2902
+ encoding: "utf8",
2903
+ mode: 384
2904
+ });
2638
2905
  }
2639
2906
  async seedFromEnv() {
2640
2907
  if (this.seeded) {
@@ -2721,7 +2988,7 @@ import path16 from "path";
2721
2988
  import spawn4 from "cross-spawn";
2722
2989
 
2723
2990
  // src/version.ts
2724
- var DEV_SURFACE_VERSION = "0.5.0";
2991
+ var DEV_SURFACE_VERSION = "0.7.0";
2725
2992
 
2726
2993
  // src/server/localAccess.ts
2727
2994
  var LOCAL_HOSTNAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
@@ -3082,6 +3349,11 @@ function handleDockerError(error, context) {
3082
3349
  }
3083
3350
  throw error;
3084
3351
  }
3352
+ async function onboardingForRoot(root) {
3353
+ const scan = await scanProject(root);
3354
+ const warnings = await runDoctor(root, scan);
3355
+ return buildOnboardingPlan(scan, warnings);
3356
+ }
3085
3357
  function registerWorkspaceRoutes(app, resolveWorkspace) {
3086
3358
  app.get("/api/workspaces/:id/project", async (context) => {
3087
3359
  const ws = await resolveWorkspace(context.req.param("id"));
@@ -3093,6 +3365,11 @@ function registerWorkspaceRoutes(app, resolveWorkspace) {
3093
3365
  if (!ws) return context.json({ error: "Workspace not found." }, 404);
3094
3366
  return context.json(await runDoctor(ws.root));
3095
3367
  });
3368
+ app.get("/api/workspaces/:id/onboarding", async (context) => {
3369
+ const ws = await resolveWorkspace(context.req.param("id"));
3370
+ if (!ws) return context.json({ error: "Workspace not found." }, 404);
3371
+ return context.json(await onboardingForRoot(ws.root));
3372
+ });
3096
3373
  app.get("/api/workspaces/:id/processes", async (context) => {
3097
3374
  const ws = await resolveWorkspace(context.req.param("id"));
3098
3375
  if (!ws) return context.json({ error: "Workspace not found." }, 404);
@@ -3315,6 +3592,11 @@ function registerHubApiRoutes(app, options) {
3315
3592
  if (entries.length === 0) return context.json({ error: "No workspaces registered." }, 404);
3316
3593
  return context.json(await runDoctor(hub.ensure(entries[0]).root));
3317
3594
  });
3595
+ app.get("/api/onboarding", async (context) => {
3596
+ const entries = await hub.registry.list();
3597
+ if (entries.length === 0) return context.json({ error: "No workspaces registered." }, 404);
3598
+ return context.json(await onboardingForRoot(hub.ensure(entries[0]).root));
3599
+ });
3318
3600
  app.get("/api/processes", async (context) => {
3319
3601
  const entries = await hub.registry.list();
3320
3602
  if (entries.length === 0) return context.json([]);
@@ -3418,6 +3700,17 @@ function setupHubWebSocket(server, hub) {
3418
3700
  }
3419
3701
 
3420
3702
  // src/server/index.ts
3703
+ function warnIfContainerRootsUnset(host) {
3704
+ if (host !== "0.0.0.0" && host !== "::") {
3705
+ return;
3706
+ }
3707
+ if (process.env.DEVSURFACE_WORKSPACE_ROOTS?.trim()) {
3708
+ return;
3709
+ }
3710
+ console.warn(
3711
+ "Warning: DEVSURFACE_WORKSPACE_ROOTS is unset in container mode. Any loopback or private-network client can register arbitrary directories as workspaces. Set DEVSURFACE_WORKSPACE_ROOTS to restrict workspace registration."
3712
+ );
3713
+ }
3421
3714
  async function fileExists(filePath) {
3422
3715
  try {
3423
3716
  await fs21.access(filePath);
@@ -3533,6 +3826,7 @@ async function startHubServer(options) {
3533
3826
  const port = options.port ?? DEFAULT_PORT;
3534
3827
  const hub = new Hub({ dataDir: options.dataDir });
3535
3828
  hub.attachCleanupHandlers();
3829
+ warnIfContainerRootsUnset(host);
3536
3830
  if (options.initialWorkspace) {
3537
3831
  await hub.registry.add(options.initialWorkspace);
3538
3832
  }
@@ -3608,7 +3902,7 @@ function dashboardUrl(workspaceId2, port = DEFAULT_PORT, host = DEFAULT_HOST) {
3608
3902
  async function startCommand(options) {
3609
3903
  const cwd = options.cwd ?? process.cwd();
3610
3904
  const port = options.port ?? 4567;
3611
- console.log(pc5.bold(`DevSurface v${DEV_SURFACE_VERSION}`));
3905
+ console.log(pc6.bold(`DevSurface v${DEV_SURFACE_VERSION}`));
3612
3906
  console.log("Scanning project...\n");
3613
3907
  const scan = await scanProject(cwd);
3614
3908
  printScanResult(scan);
@@ -3616,7 +3910,7 @@ async function startCommand(options) {
3616
3910
  if (warnings.length > 0) {
3617
3911
  console.log("\nWarnings:");
3618
3912
  for (const item of warnings) {
3619
- const marker = item.severity === "error" ? pc5.red("!") : pc5.yellow("!");
3913
+ const marker = item.severity === "error" ? pc6.red("!") : pc6.yellow("!");
3620
3914
  console.log(` ${marker} ${item.title}`);
3621
3915
  }
3622
3916
  }
@@ -3625,8 +3919,8 @@ async function startCommand(options) {
3625
3919
  const registered = await registerWorkspaceRemotely(cwd, port);
3626
3920
  if (registered) {
3627
3921
  const url = dashboardUrl(registered.id, port);
3628
- console.log(`Workspace ${pc5.cyan(registered.name)} attached.`);
3629
- console.log(`Dashboard -> ${pc5.cyan(url)}`);
3922
+ console.log(`Workspace ${pc6.cyan(registered.name)} attached.`);
3923
+ console.log(`Dashboard -> ${pc6.cyan(url)}`);
3630
3924
  if (options.openBrowser !== false) {
3631
3925
  await open_default(url);
3632
3926
  }
@@ -3640,13 +3934,13 @@ async function startCommand(options) {
3640
3934
  initialWorkspace: cwd
3641
3935
  });
3642
3936
  console.log(`
3643
- Dashboard running at -> ${pc5.cyan(server.url)}`);
3937
+ Dashboard running at -> ${pc6.cyan(server.url)}`);
3644
3938
  }
3645
3939
 
3646
3940
  // src/cli/commands/serve.ts
3647
- import pc6 from "picocolors";
3941
+ import pc7 from "picocolors";
3648
3942
  async function serveCommand(options) {
3649
- console.log(pc6.bold(`DevSurface Hub v${DEV_SURFACE_VERSION}`));
3943
+ console.log(pc7.bold(`DevSurface Hub v${DEV_SURFACE_VERSION}`));
3650
3944
  console.log("Starting hub server...\n");
3651
3945
  const server = await startHubServer({
3652
3946
  port: options.port,
@@ -3656,7 +3950,7 @@ async function serveCommand(options) {
3656
3950
  if (summaries.length > 0) {
3657
3951
  console.log(`Registered workspaces: ${summaries.length}`);
3658
3952
  for (const ws of summaries) {
3659
- console.log(` ${pc6.cyan(ws.name)} -> ${ws.path}`);
3953
+ console.log(` ${pc7.cyan(ws.name)} -> ${ws.path}`);
3660
3954
  }
3661
3955
  } else {
3662
3956
  console.log(
@@ -3664,17 +3958,17 @@ async function serveCommand(options) {
3664
3958
  );
3665
3959
  }
3666
3960
  console.log(`
3667
- Hub running at -> ${pc6.cyan(server.url)}`);
3961
+ Hub running at -> ${pc7.cyan(server.url)}`);
3668
3962
  }
3669
3963
 
3670
3964
  // src/cli/commands/workspace.ts
3671
3965
  import path18 from "path";
3672
- import pc7 from "picocolors";
3966
+ import pc8 from "picocolors";
3673
3967
  async function workspaceAddCommand(dirPath) {
3674
3968
  const registry = new WorkspaceRegistry();
3675
3969
  const target = path18.resolve(dirPath ?? process.cwd());
3676
3970
  const entry = await registry.add(target);
3677
- console.log(`Added workspace ${pc7.cyan(entry.name)} (${entry.id}) -> ${entry.path}`);
3971
+ console.log(`Added workspace ${pc8.cyan(entry.name)} (${entry.id}) -> ${entry.path}`);
3678
3972
  }
3679
3973
  async function workspaceListCommand() {
3680
3974
  const registry = new WorkspaceRegistry();
@@ -3688,7 +3982,7 @@ async function workspaceListCommand() {
3688
3982
  console.log(`${entries.length} workspace${entries.length === 1 ? "" : "s"}:
3689
3983
  `);
3690
3984
  for (const entry of entries) {
3691
- console.log(` ${pc7.cyan(entry.name)} (${entry.id})`);
3985
+ console.log(` ${pc8.cyan(entry.name)} (${entry.id})`);
3692
3986
  console.log(` ${entry.path}`);
3693
3987
  }
3694
3988
  }
@@ -3696,7 +3990,7 @@ async function workspaceRemoveCommand(id) {
3696
3990
  const registry = new WorkspaceRegistry();
3697
3991
  const removed = await registry.remove(id);
3698
3992
  if (removed) {
3699
- console.log(`Removed workspace ${pc7.cyan(id)}.`);
3993
+ console.log(`Removed workspace ${pc8.cyan(id)}.`);
3700
3994
  } else {
3701
3995
  console.error(`Workspace "${id}" not found.`);
3702
3996
  process.exitCode = 1;
@@ -3826,6 +4120,9 @@ program.command("scan").description("Print detected project info.").action(() =>
3826
4120
  program.command("doctor").description("Print setup health warnings.").action(() => {
3827
4121
  handle(doctorCommand(process.cwd()));
3828
4122
  });
4123
+ program.command("onboard").description("Print a guided setup checklist with readiness score.").action(() => {
4124
+ handle(onboardCommand(process.cwd()));
4125
+ });
3829
4126
  program.command("init").description("Create a starter devsurface.config.json.").action(() => {
3830
4127
  handle(initCommand(process.cwd()));
3831
4128
  });