codehost 0.17.0 → 0.18.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,3 +1,11 @@
1
+ # [0.18.0](https://github.com/snomiao/codehost/compare/v0.17.0...v0.18.0) (2026-06-11)
2
+
3
+
4
+ ### Features
5
+
6
+ * **cli:** ~/ws as the default workspace root + loud y/N confirm for serving $HOME ([aee5a20](https://github.com/snomiao/codehost/commit/aee5a207a930058472f5b2042a56904051e079a7))
7
+ * **provision:** batteries-included roots — setup auto-scaffolds .codehost/, scaffold detaches fresh clones ([625d958](https://github.com/snomiao/codehost/commit/625d9587d97b59da0bb595aa456a56d3bf920ac4))
8
+
1
9
  # [0.17.0](https://github.com/snomiao/codehost/compare/v0.16.0...v0.17.0) (2026-06-11)
2
10
 
3
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,10 +1,11 @@
1
- import { hostname } from "node:os";
1
+ import { mkdirSync } from "node:fs";
2
+ import { homedir, hostname } from "node:os";
2
3
  import { resolve } from "node:path";
3
4
  import type { CommandModule } from "yargs";
4
5
  import type { PeerMeta } from "../../shared/signaling";
5
6
  import { DEFAULT_LAYOUT, GITHUB_HOST, toPosixPath } from "../../shared/repo";
6
7
  import { TOKEN_REQUIREMENTS, validateToken } from "../../shared/token";
7
- import { ensureHostId } from "../config";
8
+ import { defaultRoot, ensureHostId } from "../config";
8
9
  import { launchServeDaemon } from "../daemonize";
9
10
  import { announceConnect } from "../open-url";
10
11
  import { agentYesPlugin } from "../plugins/agent-yes";
@@ -23,8 +24,43 @@ import { enumerateWorkspaces } from "../workspaces";
23
24
 
24
25
  export const DEFAULT_SIGNAL_URL = "wss://signal.codehost.dev";
25
26
 
27
+ /** Warn + interactively confirm a risky root (default No). Non-TTY contexts
28
+ * (the oxmgr-daemonized child, CI) can't answer — there the human already
29
+ * confirmed at launch time, so warn loudly and proceed. */
30
+ export async function confirmRiskyRoot(dir: string): Promise<boolean> {
31
+ const warning = rootWarning(dir);
32
+ if (!warning) return true;
33
+ console.warn(`[codehost] warning: ${warning}`);
34
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return true;
35
+ process.stdout.write("[codehost] serve it anyway? [y/N] ");
36
+ const line = await new Promise<string>((res) => {
37
+ process.stdin.resume();
38
+ process.stdin.once("data", (d) => {
39
+ process.stdin.pause();
40
+ res(String(d));
41
+ });
42
+ });
43
+ return /^y(es)?$/i.test(line.trim());
44
+ }
45
+
46
+ /** Warning for serving a risky workspace root, or null if fine. $HOME (and the
47
+ * filesystem root) expose everything you own over the room, collide the
48
+ * provisioning `.codehost/` with the machine-level `~/.codehost`, and make
49
+ * workspace enumeration walk your whole home. Allowed if you insist — but
50
+ * say so loudly and point at a dedicated dir like ~/ws. */
51
+ export function rootWarning(dir: string): string | null {
52
+ const norm = resolve(dir);
53
+ if (norm === resolve(homedir())) {
54
+ return "serving your HOME directory as the workspace root — everything in it is reachable through this room. A dedicated dir is safer: codehost serve ~/ws";
55
+ }
56
+ if (norm === resolve("/")) {
57
+ return "serving the filesystem root — the entire machine is reachable through this room. A dedicated dir is safer, e.g. ~/ws";
58
+ }
59
+ return null;
60
+ }
61
+
26
62
  interface ServeArgs {
27
- dir: string;
63
+ dir?: string;
28
64
  token: string;
29
65
  name?: string;
30
66
  signal: string;
@@ -39,9 +75,8 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
39
75
  builder: (y) =>
40
76
  y
41
77
  .positional("dir", {
42
- describe: "Directory to serve (defaults to cwd)",
78
+ describe: "Workspace root to serve (default: the remembered root, else ~/ws)",
43
79
  type: "string",
44
- default: ".",
45
80
  })
46
81
  .option("token", {
47
82
  alias: "t",
@@ -77,7 +112,17 @@ export const serveCommand: CommandModule<{}, ServeArgs> = {
77
112
  process.exit(1);
78
113
  }
79
114
 
80
- const dir = resolve(process.cwd(), argv.dir);
115
+ // Explicit dir > remembered root (config.json) > ~/ws. Bare `codehost
116
+ // serve` should land somewhere sane, never accidentally on $HOME/cwd.
117
+ const dir = argv.dir ? resolve(process.cwd(), argv.dir) : defaultRoot();
118
+ if (!argv.dir) {
119
+ mkdirSync(dir, { recursive: true });
120
+ console.log(`[codehost] no dir given — serving workspace root ${dir}`);
121
+ }
122
+ if (!(await confirmRiskyRoot(dir))) {
123
+ console.error("[codehost] aborted");
124
+ process.exit(1);
125
+ }
81
126
  const host = hostname();
82
127
 
83
128
  // `-d`: re-launch this same `serve` (without -d) under oxmgr, then exit.
@@ -1,16 +1,19 @@
1
+ import { mkdirSync } from "node:fs";
1
2
  import { hostname } from "node:os";
2
3
  import { resolve } from "node:path";
3
4
  import type { CommandModule } from "yargs";
4
5
  import { generateToken, validateToken, TOKEN_REQUIREMENTS } from "../../shared/token";
5
- import { readConfig, writeConfig } from "../config";
6
+ import { defaultRoot, readConfig, writeConfig } from "../config";
6
7
  import { launchServeDaemon } from "../daemonize";
7
8
  import { isGitRepo } from "../git";
9
+ import { scaffoldCodehost } from "../init";
10
+ import { confirmRiskyRoot } from "./serve";
8
11
  import { resolveCodeBinary } from "../vscode-install";
9
12
  import { announceConnect } from "../open-url";
10
13
  import { DEFAULT_SIGNAL_URL } from "./serve";
11
14
 
12
15
  interface SetupArgs {
13
- dir: string;
16
+ dir?: string;
14
17
  token?: string;
15
18
  newToken: boolean;
16
19
  name?: string;
@@ -23,7 +26,10 @@ export const setupCommand: CommandModule<{}, SetupArgs> = {
23
26
  describe: "One-shot: pick a token, ensure VS Code, and start a daemonized server",
24
27
  builder: (y) =>
25
28
  y
26
- .positional("dir", { describe: "Directory to serve (defaults to cwd)", type: "string", default: "." })
29
+ .positional("dir", {
30
+ describe: "Directory to serve (default: a git cwd serves itself, else the remembered root / ~/ws)",
31
+ type: "string",
32
+ })
27
33
  .option("token", {
28
34
  alias: "t",
29
35
  describe: "Room token (generated + saved if omitted)",
@@ -38,7 +44,23 @@ export const setupCommand: CommandModule<{}, SetupArgs> = {
38
44
  .option("signal", { describe: "Signaling server URL", type: "string", default: DEFAULT_SIGNAL_URL })
39
45
  .option("port", { describe: "Fixed port for the local VS Code server", type: "number" }) as any,
40
46
  handler: async (argv) => {
41
- const dir = resolve(process.cwd(), argv.dir);
47
+ // Explicit dir > a git cwd (serve THIS repo) > remembered root > ~/ws —
48
+ // so a bare `codehost setup` (e.g. from the installer) lands on a sane
49
+ // workspace root instead of whatever directory it happened to run in.
50
+ let dir: string;
51
+ if (argv.dir) {
52
+ dir = resolve(process.cwd(), argv.dir);
53
+ } else if (isGitRepo(process.cwd())) {
54
+ dir = process.cwd();
55
+ } else {
56
+ dir = defaultRoot();
57
+ mkdirSync(dir, { recursive: true });
58
+ console.log(`[codehost] no dir given — using workspace root ${dir}`);
59
+ }
60
+ if (!(await confirmRiskyRoot(dir))) {
61
+ console.error("[codehost] aborted");
62
+ process.exit(1);
63
+ }
42
64
  const host = hostname();
43
65
 
44
66
  // 1. Resolve the room token: validate an explicit one, otherwise reuse the
@@ -53,10 +75,24 @@ export const setupCommand: CommandModule<{}, SetupArgs> = {
53
75
  const codeBin = await resolveCodeBinary();
54
76
  console.log(`[codehost] using VS Code: ${codeBin}`);
55
77
 
56
- // 3. Start the WebRTC + VS Code server under oxmgr. A git repo is a single
78
+ // 3. Batteries included: a workspace root gets its `.codehost/` scaffold
79
+ // (config.yaml + clone/worktree setup hook) so /gh/<owner>/<repo> links
80
+ // provision on demand out of the box. Existing files are never touched;
81
+ // no new trust — the room token already grants code execution.
82
+ const root = !isGitRepo(dir);
83
+ if (root) {
84
+ const written = scaffoldCodehost(dir);
85
+ if (written.length > 0) {
86
+ console.log(`[codehost] scaffolded ${dir}/.codehost (config.yaml + setup hook — edit freely)`);
87
+ }
88
+ // Remember an explicitly chosen root so future bare runs reuse it.
89
+ if (argv.dir) writeConfig({ ...readConfig(), root: dir });
90
+ }
91
+
92
+ // 4. Start the WebRTC + VS Code server under oxmgr. A git repo is a single
57
93
  // workspace (`dev`); anything else is treated as a root (`serve`).
58
94
  const { ok, name } = await launchServeDaemon({
59
- command: isGitRepo(dir) ? "dev" : "serve",
95
+ command: root ? "serve" : "dev",
60
96
  dir,
61
97
  token,
62
98
  signal: argv.signal,
@@ -66,7 +102,7 @@ export const setupCommand: CommandModule<{}, SetupArgs> = {
66
102
  });
67
103
  if (!ok) process.exit(1);
68
104
 
69
- // 4. Tell the user how to connect, and open the browser straight at the
105
+ // 5. Tell the user how to connect, and open the browser straight at the
70
106
  // token-carrying URL so VS Code loads without typing the token in.
71
107
  console.log("");
72
108
  console.log(`[codehost] ✓ server "${name}" is live, serving ${dir}`);
package/src/cli/config.ts CHANGED
@@ -15,6 +15,9 @@ export interface CliConfig {
15
15
  * this machine advertises it, so the web UI can group peers by host and
16
16
  * history entries survive daemon restarts (peerIds are per-process). */
17
17
  hostId?: string;
18
+ /** Workspace root remembered from an explicit `codehost setup <dir>` —
19
+ * reused when serve/setup run with no dir. Absent -> ~/ws. */
20
+ root?: string;
18
21
  }
19
22
 
20
23
  export function readConfig(file: string = CONFIG_FILE): CliConfig {
@@ -30,6 +33,12 @@ export function writeConfig(config: CliConfig, file: string = CONFIG_FILE): void
30
33
  writeFileSync(file, JSON.stringify(config, null, 2));
31
34
  }
32
35
 
36
+ /** The workspace root to use when no dir was given: the remembered root from
37
+ * config, else ~/ws (created on demand by the caller). Never $HOME itself. */
38
+ export function defaultRoot(file: string = CONFIG_FILE): string {
39
+ return readConfig(file).root || join(homedir(), "ws");
40
+ }
41
+
33
42
  /** This machine's persistent hostId, minting + saving it on first call. */
34
43
  export function ensureHostId(file: string = CONFIG_FILE): string {
35
44
  const config = readConfig(file);
@@ -23,11 +23,11 @@ describe("scaffoldCodehost", () => {
23
23
  expect(existsSync(join(home, ".codehost", "setup.ps1"))).toBe(true);
24
24
  });
25
25
 
26
- test("config.yaml is valid YAML with a workspace template", () => {
26
+ test("config.yaml is valid YAML with a prefix-free workspace template", () => {
27
27
  const home = mkHome();
28
28
  scaffoldCodehost(home);
29
29
  const cfg = parseYaml(readFileSync(join(home, ".codehost", "config.yaml"), "utf8"));
30
- expect(cfg.workspace).toBe("ws/{owner}/{repo}/tree/{branch}");
30
+ expect(cfg.workspace).toBe("{owner}/{repo}/tree/{branch}");
31
31
  });
32
32
 
33
33
  test("setup.sh keeps shell vars literal (no JS interpolation leaked)", () => {
@@ -37,6 +37,8 @@ describe("scaffoldCodehost", () => {
37
37
  expect(sh).toContain("$CODEHOST_WS");
38
38
  expect(sh).toContain("${ws%/tree/$CODEHOST_BRANCH}");
39
39
  expect(sh).toContain("git -C \"$repo\" worktree add");
40
+ // Fresh clones detach so the default branch is worktree-able too.
41
+ expect(sh).toContain('git -C "$repo" switch --detach');
40
42
  });
41
43
 
42
44
  test("idempotent: a second run writes nothing; --force overwrites", () => {
package/src/cli/init.ts CHANGED
@@ -7,9 +7,9 @@ import { join } from "node:path";
7
7
 
8
8
  const CONFIG_YAML = `# codehost workspace config — see docs/provisioning.md
9
9
 
10
- # Where /gh/<owner>/<repo>/tree/<branch> lands, relative to this home dir.
11
- # Default (if omitted): {owner}/{repo}/tree/{branch}
12
- workspace: "ws/{owner}/{repo}/tree/{branch}"
10
+ # Where /gh/<owner>/<repo>/tree/<branch> lands, relative to this served root
11
+ # (serve a dedicated workspace dir like ~/ws — never \$HOME itself).
12
+ workspace: "{owner}/{repo}/tree/{branch}"
13
13
 
14
14
  # Optional: only these repos may auto-provision (a trailing /* is an owner
15
15
  # wildcard). Empty/absent = allow all. The room token already grants access, so
@@ -41,6 +41,10 @@ if [ ! -e "$repo/.git" ]; then
41
41
  echo "[setup] cloning $url"
42
42
  mkdir -p "$(dirname "$repo")"
43
43
  git clone "$url" "$repo"
44
+ # Detach the fresh primary clone so EVERY branch (incl. the default one it
45
+ # just checked out) can be worktree-added under tree/<branch>. Skipped for
46
+ # pre-existing clones — they may be someone's live working copy.
47
+ git -C "$repo" switch --detach
44
48
  fi
45
49
 
46
50
  echo "[setup] adding worktree $ws @ $CODEHOST_BRANCH"
@@ -67,6 +71,8 @@ if (-not (Test-Path (Join-Path $repo ".git"))) {
67
71
  Write-Host "[setup] cloning $url"
68
72
  New-Item -ItemType Directory -Force -Path (Split-Path $repo) | Out-Null
69
73
  git clone $url $repo
74
+ # Detach so every branch (incl. the default) can be worktree-added.
75
+ git -C $repo switch --detach
70
76
  }
71
77
  Write-Host "[setup] adding worktree $ws @ $($env:CODEHOST_BRANCH)"
72
78
  New-Item -ItemType Directory -Force -Path (Split-Path $ws) | Out-Null