diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..06521fb
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,5 @@
+# Shell-executable scripts carry a shebang (#!/usr/bin/env node). A CRLF shebang
+# breaks execution on Unix and breaks esbuild/vite hashbang stripping when the
+# file is imported from a test (test/skill.test.ts fails to parse on a CRLF
+# checkout). Force LF regardless of the contributor's core.autocrlf.
+*.mjs text eol=lf
diff --git a/docs/superpowers/plans/2026-07-03-self-hosted-mode-phase2.md b/docs/superpowers/plans/2026-07-03-self-hosted-mode-phase2.md
new file mode 100644
index 0000000..8f56bd4
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-03-self-hosted-mode-phase2.md
@@ -0,0 +1,1165 @@
+# self-hosted 모드 Phase 2 (자동 설치) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** `hostdoc setup --serve-root
--install`이 Caddy를 확보(재사용/다운로드)하고 Caddyfile을 기록하고 OS 시스템 서비스(launchd/systemd)로 등록·구동해 재부팅 후에도 서빙이 유지되게 하며, `hostdoc deprovision`이 이를 되돌린다.
+
+**Architecture:** 플랫폼 감지·서비스 파일 생성은 순수 함수(`platform.ts`/`service.ts`), 다운로드·파일 기록·서비스 제어는 주입 가능한 `run`/`fetch` 경계(`installer.ts`) 뒤로 격리 — `terraform.ts`가 `execFileSync`로 shell-out하는 것과 동형. Caddyfile은 Phase 1 `caddySnippet`을 **재사용**해 사용자 소유 경로(`~/.config/hostdoc/Caddyfile`)에 기록하고, 바이너리는 PATH 재사용 또는 `~/.local/bin/caddy`로 다운로드(둘 다 비특권). 시스템 서비스 파일 배치·활성화만 root가 필요하며, root이 아니면 **정확한 `sudo` 명령을 출력하고 중단**(hostdoc이 임의 sudo 실행 안 함).
+
+**Tech Stack:** TypeScript(ESM, `.js` 확장자 import), Node 18+(global `fetch`), Commander, Vitest.
+
+## Global Constraints
+
+- ESM: `.ts` 소스의 상대 import는 `.js` 확장자 필수 (`./lib/installer.js`).
+- 빌드는 `tsc`(emit), 타입체크는 `tsgo`(`npm run typecheck`). 둘 다 통과해야 함.
+- 테스트는 `vitest run`. **AWS 크레덴셜·Terraform·실네트워크·root 없이** 통과해야 함(CI 불변).
+- 기존 `s3-website`·`cloudfront`·Phase 1 self-hosted 동작·테스트 무회귀가 전제. `src/lib/aws.ts`·`src/lib/cloudfront.ts`·`src/lib/terraform.ts`·`src/lib/snippet.ts`는 수정하지 않는다.
+- Caddy 자동설치는 **Caddy 전용**. nginx는 스니펫 수동 유지(Phase 1).
+- 시스템 서비스 파일·바이너리·Caddyfile 경로는 절대경로로 서비스에 전달 — 특권 단계에서 config/HOME에 의존하지 않는다.
+- `--install`은 **가산적** — 플래그 없으면 Phase 1 스니펫 경로(`runSetupSelfHosted`) 그대로.
+- 커밋 메시지 말미: `Co-Authored-By: Claude Opus 4.8 `.
+
+## File Structure
+
+- Create `src/lib/platform.ts` — 순수: 플랫폼/아키텍처 감지 → Caddy 다운로드 URL. win32 → Phase 3 에러.
+- Modify `src/lib/which.ts` — `whichPath(bin)` 추가(절대경로 반환; 서비스 ExecStart용). 기존 `onPath` 유지.
+- Create `src/lib/service.ts` — 순수: `launchdPlist`/`systemdUnit` 파일 내용 생성기 + 라벨/유닛 상수.
+- Create `src/lib/installer.ts` — I/O 경계: 경로 헬퍼 + `downloadCaddy`/`installService`/`removeService` + `sudoInstallCommands`/`sudoRemoveCommands` + `isRoot`.
+- Modify `src/commands/setup-selfhosted.ts` — `runInstallSelfHosted`(Caddy 확보 → Caddyfile·서비스 파일 기록 → root면 설치, 아니면 sudo 명령). nginx면 스니펫 폴백.
+- Create `src/commands/deprovision-selfhosted.ts` — self-hosted teardown(사용자 파일 제거 + root면 removeService, 아니면 sudo 명령).
+- Modify `src/index.ts` — `setup --install` 배선, `deprovision` 모드 라우팅.
+- Tests: `test/platform.test.ts`, `test/which.test.ts`, `test/service.test.ts`, `test/installer.test.ts`, `test/setup-selfhosted.test.ts`(확장), `test/deprovision-selfhosted.test.ts`.
+
+---
+
+### Task 1: platform.ts — 플랫폼 감지 + Caddy 다운로드 URL
+
+**Files:**
+- Create: `src/lib/platform.ts`
+- Test: `test/platform.test.ts`
+
+**Interfaces:**
+- Produces:
+ - `interface Platform { os: "darwin" | "linux"; arch: "amd64" | "arm64" }`
+ - `function detectPlatform(nodePlatform?: string, nodeArch?: string): Platform` — 기본값 `process.platform`/`process.arch`. 미지원 → throw.
+ - `function caddyDownloadUrl(plat: Platform): string`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `test/platform.test.ts`:
+
+```ts
+import { describe, it, expect } from "vitest";
+import { detectPlatform, caddyDownloadUrl } from "../src/lib/platform.js";
+
+describe("detectPlatform", () => {
+ it("maps darwin + arm64", () => {
+ expect(detectPlatform("darwin", "arm64")).toEqual({ os: "darwin", arch: "arm64" });
+ });
+ it("maps linux + x64 to amd64", () => {
+ expect(detectPlatform("linux", "x64")).toEqual({ os: "linux", arch: "amd64" });
+ });
+ it("throws a Phase 3 error for win32", () => {
+ expect(() => detectPlatform("win32", "x64")).toThrow(/Phase 3|not supported/i);
+ });
+ it("throws for an unsupported arch", () => {
+ expect(() => detectPlatform("linux", "ia32")).toThrow(/arch/i);
+ });
+});
+
+describe("caddyDownloadUrl", () => {
+ it("builds the official download URL from os + arch", () => {
+ expect(caddyDownloadUrl({ os: "linux", arch: "amd64" })).toBe(
+ "https://caddyserver.com/api/download?os=linux&arch=amd64",
+ );
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run test/platform.test.ts`
+Expected: FAIL — `Cannot find module '../src/lib/platform.js'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `src/lib/platform.ts`:
+
+```ts
+export interface Platform {
+ os: "darwin" | "linux";
+ arch: "amd64" | "arm64";
+}
+
+/**
+ * Map Node's process.platform/arch to Caddy's download naming. Only the
+ * platforms Phase 2 supports (macOS, Linux) are accepted; everything else
+ * (Windows, NAS packaging) is deferred to Phase 3 with a clear error rather
+ * than silently picking the wrong binary.
+ */
+export function detectPlatform(
+ nodePlatform: string = process.platform,
+ nodeArch: string = process.arch,
+): Platform {
+ let os: Platform["os"];
+ if (nodePlatform === "darwin") os = "darwin";
+ else if (nodePlatform === "linux") os = "linux";
+ else
+ throw new Error(
+ `Automatic install is not supported on "${nodePlatform}" yet (Windows/NAS is Phase 3). ` +
+ "Use `hostdoc setup --serve-root ` to emit a manual web-server snippet instead.",
+ );
+
+ let arch: Platform["arch"];
+ if (nodeArch === "x64") arch = "amd64";
+ else if (nodeArch === "arm64") arch = "arm64";
+ else throw new Error(`Unsupported CPU arch "${nodeArch}" (need x64 or arm64).`);
+
+ return { os, arch };
+}
+
+/** Caddy's official static-binary download service (returns the binary). */
+export function caddyDownloadUrl(plat: Platform): string {
+ return `https://caddyserver.com/api/download?os=${plat.os}&arch=${plat.arch}`;
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run test/platform.test.ts` → Expected: PASS (5 tests).
+Run: `npm run typecheck` → Expected: no errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/platform.ts test/platform.test.ts
+git commit -m "feat: platform detection + Caddy download URL (Phase 2)
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+### Task 2: which.ts — `whichPath` (절대경로 해석)
+
+**Files:**
+- Modify: `src/lib/which.ts`
+- Test: `test/which.test.ts`
+
+**Interfaces:**
+- Consumes: 기존 `onPath`(무변경).
+- Produces: `function whichPath(bin: string): string | null` — PATH의 첫 디렉토리에서 `bin`(또는 `bin.exe`)의 절대경로를 반환, 없으면 `null`. 서비스 ExecStart는 절대경로가 필요하므로 boolean이 아닌 경로가 필요.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `test/which.test.ts`:
+
+```ts
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, writeFileSync, chmodSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, delimiter } from "node:path";
+import { whichPath } from "../src/lib/which.js";
+
+let dir: string;
+const savedPath = process.env.PATH;
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "which-"));
+});
+afterEach(() => {
+ process.env.PATH = savedPath;
+});
+
+describe("whichPath", () => {
+ it("returns the absolute path of a binary on PATH", () => {
+ const bin = join(dir, "mybin");
+ writeFileSync(bin, "#!/bin/sh\n");
+ chmodSync(bin, 0o755);
+ process.env.PATH = `${dir}${delimiter}/nonexistent`;
+ expect(whichPath("mybin")).toBe(bin);
+ });
+ it("returns null when the binary is not on PATH", () => {
+ process.env.PATH = dir;
+ expect(whichPath("definitely-not-here")).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run test/which.test.ts`
+Expected: FAIL — `whichPath` is not exported.
+
+- [ ] **Step 3: Write minimal implementation (append to `src/lib/which.ts`)**
+
+Append after the existing `onPath`:
+
+```ts
+/** Absolute path of `bin` (or `bin.exe`) if found on PATH, else null. */
+export function whichPath(bin: string): string | null {
+ const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
+ for (const d of dirs) {
+ const p = join(d, bin);
+ if (existsSync(p)) return p;
+ const pExe = join(d, `${bin}.exe`);
+ if (existsSync(pExe)) return pExe;
+ }
+ return null;
+}
+```
+
+(`existsSync`, `delimiter`, `join`는 이미 파일 상단에서 import됨.)
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run test/which.test.ts` → Expected: PASS (2 tests).
+Run: `npm run typecheck` → Expected: no errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/which.ts test/which.test.ts
+git commit -m "feat: add whichPath (absolute-path resolver for service ExecStart)
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+### Task 3: service.ts — launchd plist + systemd unit 생성기
+
+**Files:**
+- Create: `src/lib/service.ts`
+- Test: `test/service.test.ts`
+
+**Interfaces:**
+- Produces:
+ - `const LAUNCHD_LABEL = "com.hostdoc.caddy"`
+ - `const SYSTEMD_UNIT = "hostdoc-caddy"`
+ - `interface ServiceOpts { caddyBin: string; caddyfilePath: string }`
+ - `function launchdPlist(opts: ServiceOpts): string`
+ - `function systemdUnit(opts: ServiceOpts): string`
+- 참조: systemd 지시자는 공식 `caddyserver/dist/init/caddy.service`(Type/ExecStart/ExecReload/AmbientCapabilities/WantedBy)에서 검증. 우리는 다운로드 바이너리 + hostdoc Caddyfile 경로로 파라미터화하고, 전용 `caddy` 유저 생성 복잡도를 피하려 `User/Group`을 생략(서비스는 root로 구동; `CAP_NET_BIND_SERVICE` 유지).
+
+- [ ] **Step 1: Write the failing test**
+
+Create `test/service.test.ts`:
+
+```ts
+import { describe, it, expect } from "vitest";
+import { launchdPlist, systemdUnit, LAUNCHD_LABEL } from "../src/lib/service.js";
+
+const opts = { caddyBin: "/usr/local/bin/caddy", caddyfilePath: "/home/u/.config/hostdoc/Caddyfile" };
+
+describe("systemdUnit", () => {
+ const u = systemdUnit(opts);
+ it("runs caddy with the config", () =>
+ expect(u).toContain("ExecStart=/usr/local/bin/caddy run --config /home/u/.config/hostdoc/Caddyfile"));
+ it("reloads on config change", () =>
+ expect(u).toContain("ExecReload=/usr/local/bin/caddy reload --config /home/u/.config/hostdoc/Caddyfile --force"));
+ it("binds privileged ports without full root", () =>
+ expect(u).toContain("AmbientCapabilities=CAP_NET_BIND_SERVICE"));
+ it("restarts on failure", () => expect(u).toContain("Restart=on-failure"));
+ it("starts at boot", () => expect(u).toContain("WantedBy=multi-user.target"));
+});
+
+describe("launchdPlist", () => {
+ const p = launchdPlist(opts);
+ it("uses the hostdoc label", () =>
+ expect(p).toContain(`${LAUNCHD_LABEL}`));
+ it("passes caddy run --config as program arguments", () => {
+ expect(p).toContain("/usr/local/bin/caddy");
+ expect(p).toContain("run");
+ expect(p).toContain("--config");
+ expect(p).toContain("/home/u/.config/hostdoc/Caddyfile");
+ });
+ it("runs at load and stays alive (reboot/crash survival)", () => {
+ expect(p).toContain("RunAtLoad");
+ expect(p).toContain("KeepAlive");
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run test/service.test.ts`
+Expected: FAIL — `Cannot find module '../src/lib/service.js'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `src/lib/service.ts`:
+
+```ts
+export const LAUNCHD_LABEL = "com.hostdoc.caddy";
+export const SYSTEMD_UNIT = "hostdoc-caddy";
+
+export interface ServiceOpts {
+ caddyBin: string;
+ caddyfilePath: string;
+}
+
+/**
+ * systemd unit. Directives verified against Caddy's official
+ * caddyserver/dist/init/caddy.service; parameterized to our downloaded binary
+ * and hostdoc Caddyfile. User/Group are omitted (runs as root) to avoid
+ * cross-distro `useradd`; CAP_NET_BIND_SERVICE is kept for defense.
+ */
+export function systemdUnit(opts: ServiceOpts): string {
+ return (
+ [
+ "[Unit]",
+ "Description=hostdoc Caddy web server",
+ "Documentation=https://caddyserver.com/docs/",
+ "After=network.target network-online.target",
+ "Requires=network-online.target",
+ "",
+ "[Service]",
+ "Type=simple",
+ `ExecStart=${opts.caddyBin} run --config ${opts.caddyfilePath}`,
+ `ExecReload=${opts.caddyBin} reload --config ${opts.caddyfilePath} --force`,
+ "Restart=on-failure",
+ "TimeoutStopSec=5s",
+ "AmbientCapabilities=CAP_NET_BIND_SERVICE",
+ "",
+ "[Install]",
+ "WantedBy=multi-user.target",
+ ].join("\n") + "\n"
+ );
+}
+
+/** macOS LaunchDaemon plist: run caddy at load, keep alive across crash/reboot. */
+export function launchdPlist(opts: ServiceOpts): string {
+ return (
+ [
+ '',
+ '',
+ '',
+ "",
+ " Label",
+ ` ${LAUNCHD_LABEL}`,
+ " ProgramArguments",
+ " ",
+ ` ${opts.caddyBin}`,
+ " run",
+ " --config",
+ ` ${opts.caddyfilePath}`,
+ " ",
+ " RunAtLoad",
+ " ",
+ " KeepAlive",
+ " ",
+ " StandardOutPath",
+ " /var/log/hostdoc-caddy.log",
+ " StandardErrorPath",
+ " /var/log/hostdoc-caddy.log",
+ "",
+ "",
+ ].join("\n") + "\n"
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run test/service.test.ts` → Expected: PASS (9 tests).
+Run: `npm run typecheck` → Expected: no errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/service.ts test/service.test.ts
+git commit -m "feat: launchd plist + systemd unit generators (Phase 2)
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+### Task 4: installer.ts — 경로 헬퍼 + 다운로드/설치/제거 I/O 경계
+
+**Files:**
+- Create: `src/lib/installer.ts`
+- Test: `test/installer.test.ts`
+
+**Interfaces:**
+- Consumes: `detectPlatform`/`caddyDownloadUrl`(Task 1), `whichPath`(Task 2), `launchdPlist`/`systemdUnit`/`LAUNCHD_LABEL`/`SYSTEMD_UNIT`(Task 3), `configPath`(`src/lib/config.ts`).
+- Produces:
+ - `interface Deps { run?; fetchFn?; writeFile?; copy?; chmod?; mkdirp?; exists?; remove?; whichCaddy? }` (모두 optional; 미주입 시 실제 Node 구현).
+ - `function isRoot(): boolean`
+ - `function caddyfilePath(): string` · `function caddyBinDest(): string` · `function stagedServicePath(plat?): string` · `function systemServicePath(plat?): string`
+ - `function downloadCaddy(deps?: Deps): Promise<{ path: string; reused: boolean }>`
+ - `function writeServiceFile(caddyBin: string, deps?: Deps, plat?: Platform): void` — staged 경로에 서비스 파일 기록(비특권).
+ - `function installService(deps?: Deps, plat?: Platform): void` — staged→system 복사 + enable/start (root).
+ - `function removeService(deps?: Deps, plat?: Platform): void` — disable/stop + system 파일 제거 (root).
+ - `function sudoInstallCommands(plat?: Platform): string[]` · `function sudoRemoveCommands(plat?: Platform): string[]`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `test/installer.test.ts`:
+
+```ts
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import type { Deps } from "../src/lib/installer.js";
+import {
+ downloadCaddy,
+ writeServiceFile,
+ installService,
+ removeService,
+ sudoInstallCommands,
+ sudoRemoveCommands,
+ systemServicePath,
+} from "../src/lib/installer.js";
+
+const linux = { os: "linux", arch: "amd64" } as const;
+const darwin = { os: "darwin", arch: "arm64" } as const;
+
+function spyDeps(over: Partial = {}): Required & { calls: string[][] } {
+ const calls: string[][] = [];
+ return {
+ run: (cmd, args) => calls.push([cmd, ...args]),
+ fetchFn: vi.fn(),
+ writeFile: () => {},
+ copy: (s, d) => calls.push(["copy", s, d]),
+ chmod: () => {},
+ mkdirp: () => {},
+ exists: () => true,
+ remove: (p) => calls.push(["remove", p]),
+ whichCaddy: () => null,
+ ...over,
+ calls,
+ } as Required & { calls: string[][] };
+}
+
+describe("downloadCaddy", () => {
+ it("reuses caddy already on PATH (no fetch, no write)", async () => {
+ const fetchFn = vi.fn();
+ const out = await downloadCaddy({ whichCaddy: () => "/usr/bin/caddy", fetchFn });
+ expect(out).toEqual({ path: "/usr/bin/caddy", reused: true });
+ expect(fetchFn).not.toHaveBeenCalled();
+ });
+
+ it("downloads, writes, and chmods when caddy is absent", async () => {
+ const written: string[] = [];
+ const chmodded: Array<[string, number]> = [];
+ const fetchFn = vi.fn(async () => ({
+ ok: true,
+ arrayBuffer: async () => new TextEncoder().encode("BINARY").buffer,
+ })) as never;
+ const out = await downloadCaddy({
+ whichCaddy: () => null,
+ exists: () => false,
+ fetchFn,
+ mkdirp: () => {},
+ writeFile: (p) => written.push(p),
+ chmod: (p, m) => chmodded.push([p, m]),
+ });
+ expect(out.reused).toBe(false);
+ expect(out.path).toMatch(/caddy$/);
+ expect(written).toContain(out.path);
+ expect(chmodded[0][1]).toBe(0o755);
+ expect(fetchFn).toHaveBeenCalledOnce();
+ });
+
+ it("throws on a non-ok download response", async () => {
+ const fetchFn = vi.fn(async () => ({ ok: false, status: 502 })) as never;
+ await expect(
+ downloadCaddy({ whichCaddy: () => null, exists: () => false, fetchFn }),
+ ).rejects.toThrow(/502/);
+ });
+});
+
+describe("installService (linux)", () => {
+ it("copies the staged unit to the system path and enables it", () => {
+ const d = spyDeps();
+ installService(d, linux);
+ const flat = d.calls.map((c) => c.join(" "));
+ expect(flat.some((c) => c.startsWith("copy "))).toBe(true);
+ expect(flat).toContain("systemctl daemon-reload");
+ expect(flat).toContain("systemctl enable --now hostdoc-caddy");
+ });
+});
+
+describe("installService (darwin)", () => {
+ it("copies the staged plist and bootstraps it", () => {
+ const d = spyDeps();
+ installService(d, darwin);
+ const flat = d.calls.map((c) => c.join(" "));
+ expect(flat.some((c) => c.startsWith("copy "))).toBe(true);
+ expect(flat).toContain(`launchctl bootstrap system ${systemServicePath(darwin)}`);
+ });
+});
+
+describe("removeService (linux)", () => {
+ it("disables the service and removes the system unit", () => {
+ const d = spyDeps({ exists: () => true });
+ removeService(d, linux);
+ const flat = d.calls.map((c) => c.join(" "));
+ expect(flat).toContain("systemctl disable --now hostdoc-caddy");
+ expect(flat).toContain(`remove ${systemServicePath(linux)}`);
+ });
+});
+
+describe("sudo command builders", () => {
+ it("install commands reference cp + enable (linux)", () => {
+ const cmds = sudoInstallCommands(linux);
+ expect(cmds.some((c) => c.includes("cp "))).toBe(true);
+ expect(cmds.some((c) => c.includes("systemctl enable --now hostdoc-caddy"))).toBe(true);
+ });
+ it("remove commands reference disable + rm (linux)", () => {
+ const cmds = sudoRemoveCommands(linux);
+ expect(cmds.some((c) => c.includes("systemctl disable --now hostdoc-caddy"))).toBe(true);
+ expect(cmds.some((c) => c.includes("rm "))).toBe(true);
+ });
+});
+
+describe("writeServiceFile", () => {
+ it("writes the staged unit content (linux)", () => {
+ const written: Array<[string, string]> = [];
+ writeServiceFile("/usr/local/bin/caddy", {
+ mkdirp: () => {},
+ writeFile: (p, data) => written.push([p, String(data)]),
+ }, linux);
+ expect(written[0][1]).toContain("ExecStart=/usr/local/bin/caddy run --config");
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run test/installer.test.ts`
+Expected: FAIL — `Cannot find module '../src/lib/installer.js'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `src/lib/installer.ts`:
+
+```ts
+import { execFileSync } from "node:child_process";
+import {
+ writeFileSync,
+ copyFileSync,
+ chmodSync,
+ mkdirSync,
+ existsSync,
+ rmSync,
+} from "node:fs";
+import { homedir } from "node:os";
+import { join, dirname } from "node:path";
+import { configPath } from "./config.js";
+import { whichPath } from "./which.js";
+import { detectPlatform, caddyDownloadUrl, type Platform } from "./platform.js";
+import { launchdPlist, systemdUnit, LAUNCHD_LABEL, SYSTEMD_UNIT } from "./service.js";
+
+/** Injection seam so tests never execute commands, fetch, or touch system paths. */
+export interface Deps {
+ run?: (cmd: string, args: string[]) => void;
+ fetchFn?: typeof fetch;
+ writeFile?: (p: string, data: Buffer | string) => void;
+ copy?: (src: string, dst: string) => void;
+ chmod?: (p: string, mode: number) => void;
+ mkdirp?: (p: string) => void;
+ exists?: (p: string) => boolean;
+ remove?: (p: string) => void;
+ whichCaddy?: () => string | null;
+}
+
+function withDefaults(d: Deps): Required {
+ return {
+ run: (cmd, args) => execFileSync(cmd, args, { stdio: "inherit" }),
+ fetchFn: (...a) => fetch(...a),
+ writeFile: (p, data) => writeFileSync(p, data),
+ copy: (src, dst) => copyFileSync(src, dst),
+ chmod: (p, mode) => chmodSync(p, mode),
+ mkdirp: (p) => mkdirSync(p, { recursive: true }),
+ exists: (p) => existsSync(p),
+ remove: (p) => rmSync(p, { force: true }),
+ whichCaddy: () => whichPath("caddy"),
+ ...d,
+ };
+}
+
+export function isRoot(): boolean {
+ return typeof process.getuid === "function" && process.getuid() === 0;
+}
+
+/** User-writable Caddyfile path (served config; read by the root service). */
+export function caddyfilePath(): string {
+ return join(dirname(configPath()), "Caddyfile");
+}
+
+/** User-writable binary destination when Caddy is not already on PATH. */
+export function caddyBinDest(): string {
+ return join(homedir(), ".local", "bin", "caddy");
+}
+
+/** User-writable staging path for the service file (copied to the system path). */
+export function stagedServicePath(plat: Platform = detectPlatform()): string {
+ const base = dirname(configPath());
+ return plat.os === "darwin"
+ ? join(base, `${LAUNCHD_LABEL}.plist`)
+ : join(base, `${SYSTEMD_UNIT}.service`);
+}
+
+/** Privileged system location the service file must live in to survive reboot. */
+export function systemServicePath(plat: Platform = detectPlatform()): string {
+ return plat.os === "darwin"
+ ? `/Library/LaunchDaemons/${LAUNCHD_LABEL}.plist`
+ : `/etc/systemd/system/${SYSTEMD_UNIT}.service`;
+}
+
+/** Reuse caddy on PATH; else download the static binary to a user path. */
+export async function downloadCaddy(
+ d: Deps = {},
+): Promise<{ path: string; reused: boolean }> {
+ const deps = withDefaults(d);
+ const existing = deps.whichCaddy();
+ if (existing) return { path: existing, reused: true };
+ const dest = caddyBinDest();
+ if (deps.exists(dest)) return { path: dest, reused: true };
+
+ const url = caddyDownloadUrl(detectPlatform());
+ const res = await deps.fetchFn(url);
+ if (!res.ok) throw new Error(`Caddy download failed: HTTP ${res.status}.`);
+ const buf = Buffer.from(await res.arrayBuffer());
+ deps.mkdirp(dirname(dest));
+ deps.writeFile(dest, buf);
+ deps.chmod(dest, 0o755);
+ return { path: dest, reused: false };
+}
+
+/** Write the service file to the user-writable staging path (no privilege). */
+export function writeServiceFile(
+ caddyBin: string,
+ d: Deps = {},
+ plat: Platform = detectPlatform(),
+): void {
+ const deps = withDefaults(d);
+ const content =
+ plat.os === "darwin"
+ ? launchdPlist({ caddyBin, caddyfilePath: caddyfilePath() })
+ : systemdUnit({ caddyBin, caddyfilePath: caddyfilePath() });
+ const staged = stagedServicePath(plat);
+ deps.mkdirp(dirname(staged));
+ deps.writeFile(staged, content);
+}
+
+/** Copy the staged service file into place and enable it (requires root). */
+export function installService(d: Deps = {}, plat: Platform = detectPlatform()): void {
+ const deps = withDefaults(d);
+ deps.copy(stagedServicePath(plat), systemServicePath(plat));
+ if (plat.os === "darwin") {
+ deps.run("launchctl", ["bootstrap", "system", systemServicePath(plat)]);
+ } else {
+ deps.run("systemctl", ["daemon-reload"]);
+ deps.run("systemctl", ["enable", "--now", SYSTEMD_UNIT]);
+ }
+}
+
+/** Disable/stop the service and remove the system service file (requires root). */
+export function removeService(d: Deps = {}, plat: Platform = detectPlatform()): void {
+ const deps = withDefaults(d);
+ const sys = systemServicePath(plat);
+ if (plat.os === "darwin") {
+ if (deps.exists(sys)) deps.run("launchctl", ["bootout", `system/${LAUNCHD_LABEL}`]);
+ deps.remove(sys);
+ } else {
+ if (deps.exists(sys)) {
+ deps.run("systemctl", ["disable", "--now", SYSTEMD_UNIT]);
+ deps.remove(sys);
+ deps.run("systemctl", ["daemon-reload"]);
+ }
+ }
+}
+
+/** Exact sudo commands to finish install when hostdoc is not run as root. */
+export function sudoInstallCommands(plat: Platform = detectPlatform()): string[] {
+ const staged = stagedServicePath(plat);
+ const sys = systemServicePath(plat);
+ if (plat.os === "darwin") {
+ return [`sudo cp ${staged} ${sys}`, `sudo launchctl bootstrap system ${sys}`];
+ }
+ return [
+ `sudo cp ${staged} ${sys}`,
+ "sudo systemctl daemon-reload",
+ `sudo systemctl enable --now ${SYSTEMD_UNIT}`,
+ ];
+}
+
+/** Exact sudo commands to remove the system service when not run as root. */
+export function sudoRemoveCommands(plat: Platform = detectPlatform()): string[] {
+ const sys = systemServicePath(plat);
+ if (plat.os === "darwin") {
+ return [`sudo launchctl bootout system/${LAUNCHD_LABEL}`, `sudo rm -f ${sys}`];
+ }
+ return [
+ `sudo systemctl disable --now ${SYSTEMD_UNIT}`,
+ `sudo rm -f ${sys}`,
+ "sudo systemctl daemon-reload",
+ ];
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run test/installer.test.ts` → Expected: PASS.
+Run: `npm run typecheck` → Expected: no errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/installer.ts test/installer.test.ts
+git commit -m "feat: installer I/O boundary (download/install/remove Caddy service)
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+### Task 5: setup `--install` — Caddy 자동 설치 흐름 + CLI 배선
+
+**Files:**
+- Modify: `src/commands/setup-selfhosted.ts`
+- Modify: `src/index.ts`
+- Test: `test/setup-selfhosted.test.ts` (확장)
+
+**Interfaces:**
+- Consumes: `chooseServer`(기존), `caddySnippet`/`nginxSnippet`(`snippet.ts`), `saveConfig`/`Config`(`config.ts`), `downloadCaddy`/`writeServiceFile`/`installService`/`sudoInstallCommands`/`caddyfilePath`/`isRoot`/`Deps`(Task 4), `detectPlatform`(Task 1).
+- Produces:
+ - `type InstallOutcome` (discriminated union: `snippet` | `installed` | `needs-root`).
+ - `function runInstallSelfHosted(args: SelfHostedSetupArgs, deps?: { installer?: Deps; isRoot?: () => boolean }): Promise`.
+
+- [ ] **Step 1: Write the failing test (append to `test/setup-selfhosted.test.ts`)**
+
+먼저 파일 상단 import에 추가:
+
+```ts
+import { runInstallSelfHosted } from "../src/commands/setup-selfhosted.js";
+import { readFileSync, existsSync } from "node:fs";
+import { caddyfilePath, stagedServicePath } from "../src/lib/installer.js";
+```
+
+그리고 새 describe 추가 (기존 `beforeEach`/`afterEach`가 `XDG_CONFIG_HOME`를 temp로 지정한다고 가정 — 없으면 이 describe 안에서 지정):
+
+```ts
+describe("runInstallSelfHosted", () => {
+ const reuse = { installer: { whichCaddy: () => "/usr/bin/caddy" }, isRoot: () => false };
+
+ it("nginx choice falls back to a manual snippet (no install)", async () => {
+ const out = await runInstallSelfHosted(
+ { serveRoot: serveRoot(), nginx: true, nginxDetected: false },
+ reuse,
+ );
+ expect(out.kind).toBe("snippet");
+ if (out.kind === "snippet") expect(out.snippet).toContain("server {");
+ });
+
+ it("non-root Caddy install stages files and returns sudo commands", async () => {
+ const out = await runInstallSelfHosted(
+ { serveRoot: serveRoot(), host: "docs.example.com", nginxDetected: false },
+ reuse,
+ );
+ expect(out.kind).toBe("needs-root");
+ if (out.kind === "needs-root") {
+ expect(out.caddyBin).toBe("/usr/bin/caddy");
+ expect(out.sudoCommands.join("\n")).toMatch(/cp .*(hostdoc-caddy\.service|com\.hostdoc\.caddy\.plist)/);
+ }
+ // Caddyfile + staged service file were written (user-writable), config saved.
+ expect(existsSync(caddyfilePath())).toBe(true);
+ expect(readFileSync(caddyfilePath(), "utf8")).toContain("docs.example.com");
+ expect(existsSync(stagedServicePath())).toBe(true);
+ });
+
+ it("root Caddy install runs installService", async () => {
+ const calls: string[][] = [];
+ const out = await runInstallSelfHosted(
+ { serveRoot: serveRoot(), nginxDetected: false },
+ {
+ isRoot: () => true,
+ installer: {
+ whichCaddy: () => "/usr/bin/caddy",
+ copy: () => {},
+ run: (cmd, args) => calls.push([cmd, ...args]),
+ },
+ },
+ );
+ expect(out.kind).toBe("installed");
+ expect(calls.length).toBeGreaterThan(0);
+ });
+});
+```
+
+> `serveRoot()` 헬퍼: 각 테스트에서 `mkdtempSync(join(tmpdir(), "sf-serve-"))`를 반환하도록 파일 상단에 정의하거나 기존 헬퍼를 재사용. `XDG_CONFIG_HOME`는 `test/setup-env.ts`가 temp로 지정하므로 `caddyfilePath()`/config가 temp에 기록됨.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run test/setup-selfhosted.test.ts`
+Expected: FAIL — `runInstallSelfHosted` is not exported.
+
+- [ ] **Step 3: Implement `runInstallSelfHosted` (append to `src/commands/setup-selfhosted.ts`)**
+
+파일 상단 import에 추가:
+
+```ts
+import { detectPlatform } from "../lib/platform.js";
+import {
+ downloadCaddy,
+ writeServiceFile,
+ installService,
+ sudoInstallCommands,
+ caddyfilePath,
+ isRoot as realIsRoot,
+ type Deps,
+} from "../lib/installer.js";
+import { mkdirSync, writeFileSync } from "node:fs";
+import { dirname } from "node:path";
+```
+
+그리고 아래를 append:
+
+```ts
+const INSTALL_WARNING = [
+ "Security: hostdoc only serves the serve directory; listing is off and /_* is blocked.",
+ "Network exposure (port-forwarding, firewall, router) is your responsibility — expose only what you intend.",
+].join("\n");
+
+export type InstallOutcome =
+ | { kind: "snippet"; cfg: Config; server: Server; snippet: string; warning: string }
+ | {
+ kind: "installed";
+ cfg: Config;
+ caddyBin: string;
+ reused: boolean;
+ caddyfilePath: string;
+ warning: string;
+ }
+ | {
+ kind: "needs-root";
+ cfg: Config;
+ caddyBin: string;
+ reused: boolean;
+ caddyfilePath: string;
+ sudoCommands: string[];
+ warning: string;
+ };
+
+/**
+ * Auto-install path for `hostdoc setup --install`. Caddy only — an nginx choice
+ * falls back to the manual snippet (nginx is not a single static binary).
+ * Non-privileged prep (config, Caddyfile, binary, staged service file) runs as
+ * the user; the privileged service step runs only when root, otherwise the
+ * exact sudo commands are returned for the user to run.
+ */
+export async function runInstallSelfHosted(
+ args: SelfHostedSetupArgs,
+ deps: { installer?: Deps; isRoot?: () => boolean } = {},
+): Promise {
+ const plat = detectPlatform(); // throws on win32 before any side effect
+ const isRoot = deps.isRoot ?? realIsRoot;
+
+ mkdirSync(args.serveRoot, { recursive: true });
+ const cfg: Config = {
+ mode: "self-hosted",
+ serveRoot: args.serveRoot,
+ host: args.host,
+ port: args.port,
+ scheme: args.scheme,
+ };
+ saveConfig(cfg);
+
+ const server = chooseServer(args);
+ if (server === "nginx") {
+ const snippet = nginxSnippet({
+ serveRoot: args.serveRoot,
+ host: args.host,
+ port: args.port,
+ });
+ return { kind: "snippet", cfg, server, snippet, warning: INSTALL_WARNING };
+ }
+
+ // Write the Caddyfile (reuse the Phase 1 generator) to a user-writable path.
+ const cfPath = caddyfilePath();
+ mkdirSync(dirname(cfPath), { recursive: true });
+ writeFileSync(
+ cfPath,
+ caddySnippet({ serveRoot: args.serveRoot, host: args.host, port: args.port }) + "\n",
+ );
+
+ const { path: caddyBin, reused } = await downloadCaddy(deps.installer);
+ writeServiceFile(caddyBin, deps.installer, plat);
+
+ if (!isRoot()) {
+ return {
+ kind: "needs-root",
+ cfg,
+ caddyBin,
+ reused,
+ caddyfilePath: cfPath,
+ sudoCommands: sudoInstallCommands(plat),
+ warning: INSTALL_WARNING,
+ };
+ }
+
+ installService(deps.installer, plat);
+ return { kind: "installed", cfg, caddyBin, reused, caddyfilePath: cfPath, warning: INSTALL_WARNING };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run test/setup-selfhosted.test.ts` → Expected: PASS.
+Run: `npm run typecheck` → Expected: no errors.
+
+- [ ] **Step 5: Wire `--install` into `src/index.ts`**
+
+`setup` 커맨드에 옵션 추가 (`--caddy` 옵션 라인 다음):
+
+```ts
+ .option("--install", "download + install Caddy and register a boot service (self-hosted)")
+```
+
+`setup` action의 `if (opts.serveRoot) {` 블록 최상단(현행 78행 `const { cfg, server, snippet, warning } = runSetupSelfHosted({` 앞)에 install 분기를 삽입:
+
+```ts
+ if (opts.serveRoot && opts.install) {
+ const outcome = await runInstallSelfHosted({
+ serveRoot: opts.serveRoot,
+ host: opts.host,
+ port: opts.port ? Number(opts.port) : undefined,
+ scheme: opts.scheme,
+ nginx: opts.nginx,
+ caddy: opts.caddy,
+ nginxDetected: onPath("nginx"),
+ });
+ if (outcome.kind === "snippet") {
+ console.log(`Configured self-hosted mode; serve root: ${outcome.cfg.serveRoot}`);
+ console.log(
+ "\nnginx is not auto-installed (not a single static binary). Add this nginx config, then reload nginx:\n",
+ );
+ console.log(outcome.snippet);
+ console.log(`\n${outcome.warning}`);
+ return;
+ }
+ console.log(
+ `Configured self-hosted mode; serve root: ${outcome.cfg.serveRoot}`,
+ );
+ console.log(
+ `Caddy: ${outcome.caddyBin}${outcome.reused ? " (reused)" : " (downloaded)"}`,
+ );
+ console.log(`Caddyfile: ${outcome.caddyfilePath}`);
+ if (outcome.kind === "needs-root") {
+ console.log("\nRun these as root to register the boot service:\n");
+ for (const cmd of outcome.sudoCommands) console.log(` ${cmd}`);
+ } else {
+ console.log("\nCaddy service installed and started (survives reboot).");
+ }
+ console.log(`\n${outcome.warning}`);
+ return;
+ }
+```
+
+(`setup` action은 이미 `async`이므로 `await` 사용 가능 — 현행 75행 `.action(async (opts) => {`.)
+
+- [ ] **Step 6: Run the full suite + typecheck**
+
+Run: `npx vitest run` → Expected: 전체 스위트 PASS (기존 setup 스니펫 경로 무회귀).
+Run: `npm run typecheck` → Expected: no errors.
+Run: `npm run build` → Expected: `tsc` emits `dist/` without errors.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/commands/setup-selfhosted.ts src/index.ts test/setup-selfhosted.test.ts
+git commit -m "feat: hostdoc setup --install (auto-install Caddy + boot service)
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+### Task 6: `deprovision` self-hosted 라우팅 (서비스만 중지·해제)
+
+**Files:**
+- Create: `src/commands/deprovision-selfhosted.ts`
+- Modify: `src/index.ts`
+- Test: `test/deprovision-selfhosted.test.ts`
+
+**Interfaces:**
+- Consumes: `removeService`/`sudoRemoveCommands`/`caddyfilePath`/`stagedServicePath`/`isRoot`/`Deps`(Task 4), `loadConfig`(`config.ts`).
+- Produces:
+ - `type SelfHostedDeprovisionOutcome = { kind: "removed" } | { kind: "needs-root"; sudoCommands: string[] }`
+ - `function runDeprovisionSelfHosted(deps?: { installer?: Deps; isRoot?: () => boolean }): SelfHostedDeprovisionOutcome`
+- 라우팅: `deprovision` action이 `loadConfig()?.serveRoot`로 self-hosted를 판별(모드 파생 원칙과 동일한 판별자) — 있으면 self-hosted teardown, 없으면 기존 terraform destroy.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `test/deprovision-selfhosted.test.ts`:
+
+```ts
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { runDeprovisionSelfHosted } from "../src/commands/deprovision-selfhosted.js";
+import { caddyfilePath, stagedServicePath, systemServicePath } from "../src/lib/installer.js";
+
+// XDG_CONFIG_HOME is repointed to a temp dir by test/setup-env.ts.
+beforeEach(() => {
+ const cfDir = dirname(caddyfilePath());
+ mkdirSync(cfDir, { recursive: true });
+ writeFileSync(caddyfilePath(), "example.com {\n}\n");
+ writeFileSync(stagedServicePath(), "unit\n");
+});
+
+describe("runDeprovisionSelfHosted", () => {
+ it("removes user-owned files and returns sudo commands when not root", () => {
+ const out = runDeprovisionSelfHosted({ isRoot: () => false });
+ expect(existsSync(caddyfilePath())).toBe(false); // user file removed
+ expect(existsSync(stagedServicePath())).toBe(false);
+ expect(out.kind).toBe("needs-root");
+ if (out.kind === "needs-root") {
+ expect(out.sudoCommands.some((c) => c.includes("rm "))).toBe(true);
+ expect(out.sudoCommands.some((c) => c.includes(systemServicePath()))).toBe(true);
+ }
+ });
+
+ it("removes the system service directly when root", () => {
+ const calls: string[][] = [];
+ const out = runDeprovisionSelfHosted({
+ isRoot: () => true,
+ installer: {
+ exists: () => true,
+ run: (cmd, args) => calls.push([cmd, ...args]),
+ remove: () => {},
+ },
+ });
+ expect(out.kind).toBe("removed");
+ expect(calls.length).toBeGreaterThan(0);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run test/deprovision-selfhosted.test.ts`
+Expected: FAIL — `Cannot find module '../src/commands/deprovision-selfhosted.js'`.
+
+- [ ] **Step 3: Implement `src/commands/deprovision-selfhosted.ts`**
+
+```ts
+import { rmSync } from "node:fs";
+import {
+ removeService,
+ sudoRemoveCommands,
+ caddyfilePath,
+ stagedServicePath,
+ isRoot as realIsRoot,
+ type Deps,
+} from "../lib/installer.js";
+
+export type SelfHostedDeprovisionOutcome =
+ | { kind: "removed" }
+ | { kind: "needs-root"; sudoCommands: string[] };
+
+/**
+ * Tear down a self-hosted install: stop + unregister the service and remove its
+ * files. Published files (serveRoot) and the Caddy binary are intentionally
+ * kept. User-owned files (Caddyfile + staged unit) are removed here; the
+ * privileged system-service removal runs only when root, otherwise the exact
+ * sudo commands are returned.
+ */
+export function runDeprovisionSelfHosted(
+ deps: { installer?: Deps; isRoot?: () => boolean } = {},
+): SelfHostedDeprovisionOutcome {
+ const isRoot = deps.isRoot ?? realIsRoot;
+ rmSync(caddyfilePath(), { force: true });
+ rmSync(stagedServicePath(), { force: true });
+ if (!isRoot()) {
+ return { kind: "needs-root", sudoCommands: sudoRemoveCommands() };
+ }
+ removeService(deps.installer);
+ return { kind: "removed" };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run test/deprovision-selfhosted.test.ts` → Expected: PASS.
+Run: `npm run typecheck` → Expected: no errors.
+
+- [ ] **Step 5: Route `deprovision` by mode in `src/index.ts`**
+
+상단 import 추가:
+
+```ts
+import { runDeprovisionSelfHosted } from "./commands/deprovision-selfhosted.js";
+import { loadConfig } from "./lib/config.js";
+```
+
+(`loadConfig`가 이미 다른 곳에서 import돼 있으면 중복 추가하지 말 것.)
+
+`deprovision` action(현행 164행 `.action((opts) => {`) 본문 `try {` 직후에 self-hosted 분기를 삽입:
+
+```ts
+ try {
+ if (loadConfig()?.serveRoot) {
+ const outcome = runDeprovisionSelfHosted();
+ if (outcome.kind === "needs-root") {
+ console.log("Run these as root to remove the Caddy service:\n");
+ for (const cmd of outcome.sudoCommands) console.log(` ${cmd}`);
+ } else {
+ console.log(
+ "Self-hosted Caddy service removed. Published files and the Caddy binary were kept.",
+ );
+ }
+ return;
+ }
+ runDeprovision({
+ dir: opts.dir,
+```
+
+(이후 기존 terraform `runDeprovision(...)` 호출·`console.log("Domain infrastructure destroyed...")`는 그대로 둔다.)
+
+- [ ] **Step 6: Run the full suite + typecheck + build**
+
+Run: `npx vitest run` → Expected: 전체 스위트 PASS.
+Run: `npm run typecheck` → Expected: no errors.
+Run: `npm run build` → Expected: `tsc` emits `dist/` without errors.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/commands/deprovision-selfhosted.ts src/index.ts test/deprovision-selfhosted.test.ts
+git commit -m "feat: deprovision routes self-hosted to service teardown (keeps files)
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+## Self-Review (spec 대비)
+
+**Spec coverage:**
+- 플랫폼 감지 → Caddy 다운로드 URL → Task 1 ✓
+- launchd/systemd 서비스 파일 생성 → Task 3 ✓
+- Caddyfile 기록(`caddySnippet` 재사용, 도메인 시 auto-HTTPS) → Task 5 (writeFile) + `snippet.ts` 재사용 ✓
+- 다운로드(재사용/플랫폼 감지)·설치·구동 → Task 4 `downloadCaddy`/`installService` + Task 5 배선 ✓
+- 시스템 서비스 등록(재부팅 생존) → Task 3(`RunAtLoad`/`KeepAlive`/`WantedBy`) + Task 4(`installService`) ✓
+- root 아니면 정확한 sudo 명령 출력·중단 → Task 4 `sudoInstallCommands` + Task 5 `needs-root` ✓
+- nginx는 자동설치 대상 아님(스니펫 폴백) → Task 5 `kind: "snippet"` ✓
+- `deprovision` self-hosted 라우팅(서비스만, serveRoot·바이너리 보존) → Task 6 ✓
+- 미지원 플랫폼(Windows) 명확 에러 → Task 1 `detectPlatform` ✓
+- `--install` 가산적(Phase 1 무회귀) → Task 5 배선이 `opts.install` 분기로만 추가 ✓
+- CI 무네트워크·무root → 모든 I/O가 주입 `Deps` 뒤; 테스트는 fake 주입 ✓
+
+**Placeholder scan:** 모든 스텝에 실제 코드·명령·기대 출력 포함. "TBD"/"적절히 처리" 없음 ✓
+
+**Type consistency:** `Deps`·`Platform`·`InstallOutcome`·`SelfHostedDeprovisionOutcome`·`ServiceOpts` 시그니처가 정의 Task와 소비 Task에서 일치. `stagedServicePath`/`systemServicePath`/`caddyfilePath`/`caddyBinDest`는 Task 4에서 정의되고 Task 5·6·테스트에서 동일 이름으로 사용 ✓
+
+**검증 유보(구현 중 확인):** Caddy 다운로드 URL 형식과 systemd 지시자는 공식 문서(`caddyserver.com/api/download`, `caddyserver/dist/init/caddy.service`)로 이미 확인함. `launchctl bootstrap/bootout` 인자 형식은 macOS(Darwin 25.x)에서 실검증 권장.
+
+## References
+
+- Spec: [2026-07-03-self-hosted-mode-phase2-design.md](../specs/2026-07-03-self-hosted-mode-phase2-design.md)
+- Phase 1: [plan](2026-07-03-self-hosted-mode.md), [spec](../specs/2026-07-03-self-hosted-mode-design.md)
+- 공식: Caddy systemd unit(`github.com/caddyserver/dist/blob/master/init/caddy.service`), 다운로드 API(`caddyserver.com/api/download?os=&arch=`)
+- 패턴 원본: `src/lib/terraform.ts`(shell-out 경계), `src/lib/snippet.ts`(`caddySnippet` 재사용), `src/lib/which.ts`(`onPath`)
diff --git a/docs/superpowers/plans/2026-07-03-self-hosted-mode.md b/docs/superpowers/plans/2026-07-03-self-hosted-mode.md
new file mode 100644
index 0000000..aed2950
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-03-self-hosted-mode.md
@@ -0,0 +1,1553 @@
+# self-hosted 모드 (Phase 1 MVP) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** hostdoc에 세 번째 호스팅 모드 `self-hosted`를 추가해, 로컬 웹서버가 서빙하는 디렉토리에 발행하고 내 호스트/공인 IP 기반 지속 링크를 반환한다.
+
+**Architecture:** publish/list/rm의 S3 결합을 5-연산 `StorageBackend` 인터페이스로 추상화(`S3Backend`=현행 무회귀 래핑, `LocalFsBackend`=serveRoot fs). Config는 `serveRoot` 판별자로 `self-hosted`를 파생하고, 호스트는 설정값 우선·미설정 시 공인 IP 자동감지(경고)로 `buildPublicUrl` 직전에 한 번 확정한다. `_meta` 보호·index rewrite는 수동 웹서버 설정 스니펫(Caddy 기본/nginx 옵션)으로 제공한다.
+
+**Tech Stack:** TypeScript(ESM, `.js` 확장자 import), Node 18+(global `fetch`), Commander, Vitest, aws-sdk-client-mock.
+
+## Global Constraints
+
+- ESM: `.ts` 소스의 상대 import는 `.js` 확장자 필수 (`./lib/backend.js`).
+- 빌드는 `tsc`(emit), 타입체크는 `tsgo`(`npm run typecheck`). 둘 다 통과해야 함.
+- 테스트는 `vitest run`. **AWS 크레덴셜·Terraform·실네트워크 없이** 통과해야 함(CI 불변).
+- 기존 `s3-website`·`cloudfront` 동작·테스트 무회귀가 전제. `src/lib/aws.ts`·`src/lib/cloudfront.ts`는 수정하지 않는다.
+- Config 정밀도: `flags > HOSTDOC_* env > file`. mode는 파생만, 저장 안 함.
+- StorageBackend key는 항상 posix `/` 구분자(`/…`, `_meta/…`).
+- 커밋 메시지 말미: `Co-Authored-By: Claude Opus 4.8 `.
+
+---
+
+### Task 1: StorageBackend 인터페이스 + S3Backend + makeBackend(클라우드 전용)
+
+**Files:**
+- Create: `src/lib/backend.ts`
+- Test: `test/backend.test.ts`
+
+**Interfaces:**
+- Consumes: `src/lib/aws.ts`의 `makeS3/putObject/listKeys/existsPrefix/deleteKeys/getJson` (무수정), `Config`(`src/lib/config.ts`, 현행 타입).
+- Produces:
+ - `interface StorageBackend { put(key:string, body:Buffer, contentType:string):Promise; list(prefix:string):Promise; exists(prefix:string):Promise; delete(keys:string[]):Promise; getJson(key:string):Promise; }`
+ - `class S3Backend implements StorageBackend`(생성자 `(s3: S3Client, bucket: string)`)
+ - `function makeBackend(cfg: Config, opts: { profile?: string }): StorageBackend`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `test/backend.test.ts`:
+
+```ts
+import { describe, it, expect, beforeEach } from "vitest";
+import { mockClient } from "aws-sdk-client-mock";
+import {
+ S3Client,
+ PutObjectCommand,
+ ListObjectsV2Command,
+ DeleteObjectsCommand,
+ GetObjectCommand,
+} from "@aws-sdk/client-s3";
+import { S3Backend } from "../src/lib/backend.js";
+
+const s3mock = mockClient(S3Client);
+beforeEach(() => s3mock.reset());
+
+describe("S3Backend", () => {
+ it("put issues a PutObjectCommand with the bucket + key", async () => {
+ s3mock.on(PutObjectCommand).resolves({});
+ const be = new S3Backend(new S3Client({ region: "us-east-1" }), "b");
+ await be.put("abc/index.html", Buffer.from("x"), "text/html");
+ const call = s3mock.commandCalls(PutObjectCommand)[0];
+ expect(call.args[0].input.Bucket).toBe("b");
+ expect(call.args[0].input.Key).toBe("abc/index.html");
+ });
+
+ it("list returns keys under the prefix", async () => {
+ s3mock
+ .on(ListObjectsV2Command)
+ .resolves({ KeyCount: 1, Contents: [{ Key: "abc/index.html" }] });
+ const be = new S3Backend(new S3Client({ region: "us-east-1" }), "b");
+ expect(await be.list("abc/")).toEqual(["abc/index.html"]);
+ });
+
+ it("exists is true when the prefix has objects", async () => {
+ s3mock.on(ListObjectsV2Command).resolves({ KeyCount: 1 });
+ const be = new S3Backend(new S3Client({ region: "us-east-1" }), "b");
+ expect(await be.exists("abc/")).toBe(true);
+ });
+
+ it("delete issues DeleteObjectsCommand", async () => {
+ s3mock.on(DeleteObjectsCommand).resolves({});
+ const be = new S3Backend(new S3Client({ region: "us-east-1" }), "b");
+ await be.delete(["abc/index.html"]);
+ expect(s3mock.commandCalls(DeleteObjectsCommand)).toHaveLength(1);
+ });
+
+ it("getJson parses the object body", async () => {
+ s3mock.on(GetObjectCommand).resolves({
+ Body: { transformToString: async () => JSON.stringify({ code: "abc" }) },
+ } as never);
+ const be = new S3Backend(new S3Client({ region: "us-east-1" }), "b");
+ expect(await be.getJson<{ code: string }>("_meta/abc.json")).toEqual({ code: "abc" });
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run test/backend.test.ts`
+Expected: FAIL — `Cannot find module '../src/lib/backend.js'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `src/lib/backend.ts`:
+
+```ts
+import type { S3Client } from "@aws-sdk/client-s3";
+import {
+ makeS3,
+ putObject,
+ listKeys,
+ existsPrefix,
+ deleteKeys,
+ getJson,
+} from "./aws.js";
+import type { Config } from "./config.js";
+
+/** Minimal storage abstraction over the `/…` + `_meta/…` keyspace. */
+export interface StorageBackend {
+ put(key: string, body: Buffer, contentType: string): Promise;
+ list(prefix: string): Promise;
+ exists(prefix: string): Promise;
+ delete(keys: string[]): Promise;
+ getJson(key: string): Promise;
+}
+
+/** Wraps the existing S3 helpers, binding (client, bucket). No behavior change. */
+export class S3Backend implements StorageBackend {
+ constructor(
+ private readonly s3: S3Client,
+ private readonly bucket: string,
+ ) {}
+ put(key: string, body: Buffer, contentType: string): Promise {
+ return putObject(this.s3, this.bucket, key, body, contentType);
+ }
+ list(prefix: string): Promise {
+ return listKeys(this.s3, this.bucket, prefix);
+ }
+ exists(prefix: string): Promise {
+ return existsPrefix(this.s3, this.bucket, prefix);
+ }
+ delete(keys: string[]): Promise {
+ return deleteKeys(this.s3, this.bucket, keys);
+ }
+ getJson(key: string): Promise {
+ return getJson(this.s3, this.bucket, key);
+ }
+}
+
+/** Pick a backend from the resolved config. Cloud modes only (self-hosted added later). */
+export function makeBackend(cfg: Config, opts: { profile?: string }): StorageBackend {
+ return new S3Backend(makeS3({ region: cfg.region, profile: opts.profile }), cfg.bucket);
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run test/backend.test.ts` → Expected: PASS (5 tests).
+Run: `npm run typecheck` → Expected: no errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/backend.ts test/backend.test.ts
+git commit -m "feat: add StorageBackend interface + S3Backend wrapper
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+### Task 2: publish/list/rm를 백엔드 추상화로 리팩터 (무회귀)
+
+**Files:**
+- Modify: `src/commands/publish.ts`
+- Modify: `src/commands/list.ts`
+- Modify: `src/commands/rm.ts`
+- Test: 기존 `test/publish.test.ts`, `test/list.test.ts`, `test/rm.test.ts` (수정 없이 통과)
+
+**Interfaces:**
+- Consumes: `makeBackend`, `StorageBackend`(Task 1).
+- Produces: 커맨드가 `cfg.bucket`을 직접 참조하지 않는다(백엔드 내부로 이동). `uniqueCode(backend: StorageBackend): Promise`.
+
+- [ ] **Step 1: Run existing tests as the baseline (must stay green after refactor)**
+
+Run: `npx vitest run test/publish.test.ts test/list.test.ts test/rm.test.ts`
+Expected: PASS (baseline). 이 세 파일은 이번 태스크에서 **수정하지 않는다** — 리팩터가 관찰 가능 동작을 바꾸지 않았음을 증명하는 회귀 가드다.
+
+- [ ] **Step 2: Refactor `src/commands/publish.ts`**
+
+`import` 블록 상단(1–10행)을 아래로 교체 (aws.ts 직접 import와 `S3Client` 타입 import 제거):
+
+```ts
+import { readFile } from "node:fs/promises";
+import { resolveConfig } from "../lib/config.js";
+import { makeBackend, type StorageBackend } from "../lib/backend.js";
+import { generateCode, isValidPath } from "../lib/code.js";
+import { collectUploads, type Upload } from "../lib/walk.js";
+import { buildMeta, metaKey, nestedMetaPrefix, extractTitle } from "../lib/meta.js";
+import { buildPublicUrl } from "../lib/url.js";
+import { makeCloudFront, invalidate } from "../lib/cloudfront.js";
+import { mapLimit } from "../lib/concurrency.js";
+```
+
+`uniqueCode` (27–33행)을 backend 기반으로 교체:
+
+```ts
+async function uniqueCode(backend: StorageBackend): Promise {
+ for (let i = 0; i < 5; i++) {
+ const code = generateCode();
+ if (!(await backend.exists(`${code}/`))) return code;
+ }
+ throw new Error("Could not generate a unique code after 5 attempts.");
+}
+```
+
+`runPublish` 본문에서 S3 호출을 backend로 교체. `const s3 = makeS3(...)`를 `const backend = makeBackend(...)`로, 이후 각 호출을 치환:
+
+```ts
+export async function runPublish(args: PublishArgs): Promise {
+ const cfg = resolveConfig({
+ bucket: args.bucket,
+ region: args.region,
+ domain: args.domain,
+ distribution: args.distribution,
+ });
+ const backend = makeBackend(cfg, { profile: args.profile });
+ const uploads = await collectUploads(args.path);
+
+ let code: string;
+ if (args.slug) {
+ if (!isValidPath(args.slug)) {
+ throw new Error(
+ `Invalid slug "${args.slug}". Use lowercase letters, digits, and hyphens per path segment (each segment must start alphanumeric); "/" separates nested segments.`,
+ );
+ }
+ if (!args.dryRun) {
+ const exists = await backend.exists(`${args.slug}/`);
+ if (exists && !args.force) {
+ throw new Error(`Slug "${args.slug}" already exists. Use --force to overwrite.`);
+ }
+ }
+ code = args.slug;
+ } else {
+ code = args.dryRun ? generateCode() : await uniqueCode(backend);
+ }
+
+ if (args.dryRun) {
+ return buildPublicUrl(cfg, code);
+ }
+
+ let overwritten = false;
+ if (args.force) {
+ const existing = await backend.list(`${code}/`);
+ if (existing.length) {
+ const nestedMeta = await backend.list(nestedMetaPrefix(code));
+ await backend.delete([...existing, ...nestedMeta]);
+ overwritten = true;
+ }
+ }
+
+ await mapLimit(uploads, UPLOAD_CONCURRENCY, async (u) => {
+ await backend.put(`${code}/${u.key}`, await readFile(u.absPath), u.contentType);
+ });
+
+ const title = args.title ?? (await deriveTitle(uploads));
+ const meta = buildMeta({
+ code,
+ slug: args.slug ?? null,
+ title,
+ uploads,
+ sourcePath: args.path,
+ });
+ await backend.put(
+ metaKey(code),
+ Buffer.from(JSON.stringify(meta, null, 2)),
+ "application/json",
+ );
+
+ if (cfg.mode === "cloudfront" && cfg.distributionId && overwritten) {
+ const cf = makeCloudFront({ profile: args.profile });
+ await invalidate(cf, cfg.distributionId, [`/${code}/*`]);
+ }
+
+ return buildPublicUrl(cfg, code);
+}
+```
+
+(`deriveTitle` 함수와 `PublishArgs`·`UPLOAD_CONCURRENCY`는 그대로 둔다.)
+
+- [ ] **Step 3: Refactor `src/commands/list.ts`**
+
+상단 import(1–4행)와 본문을 교체:
+
+```ts
+import { resolveConfig, type Overrides } from "../lib/config.js";
+import { makeBackend } from "../lib/backend.js";
+import { buildPublicUrl } from "../lib/url.js";
+import { isValidMeta, type Meta } from "../lib/meta.js";
+
+export interface DocRow extends Meta {
+ url: string;
+}
+
+export async function listDocs(
+ flags: Overrides & { profile?: string },
+): Promise {
+ const cfg = resolveConfig(flags);
+ const backend = makeBackend(cfg, { profile: flags.profile });
+ const keys = await backend.list("_meta/");
+
+ const settled = await Promise.all(
+ keys.map(async (key): Promise => {
+ try {
+ const meta = await backend.getJson(key);
+ if (!isValidMeta(meta)) {
+ console.warn(`Skipping ${key}: not a valid document sidecar.`);
+ return null;
+ }
+ return { ...meta, url: buildPublicUrl(cfg, meta.code) };
+ } catch (err) {
+ console.warn(`Skipping ${key}: ${(err as Error).message}`);
+ return null;
+ }
+ }),
+ );
+
+ const rows = settled.filter((r): r is DocRow => r !== null);
+ rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+ return rows;
+}
+```
+
+(`formatRows`는 그대로 둔다.)
+
+- [ ] **Step 4: Refactor `src/commands/rm.ts`**
+
+상단 import(1–2행)와 S3 호출부를 교체:
+
+```ts
+import { resolveConfig, type Overrides } from "../lib/config.js";
+import { makeBackend } from "../lib/backend.js";
+import { metaKey, nestedMetaPrefix } from "../lib/meta.js";
+import { makeCloudFront, invalidate } from "../lib/cloudfront.js";
+import { isValidPath, isValidCode } from "../lib/code.js";
+import { confirm } from "../lib/prompt.js";
+```
+
+본문의 `const s3 = makeS3(...)` 이후를 교체:
+
+```ts
+ const cfg = resolveConfig(args);
+ const backend = makeBackend(cfg, { profile: args.profile });
+
+ const keys = await backend.list(`${args.id}/`);
+ if (keys.length === 0) {
+ throw new Error(`Document not found: ${args.id}`);
+ }
+
+ if (!args.yes) {
+ const ok = await confirm(
+ `Delete "${args.id}" (${keys.length} file(s))? [y/N] `,
+ );
+ if (!ok) throw new Error("Aborted.");
+ }
+
+ const nestedMeta = await backend.list(nestedMetaPrefix(args.id));
+ await backend.delete([...keys, metaKey(args.id), ...nestedMeta]);
+ if (cfg.mode === "cloudfront" && cfg.distributionId) {
+ const cf = makeCloudFront({ profile: args.profile });
+ await invalidate(cf, cfg.distributionId, [`/${args.id}/*`]);
+ }
+```
+
+- [ ] **Step 5: Run the regression suite**
+
+Run: `npx vitest run test/publish.test.ts test/list.test.ts test/rm.test.ts publish-open.test.ts`
+Expected: PASS — 동작 무회귀 (S3Backend가 동일한 S3Client 커맨드를 발행하므로 mock이 그대로 잡음).
+Run: `npm run typecheck` → Expected: no errors.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/commands/publish.ts src/commands/list.ts src/commands/rm.ts
+git commit -m "refactor: route publish/list/rm through StorageBackend (no behavior change)
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+### Task 3: LocalFsBackend
+
+**Files:**
+- Modify: `src/lib/backend.ts`
+- Test: `test/backend.test.ts`
+
+**Interfaces:**
+- Produces: `class LocalFsBackend implements StorageBackend`(생성자 `(serveRoot: string)`). key는 posix `/`; OS 경로 변환은 내부에서 처리. `put`은 `contentType`을 무시(웹서버가 확장자로 추론). `delete`는 삭제 후 빈 부모 디렉토리를 정리.
+
+- [ ] **Step 1: Write the failing test (append to `test/backend.test.ts`)**
+
+```ts
+import { mkdtempSync, rmSync, existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { LocalFsBackend } from "../src/lib/backend.js";
+
+describe("LocalFsBackend", () => {
+ let root: string;
+ beforeEach(() => { root = mkdtempSync(join(tmpdir(), "sf-fs-")); });
+ // afterEach cleanup is best-effort; tmp dirs are disposable.
+
+ it("put writes the file at the key path (nested dirs created)", async () => {
+ const be = new LocalFsBackend(root);
+ await be.put("abc/assets/a.css", Buffer.from("body{}"), "text/css");
+ expect(existsSync(join(root, "abc", "assets", "a.css"))).toBe(true);
+ });
+
+ it("list returns posix keys under the prefix", async () => {
+ const be = new LocalFsBackend(root);
+ await be.put("abc/index.html", Buffer.from("x"), "text/html");
+ await be.put("_meta/abc.json", Buffer.from("{}"), "application/json");
+ expect(await be.list("abc/")).toEqual(["abc/index.html"]);
+ expect(await be.list("_meta/")).toEqual(["_meta/abc.json"]);
+ });
+
+ it("exists reflects presence under a prefix", async () => {
+ const be = new LocalFsBackend(root);
+ expect(await be.exists("abc/")).toBe(false);
+ await be.put("abc/index.html", Buffer.from("x"), "text/html");
+ expect(await be.exists("abc/")).toBe(true);
+ });
+
+ it("list on a missing serve root returns []", async () => {
+ const be = new LocalFsBackend(join(root, "nope"));
+ expect(await be.list("abc/")).toEqual([]);
+ });
+
+ it("delete removes files and prunes empty parent dirs", async () => {
+ const be = new LocalFsBackend(root);
+ await be.put("abc/index.html", Buffer.from("x"), "text/html");
+ await be.delete(["abc/index.html"]);
+ expect(existsSync(join(root, "abc", "index.html"))).toBe(false);
+ expect(existsSync(join(root, "abc"))).toBe(false); // pruned
+ expect(existsSync(root)).toBe(true); // serve root kept
+ });
+
+ it("getJson reads and parses a JSON file", async () => {
+ const be = new LocalFsBackend(root);
+ await be.put("_meta/abc.json", Buffer.from(JSON.stringify({ code: "abc" })), "application/json");
+ expect(await be.getJson<{ code: string }>("_meta/abc.json")).toEqual({ code: "abc" });
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run test/backend.test.ts`
+Expected: FAIL — `LocalFsBackend` is not exported.
+
+- [ ] **Step 3: Implement `LocalFsBackend` (append to `src/lib/backend.ts`)**
+
+Add these imports at the top of `src/lib/backend.ts`:
+
+```ts
+import { mkdir, writeFile, readFile, rm, rmdir, readdir } from "node:fs/promises";
+import { join, dirname, sep } from "node:path";
+```
+
+Add the class:
+
+```ts
+/** Writes into a local directory that an external web server serves. */
+export class LocalFsBackend implements StorageBackend {
+ constructor(private readonly serveRoot: string) {}
+
+ /** posix key -> OS absolute path under serveRoot. */
+ private full(key: string): string {
+ return join(this.serveRoot, ...key.split("/"));
+ }
+
+ async put(key: string, body: Buffer, _contentType: string): Promise {
+ // contentType is intentionally ignored: the web server infers it from the
+ // file extension. We only need the bytes on disk at the right path.
+ const p = this.full(key);
+ await mkdir(dirname(p), { recursive: true });
+ await writeFile(p, body);
+ }
+
+ async list(prefix: string): Promise {
+ const all = await this.walk(this.serveRoot, "");
+ return all.filter((k) => k.startsWith(prefix)).sort();
+ }
+
+ async exists(prefix: string): Promise {
+ return (await this.list(prefix)).length > 0;
+ }
+
+ async delete(keys: string[]): Promise {
+ for (const key of keys) {
+ await rm(this.full(key), { force: true });
+ await this.pruneParents(key);
+ }
+ }
+
+ async getJson(key: string): Promise {
+ return JSON.parse(await readFile(this.full(key), "utf8")) as T;
+ }
+
+ private async walk(dir: string, rel: string): Promise {
+ let entries;
+ try {
+ entries = await readdir(dir, { withFileTypes: true });
+ } catch {
+ return []; // serveRoot may not exist yet
+ }
+ const out: string[] = [];
+ for (const e of entries) {
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
+ if (e.isDirectory()) out.push(...(await this.walk(join(dir, e.name), childRel)));
+ else out.push(childRel);
+ }
+ return out;
+ }
+
+ /** Remove now-empty parent dirs of a deleted key, up to (not incl.) serveRoot. */
+ private async pruneParents(key: string): Promise {
+ let dir = dirname(this.full(key));
+ while (dir.startsWith(this.serveRoot + sep)) {
+ try {
+ if ((await readdir(dir)).length > 0) break;
+ await rmdir(dir);
+ } catch {
+ break;
+ }
+ dir = dirname(dir);
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run test/backend.test.ts` → Expected: PASS (11 tests total).
+Run: `npm run typecheck` → Expected: no errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/backend.ts test/backend.test.ts
+git commit -m "feat: add LocalFsBackend (serveRoot filesystem storage backend)
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+### Task 4: Config — self-hosted 모드 파생 + 필드 + 모호성 가드 + makeBackend 분기 + describeConfig
+
+**Files:**
+- Modify: `src/lib/config.ts`
+- Modify: `src/lib/backend.ts` (makeBackend self-hosted 분기)
+- Modify: `src/commands/config.ts` (describeConfig self-hosted 표시)
+- Test: `test/config.test.ts`
+
+**Interfaces:**
+- Produces:
+ - `type Mode = "s3-website" | "cloudfront" | "self-hosted"`.
+ - `Config`에 `bucket?`/`region?`(optional로 완화) + `serveRoot?: string; host?: string; port?: number; scheme?: "http"|"https"` 추가.
+ - `Overrides`에 `serveRoot?: string; host?: string; port?: number; scheme?: "http"|"https"` 추가.
+ - `makeBackend`가 `self-hosted`에서 `LocalFsBackend(cfg.serveRoot)` 반환.
+
+- [ ] **Step 1: Write failing tests (append to `test/config.test.ts` `describe("resolveConfig")`)**
+
+먼저 파일 상단 `ENV_KEYS` 배열에 self-hosted env를 추가:
+
+```ts
+const ENV_KEYS = [
+ "XDG_CONFIG_HOME",
+ "XDG_STATE_HOME",
+ "HOSTDOC_BUCKET",
+ "HOSTDOC_REGION",
+ "HOSTDOC_DOMAIN",
+ "HOSTDOC_DISTRIBUTION",
+ "HOSTDOC_SERVE_ROOT",
+ "HOSTDOC_HOST",
+ "HOSTDOC_PORT",
+ "HOSTDOC_SCHEME",
+];
+```
+
+그리고 테스트 추가:
+
+```ts
+ it("derives self-hosted mode from serveRoot", () => {
+ const cfg = resolveConfig({ serveRoot: "/srv/www" });
+ expect(cfg.mode).toBe("self-hosted");
+ expect(cfg.serveRoot).toBe("/srv/www");
+ });
+
+ it("self-hosted carries host/port/scheme when set", () => {
+ const cfg = resolveConfig({
+ serveRoot: "/srv/www",
+ host: "docs.example.com",
+ port: 8080,
+ scheme: "https",
+ });
+ expect(cfg).toMatchObject({
+ mode: "self-hosted",
+ host: "docs.example.com",
+ port: 8080,
+ scheme: "https",
+ });
+ });
+
+ it("self-hosted reads serveRoot from env", () => {
+ process.env.HOSTDOC_SERVE_ROOT = "/env/www";
+ expect(resolveConfig({}).mode).toBe("self-hosted");
+ expect(resolveConfig({}).serveRoot).toBe("/env/www");
+ });
+
+ it("rejects mixing serveRoot with AWS fields (ambiguous mode)", () => {
+ expect(() =>
+ resolveConfig({ serveRoot: "/srv/www", bucket: "b", region: "us-east-1" }),
+ ).toThrow(/Ambiguous|self-hosted/i);
+ });
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `npx vitest run test/config.test.ts`
+Expected: FAIL — self-hosted mode not derived / no ambiguity guard.
+
+- [ ] **Step 3: Update `src/lib/config.ts`**
+
+`Mode`·`Config`·`Overrides` 타입 (6–23행)을 교체:
+
+```ts
+export type Mode = "s3-website" | "cloudfront" | "self-hosted";
+
+export interface Config {
+ mode: Mode;
+ // cloud modes (s3-website / cloudfront)
+ bucket?: string;
+ region?: string;
+ websiteEndpoint?: string; // s3-website
+ distributionId?: string; // cloudfront
+ domain?: string; // cloudfront
+ // self-hosted
+ serveRoot?: string;
+ host?: string; // DDNS/domain/static IP; if unset, public IP is auto-detected
+ port?: number;
+ scheme?: "http" | "https";
+}
+
+/** Per-field overrides accepted from CLI flags. */
+export interface Overrides {
+ bucket?: string;
+ region?: string;
+ domain?: string;
+ distribution?: string;
+ serveRoot?: string;
+ host?: string;
+ port?: number;
+ scheme?: "http" | "https";
+}
+```
+
+`resolveConfig` 안에서 4개 변수 계산 직후(현행 71–74행, `distributionId` 계산 다음)에 self-hosted 파생 블록을 삽입 — **cloudfront 부분설정 가드보다 먼저**:
+
+```ts
+ const serveRoot =
+ flags.serveRoot ?? process.env.HOSTDOC_SERVE_ROOT ?? file?.serveRoot;
+ const host = flags.host ?? process.env.HOSTDOC_HOST ?? file?.host;
+ const port =
+ flags.port ??
+ (process.env.HOSTDOC_PORT ? Number(process.env.HOSTDOC_PORT) : undefined) ??
+ file?.port;
+ const scheme =
+ flags.scheme ??
+ (process.env.HOSTDOC_SCHEME as "http" | "https" | undefined) ??
+ file?.scheme;
+
+ // serveRoot is the self-hosted discriminator. Mixing it with AWS fields is a
+ // mistake we surface rather than silently pick a mode.
+ if (serveRoot) {
+ if (bucket || domain || distributionId) {
+ throw new Error(
+ "Ambiguous config: serveRoot (self-hosted) is set alongside bucket/domain/distribution (AWS). Pick one hosting mode.",
+ );
+ }
+ return { mode: "self-hosted", serveRoot, host, port, scheme };
+ }
+```
+
+(이후 기존 cloudfront 부분설정 가드·cloudfront·s3-website 분기는 그대로 둔다.)
+
+- [ ] **Step 4: Update `makeBackend` in `src/lib/backend.ts`**
+
+`makeBackend`를 self-hosted 분기 + 가드로 교체:
+
+```ts
+export function makeBackend(cfg: Config, opts: { profile?: string }): StorageBackend {
+ if (cfg.mode === "self-hosted") {
+ if (!cfg.serveRoot) throw new Error("self-hosted config is missing serveRoot.");
+ return new LocalFsBackend(cfg.serveRoot);
+ }
+ if (!cfg.bucket) throw new Error("cloud config is missing bucket.");
+ return new S3Backend(makeS3({ region: cfg.region, profile: opts.profile }), cfg.bucket);
+}
+```
+
+- [ ] **Step 5: Update `describeConfig` in `src/commands/config.ts`**
+
+전체 교체:
+
+```ts
+import { resolveConfig, type Overrides } from "../lib/config.js";
+
+export function describeConfig(flags: Overrides): string {
+ const cfg = resolveConfig(flags);
+ if (cfg.mode === "self-hosted") {
+ return [
+ `mode: ${cfg.mode}`,
+ `serveRoot: ${cfg.serveRoot}`,
+ `host: ${cfg.host ?? "(auto-detect public IP)"}`,
+ `port: ${cfg.port ?? "(default)"}`,
+ `scheme: ${cfg.scheme ?? "http"}`,
+ ].join("\n");
+ }
+ const lines = [
+ `mode: ${cfg.mode}`,
+ `bucket: ${cfg.bucket}`,
+ `region: ${cfg.region}`,
+ ];
+ if (cfg.mode === "s3-website") lines.push(`websiteEndpoint: ${cfg.websiteEndpoint}`);
+ else lines.push(`domain: ${cfg.domain}`, `distributionId: ${cfg.distributionId}`);
+ return lines.join("\n");
+}
+```
+
+- [ ] **Step 6: Run tests + typecheck**
+
+Run: `npx vitest run test/config.test.ts test/backend.test.ts` → Expected: PASS.
+Run: `npm run typecheck` → Expected: no errors (bucket/region 완화가 커맨드를 깨지 않음 — Task 2에서 직접 참조 제거 완료).
+Run: `npx vitest run` → Expected: 전체 스위트 PASS (무회귀).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/lib/config.ts src/lib/backend.ts src/commands/config.ts test/config.test.ts
+git commit -m "feat: derive self-hosted mode from serveRoot + config fields/guard
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+### Task 5: 호스트 해석 — detectPublicIp + ensureHost
+
+**Files:**
+- Create: `src/lib/host.ts`
+- Test: `test/host.test.ts`
+
+**Interfaces:**
+- Consumes: `Config`(Task 4), global `fetch`.
+- Produces:
+ - `function detectPublicIp(): Promise` — echo 서비스에서 공인 IP 텍스트를 trim해 반환. 모두 실패 시 throw.
+ - `function ensureHost(cfg: Config): Promise` — self-hosted + host 미설정이면 IP 감지 + 경고 후 `{...cfg, host, scheme: cfg.scheme ?? "http"}` 반환. 그 외(호스트 있음/클라우드)는 cfg 그대로.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `test/host.test.ts`:
+
+```ts
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { detectPublicIp, ensureHost } from "../src/lib/host.js";
+import type { Config } from "../src/lib/config.js";
+
+const realFetch = globalThis.fetch;
+afterEach(() => {
+ globalThis.fetch = realFetch;
+ vi.restoreAllMocks();
+});
+
+function mockFetchOnce(text: string) {
+ globalThis.fetch = vi.fn(async () => ({ ok: true, text: async () => text })) as never;
+}
+
+describe("detectPublicIp", () => {
+ it("returns the trimmed IP from the echo service", async () => {
+ mockFetchOnce("203.0.113.7\n");
+ expect(await detectPublicIp()).toBe("203.0.113.7");
+ });
+
+ it("throws a helpful error when all services fail", async () => {
+ globalThis.fetch = vi.fn(async () => {
+ throw new Error("network down");
+ }) as never;
+ await expect(detectPublicIp()).rejects.toThrow(/host/i);
+ });
+});
+
+describe("ensureHost", () => {
+ it("no-ops for cloud modes", async () => {
+ const cfg: Config = { mode: "s3-website", bucket: "b", region: "us-east-1" };
+ expect(await ensureHost(cfg)).toBe(cfg);
+ });
+
+ it("returns cfg unchanged when self-hosted host is already set", async () => {
+ const cfg: Config = { mode: "self-hosted", serveRoot: "/srv", host: "docs.example.com" };
+ expect(await ensureHost(cfg)).toBe(cfg);
+ });
+
+ it("detects the public IP and warns when host is unset", async () => {
+ mockFetchOnce("203.0.113.7");
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const cfg: Config = { mode: "self-hosted", serveRoot: "/srv" };
+ const out = await ensureHost(cfg);
+ expect(out.host).toBe("203.0.113.7");
+ expect(out.scheme).toBe("http");
+ expect(warn).toHaveBeenCalledWith(expect.stringMatching(/IP/));
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run test/host.test.ts`
+Expected: FAIL — `Cannot find module '../src/lib/host.js'`.
+
+- [ ] **Step 3: Implement `src/lib/host.ts`**
+
+```ts
+import type { Config } from "./config.js";
+
+// External echo services that return the caller's public IP as plain text.
+const ECHO_SERVICES = ["https://api.ipify.org", "https://icanhazip.com"];
+
+/** Detect this machine's public IP via an external echo service. */
+export async function detectPublicIp(): Promise {
+ for (const url of ECHO_SERVICES) {
+ try {
+ const res = await fetch(url);
+ if (!res.ok) continue;
+ const ip = (await res.text()).trim();
+ if (ip) return ip;
+ } catch {
+ // try the next service
+ }
+ }
+ throw new Error(
+ "Could not detect a public IP from the echo services. Set --host / HOSTDOC_HOST (DDNS, domain, or static IP) explicitly.",
+ );
+}
+
+/**
+ * Resolve the effective host for self-hosted mode exactly once, before building
+ * URLs. If a host is configured it is used as-is; otherwise the public IP is
+ * detected and the user is warned that the link may break if the IP changes.
+ * No-op for cloud modes.
+ */
+export async function ensureHost(cfg: Config): Promise {
+ if (cfg.mode !== "self-hosted" || cfg.host) return cfg;
+ const ip = await detectPublicIp();
+ console.warn(
+ `Warning: no host configured; using detected public IP ${ip}. ` +
+ "If your IP changes (dynamic ISP), published links will break. " +
+ "Set --host (DDNS/domain/static IP) for stable links.",
+ );
+ return { ...cfg, host: ip, scheme: cfg.scheme ?? "http" };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npx vitest run test/host.test.ts` → Expected: PASS (5 tests).
+Run: `npm run typecheck` → Expected: no errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/host.ts test/host.test.ts
+git commit -m "feat: add public-IP detection + ensureHost for self-hosted links
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+### Task 6: buildPublicUrl self-hosted 분기 + ensureHost 배선 + self-hosted E2E
+
+**Files:**
+- Modify: `src/lib/url.ts`
+- Modify: `src/commands/publish.ts` (ensureHost)
+- Modify: `src/commands/list.ts` (ensureHost)
+- Modify: `src/commands/open.ts` (ensureHost, async)
+- Modify: `src/index.ts` (open 커맨드 action await, publish --open await)
+- Modify: `test/url.test.ts`, `test/open.test.ts`, `test/publish-open.test.ts` (open 경로 async 반영)
+- Test: `test/self-hosted.test.ts` (신규 E2E)
+
+**Interfaces:**
+- Consumes: `ensureHost`(Task 5).
+- Produces: `buildPublicUrl` self-hosted 분기(host 필수, 미해석 시 throw). `resolveOpenUrl`/`runOpen`/`openPublishedUrl`가 `Promise`.
+
+- [ ] **Step 1: Write failing unit tests for `buildPublicUrl` (append to `test/url.test.ts` `describe("buildPublicUrl")`)**
+
+```ts
+ it("self-hosted with a configured host uses http and appends the code", () => {
+ const cfg: Config = { mode: "self-hosted", serveRoot: "/srv", host: "docs.example.com" };
+ expect(buildPublicUrl(cfg, "abc")).toBe("http://docs.example.com/abc/");
+ });
+ it("self-hosted appends a non-standard port", () => {
+ const cfg: Config = { mode: "self-hosted", serveRoot: "/srv", host: "1.2.3.4", port: 8080 };
+ expect(buildPublicUrl(cfg, "abc")).toBe("http://1.2.3.4:8080/abc/");
+ });
+ it("self-hosted honors https scheme and omits the default 443", () => {
+ const cfg: Config = { mode: "self-hosted", serveRoot: "/srv", host: "docs.example.com", scheme: "https", port: 443 };
+ expect(buildPublicUrl(cfg, "abc")).toBe("https://docs.example.com/abc/");
+ });
+ it("self-hosted throws when the host is unresolved", () => {
+ const cfg: Config = { mode: "self-hosted", serveRoot: "/srv" };
+ expect(() => buildPublicUrl(cfg, "abc")).toThrow(/host/i);
+ });
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `npx vitest run test/url.test.ts`
+Expected: FAIL — self-hosted branch not implemented (falls through, wrong output).
+
+- [ ] **Step 3: Implement the self-hosted branch in `src/lib/url.ts`**
+
+`buildPublicUrl` (23–28행)을 교체:
+
+```ts
+export function buildPublicUrl(cfg: Config, code: string): string {
+ if (cfg.mode === "cloudfront") {
+ return `https://${cfg.domain}/${code}/`;
+ }
+ if (cfg.mode === "self-hosted") {
+ if (!cfg.host) {
+ throw new Error(
+ "self-hosted URL requires a resolved host; call ensureHost() before buildPublicUrl().",
+ );
+ }
+ const scheme = cfg.scheme ?? "http";
+ const defaultPort = scheme === "https" ? 443 : 80;
+ const portPart = cfg.port && cfg.port !== defaultPort ? `:${cfg.port}` : "";
+ return `${scheme}://${cfg.host}${portPart}/${code}/`;
+ }
+ return `${cfg.websiteEndpoint}/${code}/`;
+}
+```
+
+Run: `npx vitest run test/url.test.ts` → Expected: PASS.
+
+- [ ] **Step 4: Wire `ensureHost` into publish**
+
+`src/commands/publish.ts`에 import 추가:
+
+```ts
+import { ensureHost } from "../lib/host.js";
+```
+
+`runPublish`에서 `resolveConfig(...)`로 얻은 `cfg`를 self-hosted 호스트 확정 버전으로 바꾼다. `const backend = makeBackend(cfg, ...)` **다음 줄**에 삽입하고, 이후 모든 `buildPublicUrl(cfg, ...)`가 이 확정된 cfg를 쓰도록 변수를 재사용:
+
+```ts
+ const cfg = await ensureHost(resolveConfig({
+ bucket: args.bucket,
+ region: args.region,
+ domain: args.domain,
+ distribution: args.distribution,
+ serveRoot: args.serveRoot,
+ host: args.host,
+ port: args.port,
+ scheme: args.scheme,
+ }));
+ const backend = makeBackend(cfg, { profile: args.profile });
+```
+
+그리고 `PublishArgs`에 self-hosted 필드를 추가(14–25행 인터페이스에):
+
+```ts
+ serveRoot?: string;
+ host?: string;
+ port?: number;
+ scheme?: "http" | "https";
+```
+
+(dryRun의 `buildPublicUrl(cfg, code)`도 이제 host가 확정된 cfg를 쓰므로 self-hosted dry-run이 올바른 링크를 반환한다.)
+
+- [ ] **Step 5: Wire `ensureHost` into list**
+
+`src/commands/list.ts`에 import 추가하고 `resolveConfig`를 감싼다:
+
+```ts
+import { ensureHost } from "../lib/host.js";
+```
+```ts
+ const cfg = await ensureHost(resolveConfig(flags));
+ const backend = makeBackend(cfg, { profile: flags.profile });
+```
+
+- [ ] **Step 6: Make open async + wire ensureHost**
+
+`src/commands/open.ts` 전체 교체:
+
+```ts
+import { resolveConfig, type Overrides } from "../lib/config.js";
+import { ensureHost } from "../lib/host.js";
+import { buildPublicUrl } from "../lib/url.js";
+import { openInBrowser } from "../lib/browser.js";
+import { isValidPath, isValidCode } from "../lib/code.js";
+
+export async function resolveOpenUrl(
+ args: { id: string } & Overrides,
+): Promise {
+ if (!isValidPath(args.id) && !isValidCode(args.id)) {
+ throw new Error(`Invalid id: ${args.id}`);
+ }
+ const cfg = await ensureHost(resolveConfig(args));
+ return buildPublicUrl(cfg, args.id);
+}
+
+export async function runOpen(args: { id: string } & Overrides): Promise {
+ const url = await resolveOpenUrl(args);
+ openInBrowser(url);
+ return url;
+}
+
+/** Open a just-published URL: derive its (possibly nested) path and re-open under `overrides`. */
+export async function openPublishedUrl(
+ url: string,
+ overrides: Overrides = {},
+): Promise {
+ const id = new URL(url).pathname.replace(/^\/+|\/+$/g, "");
+ return runOpen({ id, ...overrides });
+}
+```
+
+- [ ] **Step 7: Update `src/index.ts` open wiring**
+
+`open` 커맨드 action을 async + await로:
+
+```ts
+withCommon(program.command("open "))
+ .description("Open a document's URL in your browser (does not verify the document exists)")
+ .action(async (id, opts) => {
+ try {
+ console.log(await runOpen({ id, ...overrides(opts) }));
+ } catch (err) {
+ fail(err);
+ }
+ });
+```
+
+`publish --open` 라인(165행)을 await로:
+
+```ts
+ if (opts.open && !opts.dryRun) await openPublishedUrl(url, overrides(opts));
+```
+
+- [ ] **Step 8: Update the open tests to await the now-async API**
+
+`test/open.test.ts`의 `runOpen`/`resolveOpenUrl` 호출을 `await`로 감싸고 해당 `it`을 `async`로 (모든 호출부). `test/publish-open.test.ts`의 3개 `openPublishedUrl(...)` 호출을 `await openPublishedUrl(...)`로 바꾸고 각 `it`을 `async (...) =>`로 변경. 예:
+
+```ts
+ it("forwards overrides so the opened URL matches the override config", async () => {
+ const url = await openPublishedUrl(
+ "http://envbkt.s3-website-us-east-1.amazonaws.com/abc/",
+ { bucket: "flagbkt", region: "us-east-1" },
+ );
+ expect(url).toBe("http://flagbkt.s3-website-us-east-1.amazonaws.com/abc/");
+ expect(openInBrowser).toHaveBeenCalledWith(
+ "http://flagbkt.s3-website-us-east-1.amazonaws.com/abc/",
+ );
+ });
+```
+
+(다른 두 `it`도 동일하게 `async` + `await` 적용.)
+
+- [ ] **Step 9: Write the self-hosted E2E test**
+
+Create `test/self-hosted.test.ts`:
+
+```ts
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { runPublish } from "../src/commands/publish.js";
+import { listDocs } from "../src/commands/list.js";
+import { runRm } from "../src/commands/rm.js";
+
+let src: string, serve: string;
+const ENV = ["HOSTDOC_SERVE_ROOT", "HOSTDOC_HOST", "HOSTDOC_BUCKET", "HOSTDOC_REGION", "XDG_CONFIG_HOME"];
+const saved: Record = {};
+
+beforeEach(() => {
+ for (const k of ENV) { saved[k] = process.env[k]; delete process.env[k]; }
+ src = mkdtempSync(join(tmpdir(), "sf-src-"));
+ serve = mkdtempSync(join(tmpdir(), "sf-serve-"));
+ process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "sf-cfg-"));
+ process.env.HOSTDOC_SERVE_ROOT = serve;
+ process.env.HOSTDOC_HOST = "docs.example.com"; // configured host → no network
+});
+afterEach(() => {
+ rmSync(src, { recursive: true, force: true });
+ rmSync(serve, { recursive: true, force: true });
+ for (const k of ENV) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; }
+});
+
+describe("self-hosted end-to-end", () => {
+ it("publishes to the serve root and returns a host-based URL", async () => {
+ writeFileSync(join(src, "index.html"), "Hi");
+ const url = await runPublish({ path: src, slug: "doc1" });
+ expect(url).toBe("http://docs.example.com/doc1/");
+ expect(existsSync(join(serve, "doc1", "index.html"))).toBe(true);
+ expect(existsSync(join(serve, "_meta", "doc1.json"))).toBe(true);
+ });
+
+ it("list returns published docs with the host URL", async () => {
+ writeFileSync(join(src, "index.html"), "Hi");
+ await runPublish({ path: src, slug: "doc1" });
+ const rows = await listDocs({});
+ expect(rows.map((r) => r.code)).toContain("doc1");
+ expect(rows[0].url).toBe("http://docs.example.com/doc1/");
+ });
+
+ it("rm deletes files from the serve root", async () => {
+ writeFileSync(join(src, "index.html"), "Hi");
+ await runPublish({ path: src, slug: "doc1" });
+ await runRm({ id: "doc1", yes: true });
+ expect(existsSync(join(serve, "doc1", "index.html"))).toBe(false);
+ expect(existsSync(join(serve, "_meta", "doc1.json"))).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 10: Run all tests + typecheck**
+
+Run: `npx vitest run test/self-hosted.test.ts test/url.test.ts test/open.test.ts test/publish-open.test.ts` → Expected: PASS.
+Run: `npx vitest run` → Expected: 전체 스위트 PASS.
+Run: `npm run typecheck` → Expected: no errors.
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add src/lib/url.ts src/commands/publish.ts src/commands/list.ts src/commands/open.ts src/index.ts test/url.test.ts test/open.test.ts test/publish-open.test.ts test/self-hosted.test.ts
+git commit -m "feat: self-hosted URL building + ensureHost wiring across commands
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+### Task 7: 수동 웹서버 설정 스니펫 (Caddy + nginx)
+
+**Files:**
+- Create: `src/lib/snippet.ts`
+- Test: `test/snippet.test.ts`
+
+**Interfaces:**
+- Produces:
+ - `interface SnippetOpts { serveRoot: string; host?: string; port?: number }`
+ - `function caddySnippet(opts: SnippetOpts): string`
+ - `function nginxSnippet(opts: SnippetOpts): string`
+ - 두 스니펫 모두: `/_*` 403 차단 + 트레일링슬래시/확장자없는 URI에 `index.html` + 디렉토리 리스팅 off.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `test/snippet.test.ts`:
+
+```ts
+import { describe, it, expect } from "vitest";
+import { caddySnippet, nginxSnippet } from "../src/lib/snippet.js";
+
+describe("caddySnippet", () => {
+ const s = caddySnippet({ serveRoot: "/srv/www", host: "docs.example.com" });
+ it("roots at the serve dir", () => expect(s).toContain("root * /srv/www"));
+ it("blocks /_* with 403", () => {
+ expect(s).toMatch(/path \/_\*/);
+ expect(s).toContain("respond @hidden 403");
+ });
+ it("rewrites to index.html", () => expect(s).toContain("try_files {path} {path}/index.html"));
+ it("serves via file_server (listing off by default)", () => expect(s).toContain("file_server"));
+});
+
+describe("nginxSnippet", () => {
+ const s = nginxSnippet({ serveRoot: "/srv/www", host: "docs.example.com", port: 8080 });
+ it("roots at the serve dir and listens on the port", () => {
+ expect(s).toContain("root /srv/www;");
+ expect(s).toContain("listen 8080;");
+ });
+ it("blocks /_* with 403", () => expect(s).toContain("location ~ ^/_ { return 403; }"));
+ it("rewrites to index.html", () => expect(s).toContain("try_files $uri $uri/index.html =404;"));
+ it("disables directory listing", () => expect(s).toContain("autoindex off;"));
+});
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `npx vitest run test/snippet.test.ts`
+Expected: FAIL — `Cannot find module '../src/lib/snippet.js'`.
+
+- [ ] **Step 3: Implement `src/lib/snippet.ts`**
+
+```ts
+export interface SnippetOpts {
+ serveRoot: string;
+ host?: string;
+ port?: number;
+}
+
+/** Caddyfile snippet: root, /_* block, index rewrite, static file server. */
+export function caddySnippet(opts: SnippetOpts): string {
+ const site = opts.host
+ ? opts.port
+ ? `${opts.host}:${opts.port}`
+ : opts.host
+ : opts.port
+ ? `:${opts.port}`
+ : ":80";
+ return [
+ `${site} {`,
+ `\troot * ${opts.serveRoot}`,
+ `\t# Block hostdoc's private sidecars (/_meta and any /_*).`,
+ `\t@hidden path /_*`,
+ `\trespond @hidden 403`,
+ `\t# Serve index.html for directory / extensionless URIs.`,
+ `\ttry_files {path} {path}/index.html`,
+ `\tfile_server`,
+ `}`,
+ ].join("\n");
+}
+
+/** nginx server block: root, /_* block, index rewrite, listing off. */
+export function nginxSnippet(opts: SnippetOpts): string {
+ const listen = opts.port ?? 80;
+ const serverName = opts.host ?? "_";
+ return [
+ `server {`,
+ `\tlisten ${listen};`,
+ `\tserver_name ${serverName};`,
+ `\troot ${opts.serveRoot};`,
+ `\tautoindex off;`,
+ `\tindex index.html;`,
+ `\t# Block hostdoc's private sidecars (/_meta and any /_*).`,
+ `\tlocation ~ ^/_ { return 403; }`,
+ `\t# Serve index.html for directory / extensionless URIs.`,
+ `\tlocation / { try_files $uri $uri/index.html =404; }`,
+ `}`,
+ ].join("\n");
+}
+```
+
+- [ ] **Step 4: Run to verify pass**
+
+Run: `npx vitest run test/snippet.test.ts` → Expected: PASS.
+Run: `npm run typecheck` → Expected: no errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/snippet.ts test/snippet.test.ts
+git commit -m "feat: Caddy/nginx config snippets (_meta block + index rewrite)
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+### Task 8: self-hosted setup 커맨드 + CLI 배선
+
+**Files:**
+- Create: `src/commands/setup-selfhosted.ts`
+- Create: `src/lib/which.ts`
+- Modify: `src/index.ts` (setup 옵션 분기, withCommon/overrides self-hosted 필드)
+- Test: `test/setup-selfhosted.test.ts`
+
+**Interfaces:**
+- Consumes: `saveConfig`/`Config`(Task 4), `caddySnippet`/`nginxSnippet`(Task 7).
+- Produces:
+ - `type Server = "caddy" | "nginx"`
+ - `function chooseServer(opts: { nginx?: boolean; caddy?: boolean; nginxDetected: boolean }): Server`
+ - `function runSetupSelfHosted(args): { cfg: Config; server: Server; snippet: string; warning: string }`
+ - `function onPath(bin: string): boolean` (`src/lib/which.ts`)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `test/setup-selfhosted.test.ts`:
+
+```ts
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, rmSync, existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { chooseServer, runSetupSelfHosted } from "../src/commands/setup-selfhosted.js";
+import { loadConfig } from "../src/lib/config.js";
+
+describe("chooseServer", () => {
+ it("defaults to caddy", () => expect(chooseServer({ nginxDetected: false })).toBe("caddy"));
+ it("uses nginx when detected", () => expect(chooseServer({ nginxDetected: true })).toBe("nginx"));
+ it("uses nginx when explicitly requested", () => expect(chooseServer({ nginx: true, nginxDetected: false })).toBe("nginx"));
+ it("--caddy forces caddy even if nginx is detected", () => expect(chooseServer({ caddy: true, nginxDetected: true })).toBe("caddy"));
+});
+
+describe("runSetupSelfHosted", () => {
+ let dir: string, serve: string;
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "sf-setup-cfg-"));
+ serve = join(mkdtempSync(join(tmpdir(), "sf-setup-")), "www"); // not yet created
+ process.env.XDG_CONFIG_HOME = dir;
+ });
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }); delete process.env.XDG_CONFIG_HOME; });
+
+ it("creates the serve root, saves self-hosted config, and returns a Caddy snippet by default", () => {
+ const out = runSetupSelfHosted({ serveRoot: serve, host: "docs.example.com", nginxDetected: false });
+ expect(existsSync(serve)).toBe(true);
+ expect(out.server).toBe("caddy");
+ expect(out.snippet).toContain("respond @hidden 403");
+ expect(out.warning).toMatch(/exposure/i);
+ expect(loadConfig()).toMatchObject({ mode: "self-hosted", serveRoot: serve, host: "docs.example.com" });
+ });
+
+ it("returns an nginx snippet when --nginx is set", () => {
+ const out = runSetupSelfHosted({ serveRoot: serve, nginx: true, nginxDetected: false });
+ expect(out.server).toBe("nginx");
+ expect(out.snippet).toContain("location ~ ^/_ { return 403; }");
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `npx vitest run test/setup-selfhosted.test.ts`
+Expected: FAIL — modules not found.
+
+- [ ] **Step 3: Implement `src/lib/which.ts`**
+
+```ts
+import { existsSync } from "node:fs";
+import { delimiter, join } from "node:path";
+
+/** True if `bin` (or `bin.exe`) is found on PATH. */
+export function onPath(bin: string): boolean {
+ const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
+ return dirs.some((d) => existsSync(join(d, bin)) || existsSync(join(d, `${bin}.exe`)));
+}
+```
+
+- [ ] **Step 4: Implement `src/commands/setup-selfhosted.ts`**
+
+```ts
+import { mkdirSync } from "node:fs";
+import { saveConfig, type Config } from "../lib/config.js";
+import { caddySnippet, nginxSnippet } from "../lib/snippet.js";
+
+export type Server = "caddy" | "nginx";
+
+/** Default Caddy; nginx only when explicitly requested or detected. --caddy wins. */
+export function chooseServer(opts: {
+ nginx?: boolean;
+ caddy?: boolean;
+ nginxDetected: boolean;
+}): Server {
+ if (opts.caddy) return "caddy";
+ if (opts.nginx) return "nginx";
+ return opts.nginxDetected ? "nginx" : "caddy";
+}
+
+export interface SelfHostedSetupArgs {
+ serveRoot: string;
+ host?: string;
+ port?: number;
+ scheme?: "http" | "https";
+ nginx?: boolean;
+ caddy?: boolean;
+ nginxDetected: boolean;
+}
+
+export function runSetupSelfHosted(args: SelfHostedSetupArgs): {
+ cfg: Config;
+ server: Server;
+ snippet: string;
+ warning: string;
+} {
+ mkdirSync(args.serveRoot, { recursive: true });
+ const cfg: Config = {
+ mode: "self-hosted",
+ serveRoot: args.serveRoot,
+ host: args.host,
+ port: args.port,
+ scheme: args.scheme,
+ };
+ saveConfig(cfg);
+
+ const server = chooseServer(args);
+ const snippetOpts = { serveRoot: args.serveRoot, host: args.host, port: args.port };
+ const snippet = server === "nginx" ? nginxSnippet(snippetOpts) : caddySnippet(snippetOpts);
+
+ const warning = [
+ "Security: hostdoc only writes into the serve directory; listing is off and /_* is blocked.",
+ "Network exposure (port-forwarding, firewall, router) is your responsibility — expose only what you intend.",
+ ].join("\n");
+
+ return { cfg, server, snippet, warning };
+}
+```
+
+- [ ] **Step 5: Wire the CLI in `src/index.ts`**
+
+`withCommon`에 self-hosted 오버라이드 옵션 추가:
+
+```ts
+function withCommon(cmd: Command): Command {
+ return cmd
+ .option("--profile ", "AWS profile")
+ .option("--region ", "AWS region")
+ .option("--bucket ", "override bucket")
+ .option("--domain ", "override domain (cloudfront mode)")
+ .option("--distribution ", "override distribution id (cloudfront mode)")
+ .option("--serve-root ", "local serve directory (self-hosted mode)")
+ .option("--host ", "public host: DDNS/domain/static IP (self-hosted mode)")
+ .option("--port ", "non-standard port (self-hosted mode)")
+ .option("--scheme ", "http|https (self-hosted mode)");
+}
+```
+
+`overrides`에 매핑 추가:
+
+```ts
+function overrides(o: OptionValues) {
+ return {
+ profile: o.profile as string | undefined,
+ region: o.region as string | undefined,
+ bucket: o.bucket as string | undefined,
+ domain: o.domain as string | undefined,
+ distribution: o.distribution as string | undefined,
+ serveRoot: o.serveRoot as string | undefined,
+ host: o.host as string | undefined,
+ port: o.port ? Number(o.port) : undefined,
+ scheme: o.scheme as "http" | "https" | undefined,
+ };
+}
+```
+
+파일 상단 import에 추가:
+
+```ts
+import { runSetupSelfHosted } from "./commands/setup-selfhosted.js";
+import { onPath } from "./lib/which.js";
+```
+
+`setup` 커맨드 정의 전체(53–72행)를 교체 — `--bucket`/`--region`을 `requiredOption`에서 일반 `option`으로 낮추고 `--serve-root` 분기 추가:
+
+```ts
+program
+ .command("setup")
+ .description("Create hosting infra and save config: s3-website (--bucket/--region) or self-hosted (--serve-root)")
+ .option("--bucket ", "bucket name to create (s3-website)")
+ .option("--region ", "AWS region for the bucket (s3-website)")
+ .option("--profile ", "AWS profile")
+ .option("--serve-root ", "local serve directory (self-hosted)")
+ .option("--host ", "public host: DDNS/domain/static IP (self-hosted)")
+ .option("--port ", "non-standard port (self-hosted)")
+ .option("--scheme ", "http|https (self-hosted)")
+ .option("--nginx", "emit an nginx config snippet (self-hosted)")
+ .option("--caddy", "emit a Caddy config snippet even if nginx is detected (self-hosted)")
+ .action(async (opts) => {
+ try {
+ if (opts.serveRoot) {
+ const { cfg, server, snippet, warning } = runSetupSelfHosted({
+ serveRoot: opts.serveRoot,
+ host: opts.host,
+ port: opts.port ? Number(opts.port) : undefined,
+ scheme: opts.scheme,
+ nginx: opts.nginx,
+ caddy: opts.caddy,
+ nginxDetected: onPath("nginx"),
+ });
+ console.log(`Configured self-hosted mode; serve root: ${cfg.serveRoot}`);
+ console.log(`\nAdd this ${server} config, then reload your web server:\n`);
+ console.log(snippet);
+ console.log(`\n${warning}`);
+ return;
+ }
+ if (!opts.bucket || !opts.region) {
+ throw new Error(
+ "setup requires --bucket and --region (s3-website), or --serve-root (self-hosted).",
+ );
+ }
+ const cfg = await runSetup({
+ bucket: opts.bucket,
+ region: opts.region,
+ profile: opts.profile,
+ });
+ console.log(`Created s3-website bucket "${cfg.bucket}".`);
+ console.log(`Public base: ${cfg.websiteEndpoint}/`);
+ console.log("Note: this bucket serves content publicly over HTTP.");
+ } catch (err) {
+ fail(err);
+ }
+ });
+```
+
+- [ ] **Step 6: Run tests + full suite + typecheck**
+
+Run: `npx vitest run test/setup-selfhosted.test.ts` → Expected: PASS.
+Run: `npx vitest run` → Expected: 전체 스위트 PASS.
+Run: `npm run typecheck` → Expected: no errors.
+Run: `npm run build` → Expected: `dist/` emit 성공.
+
+- [ ] **Step 7: Manual smoke (self-hosted publish, no AWS)**
+
+```bash
+node --import tsx src/index.ts setup --serve-root /tmp/hd-www --host docs.example.com
+mkdir -p /tmp/hd-src && echo 'Hi' > /tmp/hd-src/index.html
+node --import tsx src/index.ts publish /tmp/hd-src --slug demo --serve-root /tmp/hd-www --host docs.example.com
+```
+Expected: `http://docs.example.com/demo/` 출력, `/tmp/hd-www/demo/index.html`·`/tmp/hd-www/_meta/demo.json` 생성.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/commands/setup-selfhosted.ts src/lib/which.ts src/index.ts test/setup-selfhosted.test.ts
+git commit -m "feat: hostdoc setup --serve-root (self-hosted config + web-server snippet)
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- 스토리지 백엔드 추상화 → Task 1(인터페이스+S3Backend), Task 3(LocalFsBackend), Task 2(커맨드 리팩터). ✓
+- 모드 파생 & config(serveRoot/host/port/scheme, 가드) → Task 4. ✓
+- 호스트/URL 해석(설정 우선 + IP 자동감지 + 경고, sync buildPublicUrl) → Task 5(host.ts), Task 6(buildPublicUrl+배선). ✓
+- `_meta` 보호 + index + 스니펫(Caddy 기본/nginx 옵션) → Task 7(snippet), Task 8(server 선택). ✓
+- CDN 없음 → invalidation skip: publish/rm의 `cfg.mode === "cloudfront"` 가드가 그대로라 self-hosted 미진입. Task 2에서 보존. ✓
+- setup(self-hosted, 설치 없음) → Task 8. ✓
+- 무회귀(기존 두 모드 + CI) → Task 2 Step 5, Task 4 Step 6, Task 6 Step 10 전체 스위트. ✓
+- Acceptance criteria 6개 → 각각 Task 6 E2E(publish/list/rm host URL, IP 경고=Task 5), Task 7/8(스니펫 _meta/index), Task 8(setup). ✓
+
+**Placeholder scan:** TBD/TODO/"handle edge cases" 없음. 모든 코드 스텝에 실제 코드 포함. ✓
+
+**Type consistency:** `StorageBackend`(put/list/exists/delete/getJson), `makeBackend(cfg, {profile})`, `ensureHost(cfg): Promise`, `buildPublicUrl(cfg, code)`, `chooseServer`/`runSetupSelfHosted`, `Overrides`의 serveRoot/host/port/scheme — Task 간 시그니처 일치 확인. `PublishArgs`에 self-hosted 필드 추가(Task 6 Step 4)로 `resolveConfig` 인자와 정합. ✓
+
+**주의 사항(구현자용):**
+- Task 4에서 `bucket`/`region`을 optional로 완화하기 **전에** Task 2 리팩터가 완료되어야 타입체크가 깨지지 않는다(순서 엄수).
+- Task 6에서 open 경로가 async로 바뀌므로 `test/open.test.ts`·`test/publish-open.test.ts`를 반드시 함께 수정(안 하면 sync 반환 기대가 Promise를 받아 실패).
diff --git a/docs/superpowers/plans/2026-07-04-self-hosted-mode-phase3-windows.md b/docs/superpowers/plans/2026-07-04-self-hosted-mode-phase3-windows.md
new file mode 100644
index 0000000..3413b9c
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-04-self-hosted-mode-phase3-windows.md
@@ -0,0 +1,890 @@
+# self-hosted Phase 3 — Windows 서비스 통합 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** `hostdoc setup --serve-root --install`을 Windows(x64)까지 확장해 Caddy를 WinSW 래퍼로 부팅 생존 Windows 서비스로 등록하고, `deprovision`이 이를 되돌린다.
+
+**Architecture:** Phase 2의 순수 생성기(`platform.ts`/`service.ts`) + 주입 가능한 I/O 경계(`installer.ts`의 `Deps`) 패턴을 그대로 Windows로 확장한다. Windows는 launchd/systemd 대신 **WinSW**(caddy는 네이티브 SCM 스텁이 없음)를 쓰고, 서비스 디렉토리 하나(`caddy.exe`+`caddy-service.exe`+`caddy-service.xml`)에서 제자리 등록한다. 모든 I/O는 주입 seam 뒤에 있어 CI(Linux)는 `plat`를 명시 주입해 무네트워크·무관리자로 Windows 경로를 검증한다.
+
+**Tech Stack:** TypeScript(ESM, relative imports는 `.js` 확장자), Node `node:*` 내장, Commander, Vitest.
+
+## Global Constraints
+
+- ESM: `.ts` 소스의 상대 import는 `.js` 확장자 필수 (`./service.js`).
+- **동결 4파일 무수정**: `src/lib/aws.ts`·`src/lib/cloudfront.ts`·`src/lib/terraform.ts`·`src/lib/snippet.ts`. Phase 1·2 macOS/Linux·클라우드 경로 무회귀.
+- 모든 신규 I/O는 `installer.ts`의 주입 `Deps`(`run`/`fetchFn`/`writeFile`/`exists`/`isAdmin` 등) 뒤로 격리 — 테스트는 실제 exec·네트워크 0.
+- 테스트는 `plat`를 **명시 인자로 주입**(Linux CI에서 Windows 분기 검증). 경로 단언은 구분자에 의존하지 말고 `/caddy\.exe$/`류 regex로.
+- 상수(검증 완료, 추측 금지): WinSW 핀 버전 `v2.12.0`, 자산 `WinSW-x64.exe`(v2.x는 x64/x86만 — arm64 없음, Windows-on-ARM은 x64 에뮬). Caddy Windows 다운로드는 단일 `.exe`(`Content-Type: application/octet-stream`).
+- 서비스 식별자: WinSW `` = `hostdoc-caddy` (기존 `LAUNCHD_LABEL`/`SYSTEMD_UNIT`과 대칭).
+- 명령: `npm test`(vitest, AWS mock) · `npm run typecheck`(tsgo) · `npm run build`(tsc). CI는 AWS·Terraform·네트워크·관리자 없이 통과 유지.
+- 커밋 신원은 리포 기존값(`yeonigi `). 커밋 메시지 말미에 `Co-Authored-By: Claude Opus 4.8 `.
+
+---
+
+## File Structure
+
+- `src/lib/platform.ts` (수정) — `Platform.os`에 `"windows"` 추가, `detectPlatform` win32 분기(throw 제거), 신규 `winswDownloadUrl` + `WINSW_VERSION`.
+- `src/lib/service.ts` (수정) — 신규 `winswXml` + `WINSW_ID`. launchd/systemd 무변경.
+- `src/lib/installer.ts` (수정) — 개명(`sudo*`→`privileged*`, `needs-root`→`needs-privilege`), 신규 Windows 헬퍼(`serviceDir`/`winswBinDest`/`caddyBinDest` win 분기/`downloadWinsw`/`isPrivileged`/`isAdmin`), 서비스 제어 함수 windows 분기.
+- `src/commands/setup-selfhosted.ts` (수정) — 아웃컴 개명, Windows 라우팅(caddy+WinSW 다운로드, xml 기록, `isPrivileged` 게이트).
+- `src/commands/deprovision-selfhosted.ts` (수정) — 아웃컴 개명, Windows 라우팅.
+- `src/index.ts` (수정) — 아웃컴 개명 참조, 플랫폼별 특권 명령 헤더.
+- 테스트(수정): `test/platform.test.ts`·`test/service.test.ts`·`test/installer.test.ts`·`test/setup-selfhosted.test.ts`·`test/deprovision-selfhosted.test.ts`.
+
+---
+
+## Task 1: platform.ts — Windows 감지 + WinSW 다운로드 URL
+
+**Files:**
+- Modify: `src/lib/platform.ts`
+- Test: `test/platform.test.ts`
+
+**Interfaces:**
+- Consumes: 없음(리프).
+- Produces: `Platform.os: "darwin" | "linux" | "windows"`; `detectPlatform(nodePlatform?, nodeArch?): Platform` (win32→`{os:"windows"}`, throw 안 함); `winswDownloadUrl(plat: Platform): string`; `WINSW_VERSION: string`. `caddyDownloadUrl` 시그니처 불변.
+
+- [ ] **Step 1: (검증) 상수 재확인 — 추측 금지**
+
+Run:
+```bash
+gh api repos/winsw/winsw/releases/tags/v2.12.0 --jq '.assets[].name' | grep -x 'WinSW-x64.exe'
+curl -sIL "https://caddyserver.com/api/download?os=windows&arch=amd64" | grep -iE 'content-type|content-disposition'
+```
+Expected: 첫 명령이 `WinSW-x64.exe` 한 줄 출력(자산 존재 확인). 둘째가 `filename="caddy_windows_amd64.exe"` + `application/octet-stream`(단일 exe 확인). 값이 다르면 URL/버전을 실제값으로 고쳐 이후 스텝에 반영.
+
+- [ ] **Step 2: 실패 테스트 작성** — `test/platform.test.ts`의 win32-throw 테스트를 교체하고 신규 URL 테스트 추가
+
+기존 블록(11–16행)을 아래로 교체:
+```ts
+ it("maps win32 to windows (no longer throws)", () => {
+ expect(detectPlatform("win32", "x64")).toEqual({ os: "windows", arch: "amd64" });
+ });
+ it("throws for an unsupported arch", () => {
+ expect(() => detectPlatform("linux", "ia32")).toThrow(/arch/i);
+ });
+```
+파일 상단 import에 `winswDownloadUrl` 추가:
+```ts
+import { detectPlatform, caddyDownloadUrl, winswDownloadUrl } from "../src/lib/platform.js";
+```
+`caddyDownloadUrl` describe에 windows 케이스 추가:
+```ts
+ it("builds a windows download URL", () => {
+ expect(caddyDownloadUrl({ os: "windows", arch: "amd64" })).toBe(
+ "https://caddyserver.com/api/download?os=windows&arch=amd64",
+ );
+ });
+```
+파일 끝에 신규 describe:
+```ts
+describe("winswDownloadUrl", () => {
+ it("targets the pinned v2.x x64 asset (no arm64 build exists)", () => {
+ expect(winswDownloadUrl({ os: "windows", arch: "amd64" })).toBe(
+ "https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe",
+ );
+ expect(winswDownloadUrl({ os: "windows", arch: "arm64" })).toBe(
+ "https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe",
+ );
+ });
+});
+```
+
+- [ ] **Step 3: 테스트 실패 확인**
+
+Run: `npx vitest run test/platform.test.ts`
+Expected: FAIL — `winswDownloadUrl` is not a function / win32이 여전히 throw.
+
+- [ ] **Step 4: 구현** — `src/lib/platform.ts` 전체를 아래로 교체
+
+```ts
+export interface Platform {
+ os: "darwin" | "linux" | "windows";
+ arch: "amd64" | "arm64";
+}
+
+/**
+ * Map Node's process.platform/arch to Caddy's download naming. macOS, Linux, and
+ * Windows are supported (Windows uses WinSW to register the service); other
+ * platforms (NAS packaging) are deferred with a clear error rather than silently
+ * picking the wrong binary.
+ */
+export function detectPlatform(
+ nodePlatform: string = process.platform,
+ nodeArch: string = process.arch,
+): Platform {
+ let os: Platform["os"];
+ if (nodePlatform === "darwin") os = "darwin";
+ else if (nodePlatform === "linux") os = "linux";
+ else if (nodePlatform === "win32") os = "windows";
+ else
+ throw new Error(
+ `Automatic install is not supported on "${nodePlatform}" yet (NAS packaging is Phase 3). ` +
+ "Use `hostdoc setup --serve-root ` to emit a manual web-server snippet instead.",
+ );
+
+ let arch: Platform["arch"];
+ if (nodeArch === "x64") arch = "amd64";
+ else if (nodeArch === "arm64") arch = "arm64";
+ else throw new Error(`Unsupported CPU arch "${nodeArch}" (need x64 or arm64).`);
+
+ return { os, arch };
+}
+
+/** Caddy's official static-binary download service (returns the binary). */
+export function caddyDownloadUrl(plat: Platform): string {
+ return `https://caddyserver.com/api/download?os=${plat.os}&arch=${plat.arch}`;
+}
+
+/** Pinned WinSW v2.x — .NET Framework 4.6.1 build, bundled with Windows 10/11. */
+export const WINSW_VERSION = "v2.12.0";
+
+/**
+ * WinSW static service wrapper (a GitHub release asset, not the Caddy API).
+ * v2.x publishes only x64/x86; Windows-on-ARM runs the x64 build via emulation,
+ * so we always target the x64 asset regardless of arch.
+ */
+export function winswDownloadUrl(_plat: Platform): string {
+ return `https://github.com/winsw/winsw/releases/download/${WINSW_VERSION}/WinSW-x64.exe`;
+}
+```
+
+- [ ] **Step 5: 테스트 통과 확인**
+
+Run: `npx vitest run test/platform.test.ts`
+Expected: PASS (all).
+
+- [ ] **Step 6: 커밋**
+
+```bash
+git add src/lib/platform.ts test/platform.test.ts
+git commit -m "feat: detect windows + WinSW download URL (Phase 3)
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+## Task 2: service.ts — WinSW 서비스 정의 생성기
+
+**Files:**
+- Modify: `src/lib/service.ts`
+- Test: `test/service.test.ts`
+
+**Interfaces:**
+- Consumes: 기존 `ServiceOpts { caddyBin: string; caddyfilePath: string }`.
+- Produces: `WINSW_ID: string` (= `"hostdoc-caddy"`); `winswXml(opts: ServiceOpts): string`.
+
+- [ ] **Step 1: 실패 테스트 작성** — `test/service.test.ts` 상단 import 확장 + 신규 describe 추가
+
+import 교체:
+```ts
+import { launchdPlist, systemdUnit, winswXml, LAUNCHD_LABEL, WINSW_ID } from "../src/lib/service.js";
+```
+파일 끝에 추가:
+```ts
+describe("winswXml", () => {
+ const win = {
+ caddyBin: "C:\\ProgramData\\hostdoc\\service\\caddy.exe",
+ caddyfilePath: "C:\\Users\\u\\AppData\\hostdoc\\Caddyfile",
+ };
+ const x = winswXml(win);
+ it("uses the hostdoc-caddy service id", () =>
+ expect(x).toContain(`${WINSW_ID}`));
+ it("points the absolute caddy.exe as the executable", () =>
+ expect(x).toContain("C:\\ProgramData\\hostdoc\\service\\caddy.exe"));
+ it("runs caddy with the hostdoc Caddyfile", () =>
+ expect(x).toContain("run --config C:\\Users\\u\\AppData\\hostdoc\\Caddyfile"));
+ it("configures a roll-by-time log", () => {
+ expect(x).toContain('');
+ expect(x).toContain("yyyy-MM-dd");
+ });
+});
+```
+
+- [ ] **Step 2: 테스트 실패 확인**
+
+Run: `npx vitest run test/service.test.ts`
+Expected: FAIL — `winswXml` / `WINSW_ID` is not exported.
+
+- [ ] **Step 3: 구현** — `src/lib/service.ts` 끝에 추가
+
+파일 최상단 상수 근처(`SYSTEMD_UNIT` 다음)에:
+```ts
+export const WINSW_ID = "hostdoc-caddy";
+```
+파일 맨 끝(`launchdPlist` 다음)에:
+```ts
+/**
+ * WinSW service definition (caddy-service.xml). Structure per Caddy's official
+ * Windows service docs (caddyserver.com/docs/running). Absolute so
+ * a PATH-reused caddy also works; --config points at the hostdoc Caddyfile
+ * (parity with the launchd/systemd generators).
+ */
+export function winswXml(opts: ServiceOpts): string {
+ return (
+ [
+ '',
+ "",
+ ` ${WINSW_ID}`,
+ " hostdoc Caddy web server",
+ " hostdoc Caddy web server (https://caddyserver.com/)",
+ ` ${opts.caddyBin}`,
+ ` run --config ${opts.caddyfilePath}`,
+ ' ',
+ " yyyy-MM-dd",
+ " ",
+ "",
+ ].join("\n") + "\n"
+ );
+}
+```
+
+- [ ] **Step 4: 테스트 통과 확인**
+
+Run: `npx vitest run test/service.test.ts`
+Expected: PASS (all).
+
+- [ ] **Step 5: 커밋**
+
+```bash
+git add src/lib/service.ts test/service.test.ts
+git commit -m "feat: winswXml service definition generator (Phase 3)
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+## Task 3: 개명 — `sudo*`/`needs-root` → `privileged*`/`needs-privilege` (동작 불변)
+
+플랫폼 중립 개명. **동작 변화 없음** — Windows 분기는 이후 태스크. 이 태스크가 끝나도 기존 286 테스트가 전부 green이어야 한다.
+
+**Files:**
+- Modify: `src/lib/installer.ts` (함수명), `src/commands/setup-selfhosted.ts` (아웃컴 종류/필드), `src/commands/deprovision-selfhosted.ts` (아웃컴 종류/필드), `src/index.ts` (참조)
+- Test: `test/installer.test.ts`, `test/setup-selfhosted.test.ts`, `test/deprovision-selfhosted.test.ts`
+
+**Interfaces:**
+- Produces: `privilegedInstallCommands(plat?): string[]`, `privilegedRemoveCommands(plat?): string[]` (기존 `sudoInstallCommands`/`sudoRemoveCommands` 대체, 시그니처·반환 동일); 아웃컴 종류 `"needs-privilege"`, 필드 `privilegedCommands: string[]` (기존 `"needs-root"`/`sudoCommands` 대체).
+
+- [ ] **Step 1: 테스트 개명(실패 유도)**
+
+`test/installer.test.ts`:
+- import(8–9행) `sudoInstallCommands, sudoRemoveCommands` → `privilegedInstallCommands, privilegedRemoveCommands`.
+- describe `"sudo command builders"` 내부 두 호출 `sudoInstallCommands(linux)`→`privilegedInstallCommands(linux)`, `sudoRemoveCommands(linux)`→`privilegedRemoveCommands(linux)`.
+
+`test/setup-selfhosted.test.ts`:
+- `expect(out.kind).toBe("needs-root")` → `"needs-privilege"`; `if (out.kind === "needs-root")` → `"needs-privilege"`; `out.sudoCommands` → `out.privilegedCommands`.
+- 테스트 제목 `"...returns sudo commands"`는 `"...returns privileged commands"`로(선택).
+
+`test/deprovision-selfhosted.test.ts`:
+- `expect(out.kind).toBe("needs-root")` → `"needs-privilege"`; `if (out.kind === "needs-root")` → `"needs-privilege"`; `out.sudoCommands` → `out.privilegedCommands` (두 곳).
+
+- [ ] **Step 2: 테스트 실패 확인**
+
+Run: `npx vitest run test/installer.test.ts test/setup-selfhosted.test.ts test/deprovision-selfhosted.test.ts`
+Expected: FAIL — 개명된 심볼 미존재 / 아웃컴 종류 불일치.
+
+- [ ] **Step 3: 구현 — installer.ts 함수 개명**
+
+`src/lib/installer.ts`에서:
+- `export function sudoInstallCommands(` → `export function privilegedInstallCommands(`
+- `export function sudoRemoveCommands(` → `export function privilegedRemoveCommands(`
+- 두 함수의 doc 코멘트 "Exact sudo commands ..." → "Exact privileged commands ..."(선택). 본문(sudo 문자열)은 **그대로**.
+
+- [ ] **Step 4: 구현 — setup-selfhosted.ts 개명**
+
+`src/commands/setup-selfhosted.ts`:
+- import에서 `sudoInstallCommands` → `privilegedInstallCommands`.
+- `InstallOutcome`의 `| { kind: "needs-root"; ...; sudoCommands: string[]; ... }` → `| { kind: "needs-privilege"; ...; privilegedCommands: string[]; ... }`.
+- 반환문 `kind: "needs-root", ..., sudoCommands: sudoInstallCommands(plat),` → `kind: "needs-privilege", ..., privilegedCommands: privilegedInstallCommands(plat),`.
+
+- [ ] **Step 5: 구현 — deprovision-selfhosted.ts 개명**
+
+`src/commands/deprovision-selfhosted.ts`:
+- import `sudoRemoveCommands` → `privilegedRemoveCommands`.
+- `SelfHostedDeprovisionOutcome`의 `| { kind: "needs-root"; sudoCommands: string[] }` → `| { kind: "needs-privilege"; privilegedCommands: string[] }`.
+- 반환 `{ kind: "needs-root", sudoCommands: sudoRemoveCommands() }` → `{ kind: "needs-privilege", privilegedCommands: privilegedRemoveCommands() }`.
+
+- [ ] **Step 6: 구현 — index.ts 참조 개명**
+
+`src/index.ts`:
+- setup `--install` 블록(약 105–107행): `if (outcome.kind === "needs-root")` → `"needs-privilege"`; `for (const cmd of outcome.sudoCommands)` → `outcome.privilegedCommands`.
+- deprovision 블록(약 211–213행): `if (outcome.kind === "needs-root")` → `"needs-privilege"`; `outcome.sudoCommands` → `outcome.privilegedCommands`.
+
+- [ ] **Step 7: 전체 테스트 + 타입 통과 확인**
+
+Run: `npm test && npm run typecheck`
+Expected: PASS — 286 통과, 타입 클린(동작 불변 개명).
+
+- [ ] **Step 8: 커밋**
+
+```bash
+git add src/lib/installer.ts src/commands/setup-selfhosted.ts src/commands/deprovision-selfhosted.ts src/index.ts test/installer.test.ts test/setup-selfhosted.test.ts test/deprovision-selfhosted.test.ts
+git commit -m "refactor: rename sudo/root outcomes to platform-neutral privileged*
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+## Task 4: installer.ts — Windows 리프 헬퍼(경로·다운로드·권한 프로브)
+
+**Files:**
+- Modify: `src/lib/installer.ts`
+- Test: `test/installer.test.ts`
+
+**Interfaces:**
+- Consumes: `Platform`(Task 1), `winswDownloadUrl`(Task 1).
+- Produces: `Deps.isAdmin?: () => boolean`; `serviceDir(): string`; `winswBinDest(): string`; `caddyBinDest(plat?: Platform): string` (windows→serviceDir/caddy.exe); `downloadCaddy(d?, plat?)` (plat 인자 추가); `downloadWinsw(d?, plat?): Promise<{ path: string; reused: boolean }>`; `isPrivileged(plat?, d?): boolean`.
+
+- [ ] **Step 1: 실패 테스트 작성** — `test/installer.test.ts`
+
+상단 import에 심볼 추가:
+```ts
+import {
+ downloadCaddy,
+ downloadWinsw,
+ writeServiceFile,
+ installService,
+ removeService,
+ privilegedInstallCommands,
+ privilegedRemoveCommands,
+ isPrivileged,
+ caddyBinDest,
+ winswBinDest,
+ systemServicePath,
+} from "../src/lib/installer.js";
+```
+`const windows = { os: "windows", arch: "amd64" } as const;`를 `linux`/`darwin` 상수 옆에 추가.
+`spyDeps`의 반환 객체에 `isAdmin: () => true,`를 `whichCaddy` 다음에 추가(모든 Deps 키 제공 유지).
+파일 끝에 신규 describe들:
+```ts
+describe("windows leaf helpers", () => {
+ it("caddyBinDest points at caddy.exe under the service dir (windows)", () => {
+ expect(caddyBinDest(windows)).toMatch(/[\\/]service[\\/]caddy\.exe$/);
+ });
+ it("winswBinDest is caddy-service.exe under the service dir", () => {
+ expect(winswBinDest()).toMatch(/[\\/]service[\\/]caddy-service\.exe$/);
+ });
+ it("caddyBinDest stays at .local/bin/caddy on linux", () => {
+ expect(caddyBinDest(linux)).toMatch(/[\\/]\.local[\\/]bin[\\/]caddy$/);
+ });
+});
+
+describe("downloadWinsw", () => {
+ it("reuses an existing WinSW (no fetch)", async () => {
+ const fetchFn = vi.fn();
+ const out = await downloadWinsw({ exists: () => true, fetchFn }, windows);
+ expect(out.reused).toBe(true);
+ expect(fetchFn).not.toHaveBeenCalled();
+ });
+ it("downloads and writes WinSW when absent", async () => {
+ const written: string[] = [];
+ const fetchFn = vi.fn(async () => ({
+ ok: true,
+ arrayBuffer: async () => new TextEncoder().encode("WINSW").buffer,
+ })) as never;
+ const out = await downloadWinsw(
+ { exists: () => false, fetchFn, mkdirp: () => {}, writeFile: (p) => written.push(p) },
+ windows,
+ );
+ expect(out.reused).toBe(false);
+ expect(out.path).toMatch(/caddy-service\.exe$/);
+ expect(written).toContain(out.path);
+ expect(fetchFn).toHaveBeenCalledOnce();
+ });
+ it("throws on a non-ok WinSW response", async () => {
+ const fetchFn = vi.fn(async () => ({ ok: false, status: 404 })) as never;
+ await expect(
+ downloadWinsw({ exists: () => false, fetchFn }, windows),
+ ).rejects.toThrow(/404/);
+ });
+});
+
+describe("isPrivileged", () => {
+ it("uses the injected isAdmin probe on windows", () => {
+ expect(isPrivileged(windows, { isAdmin: () => true })).toBe(true);
+ expect(isPrivileged(windows, { isAdmin: () => false })).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 2: 테스트 실패 확인**
+
+Run: `npx vitest run test/installer.test.ts`
+Expected: FAIL — `downloadWinsw`/`winswBinDest`/`isPrivileged` 미존재, `caddyBinDest` 인자 미지원.
+
+- [ ] **Step 3: 구현 — installer.ts**
+
+import 교체(상단):
+```ts
+import { detectPlatform, caddyDownloadUrl, winswDownloadUrl, type Platform } from "./platform.js";
+```
+`Deps` 인터페이스에 프로브 주입 추가(`whichCaddy` 다음 줄):
+```ts
+ isAdmin?: () => boolean;
+```
+`withDefaults`의 반환 객체에 기본 구현 추가(`whichCaddy` 다음):
+```ts
+ isAdmin: () => probeWindowsAdmin(),
+```
+`isRoot` 함수 근처에 프로브 + 통합 권한 판정 추가:
+```ts
+/** Windows admin probe: `net session` exits 0 only for an elevated session. */
+function probeWindowsAdmin(): boolean {
+ try {
+ execFileSync("net", ["session"], { stdio: "ignore" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** True when the current process can register a system service on this platform. */
+export function isPrivileged(plat: Platform = detectPlatform(), d: Deps = {}): boolean {
+ if (plat.os === "windows") return withDefaults(d).isAdmin();
+ return isRoot();
+}
+```
+`serviceDir` + `winswBinDest` 추가(경로 헬퍼 근처):
+```ts
+/** Windows service directory: caddy.exe + caddy-service.exe + caddy-service.xml co-located. */
+export function serviceDir(): string {
+ return join(dirname(configPath()), "service");
+}
+
+/** WinSW wrapper destination (renamed to caddy-service.exe per Caddy docs). */
+export function winswBinDest(): string {
+ return join(serviceDir(), "caddy-service.exe");
+}
+```
+`caddyBinDest`를 plat 인지형으로 교체:
+```ts
+/** User-writable binary destination when Caddy is not already on PATH. */
+export function caddyBinDest(plat: Platform = detectPlatform()): string {
+ if (plat.os === "windows") return join(serviceDir(), "caddy.exe");
+ return join(homedir(), ".local", "bin", "caddy");
+}
+```
+`downloadCaddy`에 plat 인자 추가(대상·URL을 plat로):
+```ts
+export async function downloadCaddy(
+ d: Deps = {},
+ plat: Platform = detectPlatform(),
+): Promise<{ path: string; reused: boolean }> {
+ const deps = withDefaults(d);
+ const existing = deps.whichCaddy();
+ if (existing) return { path: existing, reused: true };
+ const dest = caddyBinDest(plat);
+ if (deps.exists(dest)) return { path: dest, reused: true };
+
+ const url = caddyDownloadUrl(plat);
+ const res = await deps.fetchFn(url);
+ if (!res.ok) throw new Error(`Caddy download failed: HTTP ${res.status}.`);
+ const buf = Buffer.from(await res.arrayBuffer());
+ deps.mkdirp(dirname(dest));
+ deps.writeFile(dest, buf);
+ deps.chmod(dest, 0o755);
+ return { path: dest, reused: false };
+}
+
+/** Download the WinSW wrapper to the service dir (skipped if already present). */
+export async function downloadWinsw(
+ d: Deps = {},
+ plat: Platform = detectPlatform(),
+): Promise<{ path: string; reused: boolean }> {
+ const deps = withDefaults(d);
+ const dest = winswBinDest();
+ if (deps.exists(dest)) return { path: dest, reused: true };
+ const res = await deps.fetchFn(winswDownloadUrl(plat));
+ if (!res.ok) throw new Error(`WinSW download failed: HTTP ${res.status}.`);
+ const buf = Buffer.from(await res.arrayBuffer());
+ deps.mkdirp(dirname(dest));
+ deps.writeFile(dest, buf);
+ return { path: dest, reused: false };
+}
+```
+(참고: windows `.exe`는 chmod 불필요 — downloadWinsw는 chmod 호출 안 함.)
+
+- [ ] **Step 4: 테스트 통과 확인**
+
+Run: `npx vitest run test/installer.test.ts`
+Expected: PASS (기존 + 신규).
+
+- [ ] **Step 5: 회귀 확인**
+
+Run: `npm test && npm run typecheck`
+Expected: PASS — 기존 스위트 무회귀(`downloadCaddy`의 새 plat 기본 인자가 linux/darwin에서 동일 동작).
+
+- [ ] **Step 6: 커밋**
+
+```bash
+git add src/lib/installer.ts test/installer.test.ts
+git commit -m "feat: windows install helpers (serviceDir, downloadWinsw, isPrivileged)
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+## Task 5: installer.ts — Windows 서비스 제어 분기
+
+**Files:**
+- Modify: `src/lib/installer.ts`
+- Test: `test/installer.test.ts`
+
+**Interfaces:**
+- Consumes: `winswXml`(Task 2), `WINSW_ID`(Task 2), `serviceDir`/`winswBinDest`(Task 4).
+- Produces: `writeServiceFile`/`installService`/`removeService`/`stagedServicePath`/`privilegedInstallCommands`/`privilegedRemoveCommands`의 windows 분기(시그니처 불변).
+
+- [ ] **Step 1: 실패 테스트 작성** — `test/installer.test.ts` 끝에 추가
+
+```ts
+describe("windows service control", () => {
+ it("writeServiceFile writes caddy-service.xml into the service dir", () => {
+ const written: Array<[string, string]> = [];
+ writeServiceFile(
+ "C:\\svc\\caddy.exe",
+ { mkdirp: () => {}, writeFile: (p, data) => written.push([p, String(data)]) },
+ windows,
+ );
+ expect(written[0][0]).toMatch(/caddy-service\.xml$/);
+ expect(written[0][1]).toContain("hostdoc-caddy");
+ expect(written[0][1]).toContain("C:\\svc\\caddy.exe");
+ });
+
+ it("installService installs + starts via WinSW (no copy step)", () => {
+ const d = spyDeps();
+ installService(d, windows);
+ const flat = d.calls.map((c) => c.join(" "));
+ expect(flat.some((c) => c.endsWith("caddy-service.exe install"))).toBe(true);
+ expect(flat.some((c) => c.endsWith("caddy-service.exe start"))).toBe(true);
+ expect(flat.some((c) => c.startsWith("copy "))).toBe(false);
+ });
+
+ it("removeService stops + uninstalls via WinSW", () => {
+ const d = spyDeps({ exists: () => true });
+ removeService(d, windows);
+ const flat = d.calls.map((c) => c.join(" "));
+ expect(flat.some((c) => c.endsWith("caddy-service.exe stop"))).toBe(true);
+ expect(flat.some((c) => c.endsWith("caddy-service.exe uninstall"))).toBe(true);
+ });
+
+ it("privileged command builders reference WinSW without sudo (windows)", () => {
+ const inst = privilegedInstallCommands(windows).join("\n");
+ expect(inst).toMatch(/caddy-service\.exe" install/);
+ expect(inst).toMatch(/caddy-service\.exe" start/);
+ expect(inst).not.toContain("sudo");
+ const rem = privilegedRemoveCommands(windows).join("\n");
+ expect(rem).toMatch(/caddy-service\.exe" uninstall/);
+ expect(rem).not.toContain("sudo");
+ });
+});
+```
+
+- [ ] **Step 2: 테스트 실패 확인**
+
+Run: `npx vitest run test/installer.test.ts -t "windows service control"`
+Expected: FAIL — windows 분기 미구현(현재 darwin/linux만).
+
+- [ ] **Step 3: 구현 — installer.ts 서비스 제어 함수 windows 분기**
+
+`stagedServicePath`에 windows 분기 추가(함수 맨 앞):
+```ts
+export function stagedServicePath(plat: Platform = detectPlatform()): string {
+ if (plat.os === "windows") return join(serviceDir(), "caddy-service.xml");
+ const base = dirname(configPath());
+ return plat.os === "darwin"
+ ? join(base, `${LAUNCHD_LABEL}.plist`)
+ : join(base, `${SYSTEMD_UNIT}.service`);
+}
+```
+`writeServiceFile` windows 분기(함수 맨 앞, `const deps` 다음):
+```ts
+ if (plat.os === "windows") {
+ const staged = stagedServicePath(plat);
+ deps.mkdirp(dirname(staged));
+ deps.writeFile(staged, winswXml({ caddyBin, caddyfilePath: caddyfilePath() }));
+ return;
+ }
+```
+service.js import에 `winswXml` 추가:
+```ts
+import { launchdPlist, systemdUnit, winswXml, LAUNCHD_LABEL, SYSTEMD_UNIT } from "./service.js";
+```
+`installService` windows 분기(함수 맨 앞, `const deps` 다음):
+```ts
+ if (plat.os === "windows") {
+ const winsw = winswBinDest();
+ deps.run(winsw, ["install"]);
+ deps.run(winsw, ["start"]);
+ return;
+ }
+```
+`removeService` windows 분기(함수 맨 앞, `const deps` 다음):
+```ts
+ if (plat.os === "windows") {
+ const winsw = winswBinDest();
+ if (deps.exists(winsw)) {
+ deps.run(winsw, ["stop"]);
+ deps.run(winsw, ["uninstall"]);
+ }
+ return;
+ }
+```
+`privilegedInstallCommands` windows 분기(함수 맨 앞):
+```ts
+ if (plat.os === "windows") {
+ const winsw = winswBinDest();
+ return [`"${winsw}" install`, `"${winsw}" start`];
+ }
+```
+`privilegedRemoveCommands` windows 분기(함수 맨 앞):
+```ts
+ if (plat.os === "windows") {
+ const winsw = winswBinDest();
+ return [`"${winsw}" stop`, `"${winsw}" uninstall`];
+ }
+```
+
+- [ ] **Step 4: (검증) WinSW stop 멱등성 확인**
+
+Run:
+```bash
+gh api repos/winsw/winsw/releases/tags/v2.12.0 --jq '.body' | grep -iE "stop|uninstall|idempot" | head
+```
+목적: WinSW v2 `stop`이 이미 멈춘 서비스에서 비정상 종료하는지 확인. 릴리스 노트로 불명확하면 [WinSW 커맨드 문서](https://github.com/winsw/winsw/blob/v2.12.0/docs/cli-commands.md)를 확인. 만약 `stop`이 stopped 상태에서 에러를 내면, `removeService`/`privilegedRemoveCommands`의 windows 분기에서 `stop`을 빼고 `uninstall`만 사용(WinSW uninstall이 실행 중이면 먼저 stop)하도록 수정하고 테스트의 `stop` 단언도 제거. (기본 계획은 stop+uninstall.)
+
+- [ ] **Step 5: 테스트 통과 확인**
+
+Run: `npx vitest run test/installer.test.ts`
+Expected: PASS (Step 4 결정 반영).
+
+- [ ] **Step 6: 회귀 + 타입 확인**
+
+Run: `npm test && npm run typecheck`
+Expected: PASS — darwin/linux 서비스 제어 무회귀.
+
+- [ ] **Step 7: 커밋**
+
+```bash
+git add src/lib/installer.ts test/installer.test.ts
+git commit -m "feat: windows service control via WinSW (install/start/stop/uninstall)
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+## Task 6: 커맨드 + CLI — Windows 라우팅 & 플랫폼별 헤더
+
+**Files:**
+- Modify: `src/commands/setup-selfhosted.ts`, `src/commands/deprovision-selfhosted.ts`, `src/index.ts`
+- Test: `test/setup-selfhosted.test.ts`, `test/deprovision-selfhosted.test.ts`
+
+**Interfaces:**
+- Consumes: `isPrivileged`(Task 4), `downloadWinsw`(Task 4), `downloadCaddy(d, plat)`(Task 4), `writeServiceFile/installService/removeService/privileged*Commands`(Task 5).
+- Produces: `runInstallSelfHosted`/`runDeprovisionSelfHosted`가 windows에서 올바른 seam 호출. 아웃컴 종류/필드 불변(Task 3).
+
+- [ ] **Step 1: 실패 테스트 작성** — `test/setup-selfhosted.test.ts`
+
+`runInstallSelfHosted` describe 끝에 windows 케이스 추가:
+```ts
+ it("windows non-admin install downloads caddy + WinSW and returns privileged commands", async () => {
+ const fetched: string[] = [];
+ const fetchFn = vi.fn(async (u: string) => {
+ fetched.push(String(u));
+ return { ok: true, arrayBuffer: async () => new TextEncoder().encode("B").buffer };
+ }) as never;
+ const out = await runInstallSelfHosted(
+ { serveRoot: serveRoot(), host: "docs.example.com", nginxDetected: false },
+ {
+ isPrivileged: () => false,
+ plat: { os: "windows", arch: "amd64" },
+ installer: { whichCaddy: () => null, exists: () => false, fetchFn, mkdirp: () => {}, writeFile: () => {}, chmod: () => {} },
+ },
+ );
+ expect(out.kind).toBe("needs-privilege");
+ if (out.kind === "needs-privilege") {
+ expect(out.privilegedCommands.join("\n")).toMatch(/caddy-service\.exe" install/);
+ expect(out.privilegedCommands.join("\n")).not.toContain("sudo");
+ }
+ expect(fetched.some((u) => u.includes("caddyserver.com/api/download?os=windows"))).toBe(true);
+ expect(fetched.some((u) => u.includes("winsw/releases/download"))).toBe(true);
+ });
+```
+> 참고: `runInstallSelfHosted`의 두 번째 인자 `deps`에 `plat?: Platform`과 `isPrivileged?: () => boolean` 주입점을 추가한다(아래 구현). 기존 unix 테스트의 `isRoot: () => false` 주입은 `isPrivileged: () => false`로 바꾼다(3곳: `reuse` 상수, non-root 테스트, root 테스트의 `isRoot: () => true`→`isPrivileged: () => true`).
+
+`test/deprovision-selfhosted.test.ts`에 windows 케이스 추가:
+```ts
+ it("windows non-admin removal deletes user files and returns privileged commands", () => {
+ const out = runDeprovisionSelfHosted({
+ isPrivileged: () => false,
+ plat: { os: "windows", arch: "amd64" },
+ });
+ expect(out.kind).toBe("needs-privilege");
+ if (out.kind === "needs-privilege") {
+ expect(out.privilegedCommands.join("\n")).toMatch(/caddy-service\.exe" uninstall/);
+ }
+ });
+```
+> 기존 두 테스트의 `isRoot:` 주입도 `isPrivileged:`로 개명. windows 케이스는 `beforeEach`가 만든 `stagedServicePath()`(기본 plat=현재 OS)와 별개로, plat=windows일 때 `stagedServicePath(windows)`(=serviceDir/caddy-service.xml)를 지운다 — 파일 존재 단언은 하지 않는다(경로가 OS마다 달라 rmSync는 force로 무해).
+
+- [ ] **Step 2: 테스트 실패 확인**
+
+Run: `npx vitest run test/setup-selfhosted.test.ts test/deprovision-selfhosted.test.ts`
+Expected: FAIL — `plat`/`isPrivileged` 주입점 미존재, windows에서 WinSW 미다운로드.
+
+- [ ] **Step 3: 구현 — setup-selfhosted.ts**
+
+import 교체:
+```ts
+import { detectPlatform, type Platform } from "../lib/platform.js";
+import {
+ downloadCaddy,
+ downloadWinsw,
+ writeServiceFile,
+ installService,
+ privilegedInstallCommands,
+ caddyfilePath,
+ isPrivileged as realIsPrivileged,
+ type Deps,
+} from "../lib/installer.js";
+```
+`runInstallSelfHosted` 시그니처의 `deps` 타입 확장 + 본문 조정:
+```ts
+export async function runInstallSelfHosted(
+ args: SelfHostedSetupArgs,
+ deps: { installer?: Deps; isPrivileged?: () => boolean; plat?: Platform } = {},
+): Promise {
+ const plat = deps.plat ?? detectPlatform(); // windows/darwin/linux (throws only on NAS)
+ const privileged = deps.isPrivileged ?? (() => realIsPrivileged(plat, deps.installer));
+```
+(이후 `mkdirSync(serveRoot)`·config 저장·nginx 분기는 그대로.)
+Caddyfile 기록 이후의 다운로드/서비스 파일 블록을 아래로 교체:
+```ts
+ const { path: caddyBin, reused } = await downloadCaddy(deps.installer, plat);
+ if (plat.os === "windows") await downloadWinsw(deps.installer, plat);
+ writeServiceFile(caddyBin, deps.installer, plat);
+
+ if (!privileged()) {
+ return {
+ kind: "needs-privilege",
+ cfg,
+ caddyBin,
+ reused,
+ caddyfilePath: cfPath,
+ privilegedCommands: privilegedInstallCommands(plat),
+ warning: SECURITY_WARNING,
+ };
+ }
+
+ installService(deps.installer, plat);
+ return { kind: "installed", cfg, caddyBin, reused, caddyfilePath: cfPath, warning: SECURITY_WARNING };
+}
+```
+(기존 `const plat = detectPlatform();`·`const isRoot = ...`·`if (!isRoot())` 라인은 위 교체로 제거됨. `realIsRoot` import 잔재 없도록 정리.)
+
+- [ ] **Step 4: 구현 — deprovision-selfhosted.ts**
+
+import 교체:
+```ts
+import { rmSync } from "node:fs";
+import { detectPlatform, type Platform } from "../lib/platform.js";
+import {
+ removeService,
+ privilegedRemoveCommands,
+ caddyfilePath,
+ stagedServicePath,
+ isPrivileged as realIsPrivileged,
+ type Deps,
+} from "../lib/installer.js";
+```
+아웃컴·본문:
+```ts
+export function runDeprovisionSelfHosted(
+ deps: { installer?: Deps; isPrivileged?: () => boolean; plat?: Platform } = {},
+): SelfHostedDeprovisionOutcome {
+ const plat = deps.plat ?? detectPlatform();
+ const privileged = deps.isPrivileged ?? (() => realIsPrivileged(plat, deps.installer));
+ rmSync(caddyfilePath(), { force: true });
+ rmSync(stagedServicePath(plat), { force: true });
+ if (!privileged()) {
+ return { kind: "needs-privilege", privilegedCommands: privilegedRemoveCommands(plat) };
+ }
+ removeService(deps.installer, plat);
+ return { kind: "removed" };
+}
+```
+
+- [ ] **Step 5: 구현 — index.ts 플랫폼별 헤더**
+
+setup `--install`의 `needs-privilege` 분기(약 105–107행) 교체:
+```ts
+ if (outcome.kind === "needs-privilege") {
+ console.log(
+ process.platform === "win32"
+ ? "\nOpen an elevated (Administrator) PowerShell and run:\n"
+ : "\nRun these as root to register the boot service:\n",
+ );
+ for (const cmd of outcome.privilegedCommands) console.log(` ${cmd}`);
+ } else {
+ console.log("\nCaddy service installed and started (survives reboot).");
+ }
+```
+deprovision의 `needs-privilege` 분기(약 211–213행) 교체:
+```ts
+ if (outcome.kind === "needs-privilege") {
+ console.log(
+ process.platform === "win32"
+ ? "Open an elevated (Administrator) PowerShell and run:\n"
+ : "Run these as root to remove the Caddy service:\n",
+ );
+ for (const cmd of outcome.privilegedCommands) console.log(` ${cmd}`);
+ } else {
+```
+
+- [ ] **Step 6: 전체 테스트 + 타입 + 빌드 확인**
+
+Run: `npm test && npm run typecheck && npm run build`
+Expected: PASS — 신규 windows 케이스 통과 + 기존 무회귀, 타입·빌드 클린.
+
+- [ ] **Step 7: (수동 스모크) CLI 도움말이 깨지지 않는지 확인**
+
+Run: `node dist/index.js setup --help`
+Expected: `--install` 옵션이 보이고 에러 없이 종료(실제 설치는 Windows 관리자에서만; 여기선 배선 회귀만 확인).
+
+- [ ] **Step 8: 커밋**
+
+```bash
+git add src/commands/setup-selfhosted.ts src/commands/deprovision-selfhosted.ts src/index.ts test/setup-selfhosted.test.ts test/deprovision-selfhosted.test.ts
+git commit -m "feat: windows routing for setup --install / deprovision (Phase 3)
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+## 최종 검증 (플랜 완료 후)
+
+- [ ] `npm test && npm run typecheck && npm run build` 전부 green (기존 286 + 신규 windows 케이스).
+- [ ] 동결 4파일 무수정 확인: `git diff --name-only main..HEAD` 에 `aws.ts`/`cloudfront.ts`/`terraform.ts`/`snippet.ts` 없음.
+- [ ] PR #42 본문 Phase 3 체크박스에서 "Windows 서비스 통합" 체크 + Phase 3 진행 코멘트 추가(NAS/DDNS는 미완 유지).
+
+---
+
+## Self-Review 결과 (작성자 체크)
+
+**Spec 커버리지:**
+- Windows 감지 + winswDownloadUrl → Task 1 ✅
+- winswXml 생성기 → Task 2 ✅
+- privilegedCommands 개명 → Task 3 ✅
+- serviceDir/caddyBinDest(win)/winswBinDest/downloadWinsw/isPrivileged → Task 4 ✅
+- writeServiceFile/installService/removeService/privileged*Commands windows 분기 → Task 5 ✅
+- runInstallSelfHosted/runDeprovisionSelfHosted windows 라우팅 + index 헤더 → Task 6 ✅
+- 테스트(무네트워크·무관리자·plat 주입) → 각 태스크 ✅
+- 수용 기준(재부팅 생존/비관리자 명령 출력/nginx 폴백/deprovision 보존/`_meta`·index/무회귀) → Task 1–6 + 최종 검증 ✅
+
+**Placeholder 스캔:** "구현 시 확정" 항목은 실행 가능한 검증 스텝(Step 1/Step 4의 gh·curl 명령)으로 구체화됨 — 미해결 placeholder 없음.
+
+**타입 일관성:** `Platform.os` 유니온, `privilegedCommands`/`needs-privilege`, `downloadCaddy(d, plat)`·`downloadWinsw(d, plat)`·`isPrivileged(plat, d)`, `runInstallSelfHosted(args, { installer, isPrivileged, plat })` 시그니처가 태스크 간 일치 확인 ✅
diff --git a/docs/superpowers/specs/2026-07-03-self-hosted-mode-design.md b/docs/superpowers/specs/2026-07-03-self-hosted-mode-design.md
new file mode 100644
index 0000000..99411a7
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-03-self-hosted-mode-design.md
@@ -0,0 +1,214 @@
+# Spec: `self-hosted` 모드 — 내 NAS/PC + 내 공인 IP로 발행 (Phase 1 MVP)
+
+- **Issue**: [#41](https://github.com/jkas2016/hostdoc/issues/41)
+- **Date**: 2026-07-03
+- **Branch**: `feat/41-self-hosted-mode`
+- **Scope**: Phase 1 (MVP, 기존 웹서버 가정). Phase 2(자동 설치)·Phase 3(NAS/Windows/DDNS)는 별도 spec.
+
+## Problem
+
+hostdoc는 로컬 HTML을 **사용자 본인 AWS**에 올려 짧은 링크를 돌려준다. 호스팅 모드는
+`s3-website`(퍼블릭 버킷, HTTP)·`cloudfront`(프라이빗 S3+OAC+CloudFront, HTTPS) 두 가지이며
+`resolveConfig()`(`src/lib/config.ts`)에서 *파생*된다.
+
+AWS 계정 없이 **이미 외부로 열어둔(또는 열 예정인) 개인 NAS/PC**를 가진 사용자가 상당수다.
+이들은 클라우드에 올리지 않고 **자기 머신 + 자기 공인 IP**로 같은 "publish → 지속 링크 공유"
+UX를 원한다. 이를 위해 세 번째 호스팅 모드 **`self-hosted`** 를 추가한다.
+
+핵심 걸림돌: publish/list/rm이 현재 S3에 **하드코딩**돼 있다. `runPublish`가 `S3Client`를 직접
+받아 `putObject(s3, bucket, key, …)`를 호출하고, list/rm도 `makeS3`+`listKeys`/`deleteKeys`/
+`getJson`를 직접 부른다. CLAUDE.md의 "two hosting modes, one code path"를 3모드에서 실제로
+성립시키려면 이 결합을 먼저 끊어야 한다.
+
+## Goal
+
+기존 웹서버가 `serveRoot`를 서빙하는 환경에서 `hostdoc publish `가 **로컬 파일시스템에
+배포**하고 **내 호스트 기반 지속 링크**를 반환한다. `host`(DDNS/도메인/고정 IP) 미설정 시 공인
+IP를 자동 감지해 링크를 만들되 "IP 변경 시 링크 무효화" 경고를 낸다. `list`/`open`/`rm`이
+클라우드 모드와 동일한 UX로 동작한다. 배포 사이트에서 `/_meta/…`(및 `/_*`) 접근이 차단되고
+트레일링슬래시/확장자 없는 경로에 `index.html`이 서빙되도록 하는 **수동 웹서버 설정 스니펫**을
+출력한다. 자동 설치는 하지 않는다(Phase 2).
+
+## Key Insight (왜 변경이 감당 가능한가)
+
+1. **S3 결합면이 작다.** publish/list/rm이 S3에 대해 하는 일은 정확히 5개 연산 —
+ `put`/`list`/`exists`/`delete`/`getJson` — 이고 모두 첫 인자가 `(s3, bucket)`이다. 이
+ `(s3, bucket)`을 백엔드 인스턴스에 묶으면 커맨드는 `/…` 키스페이스만 알면 된다.
+2. **`open.ts`는 이미 S3를 안 탄다.** `resolveConfig`+`buildPublicUrl`만 쓰므로 무변경.
+3. **cloudfront invalidation은 이미 모드 가드 뒤에 있다.** `cfg.mode === "cloudfront"` 분기라
+ self-hosted는 자연히 skip — 별도 작업 불필요.
+4. **key는 이미 posix `/` 키스페이스다.** `LocalFsBackend`는 이 key를 OS 경로로 변환만 하면
+ S3와 동일한 `/…`·`_meta/…` 레이아웃을 파일시스템에 그대로 재현한다.
+5. **Content-Type은 self-hosted에서 백엔드 밖 관심사다.** 정적 웹서버(Caddy/nginx)가 확장자로
+ 추론하므로 `LocalFsBackend.put`은 `contentType`을 무시하고 파일만 기록한다.
+
+## Decisions
+
+### 스토리지 백엔드 추상화 (신규 `src/lib/backend.ts`)
+
+- 최소 인터페이스 — `/…` + `_meta/…` 키스페이스에 대한 5개 연산:
+
+ ```ts
+ export interface StorageBackend {
+ put(key: string, body: Buffer, contentType: string): Promise;
+ list(prefix: string): Promise;
+ exists(prefix: string): Promise;
+ delete(keys: string[]): Promise;
+ getJson(key: string): Promise;
+ }
+ ```
+
+- **`S3Backend`**: 기존 `aws.ts`의 `putObject/listKeys/existsPrefix/deleteKeys/getJson`를
+ `(s3, bucket)` 바인딩해 위임. **`aws.ts`는 손대지 않는다** → 기존 `aws.test.ts`·AWS-mock
+ 테스트가 그대로 통과(무회귀 전제). `makeCloudFront`/`invalidate`는 백엔드 밖(모드별 처리)에
+ 남긴다 — 스토리지 연산이 아니다.
+- **`LocalFsBackend`**: 생성자에 `serveRoot`. 연산 매핑:
+ - `put(key, body, _ct)` → `join(serveRoot, key)`에 `mkdir -p` 후 파일 기록. **contentType
+ 무시**(웹서버가 추론). key의 posix `/`는 `path.join`이 OS 구분자로 처리.
+ - `list(prefix)` → `serveRoot`를 walk, **posix 상대경로가 `prefix`로 시작하는 파일** key
+ 목록 반환(S3 `ListObjectsV2` prefix 시맨틱과 동형). 디렉토리는 제외.
+ - `exists(prefix)` → `list(prefix).length > 0`.
+ - `delete(keys)` → 각 key 파일 삭제(없으면 무시), 삭제 후 빈 상위 디렉토리 정리.
+ - `getJson(key)` → 파일 read + `JSON.parse`.
+- **팩토리 `makeBackend(cfg, { profile }): StorageBackend`** — `self-hosted` → `LocalFsBackend`,
+ 그 외 → `S3Backend(makeS3(...), cfg.bucket)`. 커맨드는 이 팩토리만 부른다.
+- **크로스플랫폼 key 정규화.** LocalFsBackend는 내부 OS 경로 ↔ posix key 변환을 한 곳
+ (헬퍼)에서 처리해 Windows(`\`)에서도 key가 항상 posix `/`가 되도록 보장.
+
+### 커맨드 리팩터 (`publish`/`list`/`rm`)
+
+- `makeS3`+자유함수 호출을 `const backend = makeBackend(cfg, { profile })` +
+ `backend.put/list/exists/delete/getJson`로 치환. `cfg.bucket` 참조는 백엔드 안으로 사라진다.
+- `uniqueCode(s3, bucket)` → `uniqueCode(backend)`.
+- cloudfront invalidation 분기(`cfg.mode === "cloudfront" && cfg.distributionId`)는 그대로 —
+ self-hosted는 진입 안 함.
+- **`open.ts` 무변경**(S3 미접촉).
+
+### Config & 모드 파생 (`src/lib/config.ts`)
+
+- `Mode`에 `"self-hosted"` 추가. `Config`의 `bucket`/`region`을 **optional로 완화**(self-hosted가
+ 생략) + 신규 필드:
+
+ ```ts
+ serveRoot?: string; // 로컬 serve 디렉토리 (self-hosted 판별자)
+ host?: string; // DDNS/도메인/고정 IP; 미설정 시 공인 IP 자동감지
+ port?: number; // 비표준 포트
+ scheme?: "http" | "https"; // 미설정 시 http (베어 IP), 호스트명+명시 시 https
+ ```
+
+- 새 오버라이드·env: `--serve-root`/`HOSTDOC_SERVE_ROOT`, `--host`/`HOSTDOC_HOST`,
+ `--port`/`HOSTDOC_PORT`, `--scheme`/`HOSTDOC_SCHEME`. 정밀도(flags > env > file)와 "mode는
+ 파생, 저장 안 함" 유지.
+- **파생 순서**: `serveRoot` 존재 → `self-hosted`(신규 판별자, 기존 두 분기보다 먼저 평가).
+- **모호성 가드**: `serveRoot`와 `bucket|domain|distribution`이 **동시 설정** → 명확한 에러
+ (기존 "부분 cloudfront 설정" 가드와 대칭). 두 인프라를 섞어 지정한 실수를 조용히 무시하지
+ 않는다.
+
+### 호스트/URL 해석 (`src/lib/url.ts` + 신규 `src/lib/host.ts`)
+
+핵심 아키텍처 결정: **`buildPublicUrl`은 동기·순수 유지.** `list.ts`가 행마다 호출하므로 async로
+바꾸면 open/list에 파급이 크다. 공인 IP 감지(비동기 네트워크 호출)는 분리한다.
+
+- 신규 `src/lib/host.ts`:
+ - `detectPublicIp(): Promise` — 외부 echo 서비스(예: `https://api.ipify.org`,
+ 폴백 `https://icanhazip.com`) fetch로 공인 IP 반환. 실패 시 "host를 설정하라"는 명확한
+ 에러.
+ - `ensureHost(cfg): Promise` — self-hosted + `host` 미설정이면 `detectPublicIp()`로
+ 감지, **"동적 IP라 링크가 바뀔 수 있음" 경고를 한 번 출력**, `{ ...cfg, host: ip,
+ scheme: cfg.scheme ?? "http" }` 반환. host가 이미 있으면 그대로. **s3/cloudfront는 no-op.**
+- 커맨드 진입: `resolveConfig(flags)` → `await ensureHost(cfg)` → 이후 sync `buildPublicUrl`.
+ list는 루프 **이전에 한 번** `ensureHost`로 host를 확정(행마다 감지하지 않음).
+- `buildPublicUrl` self-hosted 분기:
+ - `scheme`(기본 `http`) + `host` + 비표준 포트(`port`가 scheme 기본값과 다르면 `:port`
+ 부착) + `/${code}/`.
+ - 베어 IP → `http`. 호스트명 + `scheme="https"` 명시 → `https`(Phase 1은 스킴만 반영;
+ 실제 HTTPS 종단은 사용자 웹서버 책임).
+
+### `_meta` 보호 + index 동작 + 수동 스니펫 (신규 `src/lib/snippet.ts`)
+
+- `caddySnippet(opts)` / `nginxSnippet(opts)` — 두 서버 모두 다음을 인코딩(= `infra/index-rewrite.js`
+ 패리티):
+ 1. **`/_*` 요청 403 차단**(`_meta` 등 protected prefix).
+ 2. **트레일링슬래시/확장자 없는 URI에 `index.html` 부착**(`try_files $uri $uri/index.html`
+ / Caddy `try_files`).
+ 3. **디렉토리 리스팅 off**(nginx `autoindex off`, Caddy `file_server`는 기본 미표시).
+ 4. 도메인(호스트명) 지정 시 Caddy는 자동 HTTPS(Let's Encrypt) 주석 안내.
+- 사이드카는 serveRoot에 기록되고 list/open/rm이 `LocalFsBackend`로 읽는다. `_meta/` 자체는
+ 스니펫 규칙(#1)이 웹 접근을 차단 — s3-website(bucket-policy)·cloudfront(index-rewrite.js)와
+ 동형의 3번째 방어선.
+
+### `hostdoc setup` (self-hosted, Phase 1 = 설치 없음)
+
+- `hostdoc setup --serve-root [--host ] [--port ] [--scheme ] [--nginx | --caddy]`:
+ 1. `serveRoot` 디렉토리 생성(`mkdir -p`).
+ 2. config 저장(`mode: self-hosted`, serveRoot/host/port/scheme).
+ 3. **스니펫 출력** — 서버 선택 규칙: **기본 Caddy**. `--nginx` 명시 **또는** PATH에서 기존
+ `nginx` 감지 시 nginx. `--caddy`로 nginx 감지를 무시하고 Caddy 강제.
+ 4. 보안/노출 경고 출력(관리 디렉토리만 서빙·리스팅 off·`_meta` 차단, 포트포워딩/방화벽은
+ 사용자 책임).
+- 자동 다운로드·서비스 등록은 **하지 않음**(Phase 2).
+
+## Non-goals (Phase 1)
+
+- hostdoc이 콘텐츠를 직접 서빙하는 **상주 데몬**(내장 HTTP 서버). 서빙은 웹서버가 담당.
+- **웹서버 자동 설치·구동·OS 서비스 등록**(Phase 2).
+- **포트포워딩/방화벽/공유기 설정 자동화.** 네트워크 노출은 사용자 책임 — 경고·안내만.
+- **베어 IP HTTPS 보장.** HTTPS는 해석 가능한 호스트명 + 사용자 웹서버 구성 시에만. 베어 IP는
+ HTTP.
+- **DDNS 자동 갱신 클라이언트** 내장(Phase 3).
+- 기존 `s3-website`·`cloudfront` 동작 변경(백엔드 추상화는 현행 동작 보존이 전제).
+
+## Testing (AWS·Terraform 없이)
+
+**무회귀**: 기존 AWS-mock 테스트(`aws.test.ts`, publish/list/rm/setup/config/url 등) 전부 통과.
+S3Backend가 `aws.ts`를 무수정 위임하므로 회귀 위험이 격리됨.
+
+신규 테스트(테스트 먼저):
+- `backend.test.ts` — `LocalFsBackend` put/list/exists/delete/getJson을 temp 디렉토리에 대해
+ 검증(중첩 key·posix 정규화·빈 디렉토리 정리·contentType 무시 포함).
+- `config.test.ts`(확장) — `serveRoot` 기반 self-hosted 파생, flags>env>file 정밀도,
+ serveRoot↔bucket/domain 모호성 가드.
+- `url.test.ts`(확장) — self-hosted `buildPublicUrl`(host=IP→http, host=name+scheme=https,
+ 비표준 포트, code prefix).
+- `host.test.ts` — `detectPublicIp`(fetch mock, 폴백·실패 경로), `ensureHost`(host 있음 no-op /
+ 없음 감지+경고 / s3·cloudfront no-op).
+- `snippet.test.ts` — caddy·nginx 스니펫이 `/_*` 403 + index rewrite + 리스팅 off를 각 문법으로
+ 포함.
+- publish/list/rm self-hosted **E2E** — temp serveRoot에 대해 발행→목록→삭제 사이클, 링크가
+ host 기반으로 생성됨.
+- `setup.test.ts`(확장) — self-hosted setup이 serveRoot 생성·config 저장·서버 선택 규칙
+ (기본 Caddy / nginx 감지·`--nginx` / `--caddy` 강제)대로 스니펫 출력.
+
+## Acceptance criteria
+
+- [ ] 기존 웹서버가 `serveRoot`를 서빙하는 환경에서 `hostdoc publish `가 로컬 fs에
+ 배포하고 내 호스트 기반 지속 링크를 반환한다.
+- [ ] `host` 미설정 시 공인 IP를 감지해 링크를 만들되 "IP가 바뀌면 링크가 무효화될 수 있음"을
+ 경고한다.
+- [ ] `list`/`open`/`rm`이 self-hosted 백엔드에서 클라우드 모드와 동일한 UX로 동작한다.
+- [ ] 스니펫을 적용한 웹서버에서 `/_meta/…`(및 `/_*`) 접근이 차단되고, 트레일링슬래시/확장자
+ 없는 경로에 `index.html`이 서빙된다.
+- [ ] `hostdoc setup --serve-root …`가 serveRoot 생성·config 저장 후 기본 Caddy(또는 nginx
+ 감지/`--nginx` 시 nginx) 스니펫과 보안 경고를 출력한다.
+- [ ] 기존 `s3-website`·`cloudfront` 테스트가 전부 통과. CI는 AWS 크레덴셜·Terraform 없이 그대로
+ 통과.
+
+## Risks
+
+| Risk | Mitigation |
+|---|---|
+| 동적 가정용 IP로 링크가 쉽게 무효화 | 설정 host(DDNS/도메인) 우선, 자동감지 시 명시적 경고. Phase 3 DDNS 헬퍼 검토 |
+| S3 결합 리팩터가 기존 두 모드에 회귀 유발 | S3Backend가 `aws.ts` 무수정 위임 + TDD(기존 AWS-mock 테스트 그대로 통과) |
+| self-hosted에 `bucket`/`region` 없어 커맨드가 undefined 참조 | Config에서 optional로 완화하되 접근은 `makeBackend` 분기 뒤로 격리 — 커맨드는 `cfg.bucket`을 직접 안 봄 |
+| 공인 IP echo 서비스 장애/응답형식 변동 | 1차·폴백 2개 서비스, 실패 시 "host 설정" 명확 에러. IP는 텍스트 trim으로만 파싱 |
+| 크로스플랫폼 key(`\` vs `/`) 불일치 | LocalFsBackend가 OS경로↔posix key 변환을 단일 헬퍼로 강제, 테스트로 고정 |
+| 베어 IP HTTPS 기대치 오설정 | HTTPS는 호스트명+사용자 웹서버 구성 시에만으로 명시. 스킴 기본 http |
+
+## References
+
+- 결합 지점: `src/commands/publish.ts`·`src/lib/aws.ts`(S3 5연산), `src/commands/list.ts`·`rm.ts`,
+ `src/lib/config.ts`(모드 파생), `src/lib/url.ts`(URL 빌더), `src/lib/cloudfront.ts`(invalidation
+ 가드), `src/lib/meta.ts`(사이드카), `src/commands/setup.ts`(프로비저닝 대칭)
+- `infra/index-rewrite.js` — `_meta` 보호·index rewrite 패리티 원본
+- CLAUDE.md — "Two hosting modes, one code path" / 모드 파생 규칙 / `_meta/` 보호 두 방식
+- Issue [#41](https://github.com/jkas2016/hostdoc/issues/41) — 브레인스토밍 확정: hostdoc 역할=디렉토리 배포 · 호스트=설정 우선+IP 자동감지 폴백 · 스니펫 Caddy 기본+nginx 옵션
diff --git a/docs/superpowers/specs/2026-07-03-self-hosted-mode-phase2-design.md b/docs/superpowers/specs/2026-07-03-self-hosted-mode-phase2-design.md
new file mode 100644
index 0000000..52da446
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-03-self-hosted-mode-phase2-design.md
@@ -0,0 +1,170 @@
+# Spec: `self-hosted` 모드 Phase 2 — `hostdoc setup --install`이 Caddy 설치·구동 자동
+
+- **Issue**: [#41](https://github.com/jkas2016/hostdoc/issues/41)
+- **Date**: 2026-07-03
+- **Branch**: `feat/41-self-hosted-mode` (Phase 1과 동일 PR)
+- **Scope**: Phase 2 (웹서버 자동 설치·구동·서비스 등록). Phase 1(MVP, 기존 웹서버 가정)은
+ [별도 spec](2026-07-03-self-hosted-mode-design.md)으로 완료됨. Phase 3(NAS/Windows/DDNS)는 별도 spec.
+
+## Problem
+
+Phase 1은 `self-hosted` 모드를 추가해 로컬 웹서버가 서빙하는 디렉토리에 발행하고 내 호스트/공인
+IP 기반 지속 링크를 반환한다. 단 **웹서버가 이미 있다고 가정**한다 — `hostdoc setup --serve-root`는
+config 저장 + 수동 설정 스니펫 출력까지만 하고(`src/commands/setup-selfhosted.ts`), 실제 설치·구동은
+하지 않는다.
+
+AWS 계정도 없고 **웹서버도 없는** 사용자(집 PC/NAS만 있는)는 스니펫을 손으로 적용해야 한다. Phase 2는
+`hostdoc setup --serve-root --install`로 **Caddy 정적 바이너리를 설치하고, Caddyfile을 기록하고,
+OS 서비스로 등록해 재부팅 후에도 서빙이 유지**되게 한다. `hostdoc deprovision`이 이를 되돌린다.
+
+## Goal
+
+`hostdoc setup --serve-root
--install`이:
+1. Caddy를 확보(PATH에 있으면 재사용, 없으면 공식 다운로드 서비스에서 플랫폼 감지 다운로드)하고,
+2. `_meta` 차단·index rewrite·(도메인 시)auto-HTTPS를 담은 Caddyfile을 안정 경로에 기록하고,
+3. OS 시스템 서비스(macOS launchd LaunchDaemon / Linux systemd unit)로 등록·구동해 재부팅 생존시키고,
+4. 보안/노출 경고를 출력한다.
+
+`hostdoc deprovision`(self-hosted 모드 감지 시)이 **서비스를 중지·해제**하고 서비스 파일·Caddyfile을
+제거한다(발행 파일·바이너리는 보존). 기존 `s3-website`·`cloudfront`·Phase 1 동작·테스트는 무회귀.
+CI는 AWS 크레덴셜·Terraform·실네트워크·root 없이 그대로 통과.
+
+## Key Insight (왜 변경이 감당 가능한가)
+
+1. **I/O 경계가 좁다.** 자동 설치가 하는 부작용은 정확히 세 종류 — 바이너리 **다운로드**(네트워크),
+ 파일 **기록**(서비스 파일/Caddyfile/chmod), 서비스 **제어**(launchctl/systemctl) — 이고 모두 얇은
+ 경계 함수 뒤로 격리 가능하다. `terraform.ts`가 `execFileSync`로 shell-out하는 것과 동형이다.
+2. **Caddyfile 생성기는 이미 있다.** Phase 1 `caddySnippet`(`src/lib/snippet.ts`)이 `_meta` 차단·
+ index rewrite·디렉토리 리스팅 off·(도메인 site address 시)Caddy auto-HTTPS를 이미 인코딩한다.
+ Phase 2는 이 출력을 **실제 파일로 기록만** 한다 — 새 Caddyfile 생성기가 필요 없다.
+3. **모드 파생이 라우팅을 공짜로 준다.** `deprovision`은 `resolveConfig`로 모드를 파생해 self-hosted면
+ 서비스 teardown, 그 외면 terraform destroy로 분기한다 — config에 상태를 저장하지 않는다.
+4. **setup `--install`은 가산적이다.** 플래그 없는 `setup --serve-root`는 Phase 1 동작(스니펫만) 그대로.
+ `--install`을 명시할 때만 다운로드·서비스가 돈다 — "기존 웹서버 가정"(Phase 1) vs "웹서버 없음 →
+ 자동 설치"(Phase 2)의 두 사용자 의도를 명시적으로 가른다.
+5. **순수/부작용 분리가 CI를 지킨다.** 플랫폼 감지·서비스 파일 생성은 순수 함수(유닛 테스트), 다운로드·
+ 파일 기록·서비스 제어는 주입 가능한 `run`/`fetch` 뒤(fake 주입) — CI는 네트워크·root 없이 통과한다.
+
+## Decisions
+
+### 플랫폼 감지 (신규 `src/lib/platform.ts`, 순수)
+
+- `detectPlatform(): { os: "darwin" | "linux"; arch: "amd64" | "arm64" }` — `process.platform`/
+ `process.arch`를 Caddy 명명으로 매핑. `win32` 등 미지원 → **명확한 "Phase 3에서 지원 예정" 에러**
+ (조용히 무시하지 않음).
+- `caddyDownloadUrl(plat): string` — Caddy 공식 다운로드 서비스 URL 생성.
+ **구현 시 공식 문서로 정확한 엔드포인트·쿼리 형식 검증 후 확정**(추측 금지).
+
+### 서비스 파일 생성기 (신규 `src/lib/service.ts`, 순수)
+
+- `launchdPlist(opts): string` — macOS LaunchDaemon plist. `ProgramArguments = [caddyBin, "run",
+ "--config", caddyfilePath]`, `RunAtLoad=true`, `KeepAlive=true`(재부팅·크래시 생존).
+- `systemdUnit(opts): string` — Linux systemd unit. `ExecStart= run --config `,
+ `AmbientCapabilities=CAP_NET_BIND_SERVICE`(비-root 프로세스가 80/443 바인딩), `Restart=on-failure`,
+ `[Install] WantedBy=multi-user.target`(부팅 시 시작).
+- 서비스 식별자·설치 경로 상수: 라벨 `com.hostdoc.caddy`(plist) / `hostdoc-caddy.service`(systemd);
+ 설치 경로 `/Library/LaunchDaemons/com.hostdoc.caddy.plist` / `/etc/systemd/system/hostdoc-caddy.service`.
+ **정확한 지시자·경로 관례는 구현 시 공식 문서로 검증 후 확정.**
+
+### Caddyfile 기록 (`src/lib/snippet.ts` 재사용)
+
+- Phase 2는 `caddySnippet(opts)` 출력을 안정 경로(예: `/../Caddyfile` 또는
+ `~/.config/hostdoc/Caddyfile` — 구현 시 확정)에 기록하고, 서비스가 `--config`로 이 경로를 참조한다.
+- **새 Caddyfile 생성기를 만들지 않는다** — `caddySnippet`이 `_meta` 차단·index rewrite·리스팅 off·
+ (도메인 site address 시)auto-HTTPS를 이미 인코딩(= `infra/index-rewrite.js` 패리티).
+
+### 설치/제어 I/O 경계 (신규 `src/lib/installer.ts`)
+
+`terraform.ts`와 동형의 얇은 부작용 경계. **주입 가능한 명령 러너**(기본 `execFileSync`)와 global
+`fetch`를 받아 테스트에서 fake를 주입 → 실제 실행·네트워크 0.
+
+- `downloadCaddy(...)` — PATH에 `caddy`가 있으면 **재사용(스킵)**. 없으면 `caddyDownloadUrl`에서
+ fetch → `/usr/local/bin/caddy`에 기록 → `chmod +x`. 이미 대상 경로에 있으면 스킵(idempotent).
+- `installService({ os, caddyBin, caddyfilePath })` — 서비스 파일(plist/unit)을 특권 경로에 기록 →
+ `launchctl load`(또는 `bootstrap`) / `systemctl daemon-reload && systemctl enable --now`. **root
+ 필요**.
+- `removeService({ os })` — `launchctl unload`/`systemctl disable --now` → 서비스 파일·Caddyfile 제거.
+ serveRoot 내용·Caddy 바이너리는 건드리지 않는다.
+
+### 커맨드·CLI (`setup` 확장 + `deprovision` 라우팅)
+
+- **`setup --install` (가산적, `src/commands/setup-selfhosted.ts` 확장):**
+ - `hostdoc setup --serve-root ` → **Phase 1 그대로**(config + 스니펫 출력).
+ - `hostdoc setup --serve-root
--install [--host ] [--port ]` → Caddy 확보 → Caddyfile
+ 기록 → 서비스 설치·구동 → 상태·보안 경고 출력.
+ - **nginx는 자동설치 대상 아님**: `--nginx` 또는 nginx 감지 + `--install`이면 스니펫만 출력하고
+ "nginx는 단일 정적 바이너리가 아니라 수동 설정 필요" 안내(자동설치는 Caddy 전용).
+- **권한 분기(시스템 서비스):** 비특권 작업(다운로드→`/usr/local/bin`, config 저장)은 유저로 수행.
+ 서비스 등록(특권 경로 기록 + `launchctl`/`systemctl`)은 root 필요 → **root이 아니면 실행하려던
+ 정확한 `sudo …` 명령을 출력하고 그 단계에서 중단**(사용자가 sudo로 재실행). terraform apply
+ 프롬프트가 human gate인 것과 대칭 — hostdoc이 임의로 sudo를 삼키지 않는다.
+- **`deprovision` 모드 라우팅(`src/commands/deprovision.ts` 확장):** `resolveConfig`로 모드 파생 →
+ `self-hosted`면 `removeService`(서비스 중지·해제 + 서비스 파일·Caddyfile 제거, serveRoot·바이너리
+ 보존), 그 외는 기존 terraform destroy. "mode는 파생, 저장 안 함" 유지.
+
+## Non-goals (Phase 2)
+
+- **nginx 자동 설치·서비스 관리.** nginx는 단일 정적 바이너리가 아니므로 스니펫 수동 유지(Phase 1).
+- **Windows 서비스 / Synology·QNAP NAS 패키징.** Phase 3. `platform.ts`가 명확한 에러로 안내.
+- **포트포워딩/방화벽/공유기 설정 자동화.** 네트워크 노출은 사용자 책임 — 경고·안내만(Phase 1과 동일).
+- **deprovision이 발행 파일·바이너리 삭제.** 서비스 중지·해제만. serveRoot 내용·Caddy 바이너리는 보존.
+- **hostdoc이 sudo를 임의 실행.** 특권 단계는 정확한 명령 출력 후 사용자가 실행(human gate).
+- **DDNS 자동 갱신 클라이언트.** Phase 3.
+- 기존 `s3-website`·`cloudfront`·Phase 1 동작 변경(모두 무회귀 전제).
+
+## Testing (무네트워크·무root·무실서비스)
+
+**무회귀**: 기존 AWS-mock 테스트 + Phase 1 self-hosted 테스트 전부 통과. setup `--install`은 가산적,
+deprovision은 모드 분기로만 추가 → 기존 경로 불변.
+
+신규 테스트(테스트 먼저, 전부 fake runner/fetch 주입):
+- `platform.test.ts` — `detectPlatform` 매핑(darwin/linux × amd64/arm64), `caddyDownloadUrl` 형식,
+ `win32` → throw.
+- `service.test.ts` — `launchdPlist`/`systemdUnit` 내용에 `caddy run --config`·`RunAtLoad`/`KeepAlive`·
+ `WantedBy=multi-user.target`·`CAP_NET_BIND_SERVICE` 포함.
+- `installer.test.ts` — 주입 runner + fetch mock으로: `downloadCaddy`(PATH 재사용 스킵 / 다운로드+chmod),
+ `installService`(올바른 파일 기록 + enable/start 커맨드 발행), `removeService`(disable/stop + 파일 제거).
+ 실제 exec·네트워크 0.
+- `setup-selfhosted.test.ts`(확장) — `--install`이 installer를 올바른 인자로 호출(fake), 비설치는 스니펫
+ 유지, nginx+`--install`은 스니펫만 + 안내.
+- `deprovision.test.ts`(확장) — self-hosted 모드 → `removeService`(fake) 라우팅, cloud 모드 → terraform
+ (기존 경로).
+
+## Acceptance criteria
+
+- [ ] `hostdoc setup --serve-root
--install`이 Caddy를 확보(재사용/다운로드)하고 Caddyfile을 기록하고
+ 시스템 서비스로 등록·구동하며, 재부팅 후에도 서빙이 유지된다.
+- [ ] root이 아니면 서비스 등록 단계에서 실행하려던 정확한 `sudo` 명령을 출력하고 중단한다(임의 sudo 실행 X).
+- [ ] `--nginx`/nginx 감지 + `--install`이면 스니펫만 출력하고 nginx 수동 설정을 안내한다.
+- [ ] `hostdoc deprovision`이 self-hosted 모드를 감지해 서비스를 중지·해제하고 서비스 파일·Caddyfile을
+ 제거하되, serveRoot 발행 파일과 Caddy 바이너리는 보존한다.
+- [ ] 미지원 플랫폼(Windows)에서 `--install`이 "Phase 3에서 지원 예정" 명확 에러를 낸다.
+- [ ] Caddy가 서빙하는 사이트에서 `/_meta/…`(및 `/_*`) 접근이 차단되고, 트레일링슬래시/확장자 없는 경로에
+ `index.html`이 서빙되며, 도메인 호스트 시 auto-HTTPS가 적용된다.
+- [ ] 기존 `s3-website`·`cloudfront`·Phase 1 self-hosted 테스트가 전부 통과. CI는 AWS·Terraform·네트워크·
+ root 없이 그대로 통과.
+
+## Risks
+
+| Risk | Mitigation |
+|---|---|
+| Caddy 다운로드 URL·서비스 지시자 추측 오류 | 구현 시 **공식 문서로 검증 후 확정**. 설계는 구조만 고정(CLAUDE.md/메모리 원칙) |
+| 80/443 바인딩 권한 | 시스템 서비스로 등록(재부팅 생존). root 아니면 정확한 sudo 명령 출력·중단(human gate). Linux는 `CAP_NET_BIND_SERVICE` |
+| CI가 다운로드·root·실서비스 못 씀 | 모든 I/O를 주입 `run`/`fetch` 경계 뒤로 격리 → 순수 생성기만 유닛 테스트. `terraform.ts`와 동형 |
+| 미지원 플랫폼(Windows/NAS) | `platform.ts`가 "Phase 3" 명확 에러. 조용히 잘못된 바이너리 받지 않음 |
+| 자동 설치가 집 머신을 외부 노출 | 관리 디렉토리만 서빙 + 리스팅 off + `_meta` 차단(Phase 1) + setup 시 보안 경고. 포트포워딩/방화벽은 사용자 책임으로 문서화 |
+| setup `--install`이 Phase 1 동작에 회귀 | `--install`은 가산적 — 플래그 없으면 Phase 1 스니펫 경로 그대로 |
+| deprovision이 잘못된 인프라를 지움 | `resolveConfig` 모드 파생으로 라우팅. self-hosted는 서비스만, cloud는 terraform — 서로 침범 안 함 |
+
+## References
+
+- Phase 1: [2026-07-03-self-hosted-mode-design.md](2026-07-03-self-hosted-mode-design.md),
+ [plan](../plans/2026-07-03-self-hosted-mode.md)
+- 확장 지점: `src/commands/setup-selfhosted.ts`(setup `--install`), `src/commands/deprovision.ts`(모드
+ 라우팅), `src/lib/snippet.ts`(`caddySnippet` 재사용), `src/lib/which.ts`(`onPath` 재사용),
+ `src/lib/terraform.ts`(shell-out 경계 패턴 원본), `src/index.ts`(CLI 배선)
+- `infra/index-rewrite.js` — `_meta` 보호·index rewrite 패리티 원본
+- CLAUDE.md — "Two hosting modes, one code path" / 모드 파생 규칙 / `infra/` 로컬 전용 프로비저닝
+- Issue [#41](https://github.com/jkas2016/hostdoc/issues/41) — Phase 2 태스크: Caddy 다운로드·설치 · Caddyfile
+ 생성기 · 구동+OS 서비스 등록 · 보안 경고
+- 브레인스토밍 확정(2026-07-03): 플랫폼=macOS+Linux · 권한=시스템 서비스+명시 sudo 분기 · deprovision=서비스만
diff --git a/docs/superpowers/specs/2026-07-04-self-hosted-mode-phase3-windows-design.md b/docs/superpowers/specs/2026-07-04-self-hosted-mode-phase3-windows-design.md
new file mode 100644
index 0000000..9f0a9b1
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-04-self-hosted-mode-phase3-windows-design.md
@@ -0,0 +1,219 @@
+# Spec: `self-hosted` 모드 Phase 3 — Windows 서비스 통합 (`setup --install`이 Windows에서 Caddy를 부팅 생존 서비스로 등록)
+
+- **Issue**: [#41](https://github.com/jkas2016/hostdoc/issues/41)
+- **Date**: 2026-07-04
+- **Branch**: `feat/41-self-hosted-mode` (Phase 1·2와 동일 PR)
+- **Scope**: Phase 3의 **Windows 서비스 통합만**. NAS 패키징(Synology/QNAP)·DDNS 자동 갱신 헬퍼는
+ 별도 spec으로 분리. Phase 1(MVP)·Phase 2(macOS launchd / Linux systemd 자동 설치)는 각각
+ [별도](2026-07-03-self-hosted-mode-design.md) [spec](2026-07-03-self-hosted-mode-phase2-design.md)으로
+ 완료됨.
+
+## Problem
+
+Phase 2는 `hostdoc setup --serve-root
--install`로 Caddy 정적 바이너리를 확보하고 Caddyfile을
+기록한 뒤 **OS 시스템 서비스**(macOS launchd LaunchDaemon / Linux systemd unit)로 등록해 재부팅 후에도
+서빙을 유지한다. 단 `src/lib/platform.ts`의 `detectPlatform`은 **`win32`에서 명확한 에러를 던지며
+Phase 3로 미룬다** — Windows 사용자는 `--install`을 쓸 수 없고 수동 스니펫만 받는다.
+
+집 PC가 Windows인 사용자가 상당수다. Phase 3는 `--install`을 Windows까지 확장해 macOS·Linux와 동일한
+"publish → 지속 링크" 자동화(설치·부팅 생존·`deprovision` 되돌리기)를 제공한다.
+
+핵심 걸림돌은 Windows에 launchd/systemd 대응물이 없다는 점이다. 그리고 **Caddy는 네이티브 Windows
+서비스 스텁이 없다** — 이슈 [caddyserver/caddy#4670](https://github.com/caddyserver/caddy/issues/4670)의
+PR #4790이 병합되지 않고 닫혔다. 즉 `caddy.exe run`은 Windows 서비스 제어 관리자(SCM) 프로토콜에
+응답하지 않으므로, `sc.exe create … binPath="caddy.exe run"`로 감싼 서비스는 시작 직후 SCM 타임아웃
+(error 1053)으로 죽을 실질적 위험이 있다. 그래서 Caddy 공식 문서([running](https://caddyserver.com/docs/running))와
+커뮤니티는 **WinSW 래퍼**를 권장한다.
+
+## Goal
+
+Windows(x64)에서 `hostdoc setup --serve-root
--install`이:
+1. Caddy(`caddy.exe`)를 확보(PATH 재사용 / 공식 다운로드)하고,
+2. **WinSW 래퍼 바이너리**(`caddy-service.exe`)를 확보하고,
+3. `_meta` 차단·index rewrite·(도메인 시)auto-HTTPS를 담은 Caddyfile과 `caddy-service.xml`을 안정
+ 경로에 기록하고,
+4. WinSW로 Windows 서비스를 등록·구동해 **재부팅 생존**시키고(관리자 권한 필요),
+5. 보안/노출 경고를 출력한다.
+
+`hostdoc deprovision`(self-hosted 감지 시)이 Windows에서 **서비스를 중지·해제**하고 `caddy-service.xml`·
+Caddyfile을 제거한다(발행 파일·`caddy.exe`·WinSW 바이너리는 보존). 관리자가 아니면 특권 단계는
+**정확한 명령을 출력하고 중단** — 사용자가 관리자 PowerShell에서 재실행(Unix의 sudo human-gate와 대칭).
+
+기존 `s3-website`·`cloudfront`·Phase 1·2(macOS/Linux) 동작·테스트는 **무회귀**. CI(Linux)는 AWS·
+Terraform·네트워크·관리자 없이 그대로 통과.
+
+## Key Insight (왜 변경이 감당 가능한가)
+
+1. **I/O 경계는 Phase 2와 동형이다.** Windows 자동 설치의 부작용도 정확히 세 종류 — 바이너리
+ **다운로드**(caddy + WinSW), 파일 **기록**(xml/Caddyfile), 서비스 **제어**(`caddy-service.exe
+ install/start/uninstall`) — 이고 모두 Phase 2가 이미 만든 주입 가능한 `run`/`fetch`/파일 seam
+ (`installer.ts`의 `Deps`) 뒤에 격리된다. 새 경계 패턴이 필요 없다.
+2. **Caddyfile 생성기는 그대로 재사용된다.** Phase 1 `caddySnippet`(`src/lib/snippet.ts`)이 `_meta`
+ 차단·index rewrite·리스팅 off·auto-HTTPS를 이미 인코딩한다. Windows도 같은 Caddyfile을 참조한다 —
+ `caddySnippet`은 무수정(동결 파일).
+3. **모드 파생이 라우팅을 공짜로 준다.** `deprovision`은 `resolveConfig`로 self-hosted를 파생해
+ 서비스 teardown으로 분기한다(Phase 2). Windows는 그 분기 안에서 플랫폼만 갈린다.
+4. **`--install`은 여전히 가산적이다.** 플래그 없는 `setup --serve-root`는 Phase 1 스니펫 경로 그대로.
+ Windows에서 달라지는 것은 `--install`이 던지던 에러가 실제 설치로 바뀌는 것뿐.
+5. **순수/부작용 분리가 CI를 지킨다.** 플랫폼 감지·서비스 파일 생성(`winswXml`)은 순수 함수(유닛
+ 테스트), 다운로드·기록·서비스 제어는 주입 seam 뒤 — Linux CI가 `plat`를 명시 주입해 Windows 경로를
+ 무네트워크·무관리자로 검증한다.
+
+## Decisions
+
+### 플랫폼 감지 (`src/lib/platform.ts`, 순수)
+
+- `Platform["os"]`에 `"windows"`를 추가: `"darwin" | "linux" | "windows"`.
+- `detectPlatform`: `win32` → **더 이상 throw 안 함**, `os = "windows"`. arch 매핑(`x64`→`amd64`,
+ `arm64`→`arm64`)은 유지.
+- `caddyDownloadUrl`은 이미 `os`/`arch`를 보간하므로 로직 무변경 — `?os=windows&arch=amd64`가
+ `caddy.exe`를 반환한다. **구현 시 Caddy 공식 다운로드 API로 Windows 응답이 단일 `.exe`(아카이브
+ 아님)임을 확정.**
+- 신규 `winswDownloadUrl(plat): string` — WinSW는 Caddy API가 아니라 **GitHub 릴리스 자산**이다.
+ `https://github.com/winsw/winsw/releases/download//WinSW-x64.exe` 형식. **WinSW v2.x로 버전
+ 핀** — v2.x는 .NET Framework 4.6.1 대상이며 Windows 10/11에 기본 탑재(별도 .NET 런타임 설치 불필요),
+ Caddy 공식 `caddy-service.xml` 예시도 v2.x 기준이다. **구현 시 정확한 v2.x 버전·자산명(x64/arm64
+ 존재 여부)을 공식 릴리스로 확정**(추측 금지 — Phase 2가 caddy API를 구현 시 확정한 것과 동형).
+
+### 서비스 정의 생성기 (`src/lib/service.ts`, 순수)
+
+- 신규 `winswXml(opts): string` — Caddy 공식 문서의 `caddy-service.xml` 구조를 따른다:
+ - `hostdoc-caddy`, ``/``,
+ - **절대경로** `…\caddy.exe`(`%BASE%` 대신 절대경로 → PATH 재사용 caddy도
+ 지원),
+ - `run --config …\Caddyfile`(macOS/Linux 생성기와 동일하게 hostdoc Caddyfile을
+ `--config`로 참조),
+ - roll-by-time 로그 블록(`yyyy-MM-dd`).
+- 서비스 식별자 상수 신규: `WINSW_ID = "hostdoc-caddy"`(기존 `LAUNCHD_LABEL`/`SYSTEMD_UNIT`과 대칭).
+- `launchdPlist`/`systemdUnit`는 **무변경**.
+
+### 설치/제어 I/O 경계 (`src/lib/installer.ts`)
+
+Windows는 Unix의 "스테이징 → 특권 시스템 경로 복사 → launchctl/systemctl"과 **구조가 다르다**. WinSW
+모델 = **서비스 디렉토리 하나**에 `caddy.exe` + `caddy-service.exe`(WinSW) + `caddy-service.xml`을 두고,
+`caddy-service.exe install`이 그 자리에서 서비스를 등록한다(시스템 경로 복사 단계 없음).
+
+- 신규 `serviceDir(plat): string` — windows: `join(dirname(configPath()), "service")`. WinSW 관련 세 파일
+ (`caddy.exe`·`caddy-service.exe`·`caddy-service.xml`)이 여기 산다. (Unix 경로 헬퍼 `stagedServicePath`/
+ `systemServicePath`는 Windows 분기에서 호출되지 않는다.)
+- `caddyBinDest()` windows 분기 → `join(serviceDir(win), "caddy.exe")`(Phase 2의 `homedir/.local/bin/caddy`는
+ `.exe` 확장자·단일 서비스 디렉토리 모델과 맞지 않으므로 Windows는 serviceDir에 `.exe`로 기록). Caddyfile은
+ 기존 `caddyfilePath()`(config 디렉토리) 그대로 — WinSW `--config` 절대경로가 참조하므로 co-locate 불필요.
+- 신규 `winswBinDest(): string` — `join(serviceDir(win), "caddy-service.exe")`.
+- 신규 `downloadWinsw(d): Promise<{ path; reused }>` — `downloadCaddy`와 동형. 대상 경로에 이미 있으면
+ 스킵(idempotent), 없으면 `winswDownloadUrl`에서 fetch → `caddy-service.exe`로 기록. 주입 `fetchFn`/
+ 파일 seam 그대로. (Windows 실행 권한은 chmod 불필요 — `.exe`.)
+- `writeServiceFile` windows 분기 → `winswXml(...)`을 `join(serviceDir, "caddy-service.xml")`에 기록.
+- `installService` windows 분기 → `run(winswExe, ["install"])` 후 `run(winswExe, ["start"])`. **관리자 필요.**
+- `removeService` windows 분기 → `run(winswExe, ["stop"])`(미실행 시 무시) → `run(winswExe, ["uninstall"])`
+ → `caddy-service.xml`·Caddyfile 제거. **`caddy.exe`·WinSW 바이너리는 보존**(Unix가 caddy 바이너리를
+ 보존하는 것과 패리티). **관리자 필요.**
+- **권한 게이팅.** Windows엔 `sudo`가 없고 `process.getuid`도 undefined(현 `isRoot()`는 Windows에서 항상
+ false). 신규 `isPrivileged(plat, deps): boolean` — windows는 주입 `run`으로 `net session`(관리자만
+ exit 0)을 프로브해 판정, 그 외는 기존 `isRoot()`(getuid). **부작용 전에** 게이트. 프로브 러너는 주입
+ 가능 → 테스트에서 fake.
+
+### 권한 아웃컴 개명 (플랫폼 중립)
+
+Windows에선 "sudo"·"root"가 틀린 표현이다. 다음을 개명한다(동결 4파일 무관, Phase 2 self-hosted 커맨드·
+테스트만 소폭 수정):
+
+- 아웃컴 종류 `needs-root` → `needs-privilege`.
+- 필드 `sudoCommands` → `privilegedCommands`.
+- 명령 생성기 `sudoInstallCommands`/`sudoRemoveCommands` → `privilegedInstallCommands`/
+ `privilegedRemoveCommands`(내부에서 plat 분기: Unix는 `sudo …` 접두, Windows는 접두 없이
+ `…\caddy-service.exe install`·`start` / `stop`·`uninstall`).
+- CLI(`src/index.ts`) 헤더를 플랫폼별로: Unix `"Run these as root to register/remove the … service:"` /
+ Windows `"Open an elevated (Administrator) PowerShell and run:"`. (플랫폼 판정은 아웃컴에 이미 있는
+ `plat` 또는 `process.platform`으로.)
+
+### 커맨드·CLI (`setup --install` + `deprovision` 확장)
+
+- **`runInstallSelfHosted`**(`src/commands/setup-selfhosted.ts`): `detectPlatform()`이 win32에서 더 이상
+ throw하지 않으므로 Windows도 흐름에 진입한다. Windows 분기:
+ 1. Caddyfile 기록(Phase 1 `caddySnippet` 재사용),
+ 2. `downloadCaddy`(→`caddy.exe`) + `downloadWinsw`(→`caddy-service.exe`),
+ 3. `writeServiceFile`(→`caddy-service.xml`),
+ 4. `isPrivileged` → 관리자면 `installService`, 아니면 `needs-privilege` 아웃컴 + `privilegedInstallCommands`.
+ - nginx 선택 시 스니펫 폴백은 기존과 동일(자동설치는 Caddy 전용).
+- **`runDeprovisionSelfHosted`**(`src/commands/deprovision-selfhosted.ts`): Windows 분기 → 사용자 소유
+ 파일(`caddy-service.xml`·Caddyfile) 제거, `isPrivileged` → 관리자면 `removeService`, 아니면
+ `needs-privilege` + `privilegedRemoveCommands`.
+
+## Non-goals (Phase 3 Windows)
+
+- **NAS 패키징(Synology/QNAP)·DDNS 자동 갱신 클라이언트.** Phase 3의 별도 spec(이 문서 범위 밖).
+- **`sc.exe`/scheduled-task 방식.** WinSW로 확정(SCM 미구현으로 `sc.exe`는 error 1053 위험, scheduled task는
+ 공식 문서 밖). 브레인스토밍에서 배제됨.
+- **nginx 자동 설치.** Phase 2와 동일 — 스니펫 수동 유지.
+- **포트포워딩/방화벽/공유기 설정 자동화.** 네트워크 노출은 사용자 책임 — 경고·안내만.
+- **deprovision이 발행 파일·바이너리 삭제.** 서비스 중지·해제만. serveRoot·`caddy.exe`·WinSW는 보존.
+- **hostdoc이 관리자 권한을 임의 상승(UAC 자동 트리거).** 특권 단계는 정확한 명령 출력 후 사용자가
+ 관리자 셸에서 실행(human gate).
+- **Windows arm64 확실 지원.** 1차는 **x64(amd64)**. arm64는 WinSW v2/Caddy의 arm64 자산 존재를 구현 시
+ 공식 확인 후 결정 — 있으면 지원, 없으면 x64 에뮬레이션(Windows-on-ARM은 x64 실행 가능) 또는 명확한 에러.
+- 기존 `s3-website`·`cloudfront`·Phase 1·2 동작 변경(모두 무회귀 전제).
+
+## Testing (무네트워크·무관리자·무실서비스, Linux CI에서 `plat` 명시 주입)
+
+**무회귀**: 기존 AWS-mock + Phase 1·2 self-hosted 테스트(286개) 전부 통과. Windows는 가산적 분기 —
+기존 macOS/Linux 경로 불변.
+
+신규/확장 테스트(테스트 먼저, 전부 fake runner/fetch 주입):
+- `platform.test.ts`(확장) — `detectPlatform("win32", …)` → `{ os: "windows", arch }`(**throw 안 함**),
+ `winswDownloadUrl` 형식, `caddyDownloadUrl` windows 형식.
+- `service.test.ts`(확장) — `winswXml`이 ``·`caddy.exe`·`run --config`·`hostdoc-caddy`·
+ 로그 블록을 포함.
+- `installer.test.ts`(확장) — `plat={os:"windows"}` 주입으로: `downloadWinsw`(대상 존재 시 스킵 / 없으면
+ fetch+기록), `installService`(`caddy-service.exe install`+`start` 발행), `removeService`(`stop`+`uninstall`+
+ xml·Caddyfile 제거, 바이너리 보존), `isPrivileged`(주입 `net session` 프로브 exit 0/비0), `privileged*Commands`
+ windows(**sudo 접두 없음**). 실제 exec·네트워크 0.
+- `setup-selfhosted.test.ts`(확장) — windows plat에서 `--install`이 caddy+WinSW 다운로드·xml 기록·
+ `needs-privilege` 아웃컴(비관리자, fake)·`installed`(관리자, fake)를 올바른 인자로 수행. Unix 경로·개명
+ 필드(`privilegedCommands`) 무회귀.
+- `deprovision-selfhosted.test.ts`(확장) — windows 라우팅(xml·Caddyfile 제거 + `needs-privilege`/`removed`).
+
+## Acceptance criteria
+
+- [ ] Windows(x64)에서 `hostdoc setup --serve-root --install`이 `caddy.exe`와 WinSW를 확보하고
+ Caddyfile·`caddy-service.xml`을 기록하고 WinSW로 Windows 서비스를 등록·구동하며, 재부팅 후에도
+ 서빙이 유지된다.
+- [ ] 관리자가 아니면 서비스 등록 단계에서 실행하려던 정확한 명령을 출력하고 중단한다("관리자 PowerShell에서
+ 실행" 안내, 임의 권한 상승 없음).
+- [ ] `--nginx`/nginx 감지 + `--install`이면 스니펫만 출력하고 nginx 수동 설정을 안내한다(Phase 2와 동일).
+- [ ] `hostdoc deprovision`이 Windows self-hosted를 감지해 서비스를 중지·해제하고 `caddy-service.xml`·
+ Caddyfile을 제거하되, serveRoot 발행 파일·`caddy.exe`·WinSW 바이너리는 보존한다.
+- [ ] Caddy가 서빙하는 사이트에서 `/_meta/…`(및 `/_*`) 접근이 차단되고, 트레일링슬래시/확장자 없는 경로에
+ `index.html`이 서빙되며, 도메인 호스트 시 auto-HTTPS가 적용된다(Phase 1·2 패리티, Caddyfile 재사용).
+- [ ] 기존 `s3-website`·`cloudfront`·Phase 1·2(macOS/Linux) 테스트가 전부 통과. CI는 AWS·Terraform·
+ 네트워크·관리자 없이 그대로 통과.
+
+## Risks
+
+| Risk | Mitigation |
+|---|---|
+| `sc.exe`로 감싼 `caddy run`이 SCM 미응답(error 1053) | **WinSW 래퍼로 확정**(공식 권장). caddy는 SCM 스텁 미구현(PR #4790 미병합) — 검증됨 |
+| WinSW 버전·자산명·.NET 의존 추측 오류 | **v2.x 핀**(net461 = Windows 10/11 기본). 정확한 버전·자산명은 구현 시 공식 릴리스로 확정(추측 금지) |
+| Windows 관리자 권한 게이팅 | 부작용 전 `net session` 프로브로 판정, 비관리자면 정확한 명령 출력·중단(human gate). UAC 임의 상승 안 함 |
+| WinSW 두 번째 다운로드가 공급망 표면 확대 | 공식 winsw GitHub 릴리스에서 버전 핀. Phase 2 caddy 자동 다운로드와 동일한 주입 seam·신뢰 경계 |
+| Windows arm64 자산 부재 | 1차 x64 확실 지원. arm64는 구현 시 자산 확인 후 지원/에뮬/명확한 에러 — 조용히 잘못된 바이너리 안 받음 |
+| 개명(`sudoCommands`→`privilegedCommands`)이 Phase 2 경로에 회귀 | 순수 개명 + 플랫폼 분기, 기존 테스트가 Unix 명령 문자열을 그대로 고정 — 무회귀 검증 |
+| CI가 다운로드·관리자·실서비스 못 씀 | 모든 I/O를 주입 `run`/`fetch` seam 뒤로 격리, `plat` 명시 주입 → Linux CI가 Windows 경로 검증(Phase 2와 동형) |
+
+## References
+
+- Phase 2: [2026-07-03-self-hosted-mode-phase2-design.md](2026-07-03-self-hosted-mode-phase2-design.md),
+ [plan](../plans/2026-07-03-self-hosted-mode-phase2.md) — launchd/systemd·installer 주입 seam·권한 게이팅 원본
+- 확장 지점: `src/lib/platform.ts`(win32 분기), `src/lib/service.ts`(`winswXml`), `src/lib/installer.ts`
+ (`serviceDir`/`downloadWinsw`/windows 서비스 제어/`isPrivileged`/개명), `src/commands/setup-selfhosted.ts`·
+ `src/commands/deprovision-selfhosted.ts`(windows 라우팅), `src/index.ts`(플랫폼별 헤더), `src/lib/which.ts`
+ (이미 `.exe` 처리 — 무변경)
+- `src/lib/snippet.ts`(`caddySnippet` 재사용, **동결**), `infra/index-rewrite.js`(_meta·index rewrite 패리티 원본)
+- Caddy 공식: [running](https://caddyserver.com/docs/running)(Windows: sc.exe / **WinSW**, `caddy-service.xml`
+ 구조, "Windows services cannot be reloaded"), [caddyserver/caddy#4670](https://github.com/caddyserver/caddy/issues/4670)
+ (네이티브 Windows 서비스 스텁 미병합 — WinSW 필요 근거)
+- [WinSW](https://github.com/winsw/winsw) — v2.x 래퍼(net461), 릴리스 자산 `WinSW-x64.exe`
+- CLAUDE.md — "Two hosting modes, one code path" / 모드 파생 규칙 / `infra/` 로컬 전용 프로비저닝
+- Issue [#41](https://github.com/jkas2016/hostdoc/issues/41) — Phase 3 태스크: Windows 서비스 통합
+- 브레인스토밍 확정(2026-07-04): 서비스=WinSW 래퍼 · 권한=`net session` 프로브 + 관리자 셸 명령 출력·중단 ·
+ 범위=x64 우선(arm64 구현 시 결정) · 개명=`privilegedCommands`(플랫폼 중립)
diff --git a/src/commands/config.ts b/src/commands/config.ts
index d98f17c..54b1742 100644
--- a/src/commands/config.ts
+++ b/src/commands/config.ts
@@ -2,6 +2,15 @@ import { resolveConfig, type Overrides } from "../lib/config.js";
export function describeConfig(flags: Overrides): string {
const cfg = resolveConfig(flags);
+ if (cfg.mode === "self-hosted") {
+ return [
+ `mode: ${cfg.mode}`,
+ `serveRoot: ${cfg.serveRoot}`,
+ `host: ${cfg.host ?? "(auto-detect public IP)"}`,
+ `port: ${cfg.port ?? "(default)"}`,
+ `scheme: ${cfg.scheme ?? "http"}`,
+ ].join("\n");
+ }
const lines = [
`mode: ${cfg.mode}`,
`bucket: ${cfg.bucket}`,
diff --git a/src/commands/deprovision-selfhosted.ts b/src/commands/deprovision-selfhosted.ts
new file mode 100644
index 0000000..4269b36
--- /dev/null
+++ b/src/commands/deprovision-selfhosted.ts
@@ -0,0 +1,52 @@
+import { rmSync } from "node:fs";
+import { detectPlatform, type Platform } from "../lib/platform.js";
+import {
+ removeService,
+ privilegedRemoveCommands,
+ caddyfilePath,
+ stagedServicePath,
+ isPrivileged as realIsPrivileged,
+ type Deps,
+} from "../lib/installer.js";
+
+export type SelfHostedDeprovisionOutcome =
+ | { kind: "removed" }
+ | { kind: "needs-privilege"; privilegedCommands: string[] };
+
+/**
+ * Tear down a self-hosted install: stop + unregister the service and remove its
+ * files. Published files (serveRoot) and the Caddy binary are intentionally
+ * kept. The privileged service removal runs only when elevated, otherwise the
+ * exact privileged commands are returned.
+ *
+ * Ordering differs by platform. On Windows, WinSW loads its `caddy-service.xml`
+ * for every command (stop/uninstall), so the service must be torn down BEFORE
+ * the xml is removed — and when not elevated we leave the files untouched so the
+ * stop/uninstall commands we hand the user still find the xml. On Unix the
+ * staged unit is a user-writable copy (the live service file lives at
+ * systemServicePath), so removing the user files up front is safe.
+ */
+export function runDeprovisionSelfHosted(
+ deps: { installer?: Deps; isPrivileged?: () => boolean; plat?: Platform } = {},
+): SelfHostedDeprovisionOutcome {
+ const plat = deps.plat ?? detectPlatform();
+ const privileged = deps.isPrivileged ?? (() => realIsPrivileged(plat, deps.installer));
+
+ if (plat.os === "windows") {
+ if (!privileged()) {
+ return { kind: "needs-privilege", privilegedCommands: privilegedRemoveCommands(plat) };
+ }
+ removeService(deps.installer, plat); // WinSW needs the xml present to run
+ rmSync(caddyfilePath(), { force: true });
+ rmSync(stagedServicePath(plat), { force: true });
+ return { kind: "removed" };
+ }
+
+ rmSync(caddyfilePath(), { force: true });
+ rmSync(stagedServicePath(plat), { force: true });
+ if (!privileged()) {
+ return { kind: "needs-privilege", privilegedCommands: privilegedRemoveCommands(plat) };
+ }
+ removeService(deps.installer, plat);
+ return { kind: "removed" };
+}
diff --git a/src/commands/list.ts b/src/commands/list.ts
index 5829a76..fdd4074 100644
--- a/src/commands/list.ts
+++ b/src/commands/list.ts
@@ -1,5 +1,6 @@
-import { makeS3, listKeys, getJson } from "../lib/aws.js";
import { resolveConfig, type Overrides } from "../lib/config.js";
+import { ensureHost } from "../lib/host.js";
+import { makeBackend } from "../lib/backend.js";
import { buildPublicUrl } from "../lib/url.js";
import { isValidMeta, type Meta } from "../lib/meta.js";
@@ -10,15 +11,15 @@ export interface DocRow extends Meta {
export async function listDocs(
flags: Overrides & { profile?: string },
): Promise {
- const cfg = resolveConfig(flags);
- const s3 = makeS3({ region: cfg.region, profile: flags.profile });
- const keys = await listKeys(s3, cfg.bucket, "_meta/");
+ const cfg = await ensureHost(resolveConfig(flags));
+ const backend = makeBackend(cfg, { profile: flags.profile });
+ const keys = await backend.list("_meta/");
// Fetch sidecars in parallel; a corrupt/missing one is skipped, not fatal.
const settled = await Promise.all(
keys.map(async (key): Promise => {
try {
- const meta = await getJson(s3, cfg.bucket, key);
+ const meta = await backend.getJson(key);
if (!isValidMeta(meta)) {
console.warn(`Skipping ${key}: not a valid document sidecar.`);
return null;
diff --git a/src/commands/open.ts b/src/commands/open.ts
index c820329..71fde65 100644
--- a/src/commands/open.ts
+++ b/src/commands/open.ts
@@ -1,26 +1,30 @@
import { resolveConfig, type Overrides } from "../lib/config.js";
+import { ensureHost } from "../lib/host.js";
import { buildPublicUrl } from "../lib/url.js";
import { openInBrowser } from "../lib/browser.js";
import { isValidPath, isValidCode } from "../lib/code.js";
-export function resolveOpenUrl(
+export async function resolveOpenUrl(
args: { id: string } & Overrides,
-): string {
+): Promise {
if (!isValidPath(args.id) && !isValidCode(args.id)) {
throw new Error(`Invalid id: ${args.id}`);
}
- const cfg = resolveConfig(args);
+ const cfg = await ensureHost(resolveConfig(args));
return buildPublicUrl(cfg, args.id);
}
-export function runOpen(args: { id: string } & Overrides): string {
- const url = resolveOpenUrl(args);
+export async function runOpen(args: { id: string } & Overrides): Promise {
+ const url = await resolveOpenUrl(args);
openInBrowser(url);
return url;
}
/** Open a just-published URL: derive its (possibly nested) path and re-open under `overrides`. */
-export function openPublishedUrl(url: string, overrides: Overrides = {}): string {
+export async function openPublishedUrl(
+ url: string,
+ overrides: Overrides = {},
+): Promise {
const id = new URL(url).pathname.replace(/^\/+|\/+$/g, "");
return runOpen({ id, ...overrides });
}
diff --git a/src/commands/publish.ts b/src/commands/publish.ts
index e8507c3..145e86d 100644
--- a/src/commands/publish.ts
+++ b/src/commands/publish.ts
@@ -1,13 +1,13 @@
import { readFile } from "node:fs/promises";
-import { makeS3, putObject, listKeys, existsPrefix, deleteKeys } from "../lib/aws.js";
import { resolveConfig } from "../lib/config.js";
+import { ensureHost } from "../lib/host.js";
+import { makeBackend, type StorageBackend } from "../lib/backend.js";
import { generateCode, isValidPath } from "../lib/code.js";
import { collectUploads, type Upload } from "../lib/walk.js";
import { buildMeta, metaKey, nestedMetaPrefix, extractTitle } from "../lib/meta.js";
import { buildPublicUrl } from "../lib/url.js";
import { makeCloudFront, invalidate } from "../lib/cloudfront.js";
import { mapLimit } from "../lib/concurrency.js";
-import type { S3Client } from "@aws-sdk/client-s3";
const UPLOAD_CONCURRENCY = 8;
@@ -22,12 +22,16 @@ export interface PublishArgs {
bucket?: string;
domain?: string;
distribution?: string;
+ serveRoot?: string;
+ host?: string;
+ port?: number;
+ scheme?: "http" | "https";
}
-async function uniqueCode(s3: S3Client, bucket: string): Promise {
+async function uniqueCode(backend: StorageBackend): Promise {
for (let i = 0; i < 5; i++) {
const code = generateCode();
- if (!(await existsPrefix(s3, bucket, `${code}/`))) return code;
+ if (!(await backend.exists(`${code}/`))) return code;
}
throw new Error("Could not generate a unique code after 5 attempts.");
}
@@ -39,13 +43,17 @@ async function deriveTitle(uploads: Upload[]): Promise {
}
export async function runPublish(args: PublishArgs): Promise {
- const cfg = resolveConfig({
+ const cfg = await ensureHost(resolveConfig({
bucket: args.bucket,
region: args.region,
domain: args.domain,
distribution: args.distribution,
- });
- const s3 = makeS3({ region: cfg.region, profile: args.profile });
+ serveRoot: args.serveRoot,
+ host: args.host,
+ port: args.port,
+ scheme: args.scheme,
+ }));
+ const backend = makeBackend(cfg, { profile: args.profile });
const uploads = await collectUploads(args.path);
let code: string;
@@ -56,14 +64,14 @@ export async function runPublish(args: PublishArgs): Promise {
);
}
if (!args.dryRun) {
- const exists = await existsPrefix(s3, cfg.bucket, `${args.slug}/`);
+ const exists = await backend.exists(`${args.slug}/`);
if (exists && !args.force) {
throw new Error(`Slug "${args.slug}" already exists. Use --force to overwrite.`);
}
}
code = args.slug;
} else {
- code = args.dryRun ? generateCode() : await uniqueCode(s3, cfg.bucket);
+ code = args.dryRun ? generateCode() : await uniqueCode(backend);
}
if (args.dryRun) {
@@ -72,22 +80,16 @@ export async function runPublish(args: PublishArgs): Promise {
let overwritten = false;
if (args.force) {
- const existing = await listKeys(s3, cfg.bucket, `${code}/`);
+ const existing = await backend.list(`${code}/`);
if (existing.length) {
- const nestedMeta = await listKeys(s3, cfg.bucket, nestedMetaPrefix(code));
- await deleteKeys(s3, cfg.bucket, [...existing, ...nestedMeta]);
+ const nestedMeta = await backend.list(nestedMetaPrefix(code));
+ await backend.delete([...existing, ...nestedMeta]);
overwritten = true;
}
}
await mapLimit(uploads, UPLOAD_CONCURRENCY, async (u) => {
- await putObject(
- s3,
- cfg.bucket,
- `${code}/${u.key}`,
- await readFile(u.absPath),
- u.contentType,
- );
+ await backend.put(`${code}/${u.key}`, await readFile(u.absPath), u.contentType);
});
const title = args.title ?? (await deriveTitle(uploads));
@@ -98,9 +100,7 @@ export async function runPublish(args: PublishArgs): Promise {
uploads,
sourcePath: args.path,
});
- await putObject(
- s3,
- cfg.bucket,
+ await backend.put(
metaKey(code),
Buffer.from(JSON.stringify(meta, null, 2)),
"application/json",
diff --git a/src/commands/rm.ts b/src/commands/rm.ts
index 8e35f0c..66bbb4b 100644
--- a/src/commands/rm.ts
+++ b/src/commands/rm.ts
@@ -1,5 +1,5 @@
-import { makeS3, listKeys, deleteKeys } from "../lib/aws.js";
import { resolveConfig, type Overrides } from "../lib/config.js";
+import { makeBackend } from "../lib/backend.js";
import { metaKey, nestedMetaPrefix } from "../lib/meta.js";
import { makeCloudFront, invalidate } from "../lib/cloudfront.js";
import { isValidPath, isValidCode } from "../lib/code.js";
@@ -18,9 +18,9 @@ export async function runRm(
}
const cfg = resolveConfig(args);
- const s3 = makeS3({ region: cfg.region, profile: args.profile });
+ const backend = makeBackend(cfg, { profile: args.profile });
- const keys = await listKeys(s3, cfg.bucket, `${args.id}/`);
+ const keys = await backend.list(`${args.id}/`);
if (keys.length === 0) {
throw new Error(`Document not found: ${args.id}`);
}
@@ -32,8 +32,8 @@ export async function runRm(
if (!ok) throw new Error("Aborted.");
}
- const nestedMeta = await listKeys(s3, cfg.bucket, nestedMetaPrefix(args.id));
- await deleteKeys(s3, cfg.bucket, [...keys, metaKey(args.id), ...nestedMeta]);
+ const nestedMeta = await backend.list(nestedMetaPrefix(args.id));
+ await backend.delete([...keys, metaKey(args.id), ...nestedMeta]);
if (cfg.mode === "cloudfront" && cfg.distributionId) {
const cf = makeCloudFront({ profile: args.profile });
await invalidate(cf, cfg.distributionId, [`/${args.id}/*`]);
diff --git a/src/commands/setup-selfhosted.ts b/src/commands/setup-selfhosted.ts
new file mode 100644
index 0000000..d6867ed
--- /dev/null
+++ b/src/commands/setup-selfhosted.ts
@@ -0,0 +1,158 @@
+import { mkdirSync, writeFileSync } from "node:fs";
+import { dirname } from "node:path";
+import { saveConfig, type Config } from "../lib/config.js";
+import { caddySnippet, nginxSnippet } from "../lib/snippet.js";
+import { detectPlatform, type Platform } from "../lib/platform.js";
+import {
+ downloadCaddy,
+ downloadWinsw,
+ writeServiceFile,
+ installService,
+ privilegedInstallCommands,
+ caddyfilePath,
+ isPrivileged as realIsPrivileged,
+ type Deps,
+} from "../lib/installer.js";
+
+const SECURITY_WARNING = [
+ "Security: hostdoc only serves the serve directory; listing is off and /_* is blocked.",
+ "Network exposure (port-forwarding, firewall, router) is your responsibility — expose only what you intend.",
+].join("\n");
+
+export type Server = "caddy" | "nginx";
+
+/** Default Caddy; nginx only when explicitly requested or detected. --caddy wins. */
+export function chooseServer(opts: {
+ nginx?: boolean;
+ caddy?: boolean;
+ nginxDetected: boolean;
+}): Server {
+ if (opts.caddy) return "caddy";
+ if (opts.nginx) return "nginx";
+ return opts.nginxDetected ? "nginx" : "caddy";
+}
+
+export interface SelfHostedSetupArgs {
+ serveRoot: string;
+ host?: string;
+ port?: number;
+ scheme?: "http" | "https";
+ nginx?: boolean;
+ caddy?: boolean;
+ nginxDetected: boolean;
+}
+
+export function runSetupSelfHosted(args: SelfHostedSetupArgs): {
+ cfg: Config;
+ server: Server;
+ snippet: string;
+ warning: string;
+} {
+ mkdirSync(args.serveRoot, { recursive: true });
+ const cfg: Config = {
+ mode: "self-hosted",
+ serveRoot: args.serveRoot,
+ host: args.host,
+ port: args.port,
+ scheme: args.scheme,
+ };
+ saveConfig(cfg);
+
+ const server = chooseServer(args);
+ const snippetOpts = {
+ serveRoot: args.serveRoot,
+ host: args.host,
+ port: args.port,
+ scheme: args.scheme,
+ };
+ const snippet = server === "nginx" ? nginxSnippet(snippetOpts) : caddySnippet(snippetOpts);
+
+ return { cfg, server, snippet, warning: SECURITY_WARNING };
+}
+
+export type InstallOutcome =
+ | { kind: "snippet"; cfg: Config; server: Server; snippet: string; warning: string }
+ | {
+ kind: "installed";
+ cfg: Config;
+ caddyBin: string;
+ reused: boolean;
+ caddyfilePath: string;
+ warning: string;
+ }
+ | {
+ kind: "needs-privilege";
+ cfg: Config;
+ caddyBin: string;
+ reused: boolean;
+ caddyfilePath: string;
+ privilegedCommands: string[];
+ warning: string;
+ };
+
+/**
+ * Auto-install path for `hostdoc setup --install`. Caddy only — an nginx choice
+ * falls back to the manual snippet (nginx is not a single static binary).
+ * Non-privileged prep (config, Caddyfile, binary, staged service file) runs as
+ * the user; the privileged service step runs only when root, otherwise the
+ * exact sudo commands are returned for the user to run.
+ */
+export async function runInstallSelfHosted(
+ args: SelfHostedSetupArgs,
+ deps: { installer?: Deps; isPrivileged?: () => boolean; plat?: Platform } = {},
+): Promise {
+ const plat = deps.plat ?? detectPlatform(); // windows/darwin/linux (throws only on NAS)
+ const privileged = deps.isPrivileged ?? (() => realIsPrivileged(plat, deps.installer));
+
+ mkdirSync(args.serveRoot, { recursive: true });
+ const cfg: Config = {
+ mode: "self-hosted",
+ serveRoot: args.serveRoot,
+ host: args.host,
+ port: args.port,
+ scheme: args.scheme,
+ };
+ saveConfig(cfg);
+
+ const server = chooseServer(args);
+ if (server === "nginx") {
+ const snippet = nginxSnippet({
+ serveRoot: args.serveRoot,
+ host: args.host,
+ port: args.port,
+ });
+ return { kind: "snippet", cfg, server, snippet, warning: SECURITY_WARNING };
+ }
+
+ // Write the Caddyfile (reuse the Phase 1 generator) to a user-writable path.
+ const cfPath = caddyfilePath();
+ mkdirSync(dirname(cfPath), { recursive: true });
+ writeFileSync(
+ cfPath,
+ caddySnippet({
+ serveRoot: args.serveRoot,
+ host: args.host,
+ port: args.port,
+ scheme: args.scheme,
+ }) + "\n",
+ );
+
+ const { path: caddyBin, reused } = await downloadCaddy(deps.installer, plat);
+ if (plat.os === "windows") await downloadWinsw(deps.installer, plat);
+ writeServiceFile(caddyBin, deps.installer, plat);
+
+ if (!privileged()) {
+ return {
+ kind: "needs-privilege",
+ cfg,
+ caddyBin,
+ reused,
+ caddyfilePath: cfPath,
+ privilegedCommands: privilegedInstallCommands(plat),
+ warning: SECURITY_WARNING,
+ };
+ }
+
+ installService(deps.installer, plat);
+ return { kind: "installed", cfg, caddyBin, reused, caddyfilePath: cfPath, warning: SECURITY_WARNING };
+}
diff --git a/src/index.ts b/src/index.ts
index bd66c75..f90404e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,12 +5,15 @@ import { runSetup } from "./commands/setup.js";
import { runInit } from "./commands/init.js";
import { runProvision } from "./commands/provision.js";
import { runDeprovision } from "./commands/deprovision.js";
-import { infraDir } from "./lib/config.js";
+import { runDeprovisionSelfHosted } from "./commands/deprovision-selfhosted.js";
+import { infraDir, resolveConfig } from "./lib/config.js";
import { runPublish } from "./commands/publish.js";
import { listDocs, formatRows } from "./commands/list.js";
import { runRm } from "./commands/rm.js";
import { runOpen, openPublishedUrl } from "./commands/open.js";
import { describeConfig } from "./commands/config.js";
+import { runSetupSelfHosted, runInstallSelfHosted } from "./commands/setup-selfhosted.js";
+import { onPath } from "./lib/which.js";
// package.json lives outside rootDir (src), so read it at runtime instead of
// importing it. From dist/index.js or src/index.ts, `../package.json` is the
@@ -37,7 +40,11 @@ function withCommon(cmd: Command): Command {
.option("--region ", "AWS region")
.option("--bucket ", "override bucket")
.option("--domain ", "override domain (cloudfront mode)")
- .option("--distribution ", "override distribution id (cloudfront mode)");
+ .option("--distribution ", "override distribution id (cloudfront mode)")
+ .option("--serve-root ", "local serve directory (self-hosted mode)")
+ .option("--host ", "public host: DDNS/domain/static IP (self-hosted mode)")
+ .option("--port ", "non-standard port (self-hosted mode)")
+ .option("--scheme ", "http|https (self-hosted mode)");
}
function overrides(o: OptionValues) {
@@ -47,17 +54,88 @@ function overrides(o: OptionValues) {
bucket: o.bucket as string | undefined,
domain: o.domain as string | undefined,
distribution: o.distribution as string | undefined,
+ serveRoot: o.serveRoot as string | undefined,
+ host: o.host as string | undefined,
+ port: o.port ? Number(o.port) : undefined,
+ scheme: o.scheme as "http" | "https" | undefined,
};
}
program
.command("setup")
- .description("Create a public S3 static-website bucket and save config")
- .requiredOption("--bucket ", "bucket name to create")
- .requiredOption("--region ", "AWS region for the bucket")
+ .description("Create hosting infra and save config: s3-website (--bucket/--region) or self-hosted (--serve-root)")
+ .option("--bucket ", "bucket name to create (s3-website)")
+ .option("--region ", "AWS region for the bucket (s3-website)")
.option("--profile ", "AWS profile")
+ .option("--serve-root ", "local serve directory (self-hosted)")
+ .option("--host ", "public host: DDNS/domain/static IP (self-hosted)")
+ .option("--port ", "non-standard port (self-hosted)")
+ .option("--scheme ", "http|https (self-hosted)")
+ .option("--nginx", "emit an nginx config snippet (self-hosted)")
+ .option("--caddy", "emit a Caddy config snippet even if nginx is detected (self-hosted)")
+ .option("--install", "download + install Caddy and register a boot service (self-hosted)")
.action(async (opts) => {
try {
+ if (opts.serveRoot && opts.install) {
+ const outcome = await runInstallSelfHosted({
+ serveRoot: opts.serveRoot,
+ host: opts.host,
+ port: opts.port ? Number(opts.port) : undefined,
+ scheme: opts.scheme,
+ nginx: opts.nginx,
+ caddy: opts.caddy,
+ nginxDetected: onPath("nginx"),
+ });
+ if (outcome.kind === "snippet") {
+ console.log(`Configured self-hosted mode; serve root: ${outcome.cfg.serveRoot}`);
+ console.log(
+ "\nnginx is not auto-installed (not a single static binary). Add this nginx config, then reload nginx:\n",
+ );
+ console.log(outcome.snippet);
+ console.log(`\n${outcome.warning}`);
+ return;
+ }
+ console.log(
+ `Configured self-hosted mode; serve root: ${outcome.cfg.serveRoot}`,
+ );
+ console.log(
+ `Caddy: ${outcome.caddyBin}${outcome.reused ? " (reused)" : " (downloaded)"}`,
+ );
+ console.log(`Caddyfile: ${outcome.caddyfilePath}`);
+ if (outcome.kind === "needs-privilege") {
+ console.log(
+ process.platform === "win32"
+ ? "\nOpen an elevated (Administrator) PowerShell and run:\n"
+ : "\nRun these as root to register the boot service:\n",
+ );
+ for (const cmd of outcome.privilegedCommands) console.log(` ${cmd}`);
+ } else {
+ console.log("\nCaddy service installed and started (survives reboot).");
+ }
+ console.log(`\n${outcome.warning}`);
+ return;
+ }
+ if (opts.serveRoot) {
+ const { cfg, server, snippet, warning } = runSetupSelfHosted({
+ serveRoot: opts.serveRoot,
+ host: opts.host,
+ port: opts.port ? Number(opts.port) : undefined,
+ scheme: opts.scheme,
+ nginx: opts.nginx,
+ caddy: opts.caddy,
+ nginxDetected: onPath("nginx"),
+ });
+ console.log(`Configured self-hosted mode; serve root: ${cfg.serveRoot}`);
+ console.log(`\nAdd this ${server} config, then reload your web server:\n`);
+ console.log(snippet);
+ console.log(`\n${warning}`);
+ return;
+ }
+ if (!opts.bucket || !opts.region) {
+ throw new Error(
+ "setup requires --bucket and --region (s3-website), or --serve-root (self-hosted).",
+ );
+ }
const cfg = await runSetup({
bucket: opts.bucket,
region: opts.region,
@@ -126,6 +204,28 @@ program
.option("--approve", "auto-approve terraform destroy (non-interactive; for agents/automation)")
.action((opts) => {
try {
+ let selfHosted = false;
+ try {
+ selfHosted = resolveConfig({}).mode === "self-hosted";
+ } catch {
+ // No resolvable config → cloud/terraform path (tfvars flags supply it).
+ }
+ if (selfHosted) {
+ const outcome = runDeprovisionSelfHosted();
+ if (outcome.kind === "needs-privilege") {
+ console.log(
+ process.platform === "win32"
+ ? "Open an elevated (Administrator) PowerShell and run:\n"
+ : "Run these as root to remove the Caddy service:\n",
+ );
+ for (const cmd of outcome.privilegedCommands) console.log(` ${cmd}`);
+ } else {
+ console.log(
+ "Self-hosted Caddy service removed. Published files and the Caddy binary were kept.",
+ );
+ }
+ return;
+ }
runDeprovision({
dir: opts.dir,
approve: opts.approve,
@@ -162,7 +262,7 @@ withCommon(program.command("publish "))
...overrides(opts),
});
console.log(url);
- if (opts.open && !opts.dryRun) openPublishedUrl(url, overrides(opts));
+ if (opts.open && !opts.dryRun) await openPublishedUrl(url, overrides(opts));
} catch (err) {
fail(err);
}
@@ -193,9 +293,9 @@ withCommon(program.command("rm "))
withCommon(program.command("open "))
.description("Open a document's URL in your browser (does not verify the document exists)")
- .action((id, opts) => {
+ .action(async (id, opts) => {
try {
- console.log(runOpen({ id, ...overrides(opts) }));
+ console.log(await runOpen({ id, ...overrides(opts) }));
} catch (err) {
fail(err);
}
diff --git a/src/lib/backend.ts b/src/lib/backend.ts
new file mode 100644
index 0000000..90d60a4
--- /dev/null
+++ b/src/lib/backend.ts
@@ -0,0 +1,122 @@
+import type { S3Client } from "@aws-sdk/client-s3";
+import {
+ makeS3,
+ putObject,
+ listKeys,
+ existsPrefix,
+ deleteKeys,
+ getJson,
+} from "./aws.js";
+import type { Config } from "./config.js";
+import { mkdir, writeFile, readFile, rm, rmdir, readdir } from "node:fs/promises";
+import { join, dirname, sep } from "node:path";
+
+/** Minimal storage abstraction over the `/…` + `_meta/…` keyspace. */
+export interface StorageBackend {
+ put(key: string, body: Buffer, contentType: string): Promise;
+ list(prefix: string): Promise;
+ exists(prefix: string): Promise;
+ delete(keys: string[]): Promise;
+ getJson(key: string): Promise;
+}
+
+/** Wraps the existing S3 helpers, binding (client, bucket). No behavior change. */
+export class S3Backend implements StorageBackend {
+ constructor(
+ private readonly s3: S3Client,
+ private readonly bucket: string,
+ ) {}
+ put(key: string, body: Buffer, contentType: string): Promise {
+ return putObject(this.s3, this.bucket, key, body, contentType);
+ }
+ list(prefix: string): Promise {
+ return listKeys(this.s3, this.bucket, prefix);
+ }
+ exists(prefix: string): Promise {
+ return existsPrefix(this.s3, this.bucket, prefix);
+ }
+ delete(keys: string[]): Promise {
+ return deleteKeys(this.s3, this.bucket, keys);
+ }
+ getJson(key: string): Promise {
+ return getJson(this.s3, this.bucket, key);
+ }
+}
+
+/** Pick a backend from the resolved config. */
+export function makeBackend(cfg: Config, opts: { profile?: string }): StorageBackend {
+ if (cfg.mode === "self-hosted") {
+ if (!cfg.serveRoot) throw new Error("self-hosted config is missing serveRoot.");
+ return new LocalFsBackend(cfg.serveRoot);
+ }
+ if (!cfg.bucket) throw new Error("cloud config is missing bucket.");
+ return new S3Backend(makeS3({ region: cfg.region, profile: opts.profile }), cfg.bucket);
+}
+
+/** Writes into a local directory that an external web server serves. */
+export class LocalFsBackend implements StorageBackend {
+ constructor(private readonly serveRoot: string) {}
+
+ /** posix key -> OS absolute path under serveRoot. */
+ private full(key: string): string {
+ return join(this.serveRoot, ...key.split("/"));
+ }
+
+ async put(key: string, body: Buffer, _contentType: string): Promise {
+ // contentType is intentionally ignored: the web server infers it from the
+ // file extension. We only need the bytes on disk at the right path.
+ const p = this.full(key);
+ await mkdir(dirname(p), { recursive: true });
+ await writeFile(p, body);
+ }
+
+ async list(prefix: string): Promise {
+ const all = await this.walk(this.serveRoot, "");
+ return all.filter((k) => k.startsWith(prefix)).sort();
+ }
+
+ async exists(prefix: string): Promise {
+ return (await this.list(prefix)).length > 0;
+ }
+
+ async delete(keys: string[]): Promise {
+ for (const key of keys) {
+ await rm(this.full(key), { force: true });
+ await this.pruneParents(key);
+ }
+ }
+
+ async getJson(key: string): Promise {
+ return JSON.parse(await readFile(this.full(key), "utf8")) as T;
+ }
+
+ private async walk(dir: string, rel: string): Promise {
+ let entries;
+ try {
+ entries = await readdir(dir, { withFileTypes: true });
+ } catch {
+ return []; // serveRoot may not exist yet
+ }
+ const out: string[] = [];
+ for (const e of entries) {
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
+ if (e.isDirectory()) out.push(...(await this.walk(join(dir, e.name), childRel)));
+ else out.push(childRel);
+ }
+ return out;
+ }
+
+ /** Remove now-empty parent dirs of a deleted key, up to (not incl.) serveRoot. */
+ private async pruneParents(key: string): Promise {
+ let dir = dirname(this.full(key));
+ while (dir.startsWith(this.serveRoot + sep)) {
+ try {
+ if ((await readdir(dir)).length > 0) break;
+ await rmdir(dir);
+ } catch {
+ break;
+ }
+ dir = dirname(dir);
+ }
+ }
+}
diff --git a/src/lib/config.ts b/src/lib/config.ts
index e406b26..7321390 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -3,15 +3,21 @@ import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { websiteEndpoint } from "./url.js";
-export type Mode = "s3-website" | "cloudfront";
+export type Mode = "s3-website" | "cloudfront" | "self-hosted";
export interface Config {
mode: Mode;
- bucket: string;
- region: string;
+ // cloud modes (s3-website / cloudfront)
+ bucket?: string;
+ region?: string;
websiteEndpoint?: string; // s3-website
distributionId?: string; // cloudfront
domain?: string; // cloudfront
+ // self-hosted
+ serveRoot?: string;
+ host?: string; // DDNS/domain/static IP; if unset, public IP is auto-detected
+ port?: number;
+ scheme?: "http" | "https";
}
/** Per-field overrides accepted from CLI flags. */
@@ -20,6 +26,10 @@ export interface Overrides {
region?: string;
domain?: string;
distribution?: string;
+ serveRoot?: string;
+ host?: string;
+ port?: number;
+ scheme?: "http" | "https";
}
export function configPath(): string {
@@ -73,6 +83,29 @@ export function resolveConfig(flags: Overrides): Config {
process.env.HOSTDOC_DISTRIBUTION ??
file?.distributionId;
+ const serveRoot =
+ flags.serveRoot ?? process.env.HOSTDOC_SERVE_ROOT ?? file?.serveRoot;
+ const host = flags.host ?? process.env.HOSTDOC_HOST ?? file?.host;
+ const port =
+ flags.port ??
+ (process.env.HOSTDOC_PORT ? Number(process.env.HOSTDOC_PORT) : undefined) ??
+ file?.port;
+ const scheme =
+ flags.scheme ??
+ (process.env.HOSTDOC_SCHEME as "http" | "https" | undefined) ??
+ file?.scheme;
+
+ // serveRoot is the self-hosted discriminator. Mixing it with AWS fields is a
+ // mistake we surface rather than silently pick a mode.
+ if (serveRoot) {
+ if (bucket || domain || distributionId) {
+ throw new Error(
+ "Ambiguous config: serveRoot (self-hosted) is set alongside bucket/domain/distribution (AWS). Pick one hosting mode.",
+ );
+ }
+ return { mode: "self-hosted", serveRoot, host, port, scheme };
+ }
+
// Exactly one of domain/distributionId set = partial cloudfront intent.
// Without this guard it would fall through to the s3-website branch below,
// silently dropping the domain/distribution the user asked for.
diff --git a/src/lib/host.ts b/src/lib/host.ts
new file mode 100644
index 0000000..f39563a
--- /dev/null
+++ b/src/lib/host.ts
@@ -0,0 +1,38 @@
+import type { Config } from "./config.js";
+
+// External echo services that return the caller's public IP as plain text.
+const ECHO_SERVICES = ["https://api.ipify.org", "https://icanhazip.com"];
+
+/** Detect this machine's public IP via an external echo service. */
+export async function detectPublicIp(): Promise {
+ for (const url of ECHO_SERVICES) {
+ try {
+ const res = await fetch(url);
+ if (!res.ok) continue;
+ const ip = (await res.text()).trim();
+ if (ip) return ip;
+ } catch {
+ // try the next service
+ }
+ }
+ throw new Error(
+ "Could not detect a public IP from the echo services. Set --host / HOSTDOC_HOST (DDNS, domain, or static IP) explicitly.",
+ );
+}
+
+/**
+ * Resolve the effective host for self-hosted mode exactly once, before building
+ * URLs. If a host is configured it is used as-is; otherwise the public IP is
+ * detected and the user is warned that the link may break if the IP changes.
+ * No-op for cloud modes.
+ */
+export async function ensureHost(cfg: Config): Promise {
+ if (cfg.mode !== "self-hosted" || cfg.host) return cfg;
+ const ip = await detectPublicIp();
+ console.warn(
+ `Warning: no host configured; using detected public IP ${ip}. ` +
+ "If your IP changes (dynamic ISP), published links will break. " +
+ "Set --host (DDNS/domain/static IP) for stable links.",
+ );
+ return { ...cfg, host: ip, scheme: cfg.scheme ?? "http" };
+}
diff --git a/src/lib/installer.ts b/src/lib/installer.ts
new file mode 100644
index 0000000..de9c98c
--- /dev/null
+++ b/src/lib/installer.ts
@@ -0,0 +1,240 @@
+import { execFileSync } from "node:child_process";
+import {
+ writeFileSync,
+ copyFileSync,
+ chmodSync,
+ mkdirSync,
+ existsSync,
+ rmSync,
+} from "node:fs";
+import { homedir } from "node:os";
+import { join, dirname } from "node:path";
+import { configPath } from "./config.js";
+import { whichPath } from "./which.js";
+import { detectPlatform, caddyDownloadUrl, winswDownloadUrl, type Platform } from "./platform.js";
+import { launchdPlist, systemdUnit, winswXml, LAUNCHD_LABEL, SYSTEMD_UNIT } from "./service.js";
+
+/** Injection seam so tests never execute commands, fetch, or touch system paths. */
+export interface Deps {
+ run?: (cmd: string, args: string[]) => void;
+ fetchFn?: typeof fetch;
+ writeFile?: (p: string, data: Buffer | string) => void;
+ copy?: (src: string, dst: string) => void;
+ chmod?: (p: string, mode: number) => void;
+ mkdirp?: (p: string) => void;
+ exists?: (p: string) => boolean;
+ remove?: (p: string) => void;
+ whichCaddy?: () => string | null;
+ isAdmin?: () => boolean;
+}
+
+function withDefaults(d: Deps): Required {
+ return {
+ run: (cmd, args) => execFileSync(cmd, args, { stdio: "inherit" }),
+ fetchFn: (...a) => fetch(...a),
+ writeFile: (p, data) => writeFileSync(p, data),
+ copy: (src, dst) => copyFileSync(src, dst),
+ chmod: (p, mode) => chmodSync(p, mode),
+ mkdirp: (p) => mkdirSync(p, { recursive: true }),
+ exists: (p) => existsSync(p),
+ remove: (p) => rmSync(p, { force: true }),
+ whichCaddy: () => whichPath("caddy"),
+ isAdmin: () => probeWindowsAdmin(),
+ ...d,
+ };
+}
+
+export function isRoot(): boolean {
+ return typeof process.getuid === "function" && process.getuid() === 0;
+}
+
+/** Windows admin probe: `net session` exits 0 only for an elevated session. */
+function probeWindowsAdmin(): boolean {
+ try {
+ execFileSync("net", ["session"], { stdio: "ignore" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** True when the current process can register a system service on this platform. */
+export function isPrivileged(plat: Platform = detectPlatform(), d: Deps = {}): boolean {
+ if (plat.os === "windows") return withDefaults(d).isAdmin();
+ return isRoot();
+}
+
+/** User-writable Caddyfile path (served config; read by the root service). */
+export function caddyfilePath(): string {
+ return join(dirname(configPath()), "Caddyfile");
+}
+
+/** Windows service directory: caddy.exe + caddy-service.exe + caddy-service.xml co-located. */
+export function serviceDir(): string {
+ return join(dirname(configPath()), "service");
+}
+
+/** WinSW wrapper destination (renamed to caddy-service.exe per Caddy docs). */
+export function winswBinDest(): string {
+ return join(serviceDir(), "caddy-service.exe");
+}
+
+/** User-writable binary destination when Caddy is not already on PATH. */
+export function caddyBinDest(plat: Platform = detectPlatform()): string {
+ if (plat.os === "windows") return join(serviceDir(), "caddy.exe");
+ return join(homedir(), ".local", "bin", "caddy");
+}
+
+/** User-writable staging path for the service file (copied to the system path). */
+export function stagedServicePath(plat: Platform = detectPlatform()): string {
+ if (plat.os === "windows") return join(serviceDir(), "caddy-service.xml");
+ const base = dirname(configPath());
+ return plat.os === "darwin"
+ ? join(base, `${LAUNCHD_LABEL}.plist`)
+ : join(base, `${SYSTEMD_UNIT}.service`);
+}
+
+/** Privileged system location the service file must live in to survive reboot. */
+export function systemServicePath(plat: Platform = detectPlatform()): string {
+ return plat.os === "darwin"
+ ? `/Library/LaunchDaemons/${LAUNCHD_LABEL}.plist`
+ : `/etc/systemd/system/${SYSTEMD_UNIT}.service`;
+}
+
+/** Reuse caddy on PATH; else download the static binary to a user path. */
+export async function downloadCaddy(
+ d: Deps = {},
+ plat: Platform = detectPlatform(),
+): Promise<{ path: string; reused: boolean }> {
+ const deps = withDefaults(d);
+ const existing = deps.whichCaddy();
+ if (existing) return { path: existing, reused: true };
+ const dest = caddyBinDest(plat);
+ if (deps.exists(dest)) return { path: dest, reused: true };
+
+ const url = caddyDownloadUrl(plat);
+ const res = await deps.fetchFn(url);
+ if (!res.ok) throw new Error(`Caddy download failed: HTTP ${res.status}.`);
+ const buf = Buffer.from(await res.arrayBuffer());
+ deps.mkdirp(dirname(dest));
+ deps.writeFile(dest, buf);
+ deps.chmod(dest, 0o755);
+ return { path: dest, reused: false };
+}
+
+/** Download the WinSW wrapper to the service dir (skipped if already present). */
+export async function downloadWinsw(
+ d: Deps = {},
+ plat: Platform = detectPlatform(),
+): Promise<{ path: string; reused: boolean }> {
+ const deps = withDefaults(d);
+ const dest = winswBinDest();
+ if (deps.exists(dest)) return { path: dest, reused: true };
+ const res = await deps.fetchFn(winswDownloadUrl(plat));
+ if (!res.ok) throw new Error(`WinSW download failed: HTTP ${res.status}.`);
+ const buf = Buffer.from(await res.arrayBuffer());
+ deps.mkdirp(dirname(dest));
+ deps.writeFile(dest, buf);
+ return { path: dest, reused: false };
+}
+
+/** Write the service file to the user-writable staging path (no privilege). */
+export function writeServiceFile(
+ caddyBin: string,
+ d: Deps = {},
+ plat: Platform = detectPlatform(),
+): void {
+ const deps = withDefaults(d);
+ if (plat.os === "windows") {
+ const staged = stagedServicePath(plat);
+ deps.mkdirp(dirname(staged));
+ deps.writeFile(staged, winswXml({ caddyBin, caddyfilePath: caddyfilePath() }));
+ return;
+ }
+ const content =
+ plat.os === "darwin"
+ ? launchdPlist({ caddyBin, caddyfilePath: caddyfilePath() })
+ : systemdUnit({ caddyBin, caddyfilePath: caddyfilePath() });
+ const staged = stagedServicePath(plat);
+ deps.mkdirp(dirname(staged));
+ deps.writeFile(staged, content);
+}
+
+/** Copy the staged service file into place and enable it (requires root). */
+export function installService(d: Deps = {}, plat: Platform = detectPlatform()): void {
+ const deps = withDefaults(d);
+ if (plat.os === "windows") {
+ const winsw = winswBinDest();
+ deps.run(winsw, ["install"]);
+ deps.run(winsw, ["start"]);
+ return;
+ }
+ deps.copy(stagedServicePath(plat), systemServicePath(plat));
+ if (plat.os === "darwin") {
+ deps.run("launchctl", ["bootstrap", "system", systemServicePath(plat)]);
+ } else {
+ deps.run("systemctl", ["daemon-reload"]);
+ deps.run("systemctl", ["enable", "--now", SYSTEMD_UNIT]);
+ }
+}
+
+/** Disable/stop the service and remove the system service file (requires root). */
+export function removeService(d: Deps = {}, plat: Platform = detectPlatform()): void {
+ const deps = withDefaults(d);
+ if (plat.os === "windows") {
+ const winsw = winswBinDest();
+ if (deps.exists(winsw)) {
+ deps.run(winsw, ["stop"]);
+ deps.run(winsw, ["uninstall"]);
+ }
+ return;
+ }
+ const sys = systemServicePath(plat);
+ if (plat.os === "darwin") {
+ if (deps.exists(sys)) {
+ deps.run("launchctl", ["bootout", `system/${LAUNCHD_LABEL}`]);
+ deps.remove(sys);
+ }
+ } else {
+ if (deps.exists(sys)) {
+ deps.run("systemctl", ["disable", "--now", SYSTEMD_UNIT]);
+ deps.remove(sys);
+ deps.run("systemctl", ["daemon-reload"]);
+ }
+ }
+}
+
+/** Exact privileged commands to finish install when hostdoc is not run as root. */
+export function privilegedInstallCommands(plat: Platform = detectPlatform()): string[] {
+ if (plat.os === "windows") {
+ const winsw = winswBinDest();
+ return [`"${winsw}" install`, `"${winsw}" start`];
+ }
+ const staged = stagedServicePath(plat);
+ const sys = systemServicePath(plat);
+ if (plat.os === "darwin") {
+ return [`sudo cp ${staged} ${sys}`, `sudo launchctl bootstrap system ${sys}`];
+ }
+ return [
+ `sudo cp ${staged} ${sys}`,
+ "sudo systemctl daemon-reload",
+ `sudo systemctl enable --now ${SYSTEMD_UNIT}`,
+ ];
+}
+
+/** Exact privileged commands to remove the system service when not run as root. */
+export function privilegedRemoveCommands(plat: Platform = detectPlatform()): string[] {
+ if (plat.os === "windows") {
+ const winsw = winswBinDest();
+ return [`"${winsw}" stop`, `"${winsw}" uninstall`];
+ }
+ const sys = systemServicePath(plat);
+ if (plat.os === "darwin") {
+ return [`sudo launchctl bootout system/${LAUNCHD_LABEL}`, `sudo rm -f ${sys}`];
+ }
+ return [
+ `sudo systemctl disable --now ${SYSTEMD_UNIT}`,
+ `sudo rm -f ${sys}`,
+ "sudo systemctl daemon-reload",
+ ];
+}
diff --git a/src/lib/platform.ts b/src/lib/platform.ts
new file mode 100644
index 0000000..2d94ad4
--- /dev/null
+++ b/src/lib/platform.ts
@@ -0,0 +1,49 @@
+export interface Platform {
+ os: "darwin" | "linux" | "windows";
+ arch: "amd64" | "arm64";
+}
+
+/**
+ * Map Node's process.platform/arch to Caddy's download naming. macOS, Linux, and
+ * Windows are supported (Windows uses WinSW to register the service); other
+ * platforms (NAS packaging) are deferred with a clear error rather than silently
+ * picking the wrong binary.
+ */
+export function detectPlatform(
+ nodePlatform: string = process.platform,
+ nodeArch: string = process.arch,
+): Platform {
+ let os: Platform["os"];
+ if (nodePlatform === "darwin") os = "darwin";
+ else if (nodePlatform === "linux") os = "linux";
+ else if (nodePlatform === "win32") os = "windows";
+ else
+ throw new Error(
+ `Automatic install is not supported on "${nodePlatform}" yet (NAS packaging is Phase 3). ` +
+ "Use `hostdoc setup --serve-root ` to emit a manual web-server snippet instead.",
+ );
+
+ let arch: Platform["arch"];
+ if (nodeArch === "x64") arch = "amd64";
+ else if (nodeArch === "arm64") arch = "arm64";
+ else throw new Error(`Unsupported CPU arch "${nodeArch}" (need x64 or arm64).`);
+
+ return { os, arch };
+}
+
+/** Caddy's official static-binary download service (returns the binary). */
+export function caddyDownloadUrl(plat: Platform): string {
+ return `https://caddyserver.com/api/download?os=${plat.os}&arch=${plat.arch}`;
+}
+
+/** Pinned WinSW v2.x — .NET Framework 4.6.1 build, bundled with Windows 10/11. */
+export const WINSW_VERSION = "v2.12.0";
+
+/**
+ * WinSW static service wrapper (a GitHub release asset, not the Caddy API).
+ * v2.x publishes only x64/x86; Windows-on-ARM runs the x64 build via emulation,
+ * so we always target the x64 asset regardless of arch.
+ */
+export function winswDownloadUrl(_plat: Platform): string {
+ return `https://github.com/winsw/winsw/releases/download/${WINSW_VERSION}/WinSW-x64.exe`;
+}
diff --git a/src/lib/service.ts b/src/lib/service.ts
new file mode 100644
index 0000000..75a9717
--- /dev/null
+++ b/src/lib/service.ts
@@ -0,0 +1,92 @@
+export const LAUNCHD_LABEL = "com.hostdoc.caddy";
+export const SYSTEMD_UNIT = "hostdoc-caddy";
+export const WINSW_ID = "hostdoc-caddy";
+
+export interface ServiceOpts {
+ caddyBin: string;
+ caddyfilePath: string;
+}
+
+/**
+ * systemd unit. Directives verified against Caddy's official
+ * caddyserver/dist/init/caddy.service; parameterized to our downloaded binary
+ * and hostdoc Caddyfile. User/Group are omitted (runs as root) to avoid
+ * cross-distro `useradd`; CAP_NET_BIND_SERVICE is kept for defense.
+ */
+export function systemdUnit(opts: ServiceOpts): string {
+ return (
+ [
+ "[Unit]",
+ "Description=hostdoc Caddy web server",
+ "Documentation=https://caddyserver.com/docs/",
+ "After=network.target network-online.target",
+ "Requires=network-online.target",
+ "",
+ "[Service]",
+ "Type=simple",
+ `ExecStart=${opts.caddyBin} run --config ${opts.caddyfilePath}`,
+ `ExecReload=${opts.caddyBin} reload --config ${opts.caddyfilePath} --force`,
+ "Restart=on-failure",
+ "TimeoutStopSec=5s",
+ "AmbientCapabilities=CAP_NET_BIND_SERVICE",
+ "",
+ "[Install]",
+ "WantedBy=multi-user.target",
+ ].join("\n") + "\n"
+ );
+}
+
+/** macOS LaunchDaemon plist: run caddy at load, keep alive across crash/reboot. */
+export function launchdPlist(opts: ServiceOpts): string {
+ return (
+ [
+ '',
+ '',
+ '',
+ "",
+ " Label",
+ ` ${LAUNCHD_LABEL}`,
+ " ProgramArguments",
+ " ",
+ ` ${opts.caddyBin}`,
+ " run",
+ " --config",
+ ` ${opts.caddyfilePath}`,
+ " ",
+ " RunAtLoad",
+ " ",
+ " KeepAlive",
+ " ",
+ " StandardOutPath",
+ " /var/log/hostdoc-caddy.log",
+ " StandardErrorPath",
+ " /var/log/hostdoc-caddy.log",
+ "",
+ "",
+ ].join("\n") + "\n"
+ );
+}
+
+/**
+ * WinSW service definition (caddy-service.xml). Structure per Caddy's official
+ * Windows service docs (caddyserver.com/docs/running). Absolute so
+ * a PATH-reused caddy also works; --config points at the hostdoc Caddyfile
+ * (parity with the launchd/systemd generators).
+ */
+export function winswXml(opts: ServiceOpts): string {
+ return (
+ [
+ '',
+ "",
+ ` ${WINSW_ID}`,
+ " hostdoc Caddy web server",
+ " hostdoc Caddy web server (https://caddyserver.com/)",
+ ` ${opts.caddyBin}`,
+ ` run --config ${opts.caddyfilePath}`,
+ ' ',
+ " yyyy-MM-dd",
+ " ",
+ "",
+ ].join("\n") + "\n"
+ );
+}
diff --git a/src/lib/snippet.ts b/src/lib/snippet.ts
new file mode 100644
index 0000000..428286a
--- /dev/null
+++ b/src/lib/snippet.ts
@@ -0,0 +1,52 @@
+export interface SnippetOpts {
+ serveRoot: string;
+ host?: string;
+ port?: number;
+ scheme?: "http" | "https";
+}
+
+/** Caddyfile snippet: root, /_* block, index rewrite, static file server. */
+export function caddySnippet(opts: SnippetOpts): string {
+ const hostPort = opts.host
+ ? opts.port
+ ? `${opts.host}:${opts.port}`
+ : opts.host
+ : opts.port
+ ? `:${opts.port}`
+ : ":80";
+ // Prefix an explicit scheme so Caddy serves the exact protocol hostdoc's
+ // returned link uses. A bare hostname/IP triggers Caddy's Automatic HTTPS
+ // (per its docs), which would mismatch the default http:// link; an explicit
+ // http:// disables auto-HTTPS, and https:// opts into it deliberately.
+ const site = `${opts.scheme ?? "http"}://${hostPort}`;
+ return [
+ `${site} {`,
+ `\troot * ${opts.serveRoot}`,
+ `\t# Block hostdoc's private sidecars (/_meta and any /_*).`,
+ `\t@hidden path /_*`,
+ `\trespond @hidden 403`,
+ `\t# Serve index.html for directory / extensionless URIs.`,
+ `\ttry_files {path} {path}/index.html`,
+ `\tfile_server`,
+ `}`,
+ ].join("\n");
+}
+
+/** nginx server block: root, /_* block, index rewrite, listing off. */
+export function nginxSnippet(opts: SnippetOpts): string {
+ const listen = opts.port ?? 80;
+ const serverName = opts.host ?? "_";
+ return [
+ `server {`,
+ `\tlisten ${listen};`,
+ `\tserver_name ${serverName};`,
+ `\troot ${opts.serveRoot};`,
+ `\tautoindex off;`,
+ `\tindex index.html;`,
+ `\t# Block hostdoc's private sidecars (/_meta and any /_*).`,
+ `\tlocation ~ ^/_ { return 403; }`,
+ `\t# Serve index.html for directory / extensionless URIs.`,
+ `\tlocation / { try_files $uri $uri/index.html =404; }`,
+ `}`,
+ ].join("\n");
+}
diff --git a/src/lib/url.ts b/src/lib/url.ts
index 662d5fc..433628c 100644
--- a/src/lib/url.ts
+++ b/src/lib/url.ts
@@ -24,5 +24,16 @@ export function buildPublicUrl(cfg: Config, code: string): string {
if (cfg.mode === "cloudfront") {
return `https://${cfg.domain}/${code}/`;
}
+ if (cfg.mode === "self-hosted") {
+ if (!cfg.host) {
+ throw new Error(
+ "self-hosted URL requires a resolved host; call ensureHost() before buildPublicUrl().",
+ );
+ }
+ const scheme = cfg.scheme ?? "http";
+ const defaultPort = scheme === "https" ? 443 : 80;
+ const portPart = cfg.port && cfg.port !== defaultPort ? `:${cfg.port}` : "";
+ return `${scheme}://${cfg.host}${portPart}/${code}/`;
+ }
return `${cfg.websiteEndpoint}/${code}/`;
}
diff --git a/src/lib/which.ts b/src/lib/which.ts
new file mode 100644
index 0000000..1b17073
--- /dev/null
+++ b/src/lib/which.ts
@@ -0,0 +1,20 @@
+import { existsSync } from "node:fs";
+import { delimiter, join } from "node:path";
+
+/** True if `bin` (or `bin.exe`) is found on PATH. */
+export function onPath(bin: string): boolean {
+ const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
+ return dirs.some((d) => existsSync(join(d, bin)) || existsSync(join(d, `${bin}.exe`)));
+}
+
+/** Absolute path of `bin` (or `bin.exe`) if found on PATH, else null. */
+export function whichPath(bin: string): string | null {
+ const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
+ for (const d of dirs) {
+ const p = join(d, bin);
+ if (existsSync(p)) return p;
+ const pExe = join(d, `${bin}.exe`);
+ if (existsSync(pExe)) return pExe;
+ }
+ return null;
+}
diff --git a/test/backend.test.ts b/test/backend.test.ts
new file mode 100644
index 0000000..6424bc4
--- /dev/null
+++ b/test/backend.test.ts
@@ -0,0 +1,103 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { mockClient } from "aws-sdk-client-mock";
+import {
+ S3Client,
+ PutObjectCommand,
+ ListObjectsV2Command,
+ DeleteObjectsCommand,
+ GetObjectCommand,
+} from "@aws-sdk/client-s3";
+import { mkdtempSync, rmSync, existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { S3Backend, LocalFsBackend } from "../src/lib/backend.js";
+
+const s3mock = mockClient(S3Client);
+beforeEach(() => s3mock.reset());
+
+describe("S3Backend", () => {
+ it("put issues a PutObjectCommand with the bucket + key", async () => {
+ s3mock.on(PutObjectCommand).resolves({});
+ const be = new S3Backend(new S3Client({ region: "us-east-1" }), "b");
+ await be.put("abc/index.html", Buffer.from("x"), "text/html");
+ const call = s3mock.commandCalls(PutObjectCommand)[0];
+ expect(call.args[0].input.Bucket).toBe("b");
+ expect(call.args[0].input.Key).toBe("abc/index.html");
+ });
+
+ it("list returns keys under the prefix", async () => {
+ s3mock
+ .on(ListObjectsV2Command)
+ .resolves({ KeyCount: 1, Contents: [{ Key: "abc/index.html" }] });
+ const be = new S3Backend(new S3Client({ region: "us-east-1" }), "b");
+ expect(await be.list("abc/")).toEqual(["abc/index.html"]);
+ });
+
+ it("exists is true when the prefix has objects", async () => {
+ s3mock.on(ListObjectsV2Command).resolves({ KeyCount: 1 });
+ const be = new S3Backend(new S3Client({ region: "us-east-1" }), "b");
+ expect(await be.exists("abc/")).toBe(true);
+ });
+
+ it("delete issues DeleteObjectsCommand", async () => {
+ s3mock.on(DeleteObjectsCommand).resolves({});
+ const be = new S3Backend(new S3Client({ region: "us-east-1" }), "b");
+ await be.delete(["abc/index.html"]);
+ expect(s3mock.commandCalls(DeleteObjectsCommand)).toHaveLength(1);
+ });
+
+ it("getJson parses the object body", async () => {
+ s3mock.on(GetObjectCommand).resolves({
+ Body: { transformToString: async () => JSON.stringify({ code: "abc" }) },
+ } as never);
+ const be = new S3Backend(new S3Client({ region: "us-east-1" }), "b");
+ expect(await be.getJson<{ code: string }>("_meta/abc.json")).toEqual({ code: "abc" });
+ });
+});
+
+describe("LocalFsBackend", () => {
+ let root: string;
+ beforeEach(() => { root = mkdtempSync(join(tmpdir(), "sf-fs-")); });
+ // afterEach cleanup is best-effort; tmp dirs are disposable.
+
+ it("put writes the file at the key path (nested dirs created)", async () => {
+ const be = new LocalFsBackend(root);
+ await be.put("abc/assets/a.css", Buffer.from("body{}"), "text/css");
+ expect(existsSync(join(root, "abc", "assets", "a.css"))).toBe(true);
+ });
+
+ it("list returns posix keys under the prefix", async () => {
+ const be = new LocalFsBackend(root);
+ await be.put("abc/index.html", Buffer.from("x"), "text/html");
+ await be.put("_meta/abc.json", Buffer.from("{}"), "application/json");
+ expect(await be.list("abc/")).toEqual(["abc/index.html"]);
+ expect(await be.list("_meta/")).toEqual(["_meta/abc.json"]);
+ });
+
+ it("exists reflects presence under a prefix", async () => {
+ const be = new LocalFsBackend(root);
+ expect(await be.exists("abc/")).toBe(false);
+ await be.put("abc/index.html", Buffer.from("x"), "text/html");
+ expect(await be.exists("abc/")).toBe(true);
+ });
+
+ it("list on a missing serve root returns []", async () => {
+ const be = new LocalFsBackend(join(root, "nope"));
+ expect(await be.list("abc/")).toEqual([]);
+ });
+
+ it("delete removes files and prunes empty parent dirs", async () => {
+ const be = new LocalFsBackend(root);
+ await be.put("abc/index.html", Buffer.from("x"), "text/html");
+ await be.delete(["abc/index.html"]);
+ expect(existsSync(join(root, "abc", "index.html"))).toBe(false);
+ expect(existsSync(join(root, "abc"))).toBe(false); // pruned
+ expect(existsSync(root)).toBe(true); // serve root kept
+ });
+
+ it("getJson reads and parses a JSON file", async () => {
+ const be = new LocalFsBackend(root);
+ await be.put("_meta/abc.json", Buffer.from(JSON.stringify({ code: "abc" })), "application/json");
+ expect(await be.getJson<{ code: string }>("_meta/abc.json")).toEqual({ code: "abc" });
+ });
+});
diff --git a/test/config.test.ts b/test/config.test.ts
index d8e2855..53e72fc 100644
--- a/test/config.test.ts
+++ b/test/config.test.ts
@@ -26,6 +26,10 @@ const ENV_KEYS = [
"HOSTDOC_REGION",
"HOSTDOC_DOMAIN",
"HOSTDOC_DISTRIBUTION",
+ "HOSTDOC_SERVE_ROOT",
+ "HOSTDOC_HOST",
+ "HOSTDOC_PORT",
+ "HOSTDOC_SCHEME",
];
const saved: Record = {};
@@ -136,4 +140,37 @@ describe("resolveConfig", () => {
resolveConfig({ bucket: "b", region: "us-east-1", distribution: "E1" }),
).toThrow(/'distributionId' set without 'domain'/);
});
+
+ it("derives self-hosted mode from serveRoot", () => {
+ const cfg = resolveConfig({ serveRoot: "/srv/www" });
+ expect(cfg.mode).toBe("self-hosted");
+ expect(cfg.serveRoot).toBe("/srv/www");
+ });
+
+ it("self-hosted carries host/port/scheme when set", () => {
+ const cfg = resolveConfig({
+ serveRoot: "/srv/www",
+ host: "docs.example.com",
+ port: 8080,
+ scheme: "https",
+ });
+ expect(cfg).toMatchObject({
+ mode: "self-hosted",
+ host: "docs.example.com",
+ port: 8080,
+ scheme: "https",
+ });
+ });
+
+ it("self-hosted reads serveRoot from env", () => {
+ process.env.HOSTDOC_SERVE_ROOT = "/env/www";
+ expect(resolveConfig({}).mode).toBe("self-hosted");
+ expect(resolveConfig({}).serveRoot).toBe("/env/www");
+ });
+
+ it("rejects mixing serveRoot with AWS fields (ambiguous mode)", () => {
+ expect(() =>
+ resolveConfig({ serveRoot: "/srv/www", bucket: "b", region: "us-east-1" }),
+ ).toThrow(/Ambiguous|self-hosted/i);
+ });
});
diff --git a/test/deprovision-selfhosted.test.ts b/test/deprovision-selfhosted.test.ts
new file mode 100644
index 0000000..1733c21
--- /dev/null
+++ b/test/deprovision-selfhosted.test.ts
@@ -0,0 +1,86 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { writeFileSync, existsSync, mkdirSync } from "node:fs";
+import { dirname } from "node:path";
+import { runDeprovisionSelfHosted } from "../src/commands/deprovision-selfhosted.js";
+import { caddyfilePath, stagedServicePath, systemServicePath } from "../src/lib/installer.js";
+
+const plat = { os: "linux", arch: "amd64" } as const;
+
+// XDG_CONFIG_HOME is repointed to a temp dir by test/setup-env.ts.
+beforeEach(() => {
+ const cfDir = dirname(caddyfilePath());
+ mkdirSync(cfDir, { recursive: true });
+ writeFileSync(caddyfilePath(), "example.com {\n}\n");
+ writeFileSync(stagedServicePath(plat), "unit\n");
+});
+
+describe("runDeprovisionSelfHosted", () => {
+ it("removes user-owned files and returns sudo commands when not root", () => {
+ const out = runDeprovisionSelfHosted({ isPrivileged: () => false, plat });
+ expect(existsSync(caddyfilePath())).toBe(false); // user file removed
+ expect(existsSync(stagedServicePath(plat))).toBe(false);
+ expect(out.kind).toBe("needs-privilege");
+ if (out.kind === "needs-privilege") {
+ expect(out.privilegedCommands.some((c) => c.includes("rm "))).toBe(true);
+ expect(out.privilegedCommands.some((c) => c.includes(systemServicePath(plat)))).toBe(true);
+ }
+ });
+
+ it("removes the system service directly when root", () => {
+ const calls: string[][] = [];
+ const out = runDeprovisionSelfHosted({
+ isPrivileged: () => true,
+ plat,
+ installer: {
+ exists: () => true,
+ run: (cmd, args) => calls.push([cmd, ...args]),
+ remove: () => {},
+ },
+ });
+ expect(out.kind).toBe("removed");
+ expect(calls.length).toBeGreaterThan(0);
+ });
+
+});
+
+describe("runDeprovisionSelfHosted (windows)", () => {
+ const win = { os: "windows", arch: "amd64" } as const;
+ beforeEach(() => {
+ mkdirSync(dirname(stagedServicePath(win)), { recursive: true });
+ writeFileSync(stagedServicePath(win), "\n"); // caddy-service.xml
+ mkdirSync(dirname(caddyfilePath()), { recursive: true });
+ writeFileSync(caddyfilePath(), "127.0.0.1:8087 {\n}\n");
+ });
+
+ it("keeps the xml so the emitted stop/uninstall commands still work when not admin", () => {
+ const out = runDeprovisionSelfHosted({ isPrivileged: () => false, plat: win });
+ expect(out.kind).toBe("needs-privilege");
+ if (out.kind === "needs-privilege") {
+ expect(out.privilegedCommands.join("\n")).toMatch(/caddy-service\.exe" uninstall/);
+ }
+ // WinSW loads caddy-service.xml for every command — it must NOT be pre-deleted,
+ // or the stop/uninstall commands we just told the user to run would fail.
+ expect(existsSync(stagedServicePath(win))).toBe(true);
+ });
+
+ it("tears the service down BEFORE removing the xml when admin", () => {
+ let xmlPresentDuringTeardown: boolean | undefined;
+ const out = runDeprovisionSelfHosted({
+ isPrivileged: () => true,
+ plat: win,
+ installer: {
+ exists: () => true,
+ run: (_cmd, args) => {
+ if (args.includes("stop") || args.includes("uninstall")) {
+ xmlPresentDuringTeardown = existsSync(stagedServicePath(win));
+ }
+ },
+ },
+ });
+ expect(out.kind).toBe("removed");
+ // WinSW stop/uninstall must run while the xml still exists, then the files go.
+ expect(xmlPresentDuringTeardown).toBe(true);
+ expect(existsSync(stagedServicePath(win))).toBe(false);
+ expect(existsSync(caddyfilePath())).toBe(false);
+ });
+});
diff --git a/test/host.test.ts b/test/host.test.ts
new file mode 100644
index 0000000..3211c26
--- /dev/null
+++ b/test/host.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { detectPublicIp, ensureHost } from "../src/lib/host.js";
+import type { Config } from "../src/lib/config.js";
+
+const realFetch = globalThis.fetch;
+afterEach(() => {
+ globalThis.fetch = realFetch;
+ vi.restoreAllMocks();
+});
+
+function mockFetchOnce(text: string) {
+ globalThis.fetch = vi.fn(async () => ({ ok: true, text: async () => text })) as never;
+}
+
+describe("detectPublicIp", () => {
+ it("returns the trimmed IP from the echo service", async () => {
+ mockFetchOnce("203.0.113.7\n");
+ expect(await detectPublicIp()).toBe("203.0.113.7");
+ });
+
+ it("throws a helpful error when all services fail", async () => {
+ globalThis.fetch = vi.fn(async () => {
+ throw new Error("network down");
+ }) as never;
+ await expect(detectPublicIp()).rejects.toThrow(/host/i);
+ });
+});
+
+describe("ensureHost", () => {
+ it("no-ops for cloud modes", async () => {
+ const cfg: Config = { mode: "s3-website", bucket: "b", region: "us-east-1" };
+ expect(await ensureHost(cfg)).toBe(cfg);
+ });
+
+ it("returns cfg unchanged when self-hosted host is already set", async () => {
+ const cfg: Config = { mode: "self-hosted", serveRoot: "/srv", host: "docs.example.com" };
+ expect(await ensureHost(cfg)).toBe(cfg);
+ });
+
+ it("detects the public IP and warns when host is unset", async () => {
+ mockFetchOnce("203.0.113.7");
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const cfg: Config = { mode: "self-hosted", serveRoot: "/srv" };
+ const out = await ensureHost(cfg);
+ expect(out.host).toBe("203.0.113.7");
+ expect(out.scheme).toBe("http");
+ expect(warn).toHaveBeenCalledWith(expect.stringMatching(/IP/));
+ });
+});
diff --git a/test/installer.test.ts b/test/installer.test.ts
new file mode 100644
index 0000000..e1d50b7
--- /dev/null
+++ b/test/installer.test.ts
@@ -0,0 +1,223 @@
+import { describe, it, expect, vi } from "vitest";
+import type { Deps } from "../src/lib/installer.js";
+import {
+ downloadCaddy,
+ downloadWinsw,
+ writeServiceFile,
+ installService,
+ removeService,
+ privilegedInstallCommands,
+ privilegedRemoveCommands,
+ isPrivileged,
+ caddyBinDest,
+ winswBinDest,
+ systemServicePath,
+} from "../src/lib/installer.js";
+
+const linux = { os: "linux", arch: "amd64" } as const;
+const darwin = { os: "darwin", arch: "arm64" } as const;
+const windows = { os: "windows", arch: "amd64" } as const;
+
+function spyDeps(over: Partial = {}): Required & { calls: string[][] } {
+ const calls: string[][] = [];
+ return {
+ run: (cmd, args) => calls.push([cmd, ...args]),
+ fetchFn: vi.fn(),
+ writeFile: () => {},
+ copy: (s, d) => calls.push(["copy", s, d]),
+ chmod: () => {},
+ mkdirp: () => {},
+ exists: () => true,
+ remove: (p) => calls.push(["remove", p]),
+ whichCaddy: () => null,
+ isAdmin: () => true,
+ ...over,
+ calls,
+ } as Required & { calls: string[][] };
+}
+
+describe("downloadCaddy", () => {
+ it("reuses caddy already on PATH (no fetch, no write)", async () => {
+ const fetchFn = vi.fn();
+ const out = await downloadCaddy({ whichCaddy: () => "/usr/bin/caddy", fetchFn });
+ expect(out).toEqual({ path: "/usr/bin/caddy", reused: true });
+ expect(fetchFn).not.toHaveBeenCalled();
+ });
+
+ it("downloads, writes, and chmods when caddy is absent", async () => {
+ const written: string[] = [];
+ const chmodded: Array<[string, number]> = [];
+ const fetchFn = vi.fn(async () => ({
+ ok: true,
+ arrayBuffer: async () => new TextEncoder().encode("BINARY").buffer,
+ })) as never;
+ const out = await downloadCaddy(
+ {
+ whichCaddy: () => null,
+ exists: () => false,
+ fetchFn,
+ mkdirp: () => {},
+ writeFile: (p) => written.push(p),
+ chmod: (p, m) => chmodded.push([p, m]),
+ },
+ linux,
+ );
+ expect(out.reused).toBe(false);
+ expect(out.path).toMatch(/[\\/]\.local[\\/]bin[\\/]caddy$/);
+ expect(written).toContain(out.path);
+ expect(chmodded[0][1]).toBe(0o755);
+ expect(fetchFn).toHaveBeenCalledOnce();
+ });
+
+ it("throws on a non-ok download response", async () => {
+ const fetchFn = vi.fn(async () => ({ ok: false, status: 502 })) as never;
+ await expect(
+ downloadCaddy({ whichCaddy: () => null, exists: () => false, fetchFn }),
+ ).rejects.toThrow(/502/);
+ });
+});
+
+describe("installService (linux)", () => {
+ it("copies the staged unit to the system path and enables it", () => {
+ const d = spyDeps();
+ installService(d, linux);
+ const flat = d.calls.map((c) => c.join(" "));
+ expect(flat.some((c) => c.startsWith("copy "))).toBe(true);
+ expect(flat).toContain("systemctl daemon-reload");
+ expect(flat).toContain("systemctl enable --now hostdoc-caddy");
+ });
+});
+
+describe("installService (darwin)", () => {
+ it("copies the staged plist and bootstraps it", () => {
+ const d = spyDeps();
+ installService(d, darwin);
+ const flat = d.calls.map((c) => c.join(" "));
+ expect(flat.some((c) => c.startsWith("copy "))).toBe(true);
+ expect(flat).toContain(`launchctl bootstrap system ${systemServicePath(darwin)}`);
+ });
+});
+
+describe("removeService (linux)", () => {
+ it("disables the service and removes the system unit", () => {
+ const d = spyDeps({ exists: () => true });
+ removeService(d, linux);
+ const flat = d.calls.map((c) => c.join(" "));
+ expect(flat).toContain("systemctl disable --now hostdoc-caddy");
+ expect(flat).toContain(`remove ${systemServicePath(linux)}`);
+ });
+});
+
+describe("sudo command builders", () => {
+ it("install commands reference cp + enable (linux)", () => {
+ const cmds = privilegedInstallCommands(linux);
+ expect(cmds.some((c) => c.includes("cp "))).toBe(true);
+ expect(cmds.some((c) => c.includes("systemctl enable --now hostdoc-caddy"))).toBe(true);
+ });
+ it("remove commands reference disable + rm (linux)", () => {
+ const cmds = privilegedRemoveCommands(linux);
+ expect(cmds.some((c) => c.includes("systemctl disable --now hostdoc-caddy"))).toBe(true);
+ expect(cmds.some((c) => c.includes("rm "))).toBe(true);
+ });
+});
+
+describe("writeServiceFile", () => {
+ it("writes the staged unit content (linux)", () => {
+ const written: Array<[string, string]> = [];
+ writeServiceFile("/usr/local/bin/caddy", {
+ mkdirp: () => {},
+ writeFile: (p, data) => written.push([p, String(data)]),
+ }, linux);
+ expect(written[0][1]).toContain("ExecStart=/usr/local/bin/caddy run --config");
+ });
+});
+
+describe("windows leaf helpers", () => {
+ it("caddyBinDest points at caddy.exe under the service dir (windows)", () => {
+ expect(caddyBinDest(windows)).toMatch(/[\\/]service[\\/]caddy\.exe$/);
+ });
+ it("winswBinDest is caddy-service.exe under the service dir", () => {
+ expect(winswBinDest()).toMatch(/[\\/]service[\\/]caddy-service\.exe$/);
+ });
+ it("caddyBinDest stays at .local/bin/caddy on linux", () => {
+ expect(caddyBinDest(linux)).toMatch(/[\\/]\.local[\\/]bin[\\/]caddy$/);
+ });
+});
+
+describe("downloadWinsw", () => {
+ it("reuses an existing WinSW (no fetch)", async () => {
+ const fetchFn = vi.fn();
+ const out = await downloadWinsw({ exists: () => true, fetchFn }, windows);
+ expect(out.reused).toBe(true);
+ expect(fetchFn).not.toHaveBeenCalled();
+ });
+ it("downloads and writes WinSW when absent", async () => {
+ const written: string[] = [];
+ const fetchFn = vi.fn(async () => ({
+ ok: true,
+ arrayBuffer: async () => new TextEncoder().encode("WINSW").buffer,
+ })) as never;
+ const out = await downloadWinsw(
+ { exists: () => false, fetchFn, mkdirp: () => {}, writeFile: (p) => written.push(p) },
+ windows,
+ );
+ expect(out.reused).toBe(false);
+ expect(out.path).toMatch(/caddy-service\.exe$/);
+ expect(written).toContain(out.path);
+ expect(fetchFn).toHaveBeenCalledOnce();
+ });
+ it("throws on a non-ok WinSW response", async () => {
+ const fetchFn = vi.fn(async () => ({ ok: false, status: 404 })) as never;
+ await expect(
+ downloadWinsw({ exists: () => false, fetchFn }, windows),
+ ).rejects.toThrow(/404/);
+ });
+});
+
+describe("isPrivileged", () => {
+ it("uses the injected isAdmin probe on windows", () => {
+ expect(isPrivileged(windows, { isAdmin: () => true })).toBe(true);
+ expect(isPrivileged(windows, { isAdmin: () => false })).toBe(false);
+ });
+});
+
+describe("windows service control", () => {
+ it("writeServiceFile writes caddy-service.xml into the service dir", () => {
+ const written: Array<[string, string]> = [];
+ writeServiceFile(
+ "C:\\svc\\caddy.exe",
+ { mkdirp: () => {}, writeFile: (p, data) => written.push([p, String(data)]) },
+ windows,
+ );
+ expect(written[0][0]).toMatch(/caddy-service\.xml$/);
+ expect(written[0][1]).toContain("hostdoc-caddy");
+ expect(written[0][1]).toContain("C:\\svc\\caddy.exe");
+ });
+
+ it("installService installs + starts via WinSW (no copy step)", () => {
+ const d = spyDeps();
+ installService(d, windows);
+ const flat = d.calls.map((c) => c.join(" "));
+ expect(flat.some((c) => c.endsWith("caddy-service.exe install"))).toBe(true);
+ expect(flat.some((c) => c.endsWith("caddy-service.exe start"))).toBe(true);
+ expect(flat.some((c) => c.startsWith("copy "))).toBe(false);
+ });
+
+ it("removeService stops + uninstalls via WinSW", () => {
+ const d = spyDeps({ exists: () => true });
+ removeService(d, windows);
+ const flat = d.calls.map((c) => c.join(" "));
+ expect(flat.some((c) => c.endsWith("caddy-service.exe stop"))).toBe(true);
+ expect(flat.some((c) => c.endsWith("caddy-service.exe uninstall"))).toBe(true);
+ });
+
+ it("privileged command builders reference WinSW without sudo (windows)", () => {
+ const inst = privilegedInstallCommands(windows).join("\n");
+ expect(inst).toMatch(/caddy-service\.exe" install/);
+ expect(inst).toMatch(/caddy-service\.exe" start/);
+ expect(inst).not.toContain("sudo");
+ const rem = privilegedRemoveCommands(windows).join("\n");
+ expect(rem).toMatch(/caddy-service\.exe" uninstall/);
+ expect(rem).not.toContain("sudo");
+ });
+});
diff --git a/test/open.test.ts b/test/open.test.ts
index 1a64175..c288b05 100644
--- a/test/open.test.ts
+++ b/test/open.test.ts
@@ -43,27 +43,27 @@ describe("openInBrowser", () => {
});
describe("resolveOpenUrl", () => {
- it("builds the URL for a code", () => {
- expect(resolveOpenUrl({ id: "abc" })).toBe(
+ it("builds the URL for a code", async () => {
+ expect(await resolveOpenUrl({ id: "abc" })).toBe(
"http://b.s3-website-us-east-1.amazonaws.com/abc/",
);
});
it.each(["a b", "../escape", "x?y", "x#y", "_meta"])(
"rejects invalid id %j",
- (id) => {
- expect(() => resolveOpenUrl({ id })).toThrow(/invalid id/i);
+ async (id) => {
+ await expect(resolveOpenUrl({ id })).rejects.toThrow(/invalid id/i);
},
);
- it("accepts an uppercase-containing generated code", () => {
- expect(resolveOpenUrl({ id: "spinIYr" })).toBe(
+ it("accepts an uppercase-containing generated code", async () => {
+ expect(await resolveOpenUrl({ id: "spinIYr" })).toBe(
"http://b.s3-website-us-east-1.amazonaws.com/spinIYr/",
);
});
- it("builds the URL for a nested path", () => {
- expect(resolveOpenUrl({ id: "team/q1/report" })).toBe(
+ it("builds the URL for a nested path", async () => {
+ expect(await resolveOpenUrl({ id: "team/q1/report" })).toBe(
"http://b.s3-website-us-east-1.amazonaws.com/team/q1/report/",
);
});
diff --git a/test/pack.test.ts b/test/pack.test.ts
index f855370..4a1e4e8 100644
--- a/test/pack.test.ts
+++ b/test/pack.test.ts
@@ -1,11 +1,14 @@
import { describe, it, expect } from "vitest";
-import { execFileSync } from "node:child_process";
+import { execSync } from "node:child_process";
import { TEMPLATE_FILES } from "../src/lib/templates.js";
// Assumes `npm run build` has run (CI order is build→typecheck→test), so
// dist/templates/infra exists. --ignore-scripts packs the current dist as-is.
+// execSync (shell) so `npm` resolves to npm.cmd on Windows — execFileSync can't:
+// bare "npm" is ENOENT and spawning npm.cmd directly is EINVAL since Node's
+// CVE-2024-27980 fix. The command is a fixed literal (no injection surface).
function packedFiles(): string[] {
- const out = execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], {
+ const out = execSync("npm pack --dry-run --json --ignore-scripts", {
encoding: "utf8",
});
const parsed = JSON.parse(out) as Array<{ files: Array<{ path: string }> }>;
diff --git a/test/platform.test.ts b/test/platform.test.ts
new file mode 100644
index 0000000..e974f67
--- /dev/null
+++ b/test/platform.test.ts
@@ -0,0 +1,41 @@
+import { describe, it, expect } from "vitest";
+import { detectPlatform, caddyDownloadUrl, winswDownloadUrl } from "../src/lib/platform.js";
+
+describe("detectPlatform", () => {
+ it("maps darwin + arm64", () => {
+ expect(detectPlatform("darwin", "arm64")).toEqual({ os: "darwin", arch: "arm64" });
+ });
+ it("maps linux + x64 to amd64", () => {
+ expect(detectPlatform("linux", "x64")).toEqual({ os: "linux", arch: "amd64" });
+ });
+ it("maps win32 to windows (no longer throws)", () => {
+ expect(detectPlatform("win32", "x64")).toEqual({ os: "windows", arch: "amd64" });
+ });
+ it("throws for an unsupported arch", () => {
+ expect(() => detectPlatform("linux", "ia32")).toThrow(/arch/i);
+ });
+});
+
+describe("caddyDownloadUrl", () => {
+ it("builds the official download URL from os + arch", () => {
+ expect(caddyDownloadUrl({ os: "linux", arch: "amd64" })).toBe(
+ "https://caddyserver.com/api/download?os=linux&arch=amd64",
+ );
+ });
+ it("builds a windows download URL", () => {
+ expect(caddyDownloadUrl({ os: "windows", arch: "amd64" })).toBe(
+ "https://caddyserver.com/api/download?os=windows&arch=amd64",
+ );
+ });
+});
+
+describe("winswDownloadUrl", () => {
+ it("targets the pinned v2.x x64 asset (no arm64 build exists)", () => {
+ expect(winswDownloadUrl({ os: "windows", arch: "amd64" })).toBe(
+ "https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe",
+ );
+ expect(winswDownloadUrl({ os: "windows", arch: "arm64" })).toBe(
+ "https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe",
+ );
+ });
+});
diff --git a/test/publish-open.test.ts b/test/publish-open.test.ts
index 736eb38..1b8e5d5 100644
--- a/test/publish-open.test.ts
+++ b/test/publish-open.test.ts
@@ -19,8 +19,8 @@ afterEach(() => {
});
describe("openPublishedUrl", () => {
- it("forwards overrides so the opened URL matches the override config", () => {
- const url = openPublishedUrl(
+ it("forwards overrides so the opened URL matches the override config", async () => {
+ const url = await openPublishedUrl(
"http://envbkt.s3-website-us-east-1.amazonaws.com/abc/",
{ bucket: "flagbkt", region: "us-east-1" },
);
@@ -30,15 +30,15 @@ describe("openPublishedUrl", () => {
);
});
- it("falls back to ambient config when no overrides are given", () => {
- const url = openPublishedUrl(
+ it("falls back to ambient config when no overrides are given", async () => {
+ const url = await openPublishedUrl(
"http://envbkt.s3-website-us-east-1.amazonaws.com/abc/",
);
expect(url).toBe("http://envbkt.s3-website-us-east-1.amazonaws.com/abc/");
});
- it("derives a nested path from the published URL", () => {
- const url = openPublishedUrl(
+ it("derives a nested path from the published URL", async () => {
+ const url = await openPublishedUrl(
"http://envbkt.s3-website-us-east-1.amazonaws.com/team/q1/report/",
);
expect(url).toBe(
diff --git a/test/self-hosted.test.ts b/test/self-hosted.test.ts
new file mode 100644
index 0000000..abddf61
--- /dev/null
+++ b/test/self-hosted.test.ts
@@ -0,0 +1,51 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { runPublish } from "../src/commands/publish.js";
+import { listDocs } from "../src/commands/list.js";
+import { runRm } from "../src/commands/rm.js";
+
+let src: string, serve: string;
+const ENV = ["HOSTDOC_SERVE_ROOT", "HOSTDOC_HOST", "HOSTDOC_BUCKET", "HOSTDOC_REGION", "XDG_CONFIG_HOME"];
+const saved: Record = {};
+
+beforeEach(() => {
+ for (const k of ENV) { saved[k] = process.env[k]; delete process.env[k]; }
+ src = mkdtempSync(join(tmpdir(), "sf-src-"));
+ serve = mkdtempSync(join(tmpdir(), "sf-serve-"));
+ process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "sf-cfg-"));
+ process.env.HOSTDOC_SERVE_ROOT = serve;
+ process.env.HOSTDOC_HOST = "docs.example.com"; // configured host → no network
+});
+afterEach(() => {
+ rmSync(src, { recursive: true, force: true });
+ rmSync(serve, { recursive: true, force: true });
+ for (const k of ENV) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; }
+});
+
+describe("self-hosted end-to-end", () => {
+ it("publishes to the serve root and returns a host-based URL", async () => {
+ writeFileSync(join(src, "index.html"), "Hi");
+ const url = await runPublish({ path: src, slug: "doc1" });
+ expect(url).toBe("http://docs.example.com/doc1/");
+ expect(existsSync(join(serve, "doc1", "index.html"))).toBe(true);
+ expect(existsSync(join(serve, "_meta", "doc1.json"))).toBe(true);
+ });
+
+ it("list returns published docs with the host URL", async () => {
+ writeFileSync(join(src, "index.html"), "Hi");
+ await runPublish({ path: src, slug: "doc1" });
+ const rows = await listDocs({});
+ expect(rows.map((r) => r.code)).toContain("doc1");
+ expect(rows[0].url).toBe("http://docs.example.com/doc1/");
+ });
+
+ it("rm deletes files from the serve root", async () => {
+ writeFileSync(join(src, "index.html"), "Hi");
+ await runPublish({ path: src, slug: "doc1" });
+ await runRm({ id: "doc1", yes: true });
+ expect(existsSync(join(serve, "doc1", "index.html"))).toBe(false);
+ expect(existsSync(join(serve, "_meta", "doc1.json"))).toBe(false);
+ });
+});
diff --git a/test/service.test.ts b/test/service.test.ts
new file mode 100644
index 0000000..8ad5cb2
--- /dev/null
+++ b/test/service.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect } from "vitest";
+import { launchdPlist, systemdUnit, winswXml, LAUNCHD_LABEL, WINSW_ID } from "../src/lib/service.js";
+
+const opts = { caddyBin: "/usr/local/bin/caddy", caddyfilePath: "/home/u/.config/hostdoc/Caddyfile" };
+
+describe("systemdUnit", () => {
+ const u = systemdUnit(opts);
+ it("runs caddy with the config", () =>
+ expect(u).toContain("ExecStart=/usr/local/bin/caddy run --config /home/u/.config/hostdoc/Caddyfile"));
+ it("reloads on config change", () =>
+ expect(u).toContain("ExecReload=/usr/local/bin/caddy reload --config /home/u/.config/hostdoc/Caddyfile --force"));
+ it("binds privileged ports without full root", () =>
+ expect(u).toContain("AmbientCapabilities=CAP_NET_BIND_SERVICE"));
+ it("restarts on failure", () => expect(u).toContain("Restart=on-failure"));
+ it("starts at boot", () => expect(u).toContain("WantedBy=multi-user.target"));
+});
+
+describe("launchdPlist", () => {
+ const p = launchdPlist(opts);
+ it("uses the hostdoc label", () =>
+ expect(p).toContain(`${LAUNCHD_LABEL}`));
+ it("passes caddy run --config as program arguments", () => {
+ expect(p).toContain("/usr/local/bin/caddy");
+ expect(p).toContain("run");
+ expect(p).toContain("--config");
+ expect(p).toContain("/home/u/.config/hostdoc/Caddyfile");
+ });
+ it("runs at load and stays alive (reboot/crash survival)", () => {
+ expect(p).toContain("RunAtLoad");
+ expect(p).toContain("KeepAlive");
+ });
+});
+
+describe("winswXml", () => {
+ const win = {
+ caddyBin: "C:\\ProgramData\\hostdoc\\service\\caddy.exe",
+ caddyfilePath: "C:\\Users\\u\\AppData\\hostdoc\\Caddyfile",
+ };
+ const x = winswXml(win);
+ it("uses the hostdoc-caddy service id", () =>
+ expect(x).toContain(`${WINSW_ID}`));
+ it("points the absolute caddy.exe as the executable", () =>
+ expect(x).toContain("C:\\ProgramData\\hostdoc\\service\\caddy.exe"));
+ it("runs caddy with the hostdoc Caddyfile", () =>
+ expect(x).toContain("run --config C:\\Users\\u\\AppData\\hostdoc\\Caddyfile"));
+ it("configures a roll-by-time log", () => {
+ expect(x).toContain('');
+ expect(x).toContain("yyyy-MM-dd");
+ });
+});
diff --git a/test/setup-selfhosted.test.ts b/test/setup-selfhosted.test.ts
new file mode 100644
index 0000000..fc7dc94
--- /dev/null
+++ b/test/setup-selfhosted.test.ts
@@ -0,0 +1,131 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ chooseServer,
+ runSetupSelfHosted,
+ runInstallSelfHosted,
+} from "../src/commands/setup-selfhosted.js";
+import { loadConfig } from "../src/lib/config.js";
+import { caddyfilePath, stagedServicePath } from "../src/lib/installer.js";
+
+describe("chooseServer", () => {
+ it("defaults to caddy", () => expect(chooseServer({ nginxDetected: false })).toBe("caddy"));
+ it("uses nginx when detected", () => expect(chooseServer({ nginxDetected: true })).toBe("nginx"));
+ it("uses nginx when explicitly requested", () => expect(chooseServer({ nginx: true, nginxDetected: false })).toBe("nginx"));
+ it("--caddy forces caddy even if nginx is detected", () => expect(chooseServer({ caddy: true, nginxDetected: true })).toBe("caddy"));
+});
+
+describe("runSetupSelfHosted", () => {
+ let dir: string, serve: string;
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "sf-setup-cfg-"));
+ serve = join(mkdtempSync(join(tmpdir(), "sf-setup-")), "www"); // not yet created
+ process.env.XDG_CONFIG_HOME = dir;
+ });
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }); delete process.env.XDG_CONFIG_HOME; });
+
+ it("creates the serve root, saves self-hosted config, and returns a Caddy snippet by default", () => {
+ const out = runSetupSelfHosted({ serveRoot: serve, host: "docs.example.com", nginxDetected: false });
+ expect(existsSync(serve)).toBe(true);
+ expect(out.server).toBe("caddy");
+ expect(out.snippet).toContain("respond @hidden 403");
+ expect(out.warning).toMatch(/exposure/i);
+ expect(loadConfig()).toMatchObject({ mode: "self-hosted", serveRoot: serve, host: "docs.example.com" });
+ });
+
+ it("returns an nginx snippet when --nginx is set", () => {
+ const out = runSetupSelfHosted({ serveRoot: serve, nginx: true, nginxDetected: false });
+ expect(out.server).toBe("nginx");
+ expect(out.snippet).toContain("location ~ ^/_ { return 403; }");
+ });
+});
+
+describe("runInstallSelfHosted", () => {
+ let dir: string;
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "sf-install-cfg-"));
+ process.env.XDG_CONFIG_HOME = dir;
+ });
+ afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+ delete process.env.XDG_CONFIG_HOME;
+ });
+
+ function serveRoot(): string {
+ return mkdtempSync(join(tmpdir(), "sf-serve-"));
+ }
+
+ const reuse = {
+ installer: { whichCaddy: () => "/usr/bin/caddy" },
+ isPrivileged: () => false,
+ plat: { os: "linux", arch: "amd64" } as const,
+ };
+
+ it("nginx choice falls back to a manual snippet (no install)", async () => {
+ const out = await runInstallSelfHosted(
+ { serveRoot: serveRoot(), nginx: true, nginxDetected: false },
+ reuse,
+ );
+ expect(out.kind).toBe("snippet");
+ if (out.kind === "snippet") expect(out.snippet).toContain("server {");
+ });
+
+ it("non-root Caddy install stages files and returns privileged commands", async () => {
+ const out = await runInstallSelfHosted(
+ { serveRoot: serveRoot(), host: "docs.example.com", nginxDetected: false },
+ reuse,
+ );
+ expect(out.kind).toBe("needs-privilege");
+ if (out.kind === "needs-privilege") {
+ expect(out.caddyBin).toBe("/usr/bin/caddy");
+ expect(out.privilegedCommands.join("\n")).toMatch(/cp .*(hostdoc-caddy\.service|com\.hostdoc\.caddy\.plist)/);
+ }
+ // Caddyfile + staged service file were written (user-writable), config saved.
+ expect(existsSync(caddyfilePath())).toBe(true);
+ expect(readFileSync(caddyfilePath(), "utf8")).toContain("docs.example.com");
+ expect(existsSync(stagedServicePath(reuse.plat))).toBe(true);
+ });
+
+ it("root Caddy install runs installService", async () => {
+ const calls: string[][] = [];
+ const out = await runInstallSelfHosted(
+ { serveRoot: serveRoot(), nginxDetected: false },
+ {
+ isPrivileged: () => true,
+ plat: { os: "linux", arch: "amd64" },
+ installer: {
+ whichCaddy: () => "/usr/bin/caddy",
+ copy: () => {},
+ run: (cmd, args) => calls.push([cmd, ...args]),
+ },
+ },
+ );
+ expect(out.kind).toBe("installed");
+ expect(calls.length).toBeGreaterThan(0);
+ });
+
+ it("windows non-admin install downloads caddy + WinSW and returns privileged commands", async () => {
+ const fetched: string[] = [];
+ const fetchFn = vi.fn(async (u: string) => {
+ fetched.push(String(u));
+ return { ok: true, arrayBuffer: async () => new TextEncoder().encode("B").buffer };
+ }) as never;
+ const out = await runInstallSelfHosted(
+ { serveRoot: serveRoot(), host: "docs.example.com", nginxDetected: false },
+ {
+ isPrivileged: () => false,
+ plat: { os: "windows", arch: "amd64" },
+ installer: { whichCaddy: () => null, exists: () => false, fetchFn, mkdirp: () => {}, writeFile: () => {}, chmod: () => {} },
+ },
+ );
+ expect(out.kind).toBe("needs-privilege");
+ if (out.kind === "needs-privilege") {
+ expect(out.privilegedCommands.join("\n")).toMatch(/caddy-service\.exe" install/);
+ expect(out.privilegedCommands.join("\n")).not.toContain("sudo");
+ }
+ expect(fetched.some((u) => u.includes("caddyserver.com/api/download?os=windows"))).toBe(true);
+ expect(fetched.some((u) => u.includes("winsw/releases/download"))).toBe(true);
+ });
+});
diff --git a/test/skill.test.ts b/test/skill.test.ts
index a12857f..84d47d8 100644
--- a/test/skill.test.ts
+++ b/test/skill.test.ts
@@ -76,7 +76,7 @@ describe("preflight.mjs", () => {
describe("skill structure", () => {
const skillDir = join(repo, "skills", "hostdoc");
it("has SKILL.md with name+description frontmatter", () => {
- const fm = readFileSync(join(skillDir, "SKILL.md"), "utf8").match(/^---\n([\s\S]*?)\n---/);
+ const fm = readFileSync(join(skillDir, "SKILL.md"), "utf8").match(/^---\r?\n([\s\S]*?)\r?\n---/);
expect(fm).toBeTruthy();
expect(fm![1]).toMatch(/^name:\s*hostdoc\s*$/m);
expect(fm![1]).toMatch(/^description:\s*\S+/m);
diff --git a/test/snippet.test.ts b/test/snippet.test.ts
new file mode 100644
index 0000000..aa571c4
--- /dev/null
+++ b/test/snippet.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect } from "vitest";
+import { caddySnippet, nginxSnippet } from "../src/lib/snippet.js";
+
+describe("caddySnippet", () => {
+ const s = caddySnippet({ serveRoot: "/srv/www", host: "docs.example.com" });
+ it("roots at the serve dir", () => expect(s).toContain("root * /srv/www"));
+ it("blocks /_* with 403", () => {
+ expect(s).toMatch(/path \/_\*/);
+ expect(s).toContain("respond @hidden 403");
+ });
+ it("rewrites to index.html", () => expect(s).toContain("try_files {path} {path}/index.html"));
+ it("serves via file_server (listing off by default)", () => expect(s).toContain("file_server"));
+
+ // The site address carries an explicit scheme so Caddy serves the exact
+ // protocol hostdoc's returned link uses. A bare hostname/IP would trigger
+ // Caddy's Automatic HTTPS and mismatch the default http:// link.
+ it("defaults to an explicit http:// site (disables Caddy auto-HTTPS)", () =>
+ expect(s).toContain("http://docs.example.com {"));
+ it("emits an https:// site when scheme is https", () =>
+ expect(
+ caddySnippet({ serveRoot: "/srv/www", host: "docs.example.com", scheme: "https" }),
+ ).toContain("https://docs.example.com {"));
+ it("includes a non-standard port in the site address", () =>
+ expect(
+ caddySnippet({ serveRoot: "/srv/www", host: "docs.example.com", port: 8080 }),
+ ).toContain("http://docs.example.com:8080 {"));
+});
+
+describe("nginxSnippet", () => {
+ const s = nginxSnippet({ serveRoot: "/srv/www", host: "docs.example.com", port: 8080 });
+ it("roots at the serve dir and listens on the port", () => {
+ expect(s).toContain("root /srv/www;");
+ expect(s).toContain("listen 8080;");
+ });
+ it("blocks /_* with 403", () => expect(s).toContain("location ~ ^/_ { return 403; }"));
+ it("rewrites to index.html", () => expect(s).toContain("try_files $uri $uri/index.html =404;"));
+ it("disables directory listing", () => expect(s).toContain("autoindex off;"));
+});
diff --git a/test/url.test.ts b/test/url.test.ts
index eb717f8..c8bcf3a 100644
--- a/test/url.test.ts
+++ b/test/url.test.ts
@@ -37,4 +37,21 @@ describe("buildPublicUrl", () => {
};
expect(buildPublicUrl(cfg, "abc")).toBe("https://shared.example.com/abc/");
});
+
+ it("self-hosted with a configured host uses http and appends the code", () => {
+ const cfg: Config = { mode: "self-hosted", serveRoot: "/srv", host: "docs.example.com" };
+ expect(buildPublicUrl(cfg, "abc")).toBe("http://docs.example.com/abc/");
+ });
+ it("self-hosted appends a non-standard port", () => {
+ const cfg: Config = { mode: "self-hosted", serveRoot: "/srv", host: "1.2.3.4", port: 8080 };
+ expect(buildPublicUrl(cfg, "abc")).toBe("http://1.2.3.4:8080/abc/");
+ });
+ it("self-hosted honors https scheme and omits the default 443", () => {
+ const cfg: Config = { mode: "self-hosted", serveRoot: "/srv", host: "docs.example.com", scheme: "https", port: 443 };
+ expect(buildPublicUrl(cfg, "abc")).toBe("https://docs.example.com/abc/");
+ });
+ it("self-hosted throws when the host is unresolved", () => {
+ const cfg: Config = { mode: "self-hosted", serveRoot: "/srv" };
+ expect(() => buildPublicUrl(cfg, "abc")).toThrow(/host/i);
+ });
});
diff --git a/test/which.test.ts b/test/which.test.ts
new file mode 100644
index 0000000..6d921d4
--- /dev/null
+++ b/test/which.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, writeFileSync, chmodSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, delimiter } from "node:path";
+import { whichPath } from "../src/lib/which.js";
+
+let dir: string;
+const savedPath = process.env.PATH;
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "which-"));
+});
+afterEach(() => {
+ process.env.PATH = savedPath;
+});
+
+describe("whichPath", () => {
+ it("returns the absolute path of a binary on PATH", () => {
+ const bin = join(dir, "mybin");
+ writeFileSync(bin, "#!/bin/sh\n");
+ chmodSync(bin, 0o755);
+ process.env.PATH = `${dir}${delimiter}/nonexistent`;
+ expect(whichPath("mybin")).toBe(bin);
+ });
+ it("returns null when the binary is not on PATH", () => {
+ process.env.PATH = dir;
+ expect(whichPath("definitely-not-here")).toBeNull();
+ });
+});