rootr-cli 0.9.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +230 -5
- package/bin/rootr.js +864 -3
- package/lib/client.js +341 -5
- package/lib/config.js +5 -0
- package/lib/mcp-tools/databases.js +334 -4
- package/lib/mcp-tools/documents.js +10 -4
- package/lib/mcp-tools/feedback.js +97 -0
- package/lib/mcp-tools/forms.js +62 -7
- package/lib/mcp-tools/misc.js +84 -1
- package/lib/mcp-tools/presentations.js +5 -1
- package/lib/mcp-tools/public.js +148 -0
- package/lib/mcp-tools/signed.js +495 -0
- package/lib/mcp-tools/workspaces.js +117 -13
- package/lib/mcp.js +47 -4
- package/package.json +1 -1
package/bin/rootr.js
CHANGED
|
@@ -33,17 +33,56 @@ const USAGE = `Rootr CLI v${pkg.version}
|
|
|
33
33
|
rootr db notify <path|id> [--file rules.json] [--clear] 표 알림 규칙 보기·저장
|
|
34
34
|
rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] 행 추가(상세 내용 포함)
|
|
35
35
|
rootr db row-body <path|id> <rowId> [--file <f>] 행의 상세 내용(본문 메모) 쓰기 — 없으면 표준입력
|
|
36
|
+
rootr db rows-new <path|id> --json '[{…},…]' | --file <파일> 여러 행 한 번에 넣기 (하나라도 틀리면 전부 취소)
|
|
37
|
+
rootr db rows-set <path|id> --ids <id,id> --values '<json>' 고른 행 전부에 같은 값 (날짜 일괄 변경 등)
|
|
38
|
+
rootr db rows-each <path|id> --json '[{"rowId":…,"values":{…}},…]' 행마다 다른 값으로 한 번에 수정
|
|
39
|
+
rootr db rows-rm <path|id> --ids <id,id> [--yes] 고른 행 한 번에 삭제 — 하나라도 막히면 통째로 거절
|
|
40
|
+
rootr db row-page <rowId> [--type RECORDING] 이 행만 원하는 타입으로 상세 붙이기(녹음·스프레드시트·서명문서…)
|
|
41
|
+
rootr db row-page <rowId> --node <path|id> 이미 있는 문서를 이 행에 잇기 (옮기지 않고 가리킴)
|
|
42
|
+
rootr db row-page <rowId> --unlink 연결만 끊기 — 문서는 그대로 남음
|
|
43
|
+
rootr db row-page-type <path|id> <TYPE> 새 행의 기본 타입 — 이미 만든 행은 그대로
|
|
44
|
+
rootr publish <path|id> [--children] [--view <id>] 공개 발행 — 표·시트·화이트보드·폼 등 전 타입
|
|
45
|
+
rootr publish <path|id> --off 공개 닫기 (딸려 나간 하위도 같이 닫힘)
|
|
46
|
+
rootr publish <path|id> --status 지금 공개인지·주소·하위 포함 여부
|
|
47
|
+
rootr publish <표> --layout board --skin paper 공개한 표를 무슨 꼴로 보일지 (배치 9종 × 분위기 6종)
|
|
48
|
+
rootr readreq <path|id> [--json] 읽음 확인 현황 — 누가 읽었나·완료율
|
|
49
|
+
rootr readreq <path|id> --users <id,id> [--due <ISO>] [--ack] 읽어야 할 사람 지정(명단 통째로 교체)
|
|
50
|
+
rootr readreq <path|id> --clear 읽음 확인 해제
|
|
36
51
|
rootr access <path|id> [--json] 공개범위·권한 보기 (관리 권한 필요)
|
|
37
52
|
rootr access <path|id> --private|--public|--inherit 공개범위 바꾸기
|
|
38
53
|
rootr access <path|id> --grant <userId> --level READ|WRITE|MANAGE 사람에게 권한 주기
|
|
39
54
|
rootr access <path|id> --revoke <userId> 준 권한 거두기
|
|
55
|
+
rootr form new <경로> --fields '<json>' [--collect db|sheet] 폼 만들기 (질문 18종: text·email·phone·url·rating·scale·time·file·matrix·ranking·csat·nps…)
|
|
56
|
+
rootr form show <path|id> [--json] 질문·설정 보기 (고치기 전에 먼저)
|
|
57
|
+
rootr form set <path|id> --fields '<json>' | --config '<json>' 질문·설정 고치기 (설정은 준 칸만 바뀜)
|
|
40
58
|
rootr form collect <path|id> --to db|sheet 응답 모을 곳을 새로 만들어 연결 (표는 질문 그대로 칸 생성)
|
|
41
59
|
rootr form responses <path|id> [--latest] [--json] 폼에 들어온 응답 보기 (--latest: 사람마다 최신 1건만)
|
|
42
60
|
rootr form response-edit <응답id> --values '<json>' 이미 낸 응답 고치기 (폼 검사 규칙 다시 통과)
|
|
43
61
|
rootr form response-rm <응답id> [--yes] 응답 한 건 삭제 (되돌릴 수 없음)
|
|
62
|
+
rootr sign new <경로> [--file <f>] [--reviewer <userId>] [--line <line.json>] 서명문서 만들기(본문·결재라인까지)
|
|
63
|
+
rootr sign show <path|id> [--json] 결재라인 진행·서명 목록 보기
|
|
64
|
+
rootr sign line <path|id> --file <line.json> 결재라인 다시 짜기 (올리기 전에만)
|
|
65
|
+
결재자는 {"userId"} 또는 {"email","displayName"} — 메일이면 계정 없이 서명
|
|
66
|
+
rootr sign code <path|id> | --bulk 확인 코드를 내 메일로 보내기 (서명 전 필수)
|
|
67
|
+
rootr sign submit <path|id> [--code <6자리>] 올리기 — 본문이 언다. 내가 1단계 서명자면 --code 필요
|
|
68
|
+
rootr sign approve <path|id> --code <6자리> 내 차례에 서명
|
|
69
|
+
rootr sign reject <path|id> --comment <사유> [--code <6자리>] 반려 (사유 필수)
|
|
70
|
+
rootr sign reopen <path|id> 아무도 서명 안 한 초안 다시 열기
|
|
71
|
+
rootr sign revise <path|id> --note <사유> 개정본 만들기 (원본은 그대로 남는다)
|
|
72
|
+
rootr sign verify <path|id> [--json] 위변조 검증 (서명·해시·시점확인)
|
|
73
|
+
rootr sign list [--pending] [--status <S>] [--mode <M>] [--json] 서명문서 목록 (--pending: 내 차례)
|
|
74
|
+
rootr sign bulk <id…> --code <6자리> 여러 건 한 번에 올리기+서명 (코드 하나로, 초안 포함)
|
|
75
|
+
rootr sign ledger [--json] 워크스페이스 원장(해시 체인) 전체 검증
|
|
44
76
|
rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] 설정 조회/저장
|
|
45
77
|
rootr config --add-key <k> [--label <name>] [--workspace <w>] 워크스페이스별 키 추가 등록
|
|
46
|
-
rootr
|
|
78
|
+
rootr feedback "<무엇이 안 되는지>" [--kind bug|friction|idea|praise] [--email <주소>]
|
|
79
|
+
제보 보내기 — 오류·불편·제안. 로그인·키 없어도 되고 한도에 안 걸립니다
|
|
80
|
+
rootr scaffold plan "<무엇을 위한 워크스페이스인지>" 짓기 1단계 — 물어볼 것·설계 규칙 받기
|
|
81
|
+
rootr scaffold review --file tree.json [--json] 짓기 2단계 — 만들기 전에 검사받기(점수·고칠 곳)
|
|
82
|
+
rootr scaffold apply --file tree.json [--root /경로] 짓기 3단계 — 실제로 만들기(검사 결과도 같이 나옴)
|
|
83
|
+
rootr scaffold built [--root /경로] [--json] 짓고 나서 — 실제로 만들어진 것의 **모양**을 검사
|
|
84
|
+
rootr scaffold test [--root /경로] [--json] 짓고 나서 — 실제로 **돌려 본다**(관계·롤업·수식·폼·시트). 실패 시 exit 1
|
|
85
|
+
rootr mcp MCP stdio 서버 구동 (도구 151종 — README 참고)
|
|
47
86
|
rootr --help 도움말
|
|
48
87
|
rootr --version 버전 출력
|
|
49
88
|
|
|
@@ -440,6 +479,53 @@ async function cmdAsk(args) {
|
|
|
440
479
|
}
|
|
441
480
|
}
|
|
442
481
|
|
|
482
|
+
/**
|
|
483
|
+
* FEEDBACK — "이게 안 돼요"를 보내는 명령. 일부러 워크스페이스도 API 키도 요구하지
|
|
484
|
+
* 않는다: 키 설정이 안 돼서 아무것도 못 하는 사람의 제보가 우리가 제일 받고 싶은
|
|
485
|
+
* 제보다. 요금·한도에도 걸리지 않는다.
|
|
486
|
+
*/
|
|
487
|
+
async function cmdFeedback(args) {
|
|
488
|
+
const KIND_MAP = { bug: 'BUG', friction: 'FRICTION', idea: 'IDEA', praise: 'PRAISE', other: 'OTHER' };
|
|
489
|
+
let body = args._.join(' ').trim();
|
|
490
|
+
if (args.file) body = readFileSync(args.file, 'utf8').trim();
|
|
491
|
+
if (!body) body = (await readStdin()).trim();
|
|
492
|
+
if (!body) {
|
|
493
|
+
throw new Error(
|
|
494
|
+
'사용법: rootr feedback "무엇을 하려다 무엇이 일어났는지" [--kind bug|friction|idea|praise]\n' +
|
|
495
|
+
' rootr feedback --file report.md / cat log | rootr feedback --kind bug',
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
let kind;
|
|
500
|
+
if (args.kind) {
|
|
501
|
+
kind = KIND_MAP[String(args.kind).toLowerCase()];
|
|
502
|
+
if (!kind) {
|
|
503
|
+
throw new Error(`--kind 는 ${Object.keys(KIND_MAP).join('|')} 중 하나입니다.`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const cfg = loadConfig();
|
|
508
|
+
const workspace = args.workspace || cfg.workspace;
|
|
509
|
+
const client = new RootrClient({ ...cfg, workspace, apiKey: keyForWorkspace(cfg, workspace) });
|
|
510
|
+
const result = await client.sendFeedback({
|
|
511
|
+
body,
|
|
512
|
+
title: args.title,
|
|
513
|
+
kind,
|
|
514
|
+
contactEmail: args.email,
|
|
515
|
+
reporter: args.from,
|
|
516
|
+
workspaceId: workspace || undefined,
|
|
517
|
+
client: 'cli',
|
|
518
|
+
});
|
|
519
|
+
if (result.deduped) {
|
|
520
|
+
console.log(`이미 같은 제보가 접수돼 있어 합쳤습니다. (id: ${result.id})`);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
console.log(`제보를 보냈습니다. 고맙습니다. (id: ${result.id})`);
|
|
524
|
+
if (!args.email) {
|
|
525
|
+
console.log('답을 받고 싶으면 --email <주소>를 함께 주세요.');
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
443
529
|
async function cmdConfig(args) {
|
|
444
530
|
// Registering an extra credential is its own action — several keys can live
|
|
445
531
|
// side by side, each bound to the workspace it can reach, so `rootr mcp` (and
|
|
@@ -554,11 +640,140 @@ async function cmdAccess(args) {
|
|
|
554
640
|
*/
|
|
555
641
|
async function cmdForm(args) {
|
|
556
642
|
const sub = args._[0];
|
|
643
|
+
if (sub === 'new') return cmdFormNew(args);
|
|
644
|
+
if (sub === 'show') return cmdFormShow(args);
|
|
645
|
+
if (sub === 'set') return cmdFormSet(args);
|
|
557
646
|
if (sub === 'collect') return cmdFormCollect(args);
|
|
558
647
|
if (sub === 'responses') return cmdFormResponses(args);
|
|
559
648
|
if (sub === 'response-edit') return cmdFormResponseEdit(args);
|
|
560
649
|
if (sub === 'response-rm') return cmdFormResponseRm(args);
|
|
561
|
-
throw new Error(
|
|
650
|
+
throw new Error(
|
|
651
|
+
'사용법: rootr form new|show|set|collect|responses|response-edit|response-rm … (자세한 건 rootr --help)',
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* FORM-TALLY — `rootr form new <경로> --fields '<json>'`
|
|
657
|
+
*
|
|
658
|
+
* 폼을 만들 수 있는 곳이 API·MCP뿐이고 CLI만 빠져 있었다(창업자 지적 2026-08-28:
|
|
659
|
+
* "MCP api cli scaffold도 다 구현했어?"). 질문 종류는 백엔드와 **같은 목록**을 쓴다 —
|
|
660
|
+
* 한쪽만 늘리면 만들 수 있는 것과 받아 주는 것이 어긋난다.
|
|
661
|
+
*/
|
|
662
|
+
const FORM_FIELD_TYPES = [
|
|
663
|
+
'text', 'textarea', 'number', 'select', 'multi_select', 'checkbox', 'date',
|
|
664
|
+
'email', 'phone', 'url', 'rating', 'scale', 'time', 'file',
|
|
665
|
+
'matrix', 'ranking', 'csat', 'nps',
|
|
666
|
+
];
|
|
667
|
+
|
|
668
|
+
function parseFieldsArg(args) {
|
|
669
|
+
if (typeof args.fields !== 'string') return undefined;
|
|
670
|
+
let parsed;
|
|
671
|
+
try {
|
|
672
|
+
parsed = JSON.parse(args.fields);
|
|
673
|
+
} catch {
|
|
674
|
+
throw new Error('--fields 는 JSON 배열이어야 합니다. 예: [{"name":"이메일","type":"email","required":true}]');
|
|
675
|
+
}
|
|
676
|
+
if (!Array.isArray(parsed)) throw new Error('--fields 는 배열이어야 합니다.');
|
|
677
|
+
for (const [i, f] of parsed.entries()) {
|
|
678
|
+
if (!f || typeof f.name !== 'string') throw new Error(`fields[${i}].name 이 필요합니다.`);
|
|
679
|
+
if (!FORM_FIELD_TYPES.includes(f.type)) {
|
|
680
|
+
throw new Error(`fields[${i}].type 은 다음 중 하나여야 합니다: ${FORM_FIELD_TYPES.join(', ')}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return parsed;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function parseConfigArg(args) {
|
|
687
|
+
if (typeof args.config !== 'string') return undefined;
|
|
688
|
+
try {
|
|
689
|
+
return JSON.parse(args.config);
|
|
690
|
+
} catch {
|
|
691
|
+
throw new Error('--config 는 JSON 객체여야 합니다.');
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function cmdFormNew(args) {
|
|
696
|
+
const target = args._[1];
|
|
697
|
+
if (!target) {
|
|
698
|
+
throw new Error("사용법: rootr form new <경로> --fields '<json>' [--config '<json>'] [--collect db|sheet]");
|
|
699
|
+
}
|
|
700
|
+
const client = makeClient();
|
|
701
|
+
const created = await client.createForm(client.workspace, {
|
|
702
|
+
path: target,
|
|
703
|
+
createParents: true,
|
|
704
|
+
fields: parseFieldsArg(args) ?? [],
|
|
705
|
+
config: parseConfigArg(args),
|
|
706
|
+
});
|
|
707
|
+
const id = created.id ?? created.nodeId;
|
|
708
|
+
console.log(`폼을 만들었습니다: ${target}`);
|
|
709
|
+
console.log(` id: ${id}`);
|
|
710
|
+
if (args.collect === 'db' || args.collect === 'sheet') {
|
|
711
|
+
const to = args.collect === 'db' ? 'database' : 'spreadsheet';
|
|
712
|
+
await client.request(`/forms/${encodeURIComponent(id)}/collect`, { method: 'POST', body: { to } });
|
|
713
|
+
console.log(` 응답 모을 곳: ${args.collect === 'db' ? '표' : '스프레드시트'} 생성됨`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** `rootr form show <path|id>` — 질문·설정을 그대로 본다(고치기 전에 읽으라고 있는 것). */
|
|
718
|
+
async function cmdFormShow(args) {
|
|
719
|
+
const target = args._[1];
|
|
720
|
+
if (!target) throw new Error('사용법: rootr form show <path|id> [--json]');
|
|
721
|
+
const client = makeClient();
|
|
722
|
+
// 경로도 id도 받는다(이 저장소의 다른 명령과 같은 규칙).
|
|
723
|
+
const id = await resolveDatabaseId(client, target);
|
|
724
|
+
const form = await client.getForm(id);
|
|
725
|
+
if (args.json) {
|
|
726
|
+
console.log(JSON.stringify(form, null, 2));
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
console.log(`${form.name} (응답 ${form.responseCount ?? 0}건, 방문 ${form.viewCount ?? 0}회)`);
|
|
730
|
+
for (const [i, f] of (form.fields ?? []).entries()) {
|
|
731
|
+
const marks = [
|
|
732
|
+
f.required ? '필수' : null,
|
|
733
|
+
f.hidden ? '숨김' : null,
|
|
734
|
+
f.page && f.page > 1 ? `${f.page}쪽` : null,
|
|
735
|
+
f.showIf ? '조건부' : null,
|
|
736
|
+
].filter(Boolean);
|
|
737
|
+
console.log(` ${i + 1}. ${f.name} [${f.type}]${marks.length ? ' · ' + marks.join('·') : ''}`);
|
|
738
|
+
if (f.help) console.log(` ${f.help}`);
|
|
739
|
+
}
|
|
740
|
+
const cfg = form.config ?? {};
|
|
741
|
+
const bits = [];
|
|
742
|
+
if (cfg.publicSubmit && cfg.publicSubmit !== 'off') bits.push(`공개 제출: ${cfg.publicSubmit}`);
|
|
743
|
+
if (cfg.closesAt) bits.push(`마감: ${cfg.closesAt}`);
|
|
744
|
+
if (cfg.maxResponses) bits.push(`정원: ${cfg.maxResponses}`);
|
|
745
|
+
if (Array.isArray(cfg.notifyEmails) && cfg.notifyEmails.length) bits.push(`알림: ${cfg.notifyEmails.join(', ')}`);
|
|
746
|
+
if (cfg.webhookUrl) bits.push('웹훅 있음');
|
|
747
|
+
if (cfg.theme) bits.push(`분위기: ${cfg.theme}`);
|
|
748
|
+
// 켜 놓고 잊기 쉬운 것들 — 안 보이면 "왜 두 번 못 내지?" 하고 한참 찾는다.
|
|
749
|
+
if (cfg.oncePerPerson) bits.push('한 사람 한 번만');
|
|
750
|
+
if (cfg.confirmEmailField) bits.push('확인 메일 보냄');
|
|
751
|
+
if (cfg.intro) bits.push('안내문 있음');
|
|
752
|
+
if (cfg.thankYou) bits.push('인사말 있음');
|
|
753
|
+
if (cfg.redirectUrl) bits.push(`낸 뒤 이동: ${cfg.redirectUrl}`);
|
|
754
|
+
if (bits.length) console.log(` 설정 — ${bits.join(' · ')}`);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* `rootr form set <path|id> --fields '<json>' | --config '<json>'`
|
|
759
|
+
*
|
|
760
|
+
* 설정은 **준 칸만** 바꾼다. 통째로 덮으면 응답 표 연결이나 공개 제출 방식 같은
|
|
761
|
+
* 남의 칸이 지워진다.
|
|
762
|
+
*/
|
|
763
|
+
async function cmdFormSet(args) {
|
|
764
|
+
const target = args._[1];
|
|
765
|
+
const fields = parseFieldsArg(args);
|
|
766
|
+
const config = parseConfigArg(args);
|
|
767
|
+
if (!target || (fields === undefined && config === undefined)) {
|
|
768
|
+
throw new Error("사용법: rootr form set <path|id> --fields '<json>' | --config '<json>'");
|
|
769
|
+
}
|
|
770
|
+
const client = makeClient();
|
|
771
|
+
const id = await resolveDatabaseId(client, target);
|
|
772
|
+
// 설정은 **보낸 것만** 바뀐다(서버가 합쳐 준다) — 지우려면 그 항목에 null.
|
|
773
|
+
await client.updateForm(id, { ...(fields ? { fields } : {}), ...(config ? { config } : {}) });
|
|
774
|
+
console.log('폼을 고쳤습니다.');
|
|
775
|
+
if (fields) console.log(` 질문 ${fields.length}개`);
|
|
776
|
+
if (config) console.log(` 바꾼 설정: ${Object.keys(config).join(', ')}`);
|
|
562
777
|
}
|
|
563
778
|
|
|
564
779
|
/**
|
|
@@ -641,16 +856,336 @@ async function cmdFormResponseRm(args) {
|
|
|
641
856
|
console.log(`응답 삭제됨: ${responseId}`);
|
|
642
857
|
}
|
|
643
858
|
|
|
859
|
+
/**
|
|
860
|
+
* DB-BULK — `rootr db rows-set <표> --ids a,b --values '{"작성일":{"start":"2026-08-01"}}'`
|
|
861
|
+
*
|
|
862
|
+
* 고른 행 전부에 같은 값을 덮어쓴다. 에이랩스 요청(2026-08-27)의 "한 번에 날짜
|
|
863
|
+
* 변경"이 이것이다. 칸은 **이름으로** 적어도 된다.
|
|
864
|
+
*/
|
|
865
|
+
async function cmdDbRowsSet(args) {
|
|
866
|
+
const target = args._[1];
|
|
867
|
+
const ids = typeof args.ids === 'string' ? args.ids.split(',').map((i) => i.trim()).filter(Boolean) : [];
|
|
868
|
+
if (!target || !ids.length || typeof args.values !== 'string') {
|
|
869
|
+
throw new Error("사용법: rootr db rows-set <path|id> --ids <id,id> --values '<json>'");
|
|
870
|
+
}
|
|
871
|
+
let values;
|
|
872
|
+
try {
|
|
873
|
+
values = JSON.parse(args.values);
|
|
874
|
+
} catch {
|
|
875
|
+
throw new Error('--values 를 JSON으로 읽을 수 없습니다. 예: \'{"작성일":{"start":"2026-08-01"}}\'');
|
|
876
|
+
}
|
|
877
|
+
const client = makeClient();
|
|
878
|
+
const id = await resolveDatabaseId(client, target);
|
|
879
|
+
// 칸 이름으로 적어도 되게 — 화면에서 보는 이름 그대로 쓰는 것이 자연스럽다.
|
|
880
|
+
const database = await client.getDatabase(id);
|
|
881
|
+
const byName = new Map((database.properties || []).map((p) => [p.name, p.id]));
|
|
882
|
+
const resolved = Object.fromEntries(
|
|
883
|
+
Object.entries(values).map(([k, v]) => [byName.get(k) ?? k, v]),
|
|
884
|
+
);
|
|
885
|
+
const res = await client.bulkUpdateRows(id, ids, resolved);
|
|
886
|
+
console.log(`${res.updated}개 행을 바꿨습니다.`);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* DB-BULK — `rootr db rows-new <표> --json '[{"이름":"홍길동"},…]'` (또는 `--file rows.json`)
|
|
891
|
+
*
|
|
892
|
+
* 여러 행을 **한 번에** 넣는다. 하나라도 형식이 틀리면 아무것도 안 들어간다 —
|
|
893
|
+
* 반쯤 채워진 표가 남는 게 제일 나쁘다. 칸은 이름으로 적어도 된다.
|
|
894
|
+
*/
|
|
895
|
+
async function cmdDbRowsNew(args) {
|
|
896
|
+
const target = args._[1];
|
|
897
|
+
const raw = readJsonArg(args, 'rows-new');
|
|
898
|
+
if (!target) throw new Error("사용법: rootr db rows-new <path|id> --json '<[{…},…]>' | --file <파일>");
|
|
899
|
+
const rows = Array.isArray(raw) ? raw : [raw];
|
|
900
|
+
if (!rows.length) throw new Error('넣을 행이 없습니다.');
|
|
901
|
+
const client = makeClient();
|
|
902
|
+
const id = await resolveDatabaseId(client, target);
|
|
903
|
+
const res = await client.bulkCreateRows(id, rows);
|
|
904
|
+
console.log(`${res.created}개 행을 넣었습니다.`);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* DB-BULK — `rootr db rows-each <표> --json '[{"rowId":"…","values":{…}},…]'`
|
|
909
|
+
*
|
|
910
|
+
* rows-set은 고른 행 **전부에 같은 값**을 넣는다. 이건 **행마다 다른 값**이다.
|
|
911
|
+
*/
|
|
912
|
+
async function cmdDbRowsEach(args) {
|
|
913
|
+
const target = args._[1];
|
|
914
|
+
const raw = readJsonArg(args, 'rows-each');
|
|
915
|
+
if (!target) throw new Error("사용법: rootr db rows-each <path|id> --json '<[{rowId,values},…]>' | --file <파일>");
|
|
916
|
+
const updates = Array.isArray(raw) ? raw : [raw];
|
|
917
|
+
for (const u of updates) {
|
|
918
|
+
if (!u || typeof u.rowId !== 'string' || typeof u.values !== 'object' || !u.values) {
|
|
919
|
+
throw new Error('각 항목은 { "rowId": "…", "values": { … } } 형태여야 합니다.');
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
const client = makeClient();
|
|
923
|
+
const id = await resolveDatabaseId(client, target);
|
|
924
|
+
const res = await client.bulkUpdateRowsEach(id, updates);
|
|
925
|
+
console.log(`${res.updated}개 행을 바꿨습니다.`);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** `--json '<...>'` 또는 `--file <파일>` 중 하나를 읽는다. 행이 많으면 파일이 편하다. */
|
|
929
|
+
/**
|
|
930
|
+
* SCAFFOLD-CLI — 워크스페이스 짓기의 세 단계를 사람도 스크립트도 쓸 수 있게.
|
|
931
|
+
* 예전에는 MCP 도구로만 있었다: `curl` 로 REST를 직접 두드리거나 MCP를 붙이지
|
|
932
|
+
* 않으면 스캐폴딩에 손댈 수가 없었다.
|
|
933
|
+
*
|
|
934
|
+
* rootr scaffold plan "동네 빵집 운영 관리"
|
|
935
|
+
* rootr scaffold review --file tree.json
|
|
936
|
+
* rootr scaffold apply --file tree.json [--root /경영관리]
|
|
937
|
+
*/
|
|
938
|
+
async function cmdScaffold(args) {
|
|
939
|
+
const sub = args._[0];
|
|
940
|
+
const client = makeClient();
|
|
941
|
+
if (sub === 'plan') {
|
|
942
|
+
const intent = args._.slice(1).join(' ').trim();
|
|
943
|
+
if (!intent) throw new Error('사용법: rootr scaffold plan "무엇을 위한 워크스페이스인지"');
|
|
944
|
+
console.log(JSON.stringify(await client.scaffoldPlan(intent), null, 2));
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if (sub === 'review') {
|
|
948
|
+
const tree = readJsonArg(args, 'scaffold review');
|
|
949
|
+
const r = await client.scaffoldReview(Array.isArray(tree) ? tree : tree.tree);
|
|
950
|
+
if (args.json) {
|
|
951
|
+
console.log(JSON.stringify(r, null, 2));
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
console.log(r.summary);
|
|
955
|
+
for (const f of r.findings) {
|
|
956
|
+
const mark = f.level === 'error' ? '고쳐야 함' : f.level === 'warn' ? '약함' : '더 나아짐';
|
|
957
|
+
console.log(` [${mark}] ${f.at} ${f.message}`);
|
|
958
|
+
}
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (sub === 'built') {
|
|
962
|
+
const ws = args.workspace || loadConfig().workspace;
|
|
963
|
+
if (!ws) throw new Error('워크스페이스가 없습니다: --workspace <id>');
|
|
964
|
+
const r = await client.scaffoldReviewBuilt(ws, typeof args.root === 'string' ? args.root : undefined);
|
|
965
|
+
if (args.json) {
|
|
966
|
+
console.log(JSON.stringify(r, null, 2));
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
console.log(r.summary);
|
|
970
|
+
for (const f of r.findings) {
|
|
971
|
+
const mark = f.level === 'error' ? '고쳐야 함' : f.level === 'warn' ? '약함' : '더 나아짐';
|
|
972
|
+
console.log(` [${mark}] ${f.at} ${f.message}`);
|
|
973
|
+
}
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (sub === 'test') {
|
|
977
|
+
const ws = args.workspace || loadConfig().workspace;
|
|
978
|
+
if (!ws) throw new Error('워크스페이스가 없습니다: --workspace <id>');
|
|
979
|
+
const r = await client.scaffoldSelfTest(ws, typeof args.root === 'string' ? args.root : undefined);
|
|
980
|
+
if (args.json) {
|
|
981
|
+
console.log(JSON.stringify(r, null, 2));
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
console.log(r.summary);
|
|
985
|
+
for (const c of r.checks) {
|
|
986
|
+
console.log(` [${c.level === 'fail' ? '실패' : '경고'}] ${c.at} (${c.check}) ${c.message}`);
|
|
987
|
+
}
|
|
988
|
+
if (r.failed) process.exitCode = 1;
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
if (sub === 'apply') {
|
|
992
|
+
const tree = readJsonArg(args, 'scaffold apply');
|
|
993
|
+
const ws = args.workspace || loadConfig().workspace;
|
|
994
|
+
if (!ws) throw new Error('워크스페이스가 없습니다: --workspace <id> 또는 rootr config --workspace <id>');
|
|
995
|
+
const r = await client.scaffoldApply(ws, {
|
|
996
|
+
rootPath: typeof args.root === 'string' ? args.root : undefined,
|
|
997
|
+
tree: Array.isArray(tree) ? tree : tree.tree,
|
|
998
|
+
});
|
|
999
|
+
console.log(`${r.created.length}개를 ${r.rootPath || '/'} 아래에 만들었습니다.`);
|
|
1000
|
+
if (r.review) console.log(`검사: ${r.review.summary}`);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
throw new Error('사용법: rootr scaffold plan|review|apply (review/apply 는 --file tree.json)');
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function readJsonArg(args, cmd) {
|
|
1007
|
+
let text = null;
|
|
1008
|
+
if (typeof args.file === 'string') text = readFileSync(args.file, 'utf8');
|
|
1009
|
+
else if (typeof args.json === 'string') text = args.json;
|
|
1010
|
+
if (text == null) throw new Error(`${cmd}: --json 또는 --file 이 필요합니다.`);
|
|
1011
|
+
try {
|
|
1012
|
+
return JSON.parse(text);
|
|
1013
|
+
} catch {
|
|
1014
|
+
throw new Error(`${cmd}: JSON으로 읽을 수 없습니다.`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** DB-BULK — `rootr db rows-rm <표> --ids a,b --yes` */
|
|
1019
|
+
async function cmdDbRowsRm(args) {
|
|
1020
|
+
const target = args._[1];
|
|
1021
|
+
const ids = typeof args.ids === 'string' ? args.ids.split(',').map((i) => i.trim()).filter(Boolean) : [];
|
|
1022
|
+
if (!target || !ids.length) {
|
|
1023
|
+
throw new Error('사용법: rootr db rows-rm <path|id> --ids <id,id> [--yes]');
|
|
1024
|
+
}
|
|
1025
|
+
if (!args.yes) {
|
|
1026
|
+
throw new Error(`${ids.length}개 행을 지웁니다. 되돌릴 수 없으니 정말 지우려면 --yes 를 붙이세요.`);
|
|
1027
|
+
}
|
|
1028
|
+
const client = makeClient();
|
|
1029
|
+
const id = await resolveDatabaseId(client, target);
|
|
1030
|
+
const res = await client.bulkDeleteRows(id, ids);
|
|
1031
|
+
console.log(`${res.deleted}개 행을 지웠습니다.`);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* PUBLIC-ANY — `rootr publish <노드> [--children|--off|--status]`
|
|
1036
|
+
*
|
|
1037
|
+
* 문서·덱만 되던 공개 발행을 전 타입으로 연 것(창업자 지시 2026-08-27).
|
|
1038
|
+
* `--children`을 주면 **그 아래 전부**(손자·증손자 포함)가 각자 주소를 받는다.
|
|
1039
|
+
*/
|
|
1040
|
+
/** PUBLIC-DB-DESIGN — 서버가 받는 값과 **같은 목록**이어야 한다. */
|
|
1041
|
+
const DB_LAYOUTS = ['table', 'cards', 'gallery', 'list', 'board', 'timeline', 'calendar', 'chart', 'ranking'];
|
|
1042
|
+
const DB_SKINS = ['clean', 'paper', 'dark', 'vivid', 'bento', 'brutal'];
|
|
1043
|
+
|
|
1044
|
+
async function cmdPublish(args) {
|
|
1045
|
+
const target = args._[0];
|
|
1046
|
+
if (!target) {
|
|
1047
|
+
throw new Error(
|
|
1048
|
+
'사용법: rootr publish <path|id> [--children] [--view <id>] [--layout <배치>] [--skin <분위기>] | --off | --status',
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
const client = makeClient();
|
|
1052
|
+
const id = await resolveDatabaseId(client, target);
|
|
1053
|
+
|
|
1054
|
+
if (args.status) {
|
|
1055
|
+
const st = await client.nodePublicStatus(id);
|
|
1056
|
+
if (!st.publishable) {
|
|
1057
|
+
console.log('이 종류는 공개할 수 없습니다(폴더).');
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if (!st.isPublic) {
|
|
1061
|
+
console.log(st.slug ? `닫혀 있습니다(주소는 남아 있음: /p/${st.slug})` : '아직 공개한 적이 없습니다.');
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
console.log(`공개 중: /p/${st.slug}`);
|
|
1065
|
+
if (st.expiresAt) console.log(` 만료: ${st.expiresAt}`);
|
|
1066
|
+
if (st.hasPassword) console.log(' 비밀번호 걸려 있음');
|
|
1067
|
+
console.log(` 하위 문서도 공개: ${st.includeChildren ? '예' : '아니오'}`);
|
|
1068
|
+
console.log(` 복제 허용: ${st.allowClone === false ? '아니오' : '예'}`);
|
|
1069
|
+
if (st.design && (st.design.layout || st.design.skin)) {
|
|
1070
|
+
console.log(` 보이는 방식: ${st.design.layout ?? 'table'} / ${st.design.skin ?? 'clean'}`);
|
|
1071
|
+
}
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (args.off) {
|
|
1076
|
+
await client.setNodePublic(id, { isPublic: false });
|
|
1077
|
+
console.log('공개를 닫았습니다. 딸려 나갔던 하위 문서도 같이 닫혔습니다(주소는 남아 있습니다).');
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// PUBLIC-DB-DESIGN — 표는 표 모양으로만 보일 이유가 없다. 준 것만 바뀐다.
|
|
1082
|
+
const design = {
|
|
1083
|
+
...(typeof args.layout === 'string' ? { layout: args.layout } : {}),
|
|
1084
|
+
...(typeof args.skin === 'string' ? { skin: args.skin } : {}),
|
|
1085
|
+
};
|
|
1086
|
+
if (design.layout && !DB_LAYOUTS.includes(design.layout)) {
|
|
1087
|
+
throw new Error(`--layout 은 다음 중 하나여야 합니다: ${DB_LAYOUTS.join(', ')}`);
|
|
1088
|
+
}
|
|
1089
|
+
if (design.skin && !DB_SKINS.includes(design.skin)) {
|
|
1090
|
+
throw new Error(`--skin 은 다음 중 하나여야 합니다: ${DB_SKINS.join(', ')}`);
|
|
1091
|
+
}
|
|
1092
|
+
const res = await client.publishNode(id, {
|
|
1093
|
+
...(args.children ? { includeChildren: true } : {}),
|
|
1094
|
+
...(typeof args.view === 'string' ? { view: args.view } : {}),
|
|
1095
|
+
...(Object.keys(design).length ? { design } : {}),
|
|
1096
|
+
});
|
|
1097
|
+
console.log(`공개했습니다: /p/${res.slug}`);
|
|
1098
|
+
if (args.children) console.log(' 이 문서 아래 전부(손자·증손자 포함)도 각자 주소로 공개됩니다.');
|
|
1099
|
+
if (Object.keys(design).length) {
|
|
1100
|
+
console.log(` 보이는 방식: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* READ-ACK — 읽고 확인해야 하는 문서.
|
|
1106
|
+
*
|
|
1107
|
+
* 표·폴더에 걸면 그 안의 문서 전부에 걸린다(완료율은 사람마다 굴러 올라온다).
|
|
1108
|
+
* 읽음 기록은 사람의 것이라, 사람이 붙지 않은 열쇠로는 걸 수 없다.
|
|
1109
|
+
*/
|
|
1110
|
+
async function cmdReadReq(args) {
|
|
1111
|
+
const target = args._[0];
|
|
1112
|
+
if (!target) {
|
|
1113
|
+
throw new Error(
|
|
1114
|
+
'사용법: rootr readreq <path|id> [--json] | --users <id,id> [--due <ISO>] [--ack] | --clear',
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
const client = makeClient();
|
|
1118
|
+
const id = await resolveDatabaseId(client, target);
|
|
1119
|
+
|
|
1120
|
+
if (args.clear) {
|
|
1121
|
+
await client.clearReadRequirement(id);
|
|
1122
|
+
console.log('읽음 확인 해제됨');
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (typeof args.users === 'string') {
|
|
1127
|
+
const userIds = args.users.split(',').map((u) => u.trim()).filter(Boolean);
|
|
1128
|
+
if (!userIds.length) throw new Error('--users 에 사람 id를 쉼표로 주세요.');
|
|
1129
|
+
const res = await client.setReadRequirement(id, {
|
|
1130
|
+
userIds,
|
|
1131
|
+
dueAt: typeof args.due === 'string' ? args.due : null,
|
|
1132
|
+
requireExplicitAck: Boolean(args.ack),
|
|
1133
|
+
});
|
|
1134
|
+
console.log(`읽어야 할 사람 ${userIds.length}명 지정됨${args.ack ? ' (확인 버튼까지 눌러야 함)' : ''}`);
|
|
1135
|
+
if (args.json) console.log(JSON.stringify(res, null, 2));
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
const res = await client.readRequirement(id);
|
|
1140
|
+
if (args.json) {
|
|
1141
|
+
console.log(JSON.stringify(res, null, 2));
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
if (!res.requirement) {
|
|
1145
|
+
console.log(
|
|
1146
|
+
res.ancestorRequirementId
|
|
1147
|
+
? `이 문서 자체에는 없고, 위쪽(${res.ancestorRequirementId})에 걸려 있습니다.`
|
|
1148
|
+
: '읽음 확인이 걸려 있지 않습니다.',
|
|
1149
|
+
);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const r = res.requirement;
|
|
1153
|
+
console.log(
|
|
1154
|
+
`${r.done}/${r.total}명 완료` +
|
|
1155
|
+
` · 마감: ${r.dueAt ?? '없음'}${r.overdue ? ' (지남)' : ''}` +
|
|
1156
|
+
`${r.requireExplicitAck ? ' · 확인 버튼 필요' : ''}`,
|
|
1157
|
+
);
|
|
1158
|
+
for (const target of r.targets ?? []) {
|
|
1159
|
+
const mark = target.ackedAt ? '확인' : target.firstViewedAt ? '읽음' : '아직';
|
|
1160
|
+
// 표·폴더에 걸었으면 "안의 문서를 몇 개 읽었나"가 진짜 진도다.
|
|
1161
|
+
const progress =
|
|
1162
|
+
r.childTotal > 0 ? ` ${target.readCount ?? 0}/${r.childTotal}` : '';
|
|
1163
|
+
console.log(` ${mark}${progress} ${target.name || target.email}`);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/** ROW-PAGE-PICK — 행에 붙일 수 있는 것(표·폴더는 뺀다). */
|
|
1168
|
+
const ROW_PAGE_TYPES = [
|
|
1169
|
+
'DOCUMENT', 'SIGNED', 'SPREADSHEET', 'WHITEBOARD', 'PRESENTATION',
|
|
1170
|
+
'RECORDING', 'FORM', 'ISSUE_TRACKER',
|
|
1171
|
+
];
|
|
1172
|
+
|
|
644
1173
|
async function cmdDb(args) {
|
|
645
1174
|
const sub = args._[0];
|
|
646
1175
|
if (sub === 'rows') return cmdDbRows(args);
|
|
647
1176
|
if (sub === 'row-add') return cmdDbRowAdd(args);
|
|
648
1177
|
if (sub === 'row-body') return cmdDbRowBody(args);
|
|
1178
|
+
if (sub === 'rows-new') return cmdDbRowsNew(args);
|
|
1179
|
+
if (sub === 'rows-each') return cmdDbRowsEach(args);
|
|
1180
|
+
if (sub === 'rows-set') return cmdDbRowsSet(args);
|
|
1181
|
+
if (sub === 'rows-rm') return cmdDbRowsRm(args);
|
|
1182
|
+
if (sub === 'row-page') return cmdDbRowPage(args);
|
|
1183
|
+
if (sub === 'row-page-type') return cmdDbRowPageType(args);
|
|
649
1184
|
if (sub === 'lock') return cmdDbLock(args);
|
|
650
1185
|
if (sub === 'notify') return cmdDbNotify(args);
|
|
651
1186
|
if (sub !== 'create') {
|
|
652
1187
|
throw new Error(
|
|
653
|
-
'사용법: rootr db create|rows|row-add|row-body|lock|notify … (자세한 건 rootr --help)',
|
|
1188
|
+
'사용법: rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|row-page|row-page-type|lock|notify … (자세한 건 rootr --help)',
|
|
654
1189
|
);
|
|
655
1190
|
}
|
|
656
1191
|
const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
|
|
@@ -830,6 +1365,64 @@ async function cmdDbRowBody(args) {
|
|
|
830
1365
|
console.log(`상세 내용 저장: 행 ${rowId}${row.pageNodeId ? ` → 문서 ${row.pageNodeId}` : ''}`);
|
|
831
1366
|
}
|
|
832
1367
|
|
|
1368
|
+
/**
|
|
1369
|
+
* ROW-PAGE-PICK — `rootr db row-page <rowId> [--type RECORDING]`
|
|
1370
|
+
*
|
|
1371
|
+
* 행 하나에 상세를 붙인다. `--type`을 주면 **이 행만** 그 타입으로 태어난다
|
|
1372
|
+
* (표의 기본값과 달라도 된다). 이미 붙어 있으면 그대로 알려 준다.
|
|
1373
|
+
*/
|
|
1374
|
+
async function cmdDbRowPage(args) {
|
|
1375
|
+
const rowId = args._[1];
|
|
1376
|
+
if (!rowId) {
|
|
1377
|
+
throw new Error(
|
|
1378
|
+
'사용법: rootr db row-page <rowId> [--type RECORDING | --node <path|id> | --unlink]',
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
const client = makeClient();
|
|
1382
|
+
|
|
1383
|
+
// ROW-PAGE-LINK — 연결만 끊는다. 문서는 그대로 남는다.
|
|
1384
|
+
if (args.unlink) {
|
|
1385
|
+
const res = await client.unlinkRowPage(rowId);
|
|
1386
|
+
console.log(res.unlinked ? `연결을 끊었습니다(문서 ${res.unlinked}는 그대로 있습니다).` : '붙어 있는 문서가 없습니다.');
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// ROW-PAGE-LINK — 이미 있는 문서를 잇는다. 경로로 줘도 된다.
|
|
1391
|
+
if (typeof args.node === 'string') {
|
|
1392
|
+
if (typeof args.type === 'string') {
|
|
1393
|
+
throw new Error('--type 과 --node 는 같이 못 씁니다. 새로 만들려면 --type, 있는 문서를 이으려면 --node 하나만 주세요.');
|
|
1394
|
+
}
|
|
1395
|
+
const resolved = await resolveTarget(client, args.node);
|
|
1396
|
+
if (!resolved.existed) throw new Error(`이을 문서를 찾을 수 없습니다: ${args.node}`);
|
|
1397
|
+
const linked = await client.linkRowPage(rowId, resolved.id);
|
|
1398
|
+
console.log(`행 ${rowId}에 이었습니다: ${linked.type ?? ''} ${linked.nodeId}`.trim());
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const type = typeof args.type === 'string' ? args.type.toUpperCase() : undefined;
|
|
1403
|
+
if (type && !ROW_PAGE_TYPES.includes(type)) {
|
|
1404
|
+
throw new Error(`--type 은 ${ROW_PAGE_TYPES.join(' · ')} 중 하나여야 합니다.`);
|
|
1405
|
+
}
|
|
1406
|
+
const page = await client.createRowPage(rowId, type);
|
|
1407
|
+
console.log(`행 ${rowId} 상세: ${page.type ?? ''} ${page.nodeId}`.trim());
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/** `rootr db row-page-type <path|id> <TYPE>` — 새 행의 기본값을 바꾼다. */
|
|
1411
|
+
async function cmdDbRowPageType(args) {
|
|
1412
|
+
const target = args._[1];
|
|
1413
|
+
const type = typeof args._[2] === 'string' ? String(args._[2]).toUpperCase() : undefined;
|
|
1414
|
+
if (!target || !type) {
|
|
1415
|
+
throw new Error(`사용법: rootr db row-page-type <path|id> <${ROW_PAGE_TYPES.join('|')}>`);
|
|
1416
|
+
}
|
|
1417
|
+
if (!ROW_PAGE_TYPES.includes(type)) {
|
|
1418
|
+
throw new Error(`타입은 ${ROW_PAGE_TYPES.join(' · ')} 중 하나여야 합니다.`);
|
|
1419
|
+
}
|
|
1420
|
+
const client = makeClient();
|
|
1421
|
+
const id = await resolveDatabaseId(client, target);
|
|
1422
|
+
await client.setDatabaseRowPageType(id, type);
|
|
1423
|
+
console.log(`새 행 기본값: ${type} — 이미 만들어진 행은 그대로입니다.`);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
833
1426
|
/** 경로로 줘도 되게 — 표 인자도 문서 도구들과 같은 규칙을 따른다. */
|
|
834
1427
|
async function resolveDatabaseId(client, target) {
|
|
835
1428
|
if (typeof target === 'string' && target.startsWith('/')) {
|
|
@@ -840,6 +1433,264 @@ async function resolveDatabaseId(client, target) {
|
|
|
840
1433
|
return target;
|
|
841
1434
|
}
|
|
842
1435
|
|
|
1436
|
+
// ── SIGN-001: 서명문서 · 연구노트 ────────────────────────────────────────
|
|
1437
|
+
//
|
|
1438
|
+
// 서명·올리기·반려는 메일로 받은 6자리 확인 코드를 함께 보내야 한다. 서버가
|
|
1439
|
+
// 개인키를 보관하는 구조에서 "본인이 눌렀다"의 유일한 증거라, CLI가 대신
|
|
1440
|
+
// 눌러 줄 수 있는 자리가 아니다 — `rootr sign code`로 코드를 받고, 메일에서
|
|
1441
|
+
// 읽은 값을 `--code`로 넘긴다.
|
|
1442
|
+
|
|
1443
|
+
const SIGN_USAGE =
|
|
1444
|
+
'사용법: rootr sign new|show|line|code|submit|approve|reject|reopen|revise|verify|list|bulk|ledger …\n' +
|
|
1445
|
+
' (자세한 건 rootr --help)';
|
|
1446
|
+
|
|
1447
|
+
function signWorkspace(client, args) {
|
|
1448
|
+
const ws = args.ws || client.workspace;
|
|
1449
|
+
if (!ws) throw new Error('워크스페이스를 알 수 없습니다. --ws 를 주거나 rootr use 로 정하세요.');
|
|
1450
|
+
return ws;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/** 서명 관련 인자도 문서 도구들과 같은 규칙 — 경로로 줘도 되게. */
|
|
1454
|
+
async function resolveSignedId(client, target) {
|
|
1455
|
+
if (!target) throw new Error(SIGN_USAGE);
|
|
1456
|
+
return requireResolved(await resolveTarget(client, target));
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function requireCode(args, what) {
|
|
1460
|
+
if (typeof args.code !== 'string' || !args.code.trim()) {
|
|
1461
|
+
throw new Error(
|
|
1462
|
+
`${what}에는 확인 코드가 필요합니다: 먼저 rootr sign code <path|id> 로 메일을 받고 --code <6자리> 로 넘기세요.`,
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
return args.code.trim();
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
const SIGN_STATUS_KO = {
|
|
1469
|
+
DRAFT: '작성 중',
|
|
1470
|
+
IN_PROGRESS: '서명 진행 중',
|
|
1471
|
+
COMPLETED: '완료',
|
|
1472
|
+
REJECTED: '반려됨',
|
|
1473
|
+
SUPERSEDED: '개정되어 대체됨',
|
|
1474
|
+
VOIDED: '무효',
|
|
1475
|
+
};
|
|
1476
|
+
|
|
1477
|
+
function printSignedState(st) {
|
|
1478
|
+
console.log(`${st.name} [${SIGN_STATUS_KO[st.status] ?? st.status}]${st.mode === 'RESEARCH_NOTE' ? ' (연구노트)' : ''}`);
|
|
1479
|
+
if (st.revisionNo) console.log(`개정 ${st.revisionNo}판${st.revisionNote ? ` — ${st.revisionNote}` : ''}`);
|
|
1480
|
+
if (st.latestRevisionId) console.log(`※ 더 최신 판이 있습니다: ${st.latestRevisionId} (${st.latestRevisionNo}판)`);
|
|
1481
|
+
console.log(st.lockedAt ? `잠김: ${st.lockedAt} (본문·결재라인 수정 불가)` : '잠기지 않음 (아직 고칠 수 있음)');
|
|
1482
|
+
if (!st.contentIntact) console.log('⚠️ 본문이 서명 당시와 다릅니다 — rootr sign verify 로 확인하세요.');
|
|
1483
|
+
for (const step of st.steps ?? []) {
|
|
1484
|
+
const here = step.order === st.currentStepOrder ? ' ← 지금 이 차례' : '';
|
|
1485
|
+
const rule = step.rule === 'ANY' ? '아무나 1명' : step.rule === 'N_OF_M' ? `${step.requiredCount}명` : '전원';
|
|
1486
|
+
console.log(`\n${step.order}. ${step.label} (${rule})${here}`);
|
|
1487
|
+
for (const m of step.members ?? []) {
|
|
1488
|
+
// 메일 결재자(SIGN-002)는 userId가 없다. 누구인지 화면에 보여야 하므로
|
|
1489
|
+
// 이름·주소로 대신 적고, 밖의 사람이라는 표시를 붙인다.
|
|
1490
|
+
const who = m.userId ?? `${m.displayName ? `${m.displayName} <${m.email}>` : m.email} (메일 서명)`;
|
|
1491
|
+
const sent = !m.userId && !m.invitedAt && m.state === 'PENDING' ? ' [요청 메일 아직 안 감]' : '';
|
|
1492
|
+
console.log(` - ${who} ${m.role} ${m.state}${m.actedAt ? ` ${m.actedAt}` : ''}${sent}${m.comment ? ` "${m.comment}"` : ''}`);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
if (st.signatures?.length) {
|
|
1496
|
+
console.log('\n서명:');
|
|
1497
|
+
for (const sig of st.signatures) {
|
|
1498
|
+
console.log(
|
|
1499
|
+
` #${sig.seq} ${sig.signerName} <${sig.signerEmail}> ${sig.role} ${sig.signedAt}` +
|
|
1500
|
+
(sig.timestampPending ? ' [시점확인 대기중]' : ` [시점확인 ${sig.tsTime} · ${sig.tsaName}]`),
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
if (st.canSign) console.log('\n→ 지금 서명할 수 있습니다: rootr sign code <id> 후 rootr sign approve <id> --code <6자리>');
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
async function cmdSign(args) {
|
|
1508
|
+
const sub = args._[0];
|
|
1509
|
+
const client = makeClient();
|
|
1510
|
+
|
|
1511
|
+
switch (sub) {
|
|
1512
|
+
case 'new': {
|
|
1513
|
+
const target = args._[1];
|
|
1514
|
+
if (!target) throw new Error('사용법: rootr sign new <경로> [--file <본문.md>] [--reviewer <userId>] [--line <line.json>]');
|
|
1515
|
+
const ws = signWorkspace(client, args);
|
|
1516
|
+
const content =
|
|
1517
|
+
typeof args.file === 'string'
|
|
1518
|
+
? readFileSync(args.file, 'utf8')
|
|
1519
|
+
: args._[2] !== undefined
|
|
1520
|
+
? String(args._[2])
|
|
1521
|
+
: await readStdin();
|
|
1522
|
+
const approvalLine =
|
|
1523
|
+
typeof args.line === 'string' ? JSON.parse(readFileSync(args.line, 'utf8')) : undefined;
|
|
1524
|
+
const noteMeta = typeof args.meta === 'string' ? JSON.parse(readFileSync(args.meta, 'utf8')) : undefined;
|
|
1525
|
+
const body = {
|
|
1526
|
+
content: content || '',
|
|
1527
|
+
createParents: true,
|
|
1528
|
+
approvalLine,
|
|
1529
|
+
noteMeta,
|
|
1530
|
+
reviewerUserId: typeof args.reviewer === 'string' ? args.reviewer : undefined,
|
|
1531
|
+
mode: typeof args.mode === 'string' ? args.mode : args.reviewer ? 'RESEARCH_NOTE' : undefined,
|
|
1532
|
+
};
|
|
1533
|
+
if (target.startsWith('/')) body.path = target;
|
|
1534
|
+
else body.name = target;
|
|
1535
|
+
const st = await client.createSignedDoc(ws, body);
|
|
1536
|
+
console.log(`서명문서 생성: ${st.path ?? st.name} (${st.nodeId})`);
|
|
1537
|
+
if (!st.steps?.length) {
|
|
1538
|
+
console.log('결재라인이 아직 없습니다 — rootr sign line <id> --file <line.json> 으로 짜세요.');
|
|
1539
|
+
} else {
|
|
1540
|
+
printSignedState(st);
|
|
1541
|
+
}
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
case 'show': {
|
|
1545
|
+
const st = await client.getSignedDoc(await resolveSignedId(client, args._[1]));
|
|
1546
|
+
if (args.json) return console.log(JSON.stringify(st, null, 2));
|
|
1547
|
+
return printSignedState(st);
|
|
1548
|
+
}
|
|
1549
|
+
case 'line': {
|
|
1550
|
+
const id = await resolveSignedId(client, args._[1]);
|
|
1551
|
+
const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
|
|
1552
|
+
if (!raw) throw new Error('결재라인 JSON이 없습니다 (--file 또는 표준입력).');
|
|
1553
|
+
const parsed = JSON.parse(raw);
|
|
1554
|
+
const steps = Array.isArray(parsed) ? parsed : parsed.steps;
|
|
1555
|
+
const st = await client.setApprovalLine(id, steps);
|
|
1556
|
+
console.log(`결재라인 ${st.steps.length}단계 저장됨.`);
|
|
1557
|
+
return printSignedState(st);
|
|
1558
|
+
}
|
|
1559
|
+
case 'code': {
|
|
1560
|
+
if (args.bulk) {
|
|
1561
|
+
const ws = signWorkspace(client, args);
|
|
1562
|
+
const out = await client.requestBulkSigningCode(ws);
|
|
1563
|
+
console.log(`묶음 서명용 확인 코드를 ${out.sentTo ?? '메일'}로 보냈습니다. 10분 안에 --code 로 넘기세요.`);
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
const out = await client.requestSigningCode(await resolveSignedId(client, args._[1]));
|
|
1567
|
+
console.log(`확인 코드를 ${out.sentTo ?? '메일'}로 보냈습니다. 10분 안에 --code 로 넘기세요.`);
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
case 'submit': {
|
|
1571
|
+
// 올리기와 서명은 다른 일이다. 1단계에 내가 있으면 그 자리에서 첫 서명까지
|
|
1572
|
+
// 찍히므로 코드가 필요하고, 1단계가 밖의 사람(메일 결재자)뿐이면 서명하지
|
|
1573
|
+
// 않으므로 코드 없이 올라간다. 어느 쪽인지는 서버가 안다 — 여기서 미리
|
|
1574
|
+
// 막지 말고, 코드가 필요하면 서버가 시키는 대로 안내한다.
|
|
1575
|
+
const id = await resolveSignedId(client, args._[1]);
|
|
1576
|
+
const code = typeof args.code === 'string' ? args.code.trim() : undefined;
|
|
1577
|
+
let st;
|
|
1578
|
+
try {
|
|
1579
|
+
st = await client.submitSignedDoc(id, code ? { code } : {});
|
|
1580
|
+
} catch (err) {
|
|
1581
|
+
if (String(err?.body?.code ?? '') === 'REAUTH_REQUIRED') {
|
|
1582
|
+
throw new Error(
|
|
1583
|
+
'올리는 분이 1단계 서명자라 첫 서명이 함께 찍힙니다. ' +
|
|
1584
|
+
`먼저 "rootr sign code ${args._[1]}" 로 메일을 받고 --code <6자리> 로 다시 넘기세요.`,
|
|
1585
|
+
);
|
|
1586
|
+
}
|
|
1587
|
+
throw err;
|
|
1588
|
+
}
|
|
1589
|
+
console.log(
|
|
1590
|
+
code
|
|
1591
|
+
? '올렸습니다 — 본문이 얼었고 기안자 서명이 찍혔습니다.'
|
|
1592
|
+
: '올렸습니다 — 본문이 얼었고 첫 단계 서명자에게 차례가 넘어갔습니다.',
|
|
1593
|
+
);
|
|
1594
|
+
return printSignedState(st);
|
|
1595
|
+
}
|
|
1596
|
+
case 'approve':
|
|
1597
|
+
case 'sign': {
|
|
1598
|
+
const id = await resolveSignedId(client, args._[1]);
|
|
1599
|
+
const st = await client.signSignedDoc(id, { code: requireCode(args, '서명') });
|
|
1600
|
+
console.log('서명했습니다.');
|
|
1601
|
+
return printSignedState(st);
|
|
1602
|
+
}
|
|
1603
|
+
case 'reject': {
|
|
1604
|
+
const id = await resolveSignedId(client, args._[1]);
|
|
1605
|
+
if (typeof args.comment !== 'string' || !args.comment.trim()) {
|
|
1606
|
+
throw new Error('반려에는 사유가 필요합니다: --comment "근거 자료가 빠졌습니다"');
|
|
1607
|
+
}
|
|
1608
|
+
const st = await client.rejectSignedDoc(id, { comment: args.comment, code: args.code });
|
|
1609
|
+
console.log('반려했습니다 — 작성자가 다시 고칠 수 있습니다.');
|
|
1610
|
+
return printSignedState(st);
|
|
1611
|
+
}
|
|
1612
|
+
case 'reopen': {
|
|
1613
|
+
const st = await client.reopenSignedDoc(await resolveSignedId(client, args._[1]));
|
|
1614
|
+
console.log('다시 열었습니다 — 본문과 결재라인을 고칠 수 있습니다.');
|
|
1615
|
+
return printSignedState(st);
|
|
1616
|
+
}
|
|
1617
|
+
case 'revise': {
|
|
1618
|
+
const id = await resolveSignedId(client, args._[1]);
|
|
1619
|
+
if (typeof args.note !== 'string' || !args.note.trim()) {
|
|
1620
|
+
throw new Error('개정에는 사유가 필요합니다: --note "측정값 단위 정정"');
|
|
1621
|
+
}
|
|
1622
|
+
const out = await client.reviseSignedDoc(id, args.note);
|
|
1623
|
+
console.log(`개정본을 만들었습니다: ${out.nodeId} (${out.revisionNo}판). 원본은 그대로 남습니다.`);
|
|
1624
|
+
return printSignedState(await client.getSignedDoc(out.nodeId));
|
|
1625
|
+
}
|
|
1626
|
+
case 'verify': {
|
|
1627
|
+
const v = await client.verifySignedDoc(await resolveSignedId(client, args._[1]));
|
|
1628
|
+
if (args.json) return console.log(JSON.stringify(v, null, 2));
|
|
1629
|
+
const mark = (ok) => (ok ? '✅' : '❌');
|
|
1630
|
+
console.log(`${v.name} [${SIGN_STATUS_KO[v.status] ?? v.status}]`);
|
|
1631
|
+
console.log(`${mark(v.contentIntact)} 본문이 서명 당시 그대로다`);
|
|
1632
|
+
console.log(`${mark(v.allSignaturesValid)} 서명이 전부 유효하고 체인이 이어진다`);
|
|
1633
|
+
console.log(`${mark(v.allTimestamped)} 서명마다 공인시점확인이 붙어 있다`);
|
|
1634
|
+
console.log(`${mark(v.ledgerIntact)} 워크스페이스 원장이 성하다${v.ledgerBrokenAtSeq ? ` (끊긴 지점 #${v.ledgerBrokenAtSeq})` : ''}`);
|
|
1635
|
+
for (const sig of v.signatures ?? []) {
|
|
1636
|
+
console.log(
|
|
1637
|
+
` #${sig.seq} ${sig.signerName} ${sig.role} ${sig.signedAt} ` +
|
|
1638
|
+
`${mark(sig.signatureValid)}서명 ${mark(sig.chainLinkValid)}체인 ${mark(sig.matchesCurrentContent)}현재본문` +
|
|
1639
|
+
(sig.timestampPending ? ' [시점확인 대기중]' : ''),
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
case 'list': {
|
|
1645
|
+
const ws = signWorkspace(client, args);
|
|
1646
|
+
const rows = await client.listSignedDocs(ws, {
|
|
1647
|
+
status: typeof args.status === 'string' ? args.status : undefined,
|
|
1648
|
+
mode: typeof args.mode === 'string' ? args.mode : undefined,
|
|
1649
|
+
pendingMine: !!args.pending,
|
|
1650
|
+
});
|
|
1651
|
+
if (args.json) return console.log(JSON.stringify(rows, null, 2));
|
|
1652
|
+
if (!rows.length) return console.log(args.pending ? '내 차례인 서명문서가 없습니다.' : '서명문서가 없습니다.');
|
|
1653
|
+
for (const r of rows) {
|
|
1654
|
+
const waiting = r.waitingOn?.length ? ` 대기: ${r.waitingOn.join(', ')}` : '';
|
|
1655
|
+
console.log(`${r.nodeId}\t${SIGN_STATUS_KO[r.status] ?? r.status}\t${r.path}${waiting}${r.canSign ? ' ← 내 차례' : ''}`);
|
|
1656
|
+
}
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
case 'bulk': {
|
|
1660
|
+
const ws = signWorkspace(client, args);
|
|
1661
|
+
const targets = args._.slice(1);
|
|
1662
|
+
if (!targets.length) throw new Error('사용법: rootr sign bulk <id|경로 …> --code <6자리>');
|
|
1663
|
+
const code = requireCode(args, '묶음 서명');
|
|
1664
|
+
const nodeIds = [];
|
|
1665
|
+
for (const t of targets) nodeIds.push(await resolveSignedId(client, t));
|
|
1666
|
+
const out = await client.bulkSignSignedDocs(ws, { nodeIds, code });
|
|
1667
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
1668
|
+
const results = out.results ?? [];
|
|
1669
|
+
const failed = results.filter((r) => !r.ok);
|
|
1670
|
+
// 얼리는 것은 되돌릴 수 없으니 "몇 건"이 아니라 "무엇을 했는지"를 보여준다.
|
|
1671
|
+
const n = (kind) => results.filter((r) => r.did === kind).length;
|
|
1672
|
+
const parts = [];
|
|
1673
|
+
if (n('submitted+signed')) parts.push(`올리고 서명 ${n('submitted+signed')}건`);
|
|
1674
|
+
if (n('submitted')) parts.push(`올리기만 ${n('submitted')}건`);
|
|
1675
|
+
if (n('signed')) parts.push(`서명 ${n('signed')}건`);
|
|
1676
|
+
console.log(`${parts.join(', ') || '처리 0건'}${failed.length ? `, 건너뜀 ${failed.length}건` : ''}`);
|
|
1677
|
+
for (const f of failed) console.log(` 건너뜀 ${f.nodeId}: ${f.error}`);
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
case 'ledger': {
|
|
1681
|
+
const ws = signWorkspace(client, args);
|
|
1682
|
+
const v = await client.verifyLedger(ws);
|
|
1683
|
+
if (args.json) return console.log(JSON.stringify(v, null, 2));
|
|
1684
|
+
console.log(`${v.ok ? '✅ 원장이 성합니다' : '❌ 원장이 끊겼습니다'} — 서명 ${v.count}건`);
|
|
1685
|
+
if (v.headHash) console.log(`머리 해시: ${v.headHash}`);
|
|
1686
|
+
if (!v.ok) console.log(`끊긴 지점: #${v.brokenAtSeq}${v.reason ? ` (${v.reason})` : ''}`);
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
default:
|
|
1690
|
+
throw new Error(SIGN_USAGE);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
843
1694
|
async function main() {
|
|
844
1695
|
const argv = process.argv.slice(2);
|
|
845
1696
|
const args = parseArgs(argv);
|
|
@@ -887,8 +1738,18 @@ async function main() {
|
|
|
887
1738
|
return cmdDb(args);
|
|
888
1739
|
case 'form':
|
|
889
1740
|
return cmdForm(args);
|
|
1741
|
+
case 'publish':
|
|
1742
|
+
return cmdPublish(args);
|
|
1743
|
+
case 'readreq':
|
|
1744
|
+
return cmdReadReq(args);
|
|
890
1745
|
case 'access':
|
|
891
1746
|
return cmdAccess(args);
|
|
1747
|
+
case 'sign':
|
|
1748
|
+
return cmdSign(args);
|
|
1749
|
+
case 'feedback':
|
|
1750
|
+
return cmdFeedback(args);
|
|
1751
|
+
case 'scaffold':
|
|
1752
|
+
return cmdScaffold(args);
|
|
892
1753
|
case 'mcp':
|
|
893
1754
|
return cmdMcp(args);
|
|
894
1755
|
default:
|