diff --git a/README.md b/README.md index c90e032..1ee4888 100644 --- a/README.md +++ b/README.md @@ -16,21 +16,21 @@ brew install zigbang-smarthome/tap/eac-cli curl -fsSL https://github.com/zigbang-smarthome/eac-cli/releases/latest/download/install.sh | sh ``` -Before first use: open Chrome and log in to `https://eac.zigbang.in` at least once so the session cookie lands in your Chrome cookie store. The CLI reads that cookie each run. +Before first use: open one of the supported browsers (Chrome / Edge / Brave) and log in to `https://eac.zigbang.in` at least once so the session cookie lands in that browser's cookie store. The CLI reads the cookie each run. ## Authentication -- EAC uses Google SSO + MS Azure AD SAML — login itself can't be automated. The CLI piggybacks on your normal Chrome session. -- Each run, `eac` copies Chrome's cookie DB to tmpdir, decrypts the `JSESSIONID` for `*.zigbang.in` (AES-128-CBC, key from `"Chrome Safe Storage"` Keychain), and pings EAC to confirm the session is alive. -- **If the session is missing or expired**, the CLI opens Chrome at `https://eac.zigbang.in/unidocu/view.do` and prompts: - 1. Finish the Google SSO login in Chrome. - 2. **Quit Chrome (Cmd+Q)** so the new `JSESSIONID` is flushed to disk. Chrome's cookie monster keeps cookies in memory and writes to the SQLite store on a delayed batch schedule — without an explicit quit the CLI may keep reading a stale value. +- EAC uses Google SSO + MS Azure AD SAML — login itself can't be automated. The CLI piggybacks on your normal browser session. +- Each run, `eac` walks a list of supported Chromium-family browsers (Chrome → Edge → Brave), copies the first cookie DB it finds to tmpdir, decrypts the `JSESSIONID` for `*.zigbang.in` (AES-128-CBC, key from `" Safe Storage"` Keychain entry), and pings EAC to confirm the session is alive. Force a specific browser with `EAC_BROWSER=chrome|edge|brave`. +- **If the session is missing or expired**, the CLI opens the detected browser at `https://eac.zigbang.in/unidocu/view.do` and prompts: + 1. Finish the Google SSO login in the browser. + 2. **Quit that browser (Cmd+Q)** so the new `JSESSIONID` is flushed to disk. Chromium-family cookie monsters keep cookies in memory and write to the SQLite store on a delayed batch schedule — without an explicit quit the CLI may keep reading a stale value. 3. Return to the terminal and hit Enter — the CLI re-reads the fresh cookie and continues. - 4. Reopen Chrome normally afterwards. + 4. Reopen the browser normally afterwards. - Tip: turn on Chrome → Settings → On startup → *"Continue where you left off"* so the session cookie survives the Cmd+Q cycle. EAC re-login becomes infrequent (only when the SAP-side session truly expires). + Tip: in your browser's startup settings, enable "Continue where you left off" so the session cookie survives the Cmd+Q cycle. EAC re-login becomes infrequent (only when the SAP-side session truly expires). - macOS may prompt for Keychain access the first time (click *Always Allow*). -- Override: set `EAC_JSESSIONID=` to bypass the keychain entirely and use a cookie sourced elsewhere (e.g. extracted from Playwright/CDP-controlled Chrome). Useful when EAC is open in a non-default Chrome profile or in CI scripts. +- Override: set `EAC_JSESSIONID=` to bypass the keychain entirely and use a cookie sourced elsewhere (e.g. extracted from Playwright/CDP-controlled browser). Useful in non-default profiles or CI scripts. - Non-TTY environments (CI, piped scripts) error out instead of prompting — pass `EAC_JSESSIONID` explicitly there. --- @@ -185,7 +185,27 @@ eac call ZUNIEFI_4207 --prog DRAFT_0010 --data '{"GRONO":"FI20260000023922"}' ### `eac config` - `config show` — 현재 config JSON 프린트 -- `config init` — `~/.config/eac/config.json` 없으면 기본값 생성 +- `config init` — EAC 세션만으로 본인의 SAP 필드를 자동 탐지해서 `~/.config/eac/config.json` 작성. 매칭된 결재선의 결재자 체인을 함께 출력해서 사람이 한눈에 검증 가능. + - **자동 탐지**: + - `PERNR`/`BUKRS`/`KOSTL` (저장) ← view.do `staticProperties.user` JSON + - `pernrName`/`wfIdText`/`kostlText` (저장 안 함, **매 실행 시 live**) ← 같은 view.do payload 에서 loadCtx 가 매번 refresh + - `items.*.wfLineSeq`/`wfLineLin1` ← 개인결재선(ZUNIEWF_2200) 의 `SEQ_TXT` 와 ITEM_PRESETS 의 `evikbText` 매칭 — 여러 item 이 같은 EVIKB 공유 시 동일한 결재선 식별자 주입 + - `items.*.hkont`/`evikb`/`*_Text` ← 코드의 `ITEM_PRESETS` (회사 정책 고정값) + - cache-bust (`requireBust`/`webDataCacheBust`) ← 같은 view.do 응답. 서버가 주기적으로 회전시키므로 hardcode 하지 않음. + - **검증 출력**: 매칭된 각 결재선에 대해 `결재자: 1차 → 2차 ` + 그 결재선을 공유하는 item 목록을 그룹핑해서 표시. + - **PERNR 변경 감지**: stored PERNR ≠ 세션 PERNR 이면 commands 전체가 hard error (다른 사람 PERNR 로 SAP 호출하는 사고 방지). `init --force` 또는 브라우저 세션 정리 필요. + - `--force` 기존 config 덮어쓰기 (PERNR 동일 시 `items.*` 머지) + - `--print` 파일 쓰지 않고 stdout으로만 출력 (dry-run) + - `--yes` 비대화형 — 자동 탐지값을 묻지 않고 그대로 사용 + +### 지원 브라우저 cookie 경로 + +자동 탐지 우선순위: +- Google Chrome (`~/Library/Application Support/Google/Chrome/Default/Cookies`) +- Microsoft Edge (`~/Library/Application Support/Microsoft Edge/Default/Cookies`) +- Brave (`~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Cookies`) + +`EAC_BROWSER=chrome|edge|brave` 로 강제 지정 시 그 브라우저만 시도. `Default` 프로필만 본다 — 다른 프로필을 쓰는 경우 `EAC_JSESSIONID` 직접 주입. --- @@ -304,19 +324,14 @@ eac approval line approvers 0000000002 --grono FI20260000023922 ## Config -`~/.config/eac/config.json`. 한 번 설정하고 `--item ` 으로 재사용. +`~/.config/eac/config.json`. `eac config init` 으로 생성. 한 번 설정하고 `--item ` 으로 재사용. ```json { "user": { - "pernr": "ZB01135", + "pernr": "", "bukrs": "K001", - "pernrName": "박영걸", - "wfIdText": "YG Park (박영걸)", - "kostl": "226020", - "kostlText": "Device Engineering", - "wfDept": "0000252100", - "wfDeptText": "Service Engineering" + "kostl": "" }, "items": { "자기관리비": { @@ -324,19 +339,39 @@ eac approval line approvers 0000000002 --grono FI20260000023922 "hkontText": "판)복리후생비-자기관리비", "evikb": "FI_21", "evikbText": "장려지원금", - "wfLineSeq": "0000000002", - "wfLineLin1": "0000000816" + "wfLineSeq": "", + "wfLineLin1": "" } } } ``` -새 item 추가 시 필요한 값: +저장 필드는 식별자(IDs)만: +- `user`: `pernr` / `bukrs` / `kostl` — SAP 식별자. 표시 이름/부서명 등 *_Text 라벨은 매 실행 시 `view.do` 의 `staticProperties.user` 에서 live 로 보충되므로 저장 안 함. +- `items.*`: `wfLineSeq` / `wfLineLin1` 는 사용자별 개인결재선 식별자. 나머지(`hkont`/`evikb`/`*_Text`) 는 회사 정책 정적값이라 코드의 `ITEM_PRESETS` 가 들고 있고 init 때 같이 직렬화된다. + +### 내장 ITEM_PRESETS + +`eac config init` 이 다음 item 이름을 인식해서 [개인]-결재선과 자동 매핑한다 (회사 정책 기반): + +| Item 이름 | EVIKB | HKONT | HKONT_TXT | 비고 | +|---|---|---|---|---| +| `자기관리비` | FI_21 (장려지원금) | 52010108 | 판)복리후생비-자기관리비 | | +| `가족회식비` | FI_21 (장려지원금) | 52010109 | 판)복리후생비-가족식사비 | 서버 라벨은 "가족식사비" | +| `원격근무지원비` | FI_21 (장려지원금) | 53050102 | 판)소모품비-사무용품 | 복리후생비 계정 아님 | +| `법인카드` | FI_12 (법인카드기명식) | 52010102 (placeholder) | 판)복리후생비-회식대 | corpcard create 시 카드별 HKONT 별도 지정 | + +> 체력단련비는 EAC가 아닌 flex 에서 처리하므로 preset 없음. + +같은 EVIKB(예: FI_21)를 쓰는 3 item (자기관리비/가족회식비/원격근무지원비)은 **하나의 `[개인]-장려지원금` 결재선을 공유**한다. init 이 결재선을 한 번 발견한 뒤 셋 모두에 동일한 `wfLineSeq`/`wfLineLin1` 을 주입한다. + +### 새 item 추가 + +ITEM_PRESETS 에 없는 item 을 사용하려면 `src/lib/config.ts` 의 `ITEM_PRESETS` 에 한 줄 추가하고 재빌드: - `hkont` — G/L 계정 (e.g. `52010177` 판)복리후생비-기타) - `evikb` — 전표 종류 코드 (e.g. `FI_21` 장려지원금, `FI_22` 일반경비) -- `wfLineSeq` / `wfLineLin1` — 해당 item의 개인결재선 (참고: `eac approval line list`로 확인) -Preset key는 한글 이름(`자기관리비`, `가족식사비`, `원격근무지원비`, …) 사용 권장. +그 다음 EAC UI 에서 `[개인]-` 결재선을 등록(이미 있으면 skip)하고 `eac config init --force` 로 wfLineSeq 자동 매핑. --- diff --git a/src/commands/config.ts b/src/commands/config.ts index 7e1170c..903739a 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,25 +1,231 @@ +/** + * `eac config` — per-user config management. + * + * eac config show print effective config + path + * eac config init [--force] [--print] discover from EAC session, prompt + * only for things the server doesn't + * expose (in practice: nothing — but + * we still confirm each value). + */ + import { defineCommand } from "citty"; -import { loadConfig, saveConfig, configPath, DEFAULT_CONFIG } from "../lib/config.ts"; +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import { + loadConfig, saveConfig, configPath, toDiskShape, EMPTY_CONFIG, ITEM_PRESETS, + type EacConfig, +} from "../lib/config.ts"; +import { ensureSession } from "../lib/auth.ts"; +import { fetchViewBootstrap } from "../lib/whoami.ts"; +import { + listPersonalLines, readApprovalLineApprovers, + type PersonalLine, type ApprovalLineMember, +} from "../lib/ops.ts"; +import type { ClientContext } from "../lib/client.ts"; +import type { ReimbursementItem } from "../types/index.ts"; const showCommand = defineCommand({ - meta: { name: "show", description: "Print current config (merged with defaults)." }, + meta: { name: "show", description: "Print current config (disk shape, identifiers only)." }, async run() { - const cfg = (await loadConfig()) ?? DEFAULT_CONFIG; + const cfg = (await loadConfig()) ?? EMPTY_CONFIG; console.log(`path: ${configPath()}`); - console.log(JSON.stringify(cfg, null, 2)); + console.log(JSON.stringify(toDiskShape(cfg), null, 2)); }, }); +/** Format an approver chain for display: "1차 박영걸 → 2차 김지섭 → 3차 안성우". */ +function formatChain(members: ApprovalLineMember[]): string { + const ordered = [...members].sort( + (a, b) => parseInt(a.WF_LINE_LEV || "0", 10) - parseInt(b.WF_LINE_LEV || "0", 10), + ); + return ordered + .map((m) => { + const name = (m.WF_ID_TXT ?? "").replace(/^.+\(([^)]+)\)$/, "$1") || m.WF_ID_TXT || m.WF_ID || "?"; + return `${m.WF_LINE_LEV}차 ${name}`; + }) + .join(" → ") || "(결재자 없음)"; +} + const initCommand = defineCommand({ - meta: { name: "init", description: "Write default config to ~/.config/eac/config.json." }, - async run() { + meta: { + name: "init", + description: "Discover SAP fields from your EAC session and write ~/.config/eac/config.json.", + }, + args: { + force: { type: "boolean", description: "overwrite existing config (existing items.* presets are merged)" }, + print: { type: "boolean", description: "print resulting config to stdout instead of writing" }, + yes: { type: "boolean", description: "non-interactive — accept all auto-detected values without prompting" }, + gsber: { type: "string", description: "GSBER (Business Area) override — needed when view.do doesn't push it. Check EA전표작성 > 사업영역 드롭다운." }, + }, + async run({ args }) { const existing = await loadConfig(); - if (existing) { - console.error(`config already exists at ${configPath()} — refusing to overwrite`); + if (existing && !args.force && !args.print) { + console.error(`config already exists at ${configPath()}`); + console.error(` use --force to overwrite (existing items.* presets are merged), or --print for a dry run`); process.exit(1); } - await saveConfig(DEFAULT_CONFIG); - console.log(`wrote default config to ${configPath()}`); + + const interactive = !args.yes && process.stdin.isTTY; + const rl = interactive ? createInterface({ input, output }) : null; + const ask = async (label: string, fallback: string): Promise => { + if (!rl) return fallback; + const ans = (await rl.question(fallback ? ` ${label} [${fallback}]: ` : ` ${label}: `)).trim(); + return ans || fallback; + }; + + try { + // 1) Session + console.error("→ EAC 세션 확인 중..."); + const jsessionid = await ensureSession(); + + // 2) Pull the full user profile + cache-bust from view.do `staticProperties` + console.error("→ view.do 에서 사용자 프로필 / cache-bust 추출..."); + const boot = await fetchViewBootstrap(jsessionid); + if (!boot) { + console.error(" ✗ staticProperties.user 파싱 실패 — 서버 HTML 구조가 바뀐 것 같다."); + console.error(" 수동으로 ~/.config/eac/config.json 을 작성해야 한다. README 참고."); + process.exit(1); + } + const me = boot.user; + console.error(" ✓ 자동 탐지된 값:"); + console.error(` PERNR ${me.pernr} (${me.ename})`); + console.error(` BUKRS ${me.bukrs}`); + console.error(` KOSTL ${me.kostl} (${me.kostlText})`); + console.error(` BUPLA ${me.bupla || "(empty — 입력 필요)"}`); + console.error(` GSBER ${me.gsber || "(empty — 입력 필요)"} ← SAP Business Area`); + console.error(` EMAIL ${me.email}`); + console.error(` POS ${me.jobName} / ${me.posName}`); + console.error(` requireBust=${boot.requireBust}`); + + // 3) Personal approval lines — auto-wire item presets + show approver + // chain so the user can verify "yes that's my line". + const ctx: ClientContext = { + jsessionid, + userId: me.pernr, + bukrs: me.bukrs, + requireBust: boot.requireBust, + webDataCacheBust: boot.webDataCacheBust, + }; + console.error("\n→ ZUNIEWF_2200 (개인결재선) 조회..."); + const lines: PersonalLine[] = await listPersonalLines(ctx).catch((e) => { + console.error(` ✗ ${e?.message ?? e}`); + return []; + }); + console.error(` ✓ ${lines.length}개 결재선 발견`); + + const detectedItems: Record = {}; + const chainCache = new Map(); // SEQ -> formatted chain (avoid duplicate ZUNIEWF_2203 calls) + const printed = new Set(); // SEQ already shown in detail + for (const [name, preset] of Object.entries(ITEM_PRESETS)) { + const hit = lines.find((l) => (l.SEQ_TXT ?? "").includes(preset.evikbText)); + if (!hit) { + console.error(` - "${name}" 매칭 없음 (찾는 SEQ_TXT: contains "${preset.evikbText}")`); + continue; + } + if (!chainCache.has(hit.SEQ)) { + const members = await readApprovalLineApprovers(ctx, hit).catch(() => [] as ApprovalLineMember[]); + chainCache.set(hit.SEQ, formatChain(members)); + } + if (!printed.has(hit.SEQ)) { + console.error(` ✓ ${hit.SEQ_TXT} SEQ=${hit.SEQ} LIN1=${hit.WF_LIN1 ?? ""}`); + console.error(` 결재자: ${chainCache.get(hit.SEQ)}`); + console.error(` items: "${name}"`); + printed.add(hit.SEQ); + } else { + // Same approval line as a previous item — just acknowledge the share + console.error(` items: "${name}" (위 결재선 공유)`); + } + detectedItems[name] = { + hkont: preset.hkont, + hkontText: preset.hkontText, + evikb: preset.evikb, + evikbText: preset.evikbText, + wfLineSeq: hit.SEQ, + wfLineLin1: hit.WF_LIN1 ?? "", + }; + } + + // 4) Only IDs are stored. Display labels (pernrName/wfIdText/kostlText) + // are filled live by loadCtx() from the same view.do payload every + // command run — keeping them out of config means no stale labels + // after a department/role change. + // + // BUPLA/GSBER are stored identifiers but the server often leaves + // GSBER empty in staticProperties.user — prompt to fill in. + // Precedence: CLI flag > view.do > existing config > empty + const defaults = { + pernr: me.pernr, + bukrs: me.bukrs, + kostl: me.kostl, + bupla: me.bupla || existing?.user.bupla || "", + gsber: args.gsber || me.gsber || existing?.user.gsber || "", + }; + + let ids = defaults; + if (interactive) { + console.error("\n식별자 확인. Enter = 그대로 사용. 일반적으로 수정할 일 없음."); + const pernrLabel = me.ename ? `PERNR (사번) — ${me.ename}` : "PERNR (사번)"; + const kostlLabel = me.kostlText ? `KOSTL (코스트 센터) — ${me.kostlText}` : "KOSTL (코스트 센터)"; + const gsberLabel = defaults.gsber + ? "GSBER (사업영역)" + : "GSBER (사업영역 — EAC > EA전표작성 > 사업영역 드롭다운에서 확인)"; + ids = { + pernr: await ask(pernrLabel, defaults.pernr), + bukrs: await ask("BUKRS (회사 코드)", defaults.bukrs), + kostl: await ask(kostlLabel, defaults.kostl), + bupla: await ask("BUPLA (사업장 코드)", defaults.bupla), + gsber: await ask(gsberLabel, defaults.gsber), + }; + } + if (!ids.gsber) { + throw new Error( + `GSBER (Business Area) 가 비어있다. 사내 회계 기준에 따라 본인 부서의 사업영역 코드를 입력해야 한다.\n` + + ` EAC UI > 비용정산 > EA전표작성 > 헤더의 '사업영역' 드롭다운에서 본인 default 값 확인.\n` + + ` 비대화 환경에선 CLI flag 로: 'eac config init --force --gsber '.`, + ); + } + + // 5) Items: re-init merge only when PERNR matches (wfLineSeq is per-user) + const samePernr = existing?.user.pernr === ids.pernr; + if (!samePernr && existing && Object.keys(existing.items).length) { + console.error(`\n⚠ 기존 config 의 PERNR(${existing.user.pernr}) 과 새 PERNR(${ids.pernr}) 가 달라서 기존 items 는 무시한다.`); + } + const cfg: EacConfig = { + user: { + pernr: ids.pernr, + bukrs: ids.bukrs, + kostl: ids.kostl, + bupla: ids.bupla, + gsber: ids.gsber, + // Labels intentionally empty on disk — loadCtx() refreshes them. + pernrName: "", + wfIdText: "", + kostlText: "", + }, + items: samePernr + ? { ...(existing!.items), ...detectedItems } + : detectedItems, + }; + + if (args.print) { + // Mirror what saveConfig() actually writes — slim disk shape. + console.log(JSON.stringify(toDiskShape(cfg), null, 2)); + return; + } + + await saveConfig(cfg); + console.error(`\n✓ wrote ${configPath()}`); + if (!ids.kostl) { + console.error("⚠ KOSTL 가 비어있다 — voucher/corpcard create 시 실패할 수 있다."); + } + const missingItems = Object.keys(ITEM_PRESETS).filter((k) => !(k in cfg.items)); + if (missingItems.length) { + console.error(`⚠ 매칭되지 않은 item 프리셋: ${missingItems.join(", ")}`); + console.error(` 결재선 관리에서 "[개인]-장려지원금" / "[개인]-법인카드기명식" 등을 등록한 뒤 재실행.`); + } + } finally { + rl?.close(); + } }, }); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4ed5805..49f72b1 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,34 +1,68 @@ /** - * macOS Chrome cookie store → JSESSIONID extraction. - * AES-128-CBC decrypt with key derived from "Chrome Safe Storage" keychain password. + * macOS Chromium-family cookie store → JSESSIONID extraction. + * AES-128-CBC decrypt with key derived from " Safe Storage" keychain password. + * + * Supports Chrome, Microsoft Edge, and Brave — all use the same v10 cookie format + * (PBKDF2(saltysalt, 1003, sha1) → AES-128-CBC), just with different on-disk paths + * and different keychain service names. Override autodetect with EAC_BROWSER=. * * EAC uses Google SSO + MS Azure AD SAML chain. The login itself can't be automated - * (2FA/SSO), so when the session is missing/expired we open Chrome at the EAC URL, - * wait for the user to finish login, then re-read the cookie. + * (2FA/SSO), so when the session is missing/expired we open the detected browser at + * the EAC URL, wait for the user to finish login, then re-read the cookie. */ import { Database } from "bun:sqlite"; import { createDecipheriv, pbkdf2Sync } from "node:crypto"; import { execSync, spawn } from "node:child_process"; import { homedir, tmpdir } from "node:os"; -import { copyFileSync, unlinkSync } from "node:fs"; +import { existsSync, copyFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { AuthError } from "./errors.ts"; -const CHROME_COOKIES = join(homedir(), "Library/Application Support/Google/Chrome/Default/Cookies"); -const KEYCHAIN_SERVICE = "Chrome Safe Storage"; +interface BrowserSpec { + key: string; // env-override token: "chrome" | "edge" | "brave" + label: string; // human label for prompts + cookiePath: string; // absolute path to the SQLite Cookies file + keychainService: string; // `security find-generic-password -s ...` + appName: string; // `open -a "" ` to launch / focus +} + +const BROWSERS: BrowserSpec[] = [ + { + key: "chrome", + label: "Google Chrome", + cookiePath: join(homedir(), "Library/Application Support/Google/Chrome/Default/Cookies"), + keychainService: "Chrome Safe Storage", + appName: "Google Chrome", + }, + { + key: "edge", + label: "Microsoft Edge", + cookiePath: join(homedir(), "Library/Application Support/Microsoft Edge/Default/Cookies"), + keychainService: "Microsoft Edge Safe Storage", + appName: "Microsoft Edge", + }, + { + key: "brave", + label: "Brave", + cookiePath: join(homedir(), "Library/Application Support/BraveSoftware/Brave-Browser/Default/Cookies"), + keychainService: "Brave Safe Storage", + appName: "Brave Browser", + }, +]; + const HOST_PATTERN = "%zigbang.in%"; const COOKIE_NAME = "JSESSIONID"; -function getChromeMasterKey(): Buffer { +function getBrowserMasterKey(b: BrowserSpec): Buffer { let out: string; try { - out = execSync(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -g 2>&1`, { encoding: "utf-8" }); + out = execSync(`security find-generic-password -s "${b.keychainService}" -g 2>&1`, { encoding: "utf-8" }); } catch (e: any) { - throw new AuthError(`Chrome Safe Storage keychain read failed: ${e?.message ?? e}`); + throw new AuthError(`${b.keychainService} keychain read failed: ${e?.message ?? e}`); } const pw = out.match(/password:\s*"([^"]+)"/)?.[1]; - if (!pw) throw new AuthError("Chrome Safe Storage keychain password not found"); + if (!pw) throw new AuthError(`${b.keychainService} keychain password not found`); return pbkdf2Sync(pw, "saltysalt", 1003, 16, "sha1"); } @@ -41,15 +75,19 @@ function decryptV10(encrypted: Buffer, key: Buffer): string { const out = Buffer.concat([d.update(encrypted.subarray(3)), d.final()]); const pad = out[out.length - 1]!; const unpadded = pad > 0 && pad <= 16 ? out.subarray(0, out.length - pad) : out; - // Chrome v10 encrypted values on macOS prefix 32-byte sha256 hash of (host + name); strip it. + // Chromium v10 encrypted values on macOS (Chrome/Edge/Brave — same OSCrypt impl) prefix + // a 32-byte sha256 hash of (host + name); strip it. return unpadded.subarray(32).toString("utf-8"); } -export function extractJSESSIONID(): string { - const tmp = join(tmpdir(), `eac-${Date.now()}.db`); - copyFileSync(CHROME_COOKIES, tmp); +function readCookieFromBrowser(b: BrowserSpec): string { + if (!existsSync(b.cookiePath)) { + throw new AuthError(`${b.label} cookie store not found at ${b.cookiePath}`); + } + const tmp = join(tmpdir(), `eac-${b.key}-${Date.now()}.db`); + copyFileSync(b.cookiePath, tmp); try { - const key = getChromeMasterKey(); + const key = getBrowserMasterKey(b); const db = new Database(tmp, { readonly: true }); const row = db .query<{ encrypted_value: Buffer }, [string, string]>( @@ -57,13 +95,47 @@ export function extractJSESSIONID(): string { ) .get(HOST_PATTERN, COOKIE_NAME); db.close(); - if (!row) throw new AuthError("JSESSIONID not found in Chrome cookies — log in to eac.zigbang.in in Chrome"); + if (!row) throw new AuthError(`JSESSIONID not found in ${b.label} cookies — log in to eac.zigbang.in in ${b.label}`); return decryptV10(Buffer.from(row.encrypted_value), key); } finally { try { unlinkSync(tmp); } catch {} } } +function resolveBrowsers(): BrowserSpec[] { + const env = process.env.EAC_BROWSER?.trim().toLowerCase(); + if (!env) return BROWSERS; + const pick = BROWSERS.find((b) => b.key === env); + if (!pick) { + throw new AuthError(`EAC_BROWSER="${env}" not recognized. Use one of: ${BROWSERS.map((b) => b.key).join(", ")}`); + } + return [pick]; +} + +/** + * Try each candidate browser in order. Returns `{ token, browser }` for the first + * one that yields a cookie (regardless of liveness — caller verifies). When none + * yield a cookie the last error is thrown so the user sees the failure mode. + */ +export function extractJSESSIONID(): { token: string; browser: BrowserSpec } { + const candidates = resolveBrowsers(); + let lastErr: unknown = null; + for (const b of candidates) { + if (!existsSync(b.cookiePath)) continue; + try { + const token = readCookieFromBrowser(b); + if (token) return { token, browser: b }; + } catch (e) { + lastErr = e; + } + } + if (lastErr) throw lastErr; + throw new AuthError( + `No supported browser cookie store found (looked for: ${candidates.map((b) => b.label).join(", ")}). ` + + `Log in to eac.zigbang.in in one of them, or set EAC_JSESSIONID directly.`, + ); +} + const EAC_URL = "https://eac.zigbang.in/unidocu/view.do"; /** Returns true if the JSESSIONID is accepted by EAC (server returns the SPA HTML, not a redirect to login). */ @@ -82,9 +154,9 @@ export async function isSessionAlive(jsessionid: string): Promise { } } -function openChromeAtEac(): void { - // `open -a "Google Chrome" ` reuses an existing Chrome window/profile. - spawn("open", ["-a", "Google Chrome", EAC_URL], { stdio: "ignore", detached: true }).unref(); +function openBrowserAtEac(b: BrowserSpec): void { + // `open -a "" ` reuses an existing window/profile of that browser. + spawn("open", ["-a", b.appName, EAC_URL], { stdio: "ignore", detached: true }).unref(); } async function waitForEnter(): Promise { @@ -93,7 +165,7 @@ async function waitForEnter(): Promise { process.stderr.write("\n"); } -/** Try to extract the cookie; if missing or expired, open Chrome and wait for the user. */ +/** Try to extract the cookie; if missing or expired, open the detected browser and wait. */ export async function ensureSession(): Promise { // 1) env override always wins. const envToken = process.env.EAC_JSESSIONID; @@ -102,38 +174,58 @@ export async function ensureSession(): Promise { throw new AuthError("EAC_JSESSIONID is set but the session is not valid (expired or wrong cookie)."); } - // 2) Try Chrome keychain first. Quietly swallow "cookie not present yet" — that's the - // expected state when the user has never logged in to EAC in Chrome. + // 2) Try each supported browser; quietly swallow "no cookie" — expected when the + // user has never logged in to EAC in that browser. let token = ""; - try { token = extractJSESSIONID(); } catch { token = ""; } - if (token && await isSessionAlive(token)) return token; + let browser: BrowserSpec | null = null; + try { + const r = extractJSESSIONID(); + token = r.token; + browser = r.browser; + } catch { + token = ""; + } + if (token && (await isSessionAlive(token))) return token; + + // 3) Need a (re-)login. Pick a browser to open: the one whose cookie we read + // (even if expired), else the first candidate that exists on disk, else + // fall back to the first entry. + const target = + browser ?? + resolveBrowsers().find((b) => existsSync(b.cookiePath)) ?? + resolveBrowsers()[0]!; - // 3) Need a (re-)login. Tell the user, open Chrome, wait, then re-read. if (!process.stdin.isTTY) { - throw new AuthError("EAC session missing/expired and stdin is not a TTY — log in to eac.zigbang.in in Chrome and re-run, or pass EAC_JSESSIONID."); + throw new AuthError( + `EAC session missing/expired and stdin is not a TTY — log in to eac.zigbang.in in ${target.label} and re-run, or pass EAC_JSESSIONID.`, + ); } process.stderr.write(token - ? "EAC 세션 만료됨. Chrome에서 다시 로그인 필요.\n" - : "EAC 세션 없음. Chrome에서 로그인 필요.\n"); + ? `EAC 세션 만료됨. ${target.label} 에서 다시 로그인 필요.\n` + : `EAC 세션 없음. ${target.label} 에서 로그인 필요.\n`); process.stderr.write([ "", " 순서:", - " 1. 열린 Chrome에서 SSO 로그인을 끝낸다.", - " 2. Chrome을 한 번 종료(Cmd+Q)한다. ← 디스크에 cookie flush", + ` 1. 열린 ${target.label} 에서 SSO 로그인을 끝낸다.`, + ` 2. ${target.label} 을 한 번 종료(Cmd+Q)한다. ← 디스크에 cookie flush`, " (안 끄면 새 JSESSIONID가 메모리에만 남아 CLI가 못 읽는다.)", " 3. 여기로 돌아와 Enter를 누른다.", - " 4. 작업 끝나면 Chrome 다시 켜서 평소대로 사용.", + ` 4. 작업 끝나면 ${target.label} 다시 켜서 평소대로 사용.`, "", - " 팁: Chrome 설정 > 시작할 때 > '중단한 곳에서 계속하기'를 켜면,", + ` 팁: ${target.label} 설정 > 시작할 때 > '중단한 곳에서 계속하기'를 켜면,`, " 종료해도 session cookie가 유지돼서 다음번 EAC 재로그인이 줄어든다.", "", + " (다른 브라우저를 쓰려면 EAC_BROWSER=chrome|edge|brave 로 강제.)", + "", ].join("\n")); - openChromeAtEac(); + openBrowserAtEac(target); await waitForEnter(); - token = extractJSESSIONID(); - if (!await isSessionAlive(token)) { - throw new AuthError("로그인이 확인되지 않았습니다. Chrome에서 eac.zigbang.in에 정상 로그인했는지 확인 후 다시 시도하세요."); + const r = extractJSESSIONID(); + if (!(await isSessionAlive(r.token))) { + throw new AuthError( + `로그인이 확인되지 않았습니다. ${r.browser.label} 에서 eac.zigbang.in 에 정상 로그인했는지 확인 후 다시 시도하세요.`, + ); } - return token; + return r.token; } diff --git a/src/lib/cli.ts b/src/lib/cli.ts index e20f1b0..bf44c45 100644 --- a/src/lib/cli.ts +++ b/src/lib/cli.ts @@ -2,16 +2,95 @@ * CLI helpers shared across commands. */ -import { loadConfig, DEFAULT_CONFIG } from "./config.ts"; +import { loadConfig, configPath, ITEM_PRESETS } from "./config.ts"; import { ensureSession } from "./auth.ts"; +import { fetchViewBootstrap } from "./whoami.ts"; import type { ClientContext } from "./client.ts"; import type { UserProfile, ReimbursementItem } from "../types/index.ts"; import type { EacConfig } from "./config.ts"; export async function loadCtx(): Promise<{ ctx: ClientContext; cfg: EacConfig }> { - const cfg = (await loadConfig()) ?? DEFAULT_CONFIG; + const stored = await loadConfig(); + if (!stored) { + throw new Error(`no EAC config at ${configPath()} — run 'eac config init' first.`); + } + if (!stored.user.pernr || !stored.user.bukrs) { + throw new Error(`config at ${configPath()} is missing user.pernr / user.bukrs — re-run 'eac config init' or edit by hand.`); + } + if (!stored.user.gsber || !stored.user.bupla) { + throw new Error( + `config at ${configPath()} is missing user.gsber / user.bupla.\n` + + ` SAP rule ZFI1.213 requires every FI doc line to share the same Business Area —\n` + + ` without these the server rejects voucher creation. Re-run 'eac config init --force'.`, + ); + } + const jsessionid = await ensureSession(); - const ctx: ClientContext = { jsessionid, userId: cfg.user.pernr, bukrs: cfg.user.bukrs }; + // Single round-trip to view.do delivers (a) the rotating cache-bust the + // server expects in namedService bodies and (b) the canonical display + // labels for the current user. Treat boot as mandatory — if it fails the + // session is effectively broken for any subsequent call. + const boot = await fetchViewBootstrap(jsessionid); + if (!boot) { + throw new Error( + "EAC bootstrap (view.do) 파싱 실패 — 세션은 살아있지만 staticProperties.user 를 읽을 수 없다. SPA HTML 구조가 바뀐 듯.", + ); + } + if (boot.user.pernr !== stored.user.pernr) { + // Hard fail: mixing a stored PERNR (used as staticUserID) with live labels + // from a different person creates inconsistent SAP requests + // (PERNR=A + PERNR_TXT=B). Force re-init instead. + throw new Error( + `config PERNR=${stored.user.pernr} 인데 EAC 세션은 ${boot.user.pernr} (${boot.user.ename}) 이다.\n` + + ` → 'eac config init --force' 로 재설정하거나, 다른 브라우저 세션으로 로그인해라.`, + ); + } + + // Overlay live labels onto stored IDs. ops.ts reads user.pernrName / + // user.wfIdText / user.kostlText — these come from the server every run, + // never from disk. + // + // Items: disk holds only { wfLineSeq, wfLineLin1 } per item; the policy + // fields (hkont/evikb/_Text) live in ITEM_PRESETS. Merge here so ops.ts + // sees the full ReimbursementItem. + const items: Record = {}; + for (const [name, raw] of Object.entries(stored.items ?? {})) { + const preset = ITEM_PRESETS[name]; + if (!preset) { + const known = Object.keys(ITEM_PRESETS).join(", ") || "(none)"; + throw new Error( + `config.items.${name} 가 ITEM_PRESETS 에 없다 (known: ${known}).\n` + + ` → src/lib/config.ts 의 ITEM_PRESETS 에 추가하고 재빌드하거나,\n` + + ` ~/.config/eac/config.json 에서 "${name}" 키를 제거해라.`, + ); + } + items[name] = { + hkont: preset.hkont, + hkontText: preset.hkontText, + evikb: preset.evikb, + evikbText: preset.evikbText, + wfLineSeq: raw.wfLineSeq ?? "", + wfLineLin1: raw.wfLineLin1 ?? "", + }; + } + + const cfg: EacConfig = { + user: { + ...stored.user, + pernrName: boot.user.sname || boot.user.ename, + wfIdText: boot.user.ename, + kostlText: boot.user.kostlText, + }, + items, + }; + + const ctx: ClientContext = { + jsessionid, + userId: cfg.user.pernr, + bukrs: cfg.user.bukrs, + requireBust: boot.requireBust, + webDataCacheBust: boot.webDataCacheBust, + }; return { ctx, cfg }; } diff --git a/src/lib/client.ts b/src/lib/client.ts index 40691d0..554cf55 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -17,15 +17,20 @@ import { ApiError } from "./errors.ts"; const BASE = "https://eac.zigbang.in"; -// Cache-bust values injected by server into every page load. Observed not to rotate over days. -// If the server starts rejecting requests, refresh from a page's staticProperties. -const WEB_DATA_CACHE_BUST = "1774310304657"; -const REQUIRE_BUST = "1774310304657"; +// Last-resort cache-bust fallback. The server rotates these (currently every +// few months); when out-of-date, calls fail with RequireBustMismatchException. +// Prefer scraping `staticProperties.{requireBust,webDataCacheBust}` from +// `/unidocu/view.do` at session-bootstrap time — see lib/whoami.ts. +const FALLBACK_BUST = "1779407976939"; export interface ClientContext { jsessionid: string; userId: string; // staticUserID (PERNR / EAC user id, e.g. "ZB01135") bukrs: string; // staticIS_KEY_BUKRS (company code, e.g. "K001") + /** Server-rotating cache-bust values. Populated by loadCtx() from view.do. + * When absent, falls back to the module-level constant. */ + requireBust?: string; + webDataCacheBust?: string; } export interface NSResponse { @@ -65,8 +70,8 @@ export async function callNS( ...fields, namedServiceId, IS_KEY_PROGRAM_ID: programId, - webDataCacheBust: WEB_DATA_CACHE_BUST, - requireBust: REQUIRE_BUST, + webDataCacheBust: ctx.webDataCacheBust ?? ctx.requireBust ?? FALLBACK_BUST, + requireBust: ctx.requireBust ?? FALLBACK_BUST, staticUserID: ctx.userId, staticIS_KEY_BUKRS: ctx.bukrs, }); diff --git a/src/lib/config.ts b/src/lib/config.ts index 6fb3324..1471f9e 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -32,9 +32,32 @@ export async function loadConfig(): Promise { } } +/** + * Strip everything that's live-refreshed or canonical-static from an in-memory + * EacConfig, leaving only per-user identifiers worth persisting. + * user → { pernr, bukrs, kostl } (labels live from view.do) + * items → { [name]: { wfLineSeq, wfLineLin1 } } (hkont/evikb/*_Text from ITEM_PRESETS) + */ +export function toDiskShape(cfg: EacConfig) { + return { + user: { + pernr: cfg.user.pernr, + bukrs: cfg.user.bukrs, + kostl: cfg.user.kostl, + bupla: cfg.user.bupla, + gsber: cfg.user.gsber, + }, + items: Object.fromEntries( + Object.entries(cfg.items).map(([k, v]) => [ + k, { wfLineSeq: v.wfLineSeq, wfLineLin1: v.wfLineLin1 }, + ]), + ), + }; +} + export async function saveConfig(cfg: EacConfig): Promise { await ensureConfigDir(); - await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n"); + await writeFile(CONFIG_FILE, JSON.stringify(toDiskShape(cfg), null, 2) + "\n"); } export function configPath(): string { @@ -42,39 +65,74 @@ export function configPath(): string { } /** - * Shipped defaults for the current user (박영걸/ZB01135) so the CLI works out of the box. - * Override via `eac config` or by editing ~/.config/eac/config.json. + * Empty skeleton used by `eac config show` when no config exists yet so the + * user sees a structurally-valid example. NOT a working profile — `eac config + * init` auto-discovers every field from the EAC session (view.do + * `staticProperties.user` + ZUNIEWF_2200) and writes a populated one. */ -export const DEFAULT_CONFIG: EacConfig = { +export const EMPTY_CONFIG: EacConfig = { user: { - pernr: "ZB01135", - bukrs: "K001", - pernrName: "박영걸", - wfIdText: "YG Park (박영걸)", - kostl: "226020", - kostlText: "Device Engineering", - wfDept: "0000252100", - wfDeptText: "Service Engineering", + pernr: "", + bukrs: "", + kostl: "", + bupla: "", + gsber: "", + // *_TXT labels are filled live by loadCtx() from view.do — never stored. + pernrName: "", + wfIdText: "", + kostlText: "", }, - items: { - "자기관리비": { - hkont: "52010108", - hkontText: "판)복리후생비-자기관리비", - evikb: "FI_21", - evikbText: "장려지원금", - wfLineSeq: "0000000002", - wfLineLin1: "0000000816", - }, - "법인카드": { - // hkont here is a placeholder — corpcard create takes --hkont per transaction. - // For request-approval the row's stored HKONT is what counts; this entry just - // carries EVIKB/wfLineSeq for the WF flow. - hkont: "52010102", - hkontText: "판)복리후생비-회식대", - evikb: "FI_12", - evikbText: "법인카드기명식", - wfLineSeq: "0000000004", - wfLineLin1: "0000000924", - }, + items: {}, +}; + +/** + * Catalog of well-known reimbursement item presets. Keyed by the user-facing name + * (also the CLI `--item` argument). `evikbText` is what the server stamps into + * each personal approval line's `SEQ_TXT` (e.g. `[개인]-장려지원금`), which lets + * `eac config init` auto-wire wfLineSeq/wfLineLin1 from the user's own lines. + * + * `wfLineSeq` / `wfLineLin1` are intentionally absent here — they're per-user and + * filled in at init time from ZUNIEWF_2200. + */ +export interface ItemPreset { + hkont: string; + hkontText: string; + evikb: string; + evikbText: string; +} + +export const ITEM_PRESETS: Record = { + // 장려지원금 (EVIKB=FI_21) — personal-reimbursement items handled by EAC. + // 체력단련비는 flex 에서 처리하므로 여기 없음. 셋 다 같은 [개인]-장려지원금 + // 결재선을 공유한다 — wfLineSeq/wfLineLin1 은 init 때 한 번 발견 후 + // 세 item 에 동일하게 주입된다. + "자기관리비": { + hkont: "52010108", + hkontText: "판)복리후생비-자기관리비", + evikb: "FI_21", + evikbText: "장려지원금", + }, + "가족회식비": { + // 서버 canonical 명칭은 "가족식사비". 사내 통용 명칭은 "가족회식비". + hkont: "52010109", + hkontText: "판)복리후생비-가족식사비", + evikb: "FI_21", + evikbText: "장려지원금", + }, + "원격근무지원비": { + // 복리후생비 계열이 아니라 소모품비-사무용품 계정으로 처리. + hkont: "53050102", + hkontText: "판)소모품비-사무용품", + evikb: "FI_21", + evikbText: "장려지원금", + }, + "법인카드": { + // hkont here is a placeholder — corpcard create takes --hkont per transaction. + // For request-approval the row's stored HKONT is what counts; this entry just + // carries EVIKB/wfLineSeq for the WF flow. + hkont: "52010102", + hkontText: "판)복리후생비-회식대", + evikb: "FI_12", + evikbText: "법인카드기명식", }, }; diff --git a/src/lib/ops.ts b/src/lib/ops.ts index a76d381..47b5e1d 100644 --- a/src/lib/ops.ts +++ b/src/lib/ops.ts @@ -486,7 +486,7 @@ export async function computeDefaults( const r = await callNS(ctx, "ZUNIEFI_4003", PROG_LEGACY, { BUDAT: budat, BLDAT: bldat, BLART: "KE", LIFNR: user.pernr, LIFNR_TXT: user.pernrName, - BUPLA: "K100", GSBER: "K200", MWSKZ: "T0", + BUPLA: user.bupla, GSBER: user.gsber, MWSKZ: "T0", AKONT: "21020103", ZTERM: "V123", WRBTR: String(amountWon), WRBTR_SLASH: "", WMWST: "0", BVTYP: "0001", ZFBDT: "", ZFBDT_SLASH: "", @@ -529,7 +529,7 @@ export async function createTempDoc( const commonFi = { BUDAT: budat, BLDAT: bldat, BLART: "KE", LIFNR: user.pernr, LIFNR_TXT: user.pernrName, - BUPLA: "K100", GSBER: "K200", MWSKZ: "T0", + BUPLA: user.bupla, GSBER: user.gsber, MWSKZ: "T0", AKONT: "21020103", ZTERM: "V123", WRBTR: String(amountWon), WRBTR_SLASH: "", WMWST: "0", BVTYP: "0001", ZFBDT: zfbdt, ZFBDT_SLASH: "", @@ -929,7 +929,7 @@ export async function createCorpCardVoucher( CHARGETOTAL: card.AMOUNT, CHARGETOTAL_Slash: "", WMWST_READ_ONLY: card.TAX, BUDAT: p.budat, BLDAT: bldat, BLART: "KE", - BUPLA: "K100", GSBER: "K200", + BUPLA: user.bupla, GSBER: user.gsber, WRBTR_SLASH: "", WMWST: card.TAX, ZFBDT_SLASH: "", EMPTY: "", SGTXT: p.sgtxt, diff --git a/src/lib/whoami.ts b/src/lib/whoami.ts new file mode 100644 index 0000000..7e6d7b5 --- /dev/null +++ b/src/lib/whoami.ts @@ -0,0 +1,111 @@ +/** + * "Who am I" against the EAC server. + * + * The UniDocu SPA bootstraps its user context from a single blob inside the + * `/unidocu/view.do` HTML: + * + * staticProperties.user = JSON.parse(decodeURIComponent('...')); + * + * That JSON carries PERNR / BUKRS / ENAME / SNAME / KOSTL / KOSTL_TXT / DEPT + * etc. — everything the SPA needs to populate `staticUserID` / + * `staticIS_KEY_BUKRS` in subsequent named-service calls. We piggyback on the + * same blob so `eac config init` doesn't have to ask the user for fields the + * server already knows. + * + * Parsing is best-effort: if the regex misses (server HTML change), we return + * `null` and the caller falls back to prompting. + */ + +const EAC_URL = "https://eac.zigbang.in/unidocu/view.do"; + +/** + * Subset of `staticProperties.user` we actually consume. The full record carries + * dozens of fields (KOKRS, BUPLA, authorizedMenuInfo, ...); leave them + * unparsed — pluck only what config init needs. + */ +export interface RemoteUser { + pernr: string; // PERNR + bukrs: string; // BUKRS (or IS_KEY_BUKRS — they're the same K001 etc.) + ename: string; // "Sejun Jeong (정세준)" — full display + sname: string; // "정세준" — Korean short name + kostl: string; // "343020" + kostlText: string; // "Frontend Hogangnono" + bupla: string; // "K100" (Zigbang HQ); usually populated + gsber: string; // "K200" / "K300"; often "" — server doesn't reliably push it, + // caller must fall back to prompt. + email: string; // SMTP_ADDR + jobName: string; // "Member" / "Manager" — title + posName: string; // "Frontend Engineer (Hogangnono)" +} + +export interface ViewBootstrap { + user: RemoteUser; + /** Cache-bust value the server expects in every `namedService/call.do` body. + * Rotates server-side; clients that ship a stale value get + * `RequireBustMismatchException`. */ + requireBust: string; + webDataCacheBust: string; +} + +export async function fetchViewBootstrap(jsessionid: string): Promise { + let html: string; + try { + const r = await fetch(EAC_URL, { + headers: { Cookie: `JSESSIONID=${jsessionid}` }, + redirect: "manual", + }); + if (r.status !== 200) return null; + html = await r.text(); + } catch { + return null; + } + + // staticProperties.user = JSON.parse(decodeURIComponent('%7B...%7D')); + const userBlob = html.match(/staticProperties\.user\s*=\s*JSON\.parse\(\s*decodeURIComponent\(\s*['"]([^'"]+)['"]\s*\)\s*\)/); + if (!userBlob) return null; + + let raw: Record; + try { + raw = JSON.parse(decodeURIComponent(userBlob[1]!)); + } catch { + return null; + } + + const pick = (k: string): string => { + const v = raw[k]; + return typeof v === "string" ? v : ""; + }; + + const pernr = pick("PERNR") || pick("ID"); + const bukrs = pick("BUKRS") || pick("IS_KEY_BUKRS"); + if (!pernr || !bukrs) return null; + + const requireBust = + html.match(/staticProperties\.requireBust\s*=\s*['"]([^'"]+)['"]/)?.[1] ?? ""; + const webDataCacheBust = + html.match(/staticProperties\.webDataCacheBust\s*=\s*['"]([^'"]+)['"]/)?.[1] ?? requireBust; + if (!requireBust) return null; + + return { + user: { + pernr, + bukrs, + ename: pick("ENAME"), + sname: pick("SNAME") || pick("LIFNR_TXT"), + kostl: pick("KOSTL"), + kostlText: pick("KOSTL_TXT"), + bupla: pick("BUPLA"), + gsber: pick("GSBER"), + email: pick("SMTP_ADDR"), + jobName: pick("JOB_NAME"), + posName: pick("POS_NAME"), + }, + requireBust, + webDataCacheBust, + }; +} + +/** Back-compat / convenience: just the user. */ +export async function fetchRemoteUser(jsessionid: string): Promise { + return (await fetchViewBootstrap(jsessionid))?.user ?? null; +} diff --git a/src/types/index.ts b/src/types/index.ts index bcc305b..e9fe9d4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,15 +5,28 @@ * — keep key names exactly as the server expects (uppercase SAP aliases). */ +/** + * User profile passed into ops.ts as `user`. Fields split into two layers: + * + * STORED (~/.config/eac/config.json — identity, hard for the CLI to discover) + * pernr, bukrs, kostl + * + * LIVE (filled by loadCtx() from view.do `staticProperties.user` each run — + * server-canonical display labels, refreshed every command) + * pernrName, wfIdText, kostlText + */ export interface UserProfile { pernr: string; // Employee number, e.g. "ZB01135" bukrs: string; // Company code, e.g. "K001" - pernrName: string; // Display name, e.g. "박영걸" - wfIdText: string; // Full display like "YG Park (박영걸)" kostl: string; // Cost center, e.g. "226020" - kostlText: string; // "Device Engineering" - wfDept: string; // Department code - wfDeptText: string; // "Service Engineering" + bupla: string; // Business place, e.g. "K100" (Zigbang HQ) + gsber: string; // Business area — varies per business unit + // (e.g. K200=Zigbang, K300=Property, others differ). + // SAP rule ZFI1.213: all FI doc lines must share GSBER. + // Confirm via EAC UI > EA전표작성 > 사업영역 dropdown. + pernrName: string; // Display name, e.g. "박영걸" (live) + wfIdText: string; // "YG Park (박영걸)" (live) + kostlText: string; // "Device Engineering" (live) } export interface ReimbursementItem {