951 lines
40 KiB
JavaScript
951 lines
40 KiB
JavaScript
#!/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('<!--')) {
|
||
if (!line.includes('-->')) 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 = `<!doctype html>
|
||
<html lang="ko"><meta charset="utf-8"><title>칸반</title>
|
||
<style>
|
||
:root{color-scheme:light}
|
||
body{margin:0;font:14px/1.55 system-ui,"Malgun Gothic",sans-serif;background:#f4f5f7;color:#1c1f26}
|
||
header{display:flex;align-items:center;gap:9px;padding:11px 16px;border-bottom:1px solid #e3e5ea;background:#fff}
|
||
#dot{width:9px;height:9px;border-radius:50%;background:#d9534f}
|
||
#dot.on{background:#1aa06d}
|
||
#file{color:#8a90a0;font-size:12px}
|
||
nav{display:none}
|
||
main{display:flex;gap:12px;padding:16px;align-items:flex-start;overflow-x:auto}
|
||
section{flex:1 0 250px;background:#eceef2;border:1px solid #e0e3ea;border-radius:10px;padding:10px}
|
||
h2{margin:0 0 9px;font-size:12px;letter-spacing:.04em;color:#6f7688;display:flex;justify-content:space-between}
|
||
article{background:#fff;border:1px solid #e0e3ea;border-radius:8px;padding:8px 10px;margin-bottom:7px;box-shadow:0 1px 2px rgba(16,20,32,.05)}
|
||
time{display:block;font-size:11px;color:#8a90a0;margin-bottom:2px}
|
||
.t{font-weight:600}
|
||
.d{margin:4px 0 0;font-size:12.5px;color:#5d6472;white-space:pre-wrap}
|
||
article{cursor:grab}
|
||
article.dragging{opacity:.4}
|
||
section.over{border-color:#1aa06d}
|
||
article{position:relative}
|
||
.x,.mv{position:absolute;top:5px;width:19px;height:19px;line-height:17px;text-align:center;border-radius:5px;color:#9aa1b0;cursor:pointer;opacity:0;user-select:none}
|
||
.x{right:6px}
|
||
.mv{right:28px}
|
||
article:hover .x,article:hover .mv{opacity:1}
|
||
.x:hover{background:#fde8e8;color:#b3261e}
|
||
.mv:hover{background:#e4ecfb;color:#1f5fd0}
|
||
/* 카드를 다른 열로 보내는 목록. 좁아서 드래그가 안 될 때의 길 */
|
||
.menu{position:absolute;top:26px;right:6px;z-index:5;min-width:118px;background:#fff;border:1px solid #d7dae3;border-radius:8px;box-shadow:0 6px 20px rgba(16,20,32,.16);overflow:hidden}
|
||
.menu div{padding:7px 12px;cursor:pointer;white-space:nowrap}
|
||
.menu div:hover{background:#eef1f7}
|
||
.edit,.add{width:100%;box-sizing:border-box;background:#fff;color:#1c1f26;font:inherit;resize:none;overflow:hidden;display:block}
|
||
.edit{border:1px solid #1aa06d;border-radius:6px;padding:5px 7px}
|
||
.hint{font-size:11px;color:#8a90a0;margin-top:4px}
|
||
.add{border:1px dashed #c8cdd8;border-radius:8px;padding:7px 10px;background:transparent}
|
||
.add::placeholder{color:#9aa1b0}
|
||
.add:focus{outline:none;border-color:#1aa06d;border-style:solid;background:#fff}
|
||
#toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:#fde8e8;border:1px solid #f0b4b4;color:#8c1d18;padding:8px 14px;border-radius:8px;display:none}
|
||
/* 좁으면 열을 나란히 못 놓는다. 탭으로 하나만 보여주고, 이동은 카드의 → 로 한다 */
|
||
@media (max-width:820px){
|
||
nav{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;background:#fff;border-bottom:1px solid #e3e5ea;position:sticky;top:0;z-index:4}
|
||
nav button{flex:0 0 auto;font:inherit;font-size:12.5px;padding:6px 11px;border:1px solid transparent;border-radius:999px;background:transparent;color:#6f7688;cursor:pointer;white-space:nowrap}
|
||
nav button.on{background:#e8f4ee;border-color:#bfe3d2;color:#14684a;font-weight:600}
|
||
nav b{font-weight:600;margin-left:5px;opacity:.6}
|
||
/* 경로는 길어서 헤더를 세 줄로 터뜨린다. 좁을 땐 어느 프로젝트인지 대개 아니까 숨긴다 */
|
||
#file{display:none}
|
||
main{display:block;padding:10px;overflow-x:visible}
|
||
section{background:transparent;border:0;border-radius:0;padding:0}
|
||
section:not(.on){display:none}
|
||
h2{display:none}
|
||
}
|
||
</style>
|
||
<header><span id="dot"></span><strong>칸반</strong><span id="file"></span></header>
|
||
<nav></nav><main></main><div id="toast"></div>
|
||
<script>
|
||
const main=document.querySelector('main'),nav=document.querySelector('nav'),dot=document.getElementById('dot'),toast=document.getElementById('toast');
|
||
let rev=null,dragging=null,pending=null,editing=null;
|
||
// 좁은 화면에서 지금 보고 있는 열. 넓으면 CSS 가 무시하므로 폭에 상관없이 늘 들고 있는다
|
||
let active=sessionStorage.getItem('col');
|
||
let last=null;
|
||
function say(msg){toast.textContent=msg;toast.style.display='block';setTimeout(()=>toast.style.display='none',3000)}
|
||
function el(tag,cls,text){const n=document.createElement(tag);if(cls)n.className=cls;if(text!=null)n.textContent=text;return n}
|
||
// 줄이 늘면 칸도 같이 늘어나게 (스크롤바 대신)
|
||
function grow(t){const fit=()=>{t.style.height='auto';t.style.height=t.scrollHeight+'px'};t.addEventListener('input',fit);setTimeout(fit)}
|
||
// Enter 는 저장, Shift+Enter 는 줄바꿈. 한글은 조합 중에도 Enter 가 오므로 그때는 넘긴다
|
||
function enterToSubmit(e){
|
||
if(e.key!=='Enter'||e.shiftKey||e.isComposing)return false;
|
||
e.preventDefault();return true;
|
||
}
|
||
// 카드를 다른 열로 보내는 목록. 좁아서 드래그할 자리가 없을 때의 길이고, 넓어도 그대로 쓴다
|
||
function closeMenu(){const m=document.querySelector('.menu');if(m)m.remove()}
|
||
function openMenu(box,card,here){
|
||
closeMenu();
|
||
const m=el('div','menu');
|
||
for(const col of last.columns){
|
||
if(col.name===here)continue;
|
||
const it=el('div',null,col.name);
|
||
it.addEventListener('click',e=>{e.stopPropagation();closeMenu();send('/api/move',{id:card.id,to:col.name})});
|
||
m.append(it);
|
||
}
|
||
box.append(m);
|
||
}
|
||
document.addEventListener('click',closeMenu);
|
||
function render(board){
|
||
rev=board.rev;last=board;
|
||
document.getElementById('file').textContent=board.path||'';
|
||
closeMenu();
|
||
// 보던 열이 사라졌으면(이름을 고쳤거나) 첫 열로 돌아간다
|
||
const names=board.columns.map(c=>c.name);
|
||
if(!names.includes(active))active=names[0]||null;
|
||
nav.textContent='';
|
||
for(const col of board.columns){
|
||
const tab=el('button',col.name===active?'on':null,col.name);
|
||
tab.append(el('b',null,col.cards.length));
|
||
tab.addEventListener('click',()=>{active=col.name;sessionStorage.setItem('col',active);render(last)});
|
||
nav.append(tab);
|
||
}
|
||
// 다시 그리면 입력칸이 통째로 새로 만들어진다. 치던 글이 날아가지 않게 담아뒀다 되돌린다
|
||
const at=document.activeElement;
|
||
const typing=at&&at.classList.contains('add')?{name:at.closest('section').dataset.name,value:at.value}:null;
|
||
main.textContent='';
|
||
for(const col of board.columns){
|
||
const sec=el('section',col.name===active?'on':null);sec.dataset.name=col.name;
|
||
const head=el('h2');head.append(el('span',null,col.name),el('span',null,col.cards.length));
|
||
sec.append(head);
|
||
sec.addEventListener('dragover',e=>{e.preventDefault();sec.classList.add('over')});
|
||
sec.addEventListener('dragleave',()=>sec.classList.remove('over'));
|
||
sec.addEventListener('drop',e=>{e.preventDefault();sec.classList.remove('over');move(sec.dataset.name)});
|
||
for(const card of col.cards){
|
||
const box=el('article');box.dataset.id=card.id;box.draggable=true;
|
||
box.addEventListener('dragstart',()=>{dragging=card.id;box.classList.add('dragging')});
|
||
box.addEventListener('dragend',()=>{dragging=null;box.classList.remove('dragging');if(pending){const p=pending;pending=null;render(p)}});
|
||
if(card.date)box.append(el('time',null,card.date));
|
||
box.append(el('div','t',card.title));
|
||
if(card.detail)box.append(el('p','d',card.detail));
|
||
const x=el('span','x','×');x.title='지우기';
|
||
x.addEventListener('click',()=>remove(card));
|
||
const mv=el('span','mv','→');mv.title='다른 열로';
|
||
mv.addEventListener('click',e=>{e.stopPropagation();openMenu(box,card,col.name)});
|
||
box.append(mv,x);
|
||
box.addEventListener('dblclick',()=>startEdit(box,card));
|
||
sec.append(box);
|
||
}
|
||
const input=el('textarea','add');input.rows=1;input.placeholder='+ 적고 엔터 (Shift+Enter 줄바꿈)';
|
||
grow(input);
|
||
input.addEventListener('keydown',e=>{if(enterToSubmit(e))add(sec,input)});
|
||
sec.append(input);
|
||
main.append(sec);
|
||
}
|
||
if(typing){
|
||
const back=main.querySelector('section[data-name="'+CSS.escape(typing.name)+'"] .add');
|
||
if(back){back.value=typing.value;back.focus()}
|
||
}
|
||
}
|
||
const refresh=()=>fetch('/api/board').then(r=>r.json()).then(render);
|
||
// 파일을 바꾸는 요청은 전부 여기를 지난다. rev 를 실어 보내고, 성공하면 새 rev 를 받아둔다
|
||
// (SSE 방송을 안 기다리고 바로 다음 요청을 보낼 수 있게)
|
||
async function send(path,body,retryStale){
|
||
const res=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({rev,...body})});
|
||
const b=await res.json();
|
||
if(res.status===409){
|
||
rev=b.rev;
|
||
// 추가처럼 남과 부딪힐 게 없는 일은 새 rev 로 한 번만 조용히 다시 (재귀는 안 함)
|
||
if(retryStale)return send(path,body);
|
||
say(b.message||'파일이 바뀌어서 취소됨');pending=null;refresh();return null;
|
||
}
|
||
if(!res.ok){say('안 됨: '+(b.error||res.status));return null}
|
||
rev=b.rev;return b;
|
||
}
|
||
async function add(sec,input){
|
||
const text=input.value.trim();
|
||
if(text&&await send('/api/add',{column:sec.dataset.name,text},true))input.value='';
|
||
}
|
||
async function move(to){
|
||
if(dragging)await send('/api/move',{id:dragging,to});
|
||
}
|
||
async function remove(card){
|
||
if(confirm('지울까? '+card.title))await send('/api/remove',{id:card.id});
|
||
}
|
||
// 카드를 그 자리에서 고친다. 첫 줄(제목·상세)만 바뀌고 날짜·하위 줄은 그대로다
|
||
function startEdit(box,card){
|
||
if(editing)return;
|
||
editing=card.id;
|
||
const input=el('textarea','edit');input.rows=1;
|
||
// 파일에 적힌 원문 그대로 띄운다. 안 고치고 저장하면 파일도 그대로다
|
||
input.value=card.text;
|
||
box.textContent='';box.draggable=false;
|
||
box.append(input,el('div','hint','Enter 저장 · Shift+Enter 줄바꿈 · Esc 취소'));
|
||
grow(input);input.focus();input.select();
|
||
const stop=()=>{if(editing){editing=null;refresh()}};
|
||
input.addEventListener('blur',stop);
|
||
input.addEventListener('keydown',async e=>{
|
||
if(e.key==='Escape')return stop();
|
||
if(!enterToSubmit(e))return;
|
||
const text=input.value.trim();
|
||
if(text)await send('/api/edit',{id:card.id,text});
|
||
stop();
|
||
});
|
||
}
|
||
const es=new EventSource('/events');
|
||
// 카드를 손에 들었거나 고치는 중에 화면이 갈아엎어지면 하던 일이 날아간다. 끝날 때까지 미룬다
|
||
es.addEventListener('board',e=>{const board=JSON.parse(e.data);if(dragging||editing)pending=board;else render(board)});
|
||
es.onopen=()=>dot.classList.add('on');
|
||
es.onerror=()=>dot.classList.remove('on');
|
||
</script>
|
||
`;
|
||
|
||
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 <id> <열> 카드를 다른 열로 옮김
|
||
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 <id> <열>');
|
||
|
||
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);
|
||
});
|
||
}
|