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(); + }); +});