#!/usr/bin/env node 'use strict'; // zkanban.js — zkanban.md 를 칸반 보드로 비추는 단일 파일 도구. // 의존성 0, package.json 없이 `node zkanban.js` 로 바로 돈다. // // 구역: // ① 파싱 — 파일을 줄 배열로 읽고, 어느 줄이 어느 열·카드인지 꼬리표만 붙인다 // ② 이동 — 줄 묶음을 잘라 다른 열로 옮기고 파일에 쓴다 // ③ HTTP — 로컬 서버, SSE, 화면 // ④ CLI — 인자 파싱, 보드 표 출력, move // // 절대 규칙: 카드를 문자열로 다시 만들어내지 않는다. 원본 줄을 옮기기만 한다. // (그래야 주석·빈 줄·들여쓰기·줄바꿈이 한 글자도 안 상한다) const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); const http = require('node:http'); // ──────────────────────────────────────────────────────────────── // ① 파싱 // ──────────────────────────────────────────────────────────────── const BOM = ''; /** * 파일 내용을 "자기 줄바꿈을 품은 줄" 배열로 쪼갠다. * 각 줄이 자기 \r\n / \n 을 들고 있어서, 이어붙이기만 하면 원문이 그대로 돌아온다. * → EOL 을 추론할 필요가 없고, CRLF·LF 가 섞인 파일도 안 깨진다. */ function splitLines(text) { const bom = text.startsWith(BOM) ? BOM : ''; const body = bom ? text.slice(BOM.length) : text; // 빈 문자열은 줄이 하나도 없는 것으로 본다 (split 은 [''] 을 주므로 걸러냄) const lines = body === '' ? [] : body.split(/(?<=\n)/); return { bom, lines }; } /** splitLines 의 역. 줄을 새로 만들지 않으므로 바이트가 그대로 복원된다. */ function joinLines(bom, lines) { return bom + lines.join(''); } /** 줄 끝 줄바꿈을 뗀 사본. 비교·정규식은 항상 이걸로 한다 (원본 줄은 안 고침). */ function bare(line) { return line.replace(/\r?\n$/, ''); } /** * 파일 내용의 지문. 브라우저·CLI 가 "내가 본 그 파일이 맞나"를 확인할 때 쓴다. * mtime 대신 내용 해시를 쓰는 이유는 research.md R-003. */ function revOf(text) { return crypto.createHash('sha1').update(text, 'utf8').digest('hex').slice(0, 12); } const COLUMN_HEADER = /^##\s*\[(.+)\]\s*$/; /** * `## [이름]` 줄을 찾아 열 목록을 만든다. * 열 이름·개수를 코드에 안 박으므로, 파일에 섹션을 추가하면 열이 늘어난다 (FR-002). */ function parseColumns(lines) { const columns = []; for (let i = 0; i < lines.length; i++) { const matched = bare(lines[i]).match(COLUMN_HEADER); if (!matched) continue; if (columns.length > 0) columns.at(-1).endLine = i - 1; columns.push({ name: matched[1].trim(), headerLine: i, endLine: lines.length - 1 }); } return columns; } const CARD_START = /^-\s+/; // 날짜는 있으면 활용, 없어도 됨 (FR-004). 강제하지 않는다. const DATE_HEAD = /^(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?)\s*,\s*/; /** * 한 열 안의 카드들을 뽑는다. * 카드 = `- ` 줄 하나 + 거기 딸린 들여쓴 줄들. * 빈 줄은 그 뒤에 또 들여쓴 줄이 올 때만 카드에 딸린다 (안 그러면 이동할 때마다 * 빈 줄이 따라다니며 파일이 조금씩 변형된다). */ function parseCards(lines, column) { if (!column) return []; const cards = []; let inComment = false; for (let i = column.headerLine + 1; i <= column.endLine; i++) { const line = bare(lines[i]); // `` 같은 주석 블록 안의 `- ` 줄은 카드가 아니다 if (inComment) { if (line.includes('-->')) inComment = false; continue; } if (line.trimStart().startsWith('')) inComment = true; continue; } if (!CARD_START.test(line)) continue; let end = i; for (let j = i + 1; j <= column.endLine; j++) { const next = bare(lines[j]); if (next.trim() === '') continue; // 보류 — 뒤에 들여쓴 줄이 오면 그때 포함된다 if (!/^[ \t]/.test(next)) break; end = j; // 사이에 낀 빈 줄까지 이 카드 것이 된다 } cards.push(makeCard(lines.slice(i, end + 1), column.name, i, end)); i = end; } return cards; } /** `- ` 줄을 날짜 / 제목 / 상세로 가른다. 원본 줄(raw)은 그대로 들고 있는다. */ function makeCard(raw, columnName, startLine, endLine) { const body = bare(raw[0]).replace(CARD_START, ''); const dated = body.match(DATE_HEAD); const date = dated ? dated[1] : null; const rest = dated ? body.slice(dated[0].length) : body; const comma = rest.indexOf(','); const title = (comma === -1 ? rest : rest.slice(0, comma)).trim(); let detail = (comma === -1 ? '' : rest.slice(comma + 1)).trim(); const subLines = raw.slice(1).map((l) => bare(l).trim()).filter((l) => l !== ''); if (subLines.length > 0) { detail = detail === '' ? subLines.join('\n') : `${detail}\n${subLines.join('\n')}`; } // 편집창에 띄울 원문. detail 은 첫 줄 뒷부분과 하위 줄을 합쳐놔서 되돌릴 수 없다. // 이걸 그대로 editCard 에 되먹이면 카드가 한 글자도 안 바뀐다 const text = [rest, ...subLines].join('\n'); return { column: columnName, startLine, endLine, date, title, detail, text, raw }; } /** * 카드 식별자. 줄 번호가 아니라 **내용**으로 만든다 (FR-005). * 그래야 파일이 그사이 바뀌었을 때 엉뚱한 카드를 옮기는 대신 깔끔히 실패한다. * 해시 입력에서 줄바꿈을 떼므로 CRLF·LF 차이로 id 가 흔들리지 않는다. */ function cardId(columnName, bareRaw, dupIndex) { return crypto .createHash('sha1') .update(`${columnName}\n${bareRaw}\n${dupIndex}`, 'utf8') .digest('hex') .slice(0, 6); } /** 파일 내용 하나를 보드로. 이 함수 결과가 화면·CLI 가 보는 전부다. */ function parseBoard(text, filePath = null) { const { bom, lines } = splitLines(text); const columns = parseColumns(lines).map((column) => ({ ...column, cards: parseCards(lines, column), })); for (const column of columns) { const seenCount = new Map(); for (const card of column.cards) { const key = card.raw.map(bare).join('\n'); const dupIndex = seenCount.get(key) ?? 0; seenCount.set(key, dupIndex + 1); card.id = cardId(column.name, key, dupIndex); } } return { bom, lines, columns, rev: revOf(text), path: filePath }; } /** 사람이 치기 편하게 앞자리 일부만 받되, 딱 하나에 걸릴 때만 성공시킨다. */ function resolveCardId(board, input) { const cards = board.columns.flatMap((column) => column.cards); const exact = cards.filter((card) => card.id === input); if (exact.length === 1) return { card: exact[0] }; const matched = input && input.length >= 2 ? cards.filter((card) => card.id.startsWith(input)) : []; if (matched.length === 1) return { card: matched[0] }; if (matched.length > 1) { return { error: 'ambiguous', candidates: matched.map((card) => card.id) }; } return { error: 'no-card' }; } /** 열 이름도 앞글자만 — `in` → `In Progress`. 대소문자·공백 무시. */ function resolveColumn(board, input) { const normalize = (s) => String(s).toLowerCase().replace(/\s+/g, ''); const wanted = normalize(input); const names = board.columns.map((column) => column.name); const exact = board.columns.filter((column) => normalize(column.name) === wanted); if (exact.length === 1) return { column: exact[0] }; const matched = wanted === '' ? [] : board.columns.filter((column) => normalize(column.name).startsWith(wanted)); if (matched.length === 1) return { column: matched[0] }; if (matched.length > 1) { return { error: 'bad-column', candidates: matched.map((column) => column.name) }; } return { error: 'bad-column', candidates: names }; } // ──────────────────────────────────────────────────────────────── // ② 이동 // ──────────────────────────────────────────────────────────────── /** 파일에서 제일 많이 쓰인 줄바꿈. 새 줄바꿈이 필요한 딱 한 경우에만 쓴다. */ function guessEol(lines) { const crlf = lines.filter((line) => line.endsWith('\r\n')).length; const lf = lines.filter((line) => line.endsWith('\n')).length - crlf; return lf > crlf ? '\n' : '\r\n'; } /** * 카드를 다른 열로 옮긴다. 줄을 **새로 만들지 않고** 잘라서 다른 자리에 끼워넣는다. * 그래서 주석·빈 줄·들여쓰기·줄바꿈이 그대로 남는다 (FR-006, SC-002). */ function moveCard(board, cardIdInput, columnInput) { const foundCard = resolveCardId(board, cardIdInput); if (foundCard.error) return foundCard; const foundColumn = resolveColumn(board, columnInput); if (foundColumn.error) return foundColumn; const card = foundCard.card; const target = foundColumn.column; if (card.column === target.name) return { moved: false, lines: board.lines, card, column: target }; const lines = board.lines.slice(); const cut = lines.splice(card.startLine, card.raw.length); // 잘라낸 뒤로 줄 번호가 밀리므로, 넣을 자리는 새 배열에서 다시 찾는다 lines.splice(insertPos(lines, target.name), 0, ...cut); // 원래 마지막 줄에 줄바꿈이 없었는데 그 뒤로 뭔가 붙었으면, 줄바꿈 하나를 보탠다 const eol = guessEol(board.lines); for (let i = 0; i < lines.length - 1; i++) { if (!lines[i].endsWith('\n')) lines[i] += eol; } return { moved: true, lines, card, column: target }; } /** 그 열에서 새 카드가 들어갈 자리 — 마지막 카드 다음, 카드가 없으면 헤더 다음 줄. */ function insertPos(lines, columnName) { const column = parseColumns(lines).find((c) => c.name === columnName); const cards = parseCards(lines, column); return cards.length > 0 ? cards.at(-1).endLine + 1 : column.headerLine + 1; } function stamp(now) { const two = (n) => String(n).padStart(2, '0'); return `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())} ${two(now.getHours())}:${two(now.getMinutes())}`; } /** * 카드를 하나 만들어 그 열 맨 아래에 붙인다. **사용자가 브라우저에서 적는 경로.** * (에이전트는 이걸 안 쓴다 — zkanban.md 에 항목을 적는 건 사용자다. CLAUDE.md 규칙) */ function addCard(board, columnInput, text, now = new Date()) { const found = resolveColumn(board, columnInput); if (found.error) return found; const eol = guessEol(board.lines); const fresh = toCardLines(text, `${stamp(now)}, `, eol); if (!fresh) return { error: 'empty' }; const lines = board.lines.slice(); lines.splice(insertPos(lines, found.column.name), 0, ...fresh); // 원래 마지막 줄에 줄바꿈이 없었는데 그 뒤로 뭔가 붙었으면 줄바꿈 하나를 보탠다 for (let i = 0; i < lines.length - 1; i++) { if (!lines[i].endsWith('\n')) lines[i] += eol; } return { added: true, lines, column: found.column }; } /** * 사람이 친 글을 카드 줄들로 바꾼다. 첫 줄은 `- ` 카드 줄, 나머지는 들여쓴 하위 줄. * 그게 파서가 이미 아는 구조라 새 포맷을 만들 필요가 없다 (data-model.md 줄 소유 규칙). * * 가운데 빈 줄은 버린다 — 빈 줄이 그대로 들어가면 카드가 거기서 끊겨 둘로 쪼개진다. * 빈 글이면 null. */ function toCardLines(text, head, eol) { const parts = String(text ?? '') .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); if (parts.length === 0) return null; return [`- ${head}${parts[0]}${eol}`, ...parts.slice(1).map((line) => ` ${line}${eol}`)]; } /** * 카드의 **첫 줄만** 새로 쓴다. 날짜와 줄바꿈은 원래 것을 그대로 물려받고, * 딸린 들여쓴 하위 줄은 손대지 않는다. * * 이 함수는 이 파일에서 유일하게 **줄을 새로 만든다.** 그래서 범위를 첫 줄로 좁혔다 — * 나머지 줄은 여전히 원본 문자열 그대로라 서식이 안 흘러나간다. */ function editCard(board, cardIdInput, text) { const found = resolveCardId(board, cardIdInput); if (found.error) return found; const card = found.card; const first = card.raw[0]; const eol = first.endsWith('\r\n') ? '\r\n' : first.endsWith('\n') ? '\n' : guessEol(board.lines); const fresh = toCardLines(text, card.date ? `${card.date}, ` : '', eol); if (!fresh) return { error: 'empty' }; const lines = board.lines.slice(); lines.splice(card.startLine, card.raw.length, ...fresh); return { edited: true, lines, card }; } /** 카드를 지운다. 딸린 하위 줄까지 통째로. 줄을 빼기만 하므로 나머지 서식은 안 다친다. */ function removeCard(board, cardIdInput) { const found = resolveCardId(board, cardIdInput); if (found.error) return found; const card = found.card; const lines = board.lines.slice(); lines.splice(card.startLine, card.raw.length); return { removed: true, lines, card }; } /** 파일을 읽어 보드로. 파일이 없으면 빈 보드 (죽지 않는다). */ function readBoard(filePath) { let text = ''; try { text = fs.readFileSync(filePath, 'utf8'); } catch (err) { if (err.code !== 'ENOENT') throw err; } return parseBoard(text, filePath); } /** * 보드를 파일에 쓴다. 임시 파일에 쓰고 rename 으로 갈아끼운다 — * 쓰는 도중 죽어도 원본(사용자의 유일한 할 일 목록)이 안 날아가게. */ function writeBoard(board, lines = board.lines) { const text = joinLines(board.bom, lines); const tmpPath = `${board.path}.${process.pid}.tmp`; fs.writeFileSync(tmpPath, text, 'utf8'); fs.renameSync(tmpPath, board.path); return revOf(text); } // ──────────────────────────────────────────────────────────────── // ③ HTTP // ──────────────────────────────────────────────────────────────── const HOST = '127.0.0.1'; // 로컬 전용. 밖에서 못 들어온다 (FR-012) const PORT_BASE = 39000; const PORT_SPAN = 1000; // 39000~39999 const PORT_TRIES = 10; /** * 그 보드의 주소를 파일 경로에서 정한다. **같은 프로젝트는 항상 같은 포트** — * 여러 프로젝트에서 띄워도 주소가 안 섞이고 북마크가 먹는다. * * 39000 대인 이유: 49152 위는 Windows 가 임시 포트로 자동 할당하는 대역이라 * 고정으로 잡으면 남이 이미 쓰고 있을 수 있다. 1024 아래는 권한이 필요하다. * * 해시라 다른 프로젝트와 부딪힐 수는 있다. 그땐 listen 이 하나씩 올려 잡는다. */ function portFor(filePath) { // Windows 에선 D:\x 와 d:/x 가 같은 파일이다. 주소가 갈리지 않게 맞춰준다 const key = String(filePath).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); const hash = crypto.createHash('sha1').update(key).digest(); return PORT_BASE + (hash.readUInt32BE(0) % PORT_SPAN); } /** 그 포트에 이미 뜬 게 **내 보드**인가? (남의 보드거나 빈 자리면 false) */ async function boardAlreadyServing(port, filePath) { try { const res = await fetch(`http://${HOST}:${port}/api/board`, { signal: AbortSignal.timeout(500) }); if (!res.ok) return false; return (await res.json()).path === filePath; } catch { return false; // 아무도 없거나, 칸반이 아닌 뭔가가 있거나 } } /** 화면·CLI 에 내보낼 모양으로. 줄 번호 같은 내부 사정은 안 내보낸다. */ function boardToJson(board) { return { rev: board.rev, path: board.path, columns: board.columns.map((column) => ({ name: column.name, cards: column.cards.map((card) => ({ id: card.id, date: card.date, title: card.title, detail: card.detail, text: card.text, // 편집창이 띄울 원문 lineCount: card.raw.length, })), })), }; } function sendJson(res, status, body) { const payload = JSON.stringify(body); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(payload), }); res.end(payload); } /** * 파일을 감시하다가 바뀌면 알려준다. * 파일이 아니라 **그 폴더**를 본다 — 에디터가 저장할 때 파일을 지웠다 새로 만드는 * 경우가 흔해서, 파일을 직접 감시하면 그 순간 감시가 끊긴다 (research.md R-001). */ function watchFile(filePath, onChange) { const dir = path.dirname(path.resolve(filePath)); const name = path.basename(filePath); let timer = null; // 저장 한 번에 이벤트가 여러 번 오는 게 흔해서 뭉쳐서 한 번만 알린다 const fire = () => { if (timer) clearTimeout(timer); timer = setTimeout(onChange, 120); }; try { const watcher = fs.watch(dir, (_event, changed) => { if (changed === null || changed === name) fire(); }); watcher.on('error', () => {}); // 감시가 죽어도 서버는 계속 산다 return () => { if (timer) clearTimeout(timer); watcher.close(); }; } catch { // 폴더 감시가 안 되는 환경(네트워크 드라이브 등)에서는 폴링으로 물러선다 fs.watchFile(filePath, { interval: 500 }, fire); return () => { if (timer) clearTimeout(timer); fs.unwatchFile(filePath, fire); }; } } function readJsonBody(req, done) { let raw = ''; req.on('data', (chunk) => { raw += chunk; }); req.on('end', () => { try { done(JSON.parse(raw)); } catch { done({}); } }); } /** 요청 하나를 처리해 [상태코드, 본문] 을 돌려준다 (contracts/http-api.md) */ function handleMove(filePath, { rev, id, to } = {}) { if (!id || !to) return [400, { ok: false, error: 'bad-request' }]; return handleWrite(filePath, rev, (board) => moveCard(board, id, to)); } function handleAdd(filePath, { rev, column, text } = {}) { if (!column) return [400, { ok: false, error: 'bad-request' }]; return handleWrite(filePath, rev, (board) => addCard(board, column, text)); } function handleEdit(filePath, { rev, id, text } = {}) { if (!id) return [400, { ok: false, error: 'bad-request' }]; return handleWrite(filePath, rev, (board) => editCard(board, id, text)); } function handleRemove(filePath, { rev, id } = {}) { if (!id) return [400, { ok: false, error: 'bad-request' }]; return handleWrite(filePath, rev, (board) => removeCard(board, id)); } /** * 파일을 바꾸는 요청들의 공통 뼈대: rev 확인 → 시킨 일 → 저장. * 셋 다 "내가 본 그 파일이 맞나"부터 확인하고, 아니면 아무것도 안 하고 거절한다 (FR-010). */ function handleWrite(filePath, rev, work) { const board = readBoard(filePath); if (!rev) return [400, { ok: false, error: 'bad-request' }]; if (rev !== board.rev) { return [409, { ok: false, error: 'stale', message: '파일이 바뀌어서 취소됨', rev: board.rev }]; } const result = work(board); if (result.error === 'no-card' || result.error === 'ambiguous') { return [404, { ok: false, error: 'no-card' }]; } if (result.error === 'bad-column') { return [400, { ok: false, error: 'bad-column', candidates: result.candidates }]; } if (result.error) return [400, { ok: false, error: result.error }]; if (result.moved === false) return [200, { ok: true, rev: board.rev, moved: false }]; try { return [200, { ok: true, rev: writeBoard(board, result.lines), moved: true }]; } catch (err) { return [500, { ok: false, error: 'write-failed', message: err.message }]; } } // 화면 전부. 빌드 단계도 외부 파일도 없다 — zkanban.js 하나만 복사하면 끝 (SC-004). const PAGE = ` 칸반
칸반
`; function createServer(filePath) { const clients = new Set(); const broadcast = () => { let payload; try { payload = `event: board\ndata: ${JSON.stringify(boardToJson(readBoard(filePath)))}\n\n`; } catch { return; // 저장 직후 잠깐 못 읽는 일이 있다. 다음 변경 때 다시 보내면 된다 } for (const client of clients) client.write(payload); }; const server = http.createServer((req, res) => { try { return route(req, res); } catch (err) { // 파일을 못 읽는 등 무슨 일이 나도 서버는 살아 있어야 한다 return sendJson(res, 500, { ok: false, error: 'server-error', message: err.message }); } }); function route(req, res) { const url = new URL(req.url, `http://${HOST}`); // 127.0.0.1 에 묶는 것만으로는 DNS 리바인딩을 못 막는다. 이름도 확인한다 const host = (req.headers.host ?? '').split(':')[0]; if (host !== HOST && host !== 'localhost' && host !== '[::1]') { return sendJson(res, 403, { ok: false, error: 'bad-host' }); } if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(PAGE), }); return res.end(PAGE); } if (req.method === 'GET' && url.pathname === '/api/board') { return sendJson(res, 200, boardToJson(readBoard(filePath))); } if (req.method === 'GET' && url.pathname === '/events') { res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }); clients.add(res); res.write(`event: board\ndata: ${JSON.stringify(boardToJson(readBoard(filePath)))}\n\n`); req.on('close', () => clients.delete(res)); return undefined; } if (req.method === 'POST' && url.pathname === '/api/move') { return readJsonBody(req, (body) => sendJson(res, ...handleMove(filePath, body))); } if (req.method === 'POST' && url.pathname === '/api/add') { return readJsonBody(req, (body) => sendJson(res, ...handleAdd(filePath, body))); } if (req.method === 'POST' && url.pathname === '/api/edit') { return readJsonBody(req, (body) => sendJson(res, ...handleEdit(filePath, body))); } if (req.method === 'POST' && url.pathname === '/api/remove') { return readJsonBody(req, (body) => sendJson(res, ...handleRemove(filePath, body))); } return sendJson(res, 404, { ok: false, error: 'not-found' }); } const unwatch = watchFile(filePath, broadcast); // 죽은 연결을 빨리 정리하려고 가끔 신호를 보낸다 (SSE 주석 줄) const ping = setInterval(() => { for (const client of clients) client.write(':ping\n\n'); }, 15000); ping.unref(); server.kanbanFile = filePath; server.kanbanClients = clients; server.kanbanBroadcast = broadcast; server.on('close', () => { unwatch(); clearInterval(ping); }); return server; } /** * 포트가 이미 쓰이면 하나씩 올려가며 최대 10번 시도한다. * 실제로 잡은 포트를 돌려준다 (0 을 주면 OS 가 비어 있는 걸 골라줌). */ function listen(server, port, host = HOST) { return new Promise((resolve, reject) => { let attempt = 0; const tryPort = (candidate) => { const onError = (err) => { if (err.code !== 'EADDRINUSE' || candidate === 0 || ++attempt >= PORT_TRIES) { return reject(err); } tryPort(candidate + 1); }; server.once('error', onError); server.listen(candidate, host, () => { server.removeListener('error', onError); resolve(server.address().port); }); }; tryPort(port); }); } function closeServer(server) { // SSE 연결은 끝나지 않는 응답이라, 먼저 끊어주지 않으면 close 가 안 끝난다 for (const client of server.kanbanClients ?? []) client.end(); return new Promise((resolve) => server.close(resolve)); } // ──────────────────────────────────────────────────────────────── // ④ CLI // ──────────────────────────────────────────────────────────────── const USAGE = `사용법: node zkanban.js [명령] (없음) 보드를 표로 출력 move <열> 카드를 다른 열로 옮김 serve 보드 서버를 띄움 (이미 떠 있으면 주소만 알려줌) help 이 도움말 옵션: --file <경로> 대상 파일 (기본: ./zkanban.md) --port <번호> serve 포트 (기본: 파일 경로로 정함 — 39000~39999, 프로젝트마다 항상 같은 자리라 북마크가 먹음) `; /** `--file 경로` `--port 번호` 같은 옵션을 떼어내고 나머지를 남긴다 */ function parseArgs(argv) { const options = { file: 'zkanban.md', port: null }; // port 는 안 주면 파일 경로에서 정한다 const rest = []; for (let i = 0; i < argv.length; i++) { if (argv[i] === '--file' && argv[i + 1] !== undefined) options.file = argv[++i]; else if (argv[i] === '--port' && argv[i + 1] !== undefined) options.port = Number(argv[++i]); else rest.push(argv[i]); } return { options, rest }; } async function runServe(options) { if (options.port !== null && (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535)) { return fail(`포트가 이상함: ${options.port}`); } const filePath = path.resolve(options.file); const wanted = options.port ?? portFor(filePath); // 이 보드가 이미 떠 있으면 또 띄우지 않는다. 주소만 알려주고 끝 if (await boardAlreadyServing(wanted, filePath)) { process.stdout.write(`이미 떠 있음: http://${HOST}:${wanted}\n`); process.stdout.write(`파일: ${filePath}\n`); return 0; } const server = createServer(filePath); const port = await listen(server, wanted); process.stdout.write(`칸반 보드: http://${HOST}:${port}\n`); process.stdout.write(`파일: ${filePath}\n`); // 해시로 정한 자리를 남이 쓰고 있으면 옆으로 밀린다. 그럼 다음에 주소가 또 달라지니 알려준다 if (port !== wanted) process.stdout.write(`(${wanted} 은 남이 쓰는 중이라 ${port} 으로 잡음)\n`); process.stdout.write('(Ctrl+C 로 종료)\n'); return 0; } function fail(message) { process.stderr.write(`${message}\n`); return 1; } /** 인자 없이 실행했을 때 — 보드를 표로 */ function printBoard(board) { const width = Math.max(40, (process.stdout.columns ?? 100) - 30); process.stdout.write(`${board.path} rev ${board.rev}\n\n`); for (const column of board.columns) { process.stdout.write(`[${column.name}] (${column.cards.length})\n`); for (const card of column.cards) { const title = card.title.length > width ? `${card.title.slice(0, width - 1)}…` : card.title; process.stdout.write(` ${card.id} ${card.date ?? '—'} ${title}\n`); } } return 0; } function runMove(board, idInput, columnInput) { if (idInput === undefined || columnInput === undefined) return fail('사용법: move <열>'); const result = moveCard(board, idInput, columnInput); if (result.error === 'no-card') return fail(`그런 카드 없음: ${idInput}`); if (result.error === 'ambiguous') return fail(`id 가 여러 개에 걸림: ${result.candidates.join(', ')}`); if (result.error === 'bad-column') return fail(`그런 열 없음: ${columnInput} (있는 열: ${result.candidates.join(', ')})`); if (!result.moved) { process.stdout.write(`이미 [${result.column.name}] 에 있음\n`); return 0; } writeBoard(board, result.lines); process.stdout.write(`${result.card.id} → [${result.column.name}]\n`); return 0; } async function main(argv) { const { options, rest } = parseArgs(argv); const [command, ...args] = rest; if (command === 'serve') return runServe(options); if (command === undefined || command === 'move') { const filePath = path.resolve(options.file); // 보기만 할 땐 파일이 없어도 빈 보드를 보여준다. 옮기려면 당연히 파일이 있어야 한다 if (command === 'move' && !fs.existsSync(filePath)) { return fail(`${path.basename(filePath)} 없음: ${filePath}`); } const board = readBoard(filePath); return command === undefined ? printBoard(board) : runMove(board, args[0], args[1]); } process.stdout.write(USAGE); return 0; } module.exports = { splitLines, joinLines, revOf, parseColumns, parseCards, parseBoard, resolveCardId, resolveColumn, moveCard, addCard, editCard, removeCard, readBoard, writeBoard, boardToJson, createServer, listen, closeServer, portFor, boardAlreadyServing, }; // import 만 했을 땐 아무 일도 안 일어나야 한다 (테스트가 이걸 기대함) if (require.main === module) { main(process.argv.slice(2)) .then((code) => { process.exitCode = code; }) .catch((err) => { process.exitCode = fail(err.message); }); }