diff --git a/README.md b/README.md index 1ea1012..5dbd24e 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,25 @@ published docs"*, *"open aws-design"*, *"remove aws-design"*. Flags (`--bucket/--region/...`) > `HOSTDOC_*` env vars > `~/.config/hostdoc/config.json`. +Precedence merges **fields**, but the **mode** is derived from the merged +result (`domain`+`distribution` → cloudfront; `serveRoot` → self-hosted; else +`bucket`+`region` → s3-website). So setting only `HOSTDOC_BUCKET` on top of a +cloudfront config file does **not** switch to s3-website — the file's `domain` +still wins the derivation, and the bucket is ignored for mode (hostdoc prints a +warning when it detects this). + +To pin the mode regardless of which fields merged in, use `--mode` or +`HOSTDOC_MODE` (`s3-website` | `cloudfront` | `self-hosted`, case-insensitive): + +```bash +# force s3-website for a one-off, even though the config file is cloudfront +HOSTDOC_BUCKET=demo-bucket HOSTDOC_REGION=us-east-1 \ + hostdoc publish ./x.html --mode s3-website +``` + +A forced mode validates only that mode's required fields and ignores the +others (printing a note about what it ignored). + ## License MIT diff --git a/docs/superpowers/plans/2026-07-06-explicit-mode-control.md b/docs/superpowers/plans/2026-07-06-explicit-mode-control.md new file mode 100644 index 0000000..1c437cd --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-explicit-mode-control.md @@ -0,0 +1,587 @@ +# 명시적 mode 제어 + 함정 경고 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:** `--mode` / `HOSTDOC_MODE`로 파생 hosting mode를 강제하고, 강제 시 무시된 필드나 higher-precedence bucket이 config domain에 가려지는 함정을 stderr로 경고한다. + +**Architecture:** 모든 변경은 `resolveConfig`(`src/lib/config.ts`)의 mode 파생 로직에 집중된다. 강제 mode 분기(B)를 기존 파생 앞에 추가하고, 파생 경로에는 필드 출처 레벨을 추적하는 `pick` 헬퍼로 shadow 경고(D)를 얹는다. 경고 출력은 주입 가능한 `warn` 콜백으로 흘려 `resolveConfig`를 순수하게 유지한다. CLI는 `overrides()` 단일 빌더에 `--mode`를 추가해 전 커맨드로 전파한다. + +**Tech Stack:** TypeScript(ESM), Commander, Vitest. AWS·네트워크·Terraform 없이 테스트/CI 통과. + +## Global Constraints + +- ESM: 상대 import는 `.ts` 소스에서도 `.js` 확장자 필수 (`./lib/config.js`). +- 경고는 **stderr 전용** — stdout의 발행 링크를 오염시키지 않는다. +- mode는 **파생값**: 파일에 저장/파일에서 읽지 않는다. 강제는 `--mode` flag > `HOSTDOC_MODE` env 만. +- mode 미지정 & 함정 아닌 기존 경로는 **경고 0건·동작 무변경**(회귀 없음). +- 테스트는 기존 방식 유지: `test/setup-env.ts`가 `XDG_CONFIG_HOME`을 temp로 돌리고, 상태는 `HOSTDOC_*` env / `saveConfig`로 주입. +- 값 범위: `s3-website` | `cloudfront` | `self-hosted` **세 모드 전부**. 그 외 값은 명확한 에러. + +## File Structure + +- `src/lib/config.ts` — `Overrides.mode` 추가, `warn` 주입 파라미터, `pick` 헬퍼, 강제-mode 분기, shadow 경고. (핵심) +- `test/config.test.ts` — 강제/무시-경고/함정-감지/회귀 테스트. `ENV_KEYS`에 `HOSTDOC_MODE` 추가. +- `src/index.ts` — `withCommon`에 `--mode` 옵션, `overrides()`에 `mode` 필드, `type Mode` import. +- `README.md` — Configuration precedence 절에 `--mode`/`HOSTDOC_MODE` 문서화. + +--- + +### Task 1: 강제 mode 분기 + warn 주입 (B) + +`resolveConfig`에 `--mode`/`HOSTDOC_MODE` 강제를 추가한다. 강제 시 해당 mode의 필수 필드만 검사하고, 안 쓰는 필드가 있으면 `warn`으로 알린 뒤 진행한다. 기존 파생(미지정) 경로는 이 태스크에서 손대지 않는다. + +**Files:** +- Modify: `src/lib/config.ts` (`Overrides` 인터페이스, `resolveConfig` 시그니처+본문 상단) +- Test: `test/config.test.ts` + +**Interfaces:** +- Produces: + - `Mode` = `"s3-website" | "cloudfront" | "self-hosted"` (기존, 유지) + - `interface Overrides { ...; mode?: Mode }` + - `resolveConfig(flags: Overrides, opts?: { warn?: (msg: string) => void }): Config` + - `opts.warn` 미지정 시 기본값은 `(m) => process.stderr.write(\`hostdoc: ${m}\n\`)` + +- [ ] **Step 1: `ENV_KEYS`에 `HOSTDOC_MODE` 추가 (env 누수 정리)** + +`test/config.test.ts`의 `ENV_KEYS` 배열에 항목 추가: + +```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", + "HOSTDOC_MODE", +]; +``` + +- [ ] **Step 2: 실패하는 테스트 작성 (강제 mode)** + +`test/config.test.ts` 끝, `describe("resolveConfig", ...)` 블록 뒤에 새 블록 추가: + +```ts +describe("resolveConfig forced mode (--mode / HOSTDOC_MODE)", () => { + it("forces s3-website over a cloudfront config file, warning about ignored fields", () => { + saveConfig({ + mode: "cloudfront", + bucket: "b", + region: "us-east-1", + domain: "shared.example.com", + distributionId: "E1", + }); + const warnings: string[] = []; + const cfg = resolveConfig( + { mode: "s3-website" }, + { warn: (m) => warnings.push(m) }, + ); + expect(cfg.mode).toBe("s3-website"); + expect(cfg.websiteEndpoint).toBe( + "http://b.s3-website-us-east-1.amazonaws.com", + ); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/ignoring domain\/distribution/i); + }); + + it("reads forced mode from HOSTDOC_MODE env", () => { + saveConfig({ + mode: "cloudfront", + bucket: "b", + region: "us-east-1", + domain: "d.example.com", + distributionId: "E1", + }); + process.env.HOSTDOC_MODE = "s3-website"; + expect(resolveConfig({}).mode).toBe("s3-website"); + }); + + it("--mode flag beats HOSTDOC_MODE env", () => { + process.env.HOSTDOC_MODE = "self-hosted"; + const cfg = resolveConfig({ mode: "s3-website", bucket: "b", region: "us-east-1" }); + expect(cfg.mode).toBe("s3-website"); + }); + + it("forced cloudfront without domain/distribution errors", () => { + process.env.HOSTDOC_BUCKET = "b"; + process.env.HOSTDOC_REGION = "us-east-1"; + expect(() => resolveConfig({ mode: "cloudfront" })).toThrow( + /requires domain and distributionId/i, + ); + }); + + it("forced s3-website without bucket/region errors", () => { + expect(() => resolveConfig({ mode: "s3-website", serveRoot: "/srv" })).toThrow( + /requires bucket and region/i, + ); + }); + + it("forces self-hosted and warns about ignored AWS fields", () => { + const warnings: string[] = []; + const cfg = resolveConfig( + { mode: "self-hosted", serveRoot: "/srv/www", bucket: "b", region: "us-east-1" }, + { warn: (m) => warnings.push(m) }, + ); + expect(cfg.mode).toBe("self-hosted"); + expect(cfg.serveRoot).toBe("/srv/www"); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/ignoring bucket\/region/i); + }); + + it("rejects an invalid forced mode value", () => { + expect(() => resolveConfig({ mode: "foo" as never })).toThrow(/Invalid.*mode/i); + }); +}); +``` + +- [ ] **Step 3: 테스트 실패 확인** + +Run: `npx vitest run test/config.test.ts -t "forced mode"` +Expected: FAIL — 강제 분기 미구현이라 `--mode s3-website`가 cloudfront로 파생되거나 warn이 0건. + +- [ ] **Step 4: `Overrides`에 `mode` 필드 추가** + +`src/lib/config.ts`의 `Overrides` 인터페이스 끝에 추가: + +```ts +export interface Overrides { + bucket?: string; + region?: string; + domain?: string; + distribution?: string; + serveRoot?: string; + host?: string; + port?: number; + scheme?: "http" | "https"; + mode?: Mode; +} +``` + +- [ ] **Step 5: `resolveConfig` 시그니처 + 강제 분기 구현** + +`src/lib/config.ts`의 `resolveConfig`를 아래로 교체한다. 필드 병합(bucket/region/... 부분)은 기존과 동일하고, `warn` 기본값·`MODES` 검증·세 강제 분기를 **기존 파생 로직 앞**에 삽입한다. 파일 하단(파생 로직)은 이 태스크에서 변경하지 않는다. + +`Mode` 선언 바로 아래에 상수 추가: + +```ts +export type Mode = "s3-website" | "cloudfront" | "self-hosted"; +const MODES: Mode[] = ["s3-website", "cloudfront", "self-hosted"]; +``` + +`resolveConfig` 시그니처와 상단(주석 `/** Merge file < env < flags... */` 아래 함수 본문 시작 ~ 필드 병합 직후)을 다음으로 구성: + +```ts +export function resolveConfig( + flags: Overrides, + opts: { warn?: (msg: string) => void } = {}, +): Config { + const warn = + opts.warn ?? ((m: string) => process.stderr.write(`hostdoc: ${m}\n`)); + const file = loadConfig(); + const bucket = flags.bucket ?? process.env.HOSTDOC_BUCKET ?? file?.bucket; + const region = flags.region ?? process.env.HOSTDOC_REGION ?? file?.region; + const domain = flags.domain ?? process.env.HOSTDOC_DOMAIN ?? file?.domain; + const distributionId = + flags.distribution ?? + 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; + + // --- Explicit mode pin (B): --mode > HOSTDOC_MODE. Never read from file. --- + const forced = flags.mode ?? process.env.HOSTDOC_MODE; + if (forced !== undefined && !MODES.includes(forced as Mode)) { + throw new Error( + `Invalid --mode / HOSTDOC_MODE: '${forced}'. Expected one of: ${MODES.join(", ")}.`, + ); + } + + if (forced === "self-hosted") { + if (!serveRoot) { + throw new Error( + "Forced mode 'self-hosted' requires serveRoot (--serve-root / HOSTDOC_SERVE_ROOT).", + ); + } + if (bucket || region || domain || distributionId) { + warn( + "--mode self-hosted: ignoring bucket/region/domain/distribution from config/env.", + ); + } + return { mode: "self-hosted", serveRoot, host, port, scheme }; + } + + if (forced === "s3-website") { + if (!bucket || !region) { + throw new Error( + "Forced mode 's3-website' requires bucket and region (--bucket/--region or HOSTDOC_BUCKET/HOSTDOC_REGION).", + ); + } + if (domain || distributionId || serveRoot) { + warn( + "--mode s3-website: ignoring domain/distribution/serveRoot from config/env.", + ); + } + return { + mode: "s3-website", + bucket, + region, + websiteEndpoint: websiteEndpoint(bucket, region), + }; + } + + if (forced === "cloudfront") { + if (!domain || !distributionId) { + throw new Error( + "Forced mode 'cloudfront' requires domain and distributionId (--domain/--distribution or HOSTDOC_DOMAIN/HOSTDOC_DISTRIBUTION).", + ); + } + if (!bucket || !region) { + throw new Error( + "Forced mode 'cloudfront' also requires bucket and region. Run `hostdoc init --from-terraform `.", + ); + } + if (serveRoot) { + warn("--mode cloudfront: ignoring serveRoot from config/env."); + } + return { mode: "cloudfront", bucket, region, domain, distributionId }; + } + + // 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 (serveRoot) {` 이후 파일 끝까지는 **기존 코드 그대로 유지**한다(중복 선언 금지 — 위에서 이미 `bucket/region/domain/distributionId/serveRoot/host/port/scheme`를 선언했으므로 기존 본문의 중복 `const` 선언부만 제거하고 파생 분기만 남긴다). + +- [ ] **Step 6: 테스트 통과 확인** + +Run: `npx vitest run test/config.test.ts -t "forced mode"` +Expected: PASS (7 케이스) + +- [ ] **Step 7: 전체 config 테스트로 회귀 확인** + +Run: `npx vitest run test/config.test.ts` +Expected: PASS (기존 케이스 전부 유지) + +- [ ] **Step 8: Commit** + +```bash +git add src/lib/config.ts test/config.test.ts +git commit -m "feat: --mode / HOSTDOC_MODE pins the hosting mode (#39) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 2: 함정 자동 감지 — shadow 경고 (D) + +mode 미지정 파생 경로에서, `bucket`을 `domain`보다 높은 precedence로 줬는데도 cloudfront로 파생되면(= bucket이 mode 기준으로 무시됨) stderr 경고한다. 필드 출처 레벨을 `pick` 헬퍼로 추적한다. + +**Files:** +- Modify: `src/lib/config.ts` (AWS 4필드를 `pick`으로 병합, cloudfront 파생 분기에 shadow 경고) +- Test: `test/config.test.ts` + +**Interfaces:** +- Consumes: Task 1의 `resolveConfig(flags, { warn })` +- Produces: + - `pick(flag, env, file): { value: T | undefined; level: number }` — level: flag=0, env=1, file=2, absent=3 (낮을수록 우선) + +- [ ] **Step 1: 실패하는 테스트 작성 (shadow)** + +`test/config.test.ts`에 새 블록 추가: + +```ts +describe("resolveConfig shadow warning (higher-precedence bucket vs config domain)", () => { + it("warns when an env bucket is shadowed by a config-file domain (cloudfront)", () => { + saveConfig({ + mode: "cloudfront", + bucket: "fileb", + region: "us-east-1", + domain: "shared.example.com", + distributionId: "E1", + }); + process.env.HOSTDOC_BUCKET = "envb"; + process.env.HOSTDOC_REGION = "us-east-1"; + const warnings: string[] = []; + const cfg = resolveConfig({}, { warn: (m) => warnings.push(m) }); + expect(cfg.mode).toBe("cloudfront"); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/--mode s3-website/); + }); + + it("does not warn for a pure cloudfront config (all fields from file)", () => { + saveConfig({ + mode: "cloudfront", + bucket: "b", + region: "us-east-1", + domain: "d.example.com", + distributionId: "E1", + }); + const warnings: string[] = []; + resolveConfig({}, { warn: (m) => warnings.push(m) }); + expect(warnings).toHaveLength(0); + }); + + it("does not warn for a pure s3-website config", () => { + process.env.HOSTDOC_BUCKET = "b"; + process.env.HOSTDOC_REGION = "us-east-1"; + const warnings: string[] = []; + resolveConfig({}, { warn: (m) => warnings.push(m) }); + expect(warnings).toHaveLength(0); + }); + + it("does not warn for a self-hosted config", () => { + process.env.HOSTDOC_SERVE_ROOT = "/srv/www"; + const warnings: string[] = []; + resolveConfig({}, { warn: (m) => warnings.push(m) }); + expect(warnings).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: 테스트 실패 확인** + +Run: `npx vitest run test/config.test.ts -t "shadow warning"` +Expected: FAIL — 첫 케이스가 warn 0건(shadow 감지 미구현). + +- [ ] **Step 3: `pick` 헬퍼 추가** + +`src/lib/config.ts`의 `resolveConfig` 위에 헬퍼 추가: + +```ts +// Precedence source of a merged field. level: flag=0, env=1, file=2, absent=3 +// (lower = higher precedence). Used to detect a higher-precedence field being +// shadowed by a lower-precedence one during mode derivation. +function pick( + flag: T | undefined, + env: T | undefined, + file: T | undefined, +): { value: T | undefined; level: number } { + if (flag !== undefined) return { value: flag, level: 0 }; + if (env !== undefined) return { value: env, level: 1 }; + if (file !== undefined) return { value: file, level: 2 }; + return { value: undefined, level: 3 }; +} +``` + +- [ ] **Step 4: AWS 4필드를 `pick`으로 병합 + shadow 경고** + +`resolveConfig` 본문에서 `bucket/region/domain/distributionId` 4개의 `??` 병합을 `pick`으로 교체하고 값/레벨을 분리: + +```ts + const bucketS = pick(flags.bucket, process.env.HOSTDOC_BUCKET, file?.bucket); + const regionS = pick(flags.region, process.env.HOSTDOC_REGION, file?.region); + const domainS = pick(flags.domain, process.env.HOSTDOC_DOMAIN, file?.domain); + const distS = pick( + flags.distribution, + process.env.HOSTDOC_DISTRIBUTION, + file?.distributionId, + ); + const bucket = bucketS.value; + const region = regionS.value; + const domain = domainS.value; + const distributionId = distS.value; +``` + +그리고 **mode 미지정** 경로의 cloudfront 파생 분기(`if (domain && distributionId) { ... return { mode: "cloudfront", ... } }`)에서 `return` 직전에 shadow 경고 추가: + +```ts + if (domain && distributionId) { + if (!bucket || !region) { + throw new Error( + "Incomplete cloudfront config: bucket and region are required. Run `hostdoc init --from-terraform `.", + ); + } + // Shadow (D): bucket supplied at higher precedence than domain, yet domain + // still derives mode to cloudfront — the bucket is unused for mode selection. + if (bucketS.level < domainS.level) { + warn( + "'bucket' set at higher precedence than 'domain', but 'domain' resolves the mode to cloudfront (bucket ignored for mode). Use --mode s3-website to force s3-website.", + ); + } + return { mode: "cloudfront", bucket, region, domain, distributionId }; + } +``` + +- [ ] **Step 5: 테스트 통과 확인** + +Run: `npx vitest run test/config.test.ts -t "shadow warning"` +Expected: PASS (4 케이스) + +- [ ] **Step 6: 전체 테스트 스위트로 회귀 확인** + +Run: `npm test` +Expected: PASS (전 파일) + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/config.ts test/config.test.ts +git commit -m "feat: warn when a higher-precedence bucket is shadowed by a config domain (#39) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 3: CLI `--mode` 옵션 배선 + +`overrides()` 단일 빌더에 `mode`를 추가해 publish/open/rm/list/config 전 커맨드가 `--mode`를 받도록 한다. `HOSTDOC_MODE` env는 Task 1에서 이미 동작하므로 여기선 flag만 배선한다. + +**Files:** +- Modify: `src/index.ts` (import, `withCommon`, `overrides`) + +**Interfaces:** +- Consumes: Task 1의 `Overrides.mode`, `type Mode` + +- [ ] **Step 1: `type Mode` import 추가** + +`src/index.ts:9` 교체: + +```ts +import { infraDir, resolveConfig, type Mode } from "./lib/config.js"; +``` + +- [ ] **Step 2: `withCommon`에 `--mode` 옵션 추가** + +`withCommon`의 옵션 체인 끝(`.option("--scheme ...")` 뒤)에 추가: + +```ts + .option("--scheme ", "http|https (self-hosted mode)") + .option( + "--mode ", + "force hosting mode: s3-website|cloudfront|self-hosted", + ); +``` + +- [ ] **Step 3: `overrides()` 반환에 `mode` 추가** + +`overrides()` 반환 객체 끝(`scheme:` 뒤)에 추가: + +```ts + scheme: o.scheme as "http" | "https" | undefined, + mode: o.mode as Mode | undefined, + }; +``` + +- [ ] **Step 4: 빌드 + 타입체크** + +Run: `npm run build && npm run typecheck` +Expected: 오류 없이 완료 (`dist/` 생성, tsgo 통과) + +- [ ] **Step 5: `--mode` 플래그 노출 확인** + +Run: `node dist/index.js publish --help` +Expected: 출력에 `--mode ` 줄과 `force hosting mode` 설명 포함 + +- [ ] **Step 6: 강제 동작 스모크 (dry-run, 네트워크 없음)** + +Run: +```bash +XDG_CONFIG_HOME=$(mktemp -d) HOSTDOC_BUCKET=demo-bucket HOSTDOC_REGION=us-east-1 \ + node dist/index.js config --mode s3-website +``` +Expected: `mode: s3-website` 출력, `websiteEndpoint: http://demo-bucket.s3-website-us-east-1.amazonaws.com` 포함. + +- [ ] **Step 7: Commit** + +```bash +git add src/index.ts +git commit -m "feat: wire --mode flag into shared command options (#39) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 4: 문서화 + +README의 Configuration precedence 절에 `--mode`/`HOSTDOC_MODE`를 문서화한다. + +**Files:** +- Modify: `README.md:159-161` (Configuration precedence 절) + +- [ ] **Step 1: precedence 절에 mode 강제 설명 추가** + +`README.md`의 아래 블록을 + +```markdown +## Configuration precedence + +Flags (`--bucket/--region/...`) > `HOSTDOC_*` env vars > `~/.config/hostdoc/config.json`. +``` + +다음으로 교체: + +```markdown +## Configuration precedence + +Flags (`--bucket/--region/...`) > `HOSTDOC_*` env vars > `~/.config/hostdoc/config.json`. + +Precedence merges **fields**, but the **mode** is derived from the merged +result (`domain`+`distribution` → cloudfront; `serveRoot` → self-hosted; else +`bucket`+`region` → s3-website). So setting only `HOSTDOC_BUCKET` on top of a +cloudfront config file does **not** switch to s3-website — the file's `domain` +still wins the derivation, and the bucket is ignored for mode (hostdoc prints a +warning when it detects this). + +To pin the mode regardless of which fields merged in, use `--mode` or +`HOSTDOC_MODE` (`s3-website` | `cloudfront` | `self-hosted`): + +```bash +# force s3-website for a one-off, even though the config file is cloudfront +HOSTDOC_BUCKET=demo-bucket HOSTDOC_REGION=us-east-1 \ + hostdoc publish ./x.html --mode s3-website +``` + +A forced mode validates only that mode's required fields and ignores the +others (printing a note about what it ignored). +``` + +- [ ] **Step 2: 렌더 확인** + +Run: `sed -n '159,185p' README.md` +Expected: 새 문단·코드블록이 온전히 들어가고 코드펜스(```)가 짝이 맞음. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: document --mode / HOSTDOC_MODE and the mode-derivation footgun (#39) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Self-Review + +**Spec coverage:** +- B(`--mode`/`HOSTDOC_MODE`, 세 모드, flag>env, 파일 미저장) → Task 1(env+분기) + Task 3(flag 배선). ✓ +- ② 강제 규칙표(필수 검사·무시+경고·부족 시 에러) → Task 1 Step 5. ✓ +- D-1(강제 시 무시 경고) → Task 1(self-hosted/s3-website/cloudfront warn). ✓ +- D-2(shadow 감지, `bucket.level < domain.level` && cloudfront) → Task 2. ✓ +- ④ warn 주입·순수성·stderr → Task 1 Step 5(`opts.warn` 기본 stderr). ✓ +- 테스트 계획 8종 → Task 1(7 케이스: 강제 s3-website+warn, env 읽기, flag>env, cloudfront 필드부족 에러, s3-website 필드부족 에러, self-hosted+warn, invalid mode) + Task 2(4 케이스: shadow, pure cloudfront/s3-website/self-hosted 무경고). 스펙의 "8. flag>env" 포함. ✓ +- Non-goal(C 미채택, 상시 mode 출력 없음, 파일 mode 저장 없음) → 플랜 어디서도 도입 안 함. ✓ +- Acceptance(강제 가능/무시 경고/shadow 경고/회귀 없음/stdout 무오염/CI 무AWS) → Task 1·2 테스트 + Task 3 스모크. ✓ + +**Placeholder scan:** "기존 코드 그대로 유지"는 Task 1 Step 5에서 실제 변경 경계(중복 const 제거 + 파생 분기 유지)를 명시 — 모호한 TODO 아님. 그 외 placeholder 없음. ✓ + +**Type consistency:** `Mode`(config.ts export) → index.ts import 일치. `resolveConfig(flags, opts?)` 시그니처가 Task 1 정의·Task 2 사용·기존 호출부(`resolveConfig({})`)와 호환(opts optional). `pick` 반환 `{value, level}`가 Task 2 내부에서 일관 사용. `Overrides.mode?: Mode`가 Task 1 정의·Task 3 `o.mode as Mode` 캐스트와 일치. ✓ diff --git a/docs/superpowers/specs/2026-07-06-explicit-mode-control-design.md b/docs/superpowers/specs/2026-07-06-explicit-mode-control-design.md new file mode 100644 index 0000000..0b4d294 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-explicit-mode-control-design.md @@ -0,0 +1,121 @@ +# 명시적 mode 제어 + 함정 경고 + +- **이슈**: [#39](https://github.com/jkas2016/hostdoc/issues/39) — `HOSTDOC_BUCKET` env가 cloudfront config를 s3-website로 override 못함 (mode가 domain에서 파생되어 silent) +- **날짜**: 2026-07-06 +- **모드 영향**: 세 모드 공통 (mode 파생/precedence 로직). 발행·조회 동작 자체는 무변경 + +## 문제 + +mode는 저장되지 않고 **병합된 config에서 파생**된다([`src/lib/config.ts`](../../../src/lib/config.ts) `resolveConfig`): `domain`+`distributionId` → `cloudfront`, `serveRoot` → `self-hosted`, 그 외 `bucket`+`region` → `s3-website`. Precedence는 `flags > HOSTDOC_* env > config.json`인데, precedence는 **필드 단위로 병합**되고 mode는 **병합 결과**에서 파생된다. + +문제는 이 조합 하나다: 설정파일이 완전한 cloudfront config(`domain`+`distributionId`)인데, 사용자가 env/flag로 `bucket`+`region`만 얹는 경우. `bucket`은 cloudfront에서도 정상 필드(비공개 S3 오리진)라 병합 결과에 `domain`이 그대로 살아있고, mode는 `cloudfront`로 파생된다. 사용자가 준 `bucket`은 URL/mode 관점에서 **조용히 무시**된다. + +```bash +# ~/.config/hostdoc/config.json = { domain, distributionId } (cloudfront) +HOSTDOC_BUCKET=demo-bucket HOSTDOC_REGION=us-east-1 hostdoc publish ./x.html --dry-run +# 기대: http://demo-bucket.s3-website-... 실제: https://shared.example.com/... (cloudfront) +``` + +### 근본 원인 + +1. **s3-website로 강제할 수단이 없다.** `domain`을 *지우는* env/flag가 없어, cloudfront config를 가진 사용자가 일회성으로 s3-website를 강제할 방법이 없다. 유일한 우회는 `XDG_CONFIG_HOME`을 빈 디렉터리로 격리하는 것. +2. **조용하다.** 경고·에러 없이 예상과 다른 mode로 실행된다. CLAUDE.md는 `HOSTDOC_*`가 config를 override해 "bring-your-own infra를 가리킬 수 있다"고 문서화하는데, mode를 결정하는 필드는 이 약속이 깨진다 = footgun. + +`resolveConfig`는 이미 "조용히 mode를 고르지 말고 드러내라" 철학을 갖는다(serveRoot+AWS 혼용 에러 [config.ts:100](../../../src/lib/config.ts), domain XOR distributionId 에러 [config.ts:112](../../../src/lib/config.ts)). 남은 이 한 케이스만 그 철학에서 벗어나 있다. + +## 기대 동작 + +1. **강제(B):** `--mode` / `HOSTDOC_MODE`로 mode를 명시하면, 병합된 필드와 무관하게 그 mode로 못 박는다. +2. **드러냄(D):** mode 강제 시 안 쓰는 필드를 무시하면 그 사실을, mode 미지정인데 higher-precedence `bucket`이 lower-precedence `domain`에 가려지면 그 함정을 stderr로 경고한다. 정상 진행은 유지(에러로 막지 않음). + +## 설계 + +### 변경 대상 + +- `src/lib/config.ts` — `Overrides`에 `mode` 추가, `pick` 헬퍼(출처 레벨 추적), `resolveConfig` mode 강제 분기 + 경고, `warn` 주입 파라미터 +- `src/index.ts` — `--mode ` 전역 옵션 등록, 커맨드들이 `Overrides`로 전달 +- `test/config.test.ts` — mode 강제/무시-경고/함정-감지/회귀 케이스 +- `README.md` + config 문서 — `--mode`/`HOSTDOC_MODE` 문서화 + +### ① `--mode` / `HOSTDOC_MODE` (B) + +- 값: `s3-website` | `cloudfront` | `self-hosted` (**세 모드 전부**, 대칭). 그 외 값은 명확한 에러. +- Precedence: `--mode` flag > `HOSTDOC_MODE` env. **파일에 저장하지 않는다** — mode는 파생값 원칙 유지(`resolveConfig`는 파일의 `mode` 필드를 읽지 않는다). +- mode 미지정 시: **기존 파생 로직 그대로**(하위호환 100%). + +### ② mode 강제 시 검증·무시 규칙 + +강제한 mode의 필수 필드만 검사하고, 그 mode에 안 쓰는 필드가 병합 결과에 남아있으면 **무시 + 경고**한다. + +| 강제 mode | 필수 필드 | 안 쓰는 필드가 있으면 | +|---|---|---| +| `s3-website` | `bucket` + `region` | `domain`·`distributionId`·`serveRoot` → 무시 + 경고 / 필수 부족 → 에러 | +| `cloudfront` | `bucket` + `region` + `domain` + `distributionId` | `serveRoot` → 무시 + 경고 / 필수 부족 → 에러 | +| `self-hosted` | `serveRoot` | `bucket`·`region`·`domain`·`distributionId` → 무시 + 경고 / 필수 부족 → 에러 | + +즉 mode 강제는 기존 "혼용은 에러"(serveRoot+AWS) 규칙보다 **약한 우선순위** — 명시적으로 mode를 골랐으니 혼용을 에러 대신 "무시 + 경고"로 완화한다. 이슈의 핵심 시나리오(`HOSTDOC_MODE=s3-website`로 cloudfront config를 눌러 s3-website 강제)가 여기서 풀린다. + +### ③ 함정 자동 감지 (D) + +mode 미지정 + 최종 파생 mode가 `cloudfront` + `bucket`이 `domain`보다 **높은 precedence**에서 온 경우 → shadow 경고. 감지하려면 각 필드의 출처 레벨이 필요한데, 현재 `??` 병합은 그 정보를 버린다. 작은 헬퍼로 추적한다: + +```ts +// level: 낮을수록 우선 (flag=0, env=1, file=2, 없음=3) +function pick(flag: T | undefined, env: T | undefined, file: T | undefined): + { value: T | undefined; level: number } { + if (flag !== undefined) return { value: flag, level: 0 }; + if (env !== undefined) return { value: env, level: 1 }; + if (file !== undefined) return { value: file, level: 2 }; + return { value: undefined, level: 3 }; +} +``` + +`bucket.level < domain.level`(bucket을 더 높은 우선순위로 줬는데) && 최종 mode가 cloudfront → 경고: +`warning: 'bucket' set at higher precedence than 'domain', but domain resolves mode to cloudfront. Use --mode s3-website to force s3-website.` + +### ④ 경고 흘리는 방식 (순수성·테스트) + +`resolveConfig`를 순수하게 유지하기 위해 경고 출력을 주입한다: + +```ts +export function resolveConfig(flags: Overrides, opts?: { warn?: (msg: string) => void }): Config +// 기본 warn = (m) => process.stderr.write(`hostdoc: ${m}\n`) +``` + +기존 호출부 `resolveConfig(flags)`는 무변경으로 동작(경고는 기본 stderr). 테스트는 `warn`에 배열 push 콜백을 주입해 경고를 검증한다. 경고는 **stderr 전용** — stdout의 발행 링크를 오염시키지 않는다. + +## 테스트 계획 (TDD: 먼저 작성) + +`test/config.test.ts` (전부 AWS·네트워크·Terraform 없음, 기존 env-주입 방식): + +1. `HOSTDOC_MODE=s3-website` + 파일 cloudfront(`domain`+`distributionId`) + `bucket`/`region` 존재 → mode `s3-website`, 경고 1건(domain/distribution 무시) +2. `--mode s3-website`인데 `bucket`/`region` 없음 → 에러 +3. `--mode cloudfront`인데 `domain`/`distributionId` 부족 → 에러 +4. `--mode self-hosted` + serveRoot + 잔여 AWS 필드 → self-hosted, 경고 1건 +5. 함정 감지: env `bucket`/`region` + 파일 `domain`/`distributionId`, mode 미지정 → cloudfront + shadow 경고 1건 +6. 회귀: mode 미지정 & 함정 아님(순수 s3-website, 순수 cloudfront, self-hosted) → 경고 0건 +7. `--mode foo`(잘못된 값) → 명확한 에러 +8. Precedence: `--mode` flag가 `HOSTDOC_MODE` env를 이김 + +## Non-goals + +- **Override 의미 변경(방향 C)**: higher-precedence `bucket`이 자동으로 mode를 뒤집게 하는 것 — 채택 안 함. `bucket`은 cloudfront에서도 정상 필드라 더 놀랍고 위험. 대신 명시적 `--mode`(B) + 경고(D)로 해결. +- **발행 시 항상 mode 출력**: `hostdoc config`가 이미 mode를 출력. 발행 흐름엔 함정 경고만 얹고 상시 출력은 노이즈라 제외. +- 파일에 `mode` 저장 / mode 파생 원칙 변경. + +## Acceptance criteria + +- [ ] `HOSTDOC_MODE=s3-website`(또는 `--mode`)로 cloudfront config에서 s3-website를 강제할 수 있다 +- [ ] 강제 시 무시된 필드를 stderr로 경고한다 (정상 진행 유지) +- [ ] mode 미지정 시 함정(higher-precedence bucket이 domain에 가려짐)을 stderr로 경고한다 +- [ ] mode 미지정 & 함정 아닌 기존 경로는 경고 없이 무변경 (회귀 없음) +- [ ] stdout 발행 링크는 경고에 오염되지 않는다 +- [ ] CI는 AWS creds·네트워크·Terraform 없이 통과 유지 + +## References + +- `src/lib/config.ts` — `resolveConfig`, mode 파생 + precedence, 기존 "surface, don't silently pick" 가드(serveRoot+AWS, domain XOR distributionId) +- `src/commands/config.ts` — `describeConfig`(이미 resolved mode 출력) +- `src/index.ts` — 전역 옵션·`Overrides` 전달 지점 +- CLAUDE.md → Architecture(mode 파생), Config precedence +- 이슈 #39 (4가지 방향 중 B+D 채택, C 기각) diff --git a/src/commands/publish.ts b/src/commands/publish.ts index 145e86d..5733e7e 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import { resolveConfig } from "../lib/config.js"; +import { resolveConfig, type Mode } 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"; @@ -26,6 +26,7 @@ export interface PublishArgs { host?: string; port?: number; scheme?: "http" | "https"; + mode?: Mode; } async function uniqueCode(backend: StorageBackend): Promise { @@ -52,6 +53,7 @@ export async function runPublish(args: PublishArgs): Promise { host: args.host, port: args.port, scheme: args.scheme, + mode: args.mode, })); const backend = makeBackend(cfg, { profile: args.profile }); const uploads = await collectUploads(args.path); diff --git a/src/index.ts b/src/index.ts index f90404e..75707a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ import { runInit } from "./commands/init.js"; import { runProvision } from "./commands/provision.js"; import { runDeprovision } from "./commands/deprovision.js"; import { runDeprovisionSelfHosted } from "./commands/deprovision-selfhosted.js"; -import { infraDir, resolveConfig } from "./lib/config.js"; +import { infraDir, resolveConfig, type Mode } from "./lib/config.js"; import { runPublish } from "./commands/publish.js"; import { listDocs, formatRows } from "./commands/list.js"; import { runRm } from "./commands/rm.js"; @@ -44,7 +44,11 @@ function withCommon(cmd: Command): Command { .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)"); + .option("--scheme ", "http|https (self-hosted mode)") + .option( + "--mode ", + "force hosting mode: s3-website|cloudfront|self-hosted", + ); } function overrides(o: OptionValues) { @@ -58,6 +62,7 @@ function overrides(o: OptionValues) { host: o.host as string | undefined, port: o.port ? Number(o.port) : undefined, scheme: o.scheme as "http" | "https" | undefined, + mode: o.mode as Mode | undefined, }; } diff --git a/src/lib/config.ts b/src/lib/config.ts index 7321390..90ad2db 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { websiteEndpoint } from "./url.js"; export type Mode = "s3-website" | "cloudfront" | "self-hosted"; +const MODES: Mode[] = ["s3-website", "cloudfront", "self-hosted"]; export interface Config { mode: Mode; @@ -30,6 +31,7 @@ export interface Overrides { host?: string; port?: number; scheme?: "http" | "https"; + mode?: Mode; } export function configPath(): string { @@ -72,16 +74,40 @@ export function loadConfig(): Config | null { return parsed as Config; } +// Precedence source of a merged field. level: flag=0, env=1, file=2, absent=3 +// (lower = higher precedence). Used to detect a higher-precedence field being +// shadowed by a lower-precedence one during mode derivation. +function pick( + flag: T | undefined, + env: T | undefined, + file: T | undefined, +): { value: T | undefined; level: number } { + if (flag !== undefined) return { value: flag, level: 0 }; + if (env !== undefined) return { value: env, level: 1 }; + if (file !== undefined) return { value: file, level: 2 }; + return { value: undefined, level: 3 }; +} + /** Merge file < env < flags, then derive mode and required fields. */ -export function resolveConfig(flags: Overrides): Config { +export function resolveConfig( + flags: Overrides, + opts: { warn?: (msg: string) => void } = {}, +): Config { + const warn = + opts.warn ?? ((m: string) => process.stderr.write(`hostdoc: ${m}\n`)); const file = loadConfig(); - const bucket = flags.bucket ?? process.env.HOSTDOC_BUCKET ?? file?.bucket; - const region = flags.region ?? process.env.HOSTDOC_REGION ?? file?.region; - const domain = flags.domain ?? process.env.HOSTDOC_DOMAIN ?? file?.domain; - const distributionId = - flags.distribution ?? - process.env.HOSTDOC_DISTRIBUTION ?? - file?.distributionId; + const bucketS = pick(flags.bucket, process.env.HOSTDOC_BUCKET, file?.bucket); + const regionS = pick(flags.region, process.env.HOSTDOC_REGION, file?.region); + const domainS = pick(flags.domain, process.env.HOSTDOC_DOMAIN, file?.domain); + const distS = pick( + flags.distribution, + process.env.HOSTDOC_DISTRIBUTION, + file?.distributionId, + ); + const bucket = bucketS.value; + const region = regionS.value; + const domain = domainS.value; + const distributionId = distS.value; const serveRoot = flags.serveRoot ?? process.env.HOSTDOC_SERVE_ROOT ?? file?.serveRoot; @@ -95,6 +121,67 @@ export function resolveConfig(flags: Overrides): Config { (process.env.HOSTDOC_SCHEME as "http" | "https" | undefined) ?? file?.scheme; + // --- Explicit mode pin (B): --mode > HOSTDOC_MODE. Never read from file. --- + // Accept any case (e.g. S3-Website, CLOUDFRONT); normalize before matching, + // but echo the user's original spelling in the error. + const forcedRaw = flags.mode ?? process.env.HOSTDOC_MODE; + const forced = forcedRaw?.toLowerCase(); + if (forcedRaw !== undefined && !MODES.includes(forced as Mode)) { + throw new Error( + `Invalid --mode / HOSTDOC_MODE: '${forcedRaw}'. Expected one of: ${MODES.join(", ")}.`, + ); + } + + if (forced === "self-hosted") { + if (!serveRoot) { + throw new Error( + "Forced mode 'self-hosted' requires serveRoot (--serve-root / HOSTDOC_SERVE_ROOT).", + ); + } + if (bucket || region || domain || distributionId) { + warn( + "--mode self-hosted: ignoring bucket/region/domain/distribution from config/env.", + ); + } + return { mode: "self-hosted", serveRoot, host, port, scheme }; + } + + if (forced === "s3-website") { + if (!bucket || !region) { + throw new Error( + "Forced mode 's3-website' requires bucket and region (--bucket/--region or HOSTDOC_BUCKET/HOSTDOC_REGION).", + ); + } + if (domain || distributionId || serveRoot) { + warn( + "--mode s3-website: ignoring domain/distribution/serveRoot from config/env.", + ); + } + return { + mode: "s3-website", + bucket, + region, + websiteEndpoint: websiteEndpoint(bucket, region), + }; + } + + if (forced === "cloudfront") { + if (!domain || !distributionId) { + throw new Error( + "Forced mode 'cloudfront' requires domain and distributionId (--domain/--distribution or HOSTDOC_DOMAIN/HOSTDOC_DISTRIBUTION).", + ); + } + if (!bucket || !region) { + throw new Error( + "Forced mode 'cloudfront' also requires bucket and region. Run `hostdoc init --from-terraform `.", + ); + } + if (serveRoot) { + warn("--mode cloudfront: ignoring serveRoot from config/env."); + } + return { mode: "cloudfront", bucket, region, domain, distributionId }; + } + // serveRoot is the self-hosted discriminator. Mixing it with AWS fields is a // mistake we surface rather than silently pick a mode. if (serveRoot) { @@ -123,6 +210,13 @@ export function resolveConfig(flags: Overrides): Config { "Incomplete cloudfront config: bucket and region are required. Run `hostdoc init --from-terraform `.", ); } + // Shadow (D): bucket supplied at higher precedence than domain, yet domain + // still derives mode to cloudfront — the bucket is unused for mode selection. + if (bucketS.level < domainS.level) { + warn( + "'bucket' set at higher precedence than 'domain', but 'domain' resolves the mode to cloudfront (bucket ignored for mode). Use --mode s3-website to force s3-website.", + ); + } return { mode: "cloudfront", bucket, region, domain, distributionId }; } diff --git a/test/config.test.ts b/test/config.test.ts index 53e72fc..5d98a3d 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -30,6 +30,7 @@ const ENV_KEYS = [ "HOSTDOC_HOST", "HOSTDOC_PORT", "HOSTDOC_SCHEME", + "HOSTDOC_MODE", ]; const saved: Record = {}; @@ -174,3 +175,145 @@ describe("resolveConfig", () => { ).toThrow(/Ambiguous|self-hosted/i); }); }); + +describe("resolveConfig forced mode (--mode / HOSTDOC_MODE)", () => { + it("forces s3-website over a cloudfront config file, warning about ignored fields", () => { + saveConfig({ + mode: "cloudfront", + bucket: "b", + region: "us-east-1", + domain: "shared.example.com", + distributionId: "E1", + }); + const warnings: string[] = []; + const cfg = resolveConfig( + { mode: "s3-website" }, + { warn: (m) => warnings.push(m) }, + ); + expect(cfg.mode).toBe("s3-website"); + expect(cfg.websiteEndpoint).toBe( + "http://b.s3-website-us-east-1.amazonaws.com", + ); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/ignoring domain\/distribution/i); + }); + + it("reads forced mode from HOSTDOC_MODE env", () => { + saveConfig({ + mode: "cloudfront", + bucket: "b", + region: "us-east-1", + domain: "d.example.com", + distributionId: "E1", + }); + process.env.HOSTDOC_MODE = "s3-website"; + // warn is stubbed: this case asserts mode resolution, not the ignored-field + // warning (covered separately) — keep test output pristine. + expect(resolveConfig({}, { warn: () => {} }).mode).toBe("s3-website"); + }); + + it("--mode flag beats HOSTDOC_MODE env", () => { + process.env.HOSTDOC_MODE = "self-hosted"; + const cfg = resolveConfig({ mode: "s3-website", bucket: "b", region: "us-east-1" }); + expect(cfg.mode).toBe("s3-website"); + }); + + it("forced cloudfront without domain/distribution errors", () => { + process.env.HOSTDOC_BUCKET = "b"; + process.env.HOSTDOC_REGION = "us-east-1"; + expect(() => resolveConfig({ mode: "cloudfront" })).toThrow( + /requires domain and distributionId/i, + ); + }); + + it("forced s3-website without bucket/region errors", () => { + expect(() => resolveConfig({ mode: "s3-website", serveRoot: "/srv" })).toThrow( + /requires bucket and region/i, + ); + }); + + it("forces self-hosted and warns about ignored AWS fields", () => { + const warnings: string[] = []; + const cfg = resolveConfig( + { mode: "self-hosted", serveRoot: "/srv/www", bucket: "b", region: "us-east-1" }, + { warn: (m) => warnings.push(m) }, + ); + expect(cfg.mode).toBe("self-hosted"); + expect(cfg.serveRoot).toBe("/srv/www"); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/ignoring bucket\/region/i); + }); + + it("rejects an invalid forced mode value", () => { + expect(() => resolveConfig({ mode: "foo" as never })).toThrow(/Invalid.*mode/i); + }); + + it("normalizes the forced mode value case-insensitively (flag)", () => { + saveConfig({ + mode: "cloudfront", + bucket: "b", + region: "us-east-1", + domain: "d.example.com", + distributionId: "E1", + }); + const cfg = resolveConfig({ mode: "S3-Website" as never }, { warn: () => {} }); + expect(cfg.mode).toBe("s3-website"); + }); + + it("normalizes the forced mode value case-insensitively (env)", () => { + process.env.HOSTDOC_SERVE_ROOT = "/srv/www"; + process.env.HOSTDOC_MODE = "SELF-HOSTED"; + expect(resolveConfig({}).mode).toBe("self-hosted"); + }); + + it("echoes the original (un-normalized) value in the invalid-mode error", () => { + expect(() => resolveConfig({ mode: "Foo" as never })).toThrow(/'Foo'/); + }); +}); + +describe("resolveConfig shadow warning (higher-precedence bucket vs config domain)", () => { + it("warns when an env bucket is shadowed by a config-file domain (cloudfront)", () => { + saveConfig({ + mode: "cloudfront", + bucket: "fileb", + region: "us-east-1", + domain: "shared.example.com", + distributionId: "E1", + }); + process.env.HOSTDOC_BUCKET = "envb"; + process.env.HOSTDOC_REGION = "us-east-1"; + const warnings: string[] = []; + const cfg = resolveConfig({}, { warn: (m) => warnings.push(m) }); + expect(cfg.mode).toBe("cloudfront"); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/--mode s3-website/); + }); + + it("does not warn for a pure cloudfront config (all fields from file)", () => { + saveConfig({ + mode: "cloudfront", + bucket: "b", + region: "us-east-1", + domain: "d.example.com", + distributionId: "E1", + }); + const warnings: string[] = []; + resolveConfig({}, { warn: (m) => warnings.push(m) }); + expect(warnings).toHaveLength(0); + }); + + it("does not warn for a pure s3-website config", () => { + process.env.HOSTDOC_BUCKET = "b"; + process.env.HOSTDOC_REGION = "us-east-1"; + const warnings: string[] = []; + resolveConfig({}, { warn: (m) => warnings.push(m) }); + expect(warnings).toHaveLength(0); + }); + + it("does not warn for a self-hosted config", () => { + process.env.HOSTDOC_SERVE_ROOT = "/srv/www"; + const warnings: string[] = []; + resolveConfig({}, { warn: (m) => warnings.push(m) }); + expect(warnings).toHaveLength(0); + }); +}); diff --git a/test/publish.test.ts b/test/publish.test.ts index 2099707..f471c26 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -86,6 +86,26 @@ describe("runPublish", () => { ).rejects.toThrow(/slug/i); }); + it("threads the mode override so --mode forces s3-website over a cloudfront config", async () => { + // ambient config is cloudfront (env domain+distribution on top of bucket+region) + process.env.HOSTDOC_DOMAIN = "shared.example.com"; + process.env.HOSTDOC_DISTRIBUTION = "DIST1"; + writeFileSync(join(dir, "index.html"), "x"); + + const url = await runPublish({ + path: dir, + slug: "doc1", + dryRun: true, + mode: "s3-website", + }); + // without the mode override reaching resolveConfig this would be the + // cloudfront URL https://shared.example.com/doc1/ + expect(url).toBe("http://b.s3-website-us-east-1.amazonaws.com/doc1/"); + + delete process.env.HOSTDOC_DOMAIN; + delete process.env.HOSTDOC_DISTRIBUTION; + }); + it("invalidates //* when overwriting in cloudfront mode", async () => { process.env.HOSTDOC_DOMAIN = "shared.example.com"; process.env.HOSTDOC_DISTRIBUTION = "DIST1";