rootr-cli 0.8.1 → 0.10.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/bin/rootr.js CHANGED
@@ -19,6 +19,7 @@ const USAGE = `Rootr CLI v${pkg.version}
19
19
  rootr write <path|id> [--file <f>] [--if-match <etag>] 문서 전체 교체(파일 없으면 생성)
20
20
  rootr append <path|id> [<text>] [--heading "## X"] [--file <f>] 문서 끝(또는 헤딩 아래)에 안전하게 추가
21
21
  rootr edit <path|id> --find <text> --replace <text> [--all] 본문 내 텍스트 치환
22
+ rootr comments <path|id> [--block <id>] [--json] 문서에 달린 댓글 읽기 (공개 페이지 댓글 포함)
22
23
  rootr search <query> 워크스페이스 검색
23
24
  rootr mv <path|id> <새 경로|폴더 id> 문서/폴더 이동 및/또는 이름 변경
24
25
  rootr rm <path|id> [--yes] 문서/폴더를 휴지통으로 이동 (복구 가능)
@@ -28,11 +29,34 @@ const USAGE = `Rootr CLI v${pkg.version}
28
29
  rootr ask <question> [--workspace <w>] GraphRAG 질의응답 (RCA), 인용 포함 출력
29
30
  rootr db create [--file <spec.json>] [--parent <path|id>] 표(DATABASE) 생성 — 컬럼 순서 그대로, 행까지 한 번에
30
31
  rootr db rows <path|id> [--body] [--json] 표의 행 목록 (--body: 상세 내용까지)
32
+ rootr db lock <path|id> [--schema managers] [--rows own] [--locked "승인,점수"] 표 자물쇠 보기·걸기
33
+ rootr db notify <path|id> [--file rules.json] [--clear] 표 알림 규칙 보기·저장
31
34
  rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] 행 추가(상세 내용 포함)
32
35
  rootr db row-body <path|id> <rowId> [--file <f>] 행의 상세 내용(본문 메모) 쓰기 — 없으면 표준입력
36
+ rootr access <path|id> [--json] 공개범위·권한 보기 (관리 권한 필요)
37
+ rootr access <path|id> --private|--public|--inherit 공개범위 바꾸기
38
+ rootr access <path|id> --grant <userId> --level READ|WRITE|MANAGE 사람에게 권한 주기
39
+ rootr access <path|id> --revoke <userId> 준 권한 거두기
40
+ rootr form collect <path|id> --to db|sheet 응답 모을 곳을 새로 만들어 연결 (표는 질문 그대로 칸 생성)
41
+ rootr form responses <path|id> [--latest] [--json] 폼에 들어온 응답 보기 (--latest: 사람마다 최신 1건만)
42
+ rootr form response-edit <응답id> --values '<json>' 이미 낸 응답 고치기 (폼 검사 규칙 다시 통과)
43
+ rootr form response-rm <응답id> [--yes] 응답 한 건 삭제 (되돌릴 수 없음)
44
+ rootr sign new <경로> [--file <f>] [--reviewer <userId>] [--line <line.json>] 서명문서 만들기(본문·결재라인까지)
45
+ rootr sign show <path|id> [--json] 결재라인 진행·서명 목록 보기
46
+ rootr sign line <path|id> --file <line.json> 결재라인 다시 짜기 (올리기 전에만)
47
+ rootr sign code <path|id> | --bulk 확인 코드를 내 메일로 보내기 (서명 전 필수)
48
+ rootr sign submit <path|id> --code <6자리> 올리기 — 본문이 얼고 기안자가 첫 서명
49
+ rootr sign approve <path|id> --code <6자리> 내 차례에 서명
50
+ rootr sign reject <path|id> --comment <사유> [--code <6자리>] 반려 (사유 필수)
51
+ rootr sign reopen <path|id> 아무도 서명 안 한 초안 다시 열기
52
+ rootr sign revise <path|id> --note <사유> 개정본 만들기 (원본은 그대로 남는다)
53
+ rootr sign verify <path|id> [--json] 위변조 검증 (서명·해시·시점확인)
54
+ rootr sign list [--pending] [--status <S>] [--mode <M>] [--json] 서명문서 목록 (--pending: 내 차례)
55
+ rootr sign bulk <id…> --code <6자리> 여러 건 한 번에 서명 (코드 하나로)
56
+ rootr sign ledger [--json] 워크스페이스 원장(해시 체인) 전체 검증
33
57
  rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] 설정 조회/저장
34
58
  rootr config --add-key <k> [--label <name>] [--workspace <w>] 워크스페이스별 키 추가 등록
35
- rootr mcp MCP stdio 서버 구동 (도구 90종+ — README 참고)
59
+ rootr mcp MCP stdio 서버 구동 (도구 130종+ — README 참고)
36
60
  rootr --help 도움말
37
61
  rootr --version 버전 출력
38
62
 
@@ -115,6 +139,41 @@ async function cmdLs(args) {
115
139
  }
116
140
  }
117
141
 
142
+ /**
143
+ * `rootr comments <path|id>` — 그 문서에 달린 댓글을 읽는다.
144
+ *
145
+ * 2026-08-23 창업자 확인 요청: "댓글 써진 거 mcp/api/cli로 읽어올 수 있는 거 맞는지."
146
+ * API와 MCP에는 있었고 CLI에만 없었다. 공개 페이지에 달린 댓글도 같은 곳에 쌓이므로
147
+ * 여기서 함께 나온다.
148
+ */
149
+ async function cmdComments(args) {
150
+ const target = args._[0];
151
+ if (!target) throw new Error('사용법: rootr comments <path|id> [--block <blockId>] [--json]');
152
+
153
+ const client = makeClient();
154
+ const id = requireResolved(await resolveTarget(client, target));
155
+ const threads = await client.listComments(id, { blockId: args.block });
156
+
157
+ if (args.json) {
158
+ console.log(JSON.stringify(threads, null, 2));
159
+ return;
160
+ }
161
+ if (!threads.length) {
162
+ console.log('댓글이 없습니다.');
163
+ return;
164
+ }
165
+ for (const t of threads) {
166
+ const when = String(t.createdAt ?? '').slice(0, 16).replace('T', ' ');
167
+ console.log(`- ${t.author?.name ?? '(알 수 없음)'} · ${when}`);
168
+ console.log(` ${String(t.body ?? '').replace(/\n/g, '\n ')}`);
169
+ for (const r of t.replies ?? []) {
170
+ const rWhen = String(r.createdAt ?? '').slice(0, 16).replace('T', ' ');
171
+ console.log(` ↳ ${r.author?.name ?? '(알 수 없음)'} · ${rWhen}`);
172
+ console.log(` ${String(r.body ?? '').replace(/\n/g, '\n ')}`);
173
+ }
174
+ }
175
+ }
176
+
118
177
  async function cmdRead(args) {
119
178
  const target = args._[0];
120
179
  if (!target) throw new Error('사용법: rootr read <path|id> [--json]');
@@ -445,14 +504,166 @@ async function cmdMcp() {
445
504
  * 컬럼을 하나씩 붙이는 방식은 호출 순서가 곧 컬럼 순서가 되어 옮겨온 표가 뒤죽박죽이
446
505
  * 됐다 — 그래서 CLI도 "한 번에" 쪽만 제공한다.
447
506
  */
507
+ /**
508
+ * `rootr access <path|id> [--private|--public|--inherit] [--grant <id> --level …] [--revoke <id>]`
509
+ *
510
+ * 화면의 "공개범위"·"권한" 창을 터미널에서. 인자 없이 부르면 지금 상태를 보여 준다.
511
+ * 관리 권한이 없으면 서버가 막는다(보기도 안 된다).
512
+ */
513
+ async function cmdAccess(args) {
514
+ const target = args._[0];
515
+ if (!target) {
516
+ throw new Error(
517
+ '사용법: rootr access <path|id> [--private|--public|--inherit] [--grant <userId> --level READ|WRITE|MANAGE] [--revoke <userId>]',
518
+ );
519
+ }
520
+ const client = makeClient();
521
+ const id = requireResolved(await resolveTarget(client, target));
522
+ const group = args.group ? 'GROUP' : 'USER';
523
+
524
+ let changed = false;
525
+ if (args.private || args.public || args.inherit) {
526
+ const visibility = args.private ? 'PRIVATE' : args.public ? 'PUBLIC' : null;
527
+ await client.setNodeVisibility(id, visibility);
528
+ console.log(
529
+ `공개범위: ${visibility === 'PRIVATE' ? '비공개(만든 사람·관리자·따로 준 사람만)' : visibility === 'PUBLIC' ? '워크스페이스 전체' : '상위 폴더/워크스페이스 기본값을 따름'}`,
530
+ );
531
+ changed = true;
532
+ }
533
+ if (typeof args.grant === 'string') {
534
+ const level = String(args.level || '').toUpperCase();
535
+ if (!['READ', 'WRITE', 'MANAGE'].includes(level)) {
536
+ throw new Error('--grant 에는 --level READ|WRITE|MANAGE 를 함께 주세요.');
537
+ }
538
+ await client.grantNodePermission(id, { subjectType: group, subjectId: args.grant, level });
539
+ console.log(`권한 부여: ${args.grant} → ${level}`);
540
+ changed = true;
541
+ }
542
+ if (typeof args.revoke === 'string') {
543
+ await client.revokeNodePermission(id, { subjectType: group, subjectId: args.revoke });
544
+ console.log(`권한 회수: ${args.revoke}`);
545
+ changed = true;
546
+ }
547
+
548
+ const info = await client.getNodeAccess(id);
549
+ if (args.json) {
550
+ console.log(JSON.stringify(info, null, 2));
551
+ return;
552
+ }
553
+ if (!changed) {
554
+ const eff = info.effectiveVisibility ?? info.visibility;
555
+ console.log(`공개범위: ${eff === 'PRIVATE' ? '비공개' : '워크스페이스 전체'}${info.visibility === null ? ' (상속받음)' : ''}`);
556
+ }
557
+ const direct = info.direct ?? [];
558
+ console.log(`따로 준 권한: ${direct.length ? '' : '없음'}`);
559
+ for (const p of direct) console.log(` - ${p.subjectType} ${p.subjectId} → ${p.level}`);
560
+ }
561
+
562
+ /**
563
+ * `rootr form responses|response-edit|response-rm`
564
+ *
565
+ * 폼에 들어온 응답을 터미널에서 보고 고치고 지운다. 화면에서만 되면
566
+ * 에이전트에게는 없는 기능이라 여기까지 열어 둔다.
567
+ */
568
+ async function cmdForm(args) {
569
+ const sub = args._[0];
570
+ if (sub === 'collect') return cmdFormCollect(args);
571
+ if (sub === 'responses') return cmdFormResponses(args);
572
+ if (sub === 'response-edit') return cmdFormResponseEdit(args);
573
+ if (sub === 'response-rm') return cmdFormResponseRm(args);
574
+ throw new Error('사용법: rootr form collect|responses|response-edit|response-rm … (자세한 건 rootr --help)');
575
+ }
576
+
577
+ /**
578
+ * `rootr form collect <path|id> --to db|sheet`
579
+ *
580
+ * 응답 모을 곳을 새로 만들어 연결한다. 표로 만들면 폼 질문 그대로 칸이 생기므로,
581
+ * 표를 미리 만들고 칸 이름을 하나하나 맞출 필요가 없다.
582
+ */
583
+ async function cmdFormCollect(args) {
584
+ const target = args._[1];
585
+ if (!target) throw new Error('사용법: rootr form collect <path|id> --to db|sheet');
586
+ const to = String(args.to || '').toLowerCase();
587
+ const map = { db: 'database', database: 'database', sheet: 'spreadsheet', spreadsheet: 'spreadsheet' };
588
+ if (!map[to]) throw new Error('--to 에 db 또는 sheet 를 주세요.');
589
+
590
+ const client = makeClient();
591
+ const id = requireResolved(await resolveTarget(client, target));
592
+ const made = await client.createFormCollector(id, map[to]);
593
+ if (args.json) {
594
+ console.log(JSON.stringify(made, null, 2));
595
+ return;
596
+ }
597
+ console.log(
598
+ made.kind === 'database'
599
+ ? `표를 만들어 연결했습니다: ${made.nodeId} (폼 질문 그대로 칸이 생겼습니다)`
600
+ : `스프레드시트를 만들어 연결했습니다: ${made.nodeId}`,
601
+ );
602
+ }
603
+
604
+ async function cmdFormResponses(args) {
605
+ const target = args._[1];
606
+ if (!target) throw new Error('사용법: rootr form responses <path|id> [--latest] [--json]');
607
+ const client = makeClient();
608
+ const id = requireResolved(await resolveTarget(client, target));
609
+ const rows = await client.listFormResponses(id, { latestPerRespondent: Boolean(args.latest) });
610
+
611
+ if (args.json) {
612
+ console.log(JSON.stringify(rows, null, 2));
613
+ return;
614
+ }
615
+ if (!rows.length) {
616
+ console.log('아직 들어온 응답이 없습니다.');
617
+ return;
618
+ }
619
+ console.log(`응답 ${rows.length}건${args.latest ? ' (사람마다 최신 1건만)' : ''}`);
620
+ for (const r of rows) {
621
+ const who = r.submitterUserId ? r.submitterUserId : '익명';
622
+ console.log(`- ${r.id} ${new Date(r.createdAt).toISOString()} 제출자:${who}`);
623
+ console.log(` ${JSON.stringify(r.values)}`);
624
+ }
625
+ }
626
+
627
+ async function cmdFormResponseEdit(args) {
628
+ const responseId = args._[1];
629
+ if (!responseId) throw new Error("사용법: rootr form response-edit <응답id> --values '<json>'");
630
+ const raw = typeof args.values === 'string' ? args.values : await readStdin();
631
+ let values;
632
+ try {
633
+ values = JSON.parse(raw);
634
+ } catch {
635
+ throw new Error('--values 를 JSON으로 읽을 수 없습니다. {"필드id":"값"} 형태여야 합니다.');
636
+ }
637
+ // 통째로 바뀐다 — 남기고 싶은 답도 같이 보내야 한다.
638
+ const result = await makeClient().updateFormResponse(responseId, values);
639
+ if (args.json) {
640
+ console.log(JSON.stringify(result, null, 2));
641
+ return;
642
+ }
643
+ console.log(`응답 수정됨: ${result.id ?? responseId}`);
644
+ console.log(` ${JSON.stringify(result.values ?? values)}`);
645
+ }
646
+
647
+ async function cmdFormResponseRm(args) {
648
+ const responseId = args._[1];
649
+ if (!responseId) throw new Error('사용법: rootr form response-rm <응답id> [--yes]');
650
+ if (!args.yes) {
651
+ throw new Error('응답 삭제는 되돌릴 수 없습니다. 정말 지우려면 --yes 를 붙이세요.');
652
+ }
653
+ await makeClient().deleteFormResponse(responseId);
654
+ console.log(`응답 삭제됨: ${responseId}`);
655
+ }
656
+
448
657
  async function cmdDb(args) {
449
658
  const sub = args._[0];
450
659
  if (sub === 'rows') return cmdDbRows(args);
451
660
  if (sub === 'row-add') return cmdDbRowAdd(args);
452
661
  if (sub === 'row-body') return cmdDbRowBody(args);
662
+ if (sub === 'lock') return cmdDbLock(args);
663
+ if (sub === 'notify') return cmdDbNotify(args);
453
664
  if (sub !== 'create') {
454
665
  throw new Error(
455
- '사용법: rootr db create|rows|row-add|row-body … (자세한 건 rootr --help)',
666
+ '사용법: rootr db create|rows|row-add|row-body|lock|notify … (자세한 건 rootr --help)',
456
667
  );
457
668
  }
458
669
  const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
@@ -482,6 +693,90 @@ async function cmdDb(args) {
482
693
  }
483
694
 
484
695
  /** 표의 행 목록. --body 를 주면 행마다 상세 내용(본문 메모)까지 가져온다. */
696
+ /**
697
+ * `rootr db lock <path|id> [--schema managers|open] [--rows own|all] [--locked <칸,칸>]`
698
+ *
699
+ * 표에 거는 자물쇠 — 노션에 없는 것이다. 인자 없이 부르면 지금 상태를 보여 준다.
700
+ */
701
+ async function cmdDbLock(args) {
702
+ const target = args._[1];
703
+ if (!target) throw new Error('사용법: rootr db lock <path|id> [--schema managers|open] [--rows own|all] [--locked "승인,점수"]');
704
+
705
+ const client = makeClient();
706
+ const id = requireResolved(await resolveTarget(client, target));
707
+
708
+ const patch = {};
709
+ if (args.schema !== undefined) patch.schemaLock = args.schema;
710
+ if (args.rows !== undefined) patch.rowScope = args.rows;
711
+ if (args.locked !== undefined) {
712
+ patch.lockedProps = String(args.locked)
713
+ .split(',')
714
+ .map((x) => x.trim())
715
+ .filter(Boolean);
716
+ }
717
+
718
+ const result = Object.keys(patch).length
719
+ ? await client.setDatabasePermissions(id, patch)
720
+ : await client.getDatabasePermissions(id);
721
+
722
+ if (args.json) {
723
+ console.log(JSON.stringify(result, null, 2));
724
+ return;
725
+ }
726
+ console.log(`칸·뷰 잠금: ${result.schemaLock === 'managers' ? '관리자만' : '누구나'}`);
727
+ console.log(`행 보기 : ${result.rowScope === 'own' ? '본인이 만든 행만' : '전부'}`);
728
+ console.log(`잠긴 칸 : ${(result.lockedProps ?? []).length ? result.lockedProps.join(', ') : '없음'}`);
729
+ }
730
+
731
+ /**
732
+ * `rootr db notify <path|id> [--file rules.json]`
733
+ *
734
+ * 표에 무슨 일이 생기면 누구에게 알릴지. 파일 없이 부르면 지금 규칙을 보여 준다.
735
+ * 규칙 모양: [{ "userIds": ["…"], "events": ["created"], "filter": {"propId":"p1","op":"is","value":"긴급"} }]
736
+ */
737
+ async function cmdDbNotify(args) {
738
+ const target = args._[1];
739
+ if (!target) throw new Error('사용법: rootr db notify <path|id> [--file rules.json] [--clear] [--json]');
740
+
741
+ const client = makeClient();
742
+ const id = requireResolved(await resolveTarget(client, target));
743
+
744
+ if (args.clear) {
745
+ const cleared = await client.setDatabaseNotifications(id, []);
746
+ console.log('알림 규칙을 모두 지웠습니다.');
747
+ if (args.json) console.log(JSON.stringify(cleared, null, 2));
748
+ return;
749
+ }
750
+ if (args.file) {
751
+ const raw = readFileSync(args.file, 'utf8');
752
+ const saved = await client.setDatabaseNotifications(id, JSON.parse(raw));
753
+ console.log(`알림 규칙 ${saved.watchers.length}개를 저장했습니다.`);
754
+ if (args.json) console.log(JSON.stringify(saved, null, 2));
755
+ return;
756
+ }
757
+
758
+ const { watchers } = await client.getDatabaseNotifications(id);
759
+ if (args.json) {
760
+ console.log(JSON.stringify(watchers, null, 2));
761
+ return;
762
+ }
763
+ if (!watchers.length) {
764
+ console.log('알림 규칙이 없습니다.');
765
+ return;
766
+ }
767
+ const WHEN = { created: '행 추가', updated: '행 수정', deleted: '행 삭제' };
768
+ for (const w of watchers) {
769
+ const when = (w.events ?? []).map((e) => WHEN[e] ?? e).join(' · ');
770
+ const f = w.filter;
771
+ const only = f
772
+ ? f.op === 'changed'
773
+ ? ` (${f.propId} 칸이 바뀌었을 때만)`
774
+ : ` (${f.propId} ${f.op === 'is' ? '=' : '⊃'} ${f.value})`
775
+ : '';
776
+ console.log(`- ${when}${only} → ${(w.userIds ?? []).length}명`);
777
+ }
778
+ }
779
+
485
780
  async function cmdDbRows(args) {
486
781
  const target = args._[1];
487
782
  if (!target) throw new Error('사용법: rootr db rows <path|id> [--body] [--json]');
@@ -558,6 +853,233 @@ async function resolveDatabaseId(client, target) {
558
853
  return target;
559
854
  }
560
855
 
856
+ // ── SIGN-001: 서명문서 · 연구노트 ────────────────────────────────────────
857
+ //
858
+ // 서명·올리기·반려는 메일로 받은 6자리 확인 코드를 함께 보내야 한다. 서버가
859
+ // 개인키를 보관하는 구조에서 "본인이 눌렀다"의 유일한 증거라, CLI가 대신
860
+ // 눌러 줄 수 있는 자리가 아니다 — `rootr sign code`로 코드를 받고, 메일에서
861
+ // 읽은 값을 `--code`로 넘긴다.
862
+
863
+ const SIGN_USAGE =
864
+ '사용법: rootr sign new|show|line|code|submit|approve|reject|reopen|revise|verify|list|bulk|ledger …\n' +
865
+ ' (자세한 건 rootr --help)';
866
+
867
+ function signWorkspace(client, args) {
868
+ const ws = args.ws || client.workspace;
869
+ if (!ws) throw new Error('워크스페이스를 알 수 없습니다. --ws 를 주거나 rootr use 로 정하세요.');
870
+ return ws;
871
+ }
872
+
873
+ /** 서명 관련 인자도 문서 도구들과 같은 규칙 — 경로로 줘도 되게. */
874
+ async function resolveSignedId(client, target) {
875
+ if (!target) throw new Error(SIGN_USAGE);
876
+ return requireResolved(await resolveTarget(client, target));
877
+ }
878
+
879
+ function requireCode(args, what) {
880
+ if (typeof args.code !== 'string' || !args.code.trim()) {
881
+ throw new Error(
882
+ `${what}에는 확인 코드가 필요합니다: 먼저 rootr sign code <path|id> 로 메일을 받고 --code <6자리> 로 넘기세요.`,
883
+ );
884
+ }
885
+ return args.code.trim();
886
+ }
887
+
888
+ const SIGN_STATUS_KO = {
889
+ DRAFT: '작성 중',
890
+ IN_PROGRESS: '서명 진행 중',
891
+ COMPLETED: '완료',
892
+ REJECTED: '반려됨',
893
+ SUPERSEDED: '개정되어 대체됨',
894
+ VOIDED: '무효',
895
+ };
896
+
897
+ function printSignedState(st) {
898
+ console.log(`${st.name} [${SIGN_STATUS_KO[st.status] ?? st.status}]${st.mode === 'RESEARCH_NOTE' ? ' (연구노트)' : ''}`);
899
+ if (st.revisionNo) console.log(`개정 ${st.revisionNo}판${st.revisionNote ? ` — ${st.revisionNote}` : ''}`);
900
+ if (st.latestRevisionId) console.log(`※ 더 최신 판이 있습니다: ${st.latestRevisionId} (${st.latestRevisionNo}판)`);
901
+ console.log(st.lockedAt ? `잠김: ${st.lockedAt} (본문·결재라인 수정 불가)` : '잠기지 않음 (아직 고칠 수 있음)');
902
+ if (!st.contentIntact) console.log('⚠️ 본문이 서명 당시와 다릅니다 — rootr sign verify 로 확인하세요.');
903
+ for (const step of st.steps ?? []) {
904
+ const here = step.order === st.currentStepOrder ? ' ← 지금 이 차례' : '';
905
+ const rule = step.rule === 'ANY' ? '아무나 1명' : step.rule === 'N_OF_M' ? `${step.requiredCount}명` : '전원';
906
+ console.log(`\n${step.order}. ${step.label} (${rule})${here}`);
907
+ for (const m of step.members ?? []) {
908
+ console.log(` - ${m.userId} ${m.role} ${m.state}${m.actedAt ? ` ${m.actedAt}` : ''}${m.comment ? ` "${m.comment}"` : ''}`);
909
+ }
910
+ }
911
+ if (st.signatures?.length) {
912
+ console.log('\n서명:');
913
+ for (const sig of st.signatures) {
914
+ console.log(
915
+ ` #${sig.seq} ${sig.signerName} <${sig.signerEmail}> ${sig.role} ${sig.signedAt}` +
916
+ (sig.timestampPending ? ' [시점확인 대기중]' : ` [시점확인 ${sig.tsTime} · ${sig.tsaName}]`),
917
+ );
918
+ }
919
+ }
920
+ if (st.canSign) console.log('\n→ 지금 서명할 수 있습니다: rootr sign code <id> 후 rootr sign approve <id> --code <6자리>');
921
+ }
922
+
923
+ async function cmdSign(args) {
924
+ const sub = args._[0];
925
+ const client = makeClient();
926
+
927
+ switch (sub) {
928
+ case 'new': {
929
+ const target = args._[1];
930
+ if (!target) throw new Error('사용법: rootr sign new <경로> [--file <본문.md>] [--reviewer <userId>] [--line <line.json>]');
931
+ const ws = signWorkspace(client, args);
932
+ const content =
933
+ typeof args.file === 'string'
934
+ ? readFileSync(args.file, 'utf8')
935
+ : args._[2] !== undefined
936
+ ? String(args._[2])
937
+ : await readStdin();
938
+ const approvalLine =
939
+ typeof args.line === 'string' ? JSON.parse(readFileSync(args.line, 'utf8')) : undefined;
940
+ const noteMeta = typeof args.meta === 'string' ? JSON.parse(readFileSync(args.meta, 'utf8')) : undefined;
941
+ const body = {
942
+ content: content || '',
943
+ createParents: true,
944
+ approvalLine,
945
+ noteMeta,
946
+ reviewerUserId: typeof args.reviewer === 'string' ? args.reviewer : undefined,
947
+ mode: typeof args.mode === 'string' ? args.mode : args.reviewer ? 'RESEARCH_NOTE' : undefined,
948
+ };
949
+ if (target.startsWith('/')) body.path = target;
950
+ else body.name = target;
951
+ const st = await client.createSignedDoc(ws, body);
952
+ console.log(`서명문서 생성: ${st.path ?? st.name} (${st.nodeId})`);
953
+ if (!st.steps?.length) {
954
+ console.log('결재라인이 아직 없습니다 — rootr sign line <id> --file <line.json> 으로 짜세요.');
955
+ } else {
956
+ printSignedState(st);
957
+ }
958
+ return;
959
+ }
960
+ case 'show': {
961
+ const st = await client.getSignedDoc(await resolveSignedId(client, args._[1]));
962
+ if (args.json) return console.log(JSON.stringify(st, null, 2));
963
+ return printSignedState(st);
964
+ }
965
+ case 'line': {
966
+ const id = await resolveSignedId(client, args._[1]);
967
+ const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
968
+ if (!raw) throw new Error('결재라인 JSON이 없습니다 (--file 또는 표준입력).');
969
+ const parsed = JSON.parse(raw);
970
+ const steps = Array.isArray(parsed) ? parsed : parsed.steps;
971
+ const st = await client.setApprovalLine(id, steps);
972
+ console.log(`결재라인 ${st.steps.length}단계 저장됨.`);
973
+ return printSignedState(st);
974
+ }
975
+ case 'code': {
976
+ if (args.bulk) {
977
+ const ws = signWorkspace(client, args);
978
+ const out = await client.requestBulkSigningCode(ws);
979
+ console.log(`묶음 서명용 확인 코드를 ${out.sentTo ?? '메일'}로 보냈습니다. 10분 안에 --code 로 넘기세요.`);
980
+ return;
981
+ }
982
+ const out = await client.requestSigningCode(await resolveSignedId(client, args._[1]));
983
+ console.log(`확인 코드를 ${out.sentTo ?? '메일'}로 보냈습니다. 10분 안에 --code 로 넘기세요.`);
984
+ return;
985
+ }
986
+ case 'submit': {
987
+ const id = await resolveSignedId(client, args._[1]);
988
+ const st = await client.submitSignedDoc(id, { code: requireCode(args, '올리기') });
989
+ console.log('올렸습니다 — 본문이 얼었고 기안자 서명이 찍혔습니다.');
990
+ return printSignedState(st);
991
+ }
992
+ case 'approve':
993
+ case 'sign': {
994
+ const id = await resolveSignedId(client, args._[1]);
995
+ const st = await client.signSignedDoc(id, { code: requireCode(args, '서명') });
996
+ console.log('서명했습니다.');
997
+ return printSignedState(st);
998
+ }
999
+ case 'reject': {
1000
+ const id = await resolveSignedId(client, args._[1]);
1001
+ if (typeof args.comment !== 'string' || !args.comment.trim()) {
1002
+ throw new Error('반려에는 사유가 필요합니다: --comment "근거 자료가 빠졌습니다"');
1003
+ }
1004
+ const st = await client.rejectSignedDoc(id, { comment: args.comment, code: args.code });
1005
+ console.log('반려했습니다 — 작성자가 다시 고칠 수 있습니다.');
1006
+ return printSignedState(st);
1007
+ }
1008
+ case 'reopen': {
1009
+ const st = await client.reopenSignedDoc(await resolveSignedId(client, args._[1]));
1010
+ console.log('다시 열었습니다 — 본문과 결재라인을 고칠 수 있습니다.');
1011
+ return printSignedState(st);
1012
+ }
1013
+ case 'revise': {
1014
+ const id = await resolveSignedId(client, args._[1]);
1015
+ if (typeof args.note !== 'string' || !args.note.trim()) {
1016
+ throw new Error('개정에는 사유가 필요합니다: --note "측정값 단위 정정"');
1017
+ }
1018
+ const out = await client.reviseSignedDoc(id, args.note);
1019
+ console.log(`개정본을 만들었습니다: ${out.nodeId} (${out.revisionNo}판). 원본은 그대로 남습니다.`);
1020
+ return printSignedState(await client.getSignedDoc(out.nodeId));
1021
+ }
1022
+ case 'verify': {
1023
+ const v = await client.verifySignedDoc(await resolveSignedId(client, args._[1]));
1024
+ if (args.json) return console.log(JSON.stringify(v, null, 2));
1025
+ const mark = (ok) => (ok ? '✅' : '❌');
1026
+ console.log(`${v.name} [${SIGN_STATUS_KO[v.status] ?? v.status}]`);
1027
+ console.log(`${mark(v.contentIntact)} 본문이 서명 당시 그대로다`);
1028
+ console.log(`${mark(v.allSignaturesValid)} 서명이 전부 유효하고 체인이 이어진다`);
1029
+ console.log(`${mark(v.allTimestamped)} 서명마다 공인시점확인이 붙어 있다`);
1030
+ console.log(`${mark(v.ledgerIntact)} 워크스페이스 원장이 성하다${v.ledgerBrokenAtSeq ? ` (끊긴 지점 #${v.ledgerBrokenAtSeq})` : ''}`);
1031
+ for (const sig of v.signatures ?? []) {
1032
+ console.log(
1033
+ ` #${sig.seq} ${sig.signerName} ${sig.role} ${sig.signedAt} ` +
1034
+ `${mark(sig.signatureValid)}서명 ${mark(sig.chainLinkValid)}체인 ${mark(sig.matchesCurrentContent)}현재본문` +
1035
+ (sig.timestampPending ? ' [시점확인 대기중]' : ''),
1036
+ );
1037
+ }
1038
+ return;
1039
+ }
1040
+ case 'list': {
1041
+ const ws = signWorkspace(client, args);
1042
+ const rows = await client.listSignedDocs(ws, {
1043
+ status: typeof args.status === 'string' ? args.status : undefined,
1044
+ mode: typeof args.mode === 'string' ? args.mode : undefined,
1045
+ pendingMine: !!args.pending,
1046
+ });
1047
+ if (args.json) return console.log(JSON.stringify(rows, null, 2));
1048
+ if (!rows.length) return console.log(args.pending ? '내 차례인 서명문서가 없습니다.' : '서명문서가 없습니다.');
1049
+ for (const r of rows) {
1050
+ const waiting = r.waitingOn?.length ? ` 대기: ${r.waitingOn.join(', ')}` : '';
1051
+ console.log(`${r.nodeId}\t${SIGN_STATUS_KO[r.status] ?? r.status}\t${r.path}${waiting}${r.canSign ? ' ← 내 차례' : ''}`);
1052
+ }
1053
+ return;
1054
+ }
1055
+ case 'bulk': {
1056
+ const ws = signWorkspace(client, args);
1057
+ const targets = args._.slice(1);
1058
+ if (!targets.length) throw new Error('사용법: rootr sign bulk <id|경로 …> --code <6자리>');
1059
+ const code = requireCode(args, '묶음 서명');
1060
+ const nodeIds = [];
1061
+ for (const t of targets) nodeIds.push(await resolveSignedId(client, t));
1062
+ const out = await client.bulkSignSignedDocs(ws, { nodeIds, code });
1063
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
1064
+ const failed = (out.results ?? []).filter((r) => !r.ok);
1065
+ console.log(`서명 ${out.signed ?? 0}건, 건너뜀 ${failed.length}건`);
1066
+ for (const f of failed) console.log(` 건너뜀 ${f.nodeId}: ${f.error}`);
1067
+ return;
1068
+ }
1069
+ case 'ledger': {
1070
+ const ws = signWorkspace(client, args);
1071
+ const v = await client.verifyLedger(ws);
1072
+ if (args.json) return console.log(JSON.stringify(v, null, 2));
1073
+ console.log(`${v.ok ? '✅ 원장이 성합니다' : '❌ 원장이 끊겼습니다'} — 서명 ${v.count}건`);
1074
+ if (v.headHash) console.log(`머리 해시: ${v.headHash}`);
1075
+ if (!v.ok) console.log(`끊긴 지점: #${v.brokenAtSeq}${v.reason ? ` (${v.reason})` : ''}`);
1076
+ return;
1077
+ }
1078
+ default:
1079
+ throw new Error(SIGN_USAGE);
1080
+ }
1081
+ }
1082
+
561
1083
  async function main() {
562
1084
  const argv = process.argv.slice(2);
563
1085
  const args = parseArgs(argv);
@@ -575,6 +1097,8 @@ async function main() {
575
1097
  switch (cmd) {
576
1098
  case 'ls':
577
1099
  return cmdLs(args);
1100
+ case 'comments':
1101
+ return cmdComments(args);
578
1102
  case 'read':
579
1103
  return cmdRead(args);
580
1104
  case 'write':
@@ -601,6 +1125,12 @@ async function main() {
601
1125
  return cmdUse(args);
602
1126
  case 'db':
603
1127
  return cmdDb(args);
1128
+ case 'form':
1129
+ return cmdForm(args);
1130
+ case 'access':
1131
+ return cmdAccess(args);
1132
+ case 'sign':
1133
+ return cmdSign(args);
604
1134
  case 'mcp':
605
1135
  return cmdMcp(args);
606
1136
  default: