rootr-cli 0.14.1 → 0.17.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 CHANGED
@@ -6,7 +6,7 @@ Rootr(마크다운 지식협업 SaaS) 공식 CLI + MCP 서버. 에이전트나
6
6
  - `rootr` CLI: `ls`, `read`, `write`, `append`, `edit`, `search`, `rm`, `attach`, `ws`, `ask`, `config`
7
7
  - `rootr mcp`: stdio 기반 MCP 서버 (Claude Code / Claude Desktop 등에 등록해서 사용), 도구 90종+
8
8
  — 문서·워크스페이스/스캐폴딩·LOG 데이터스토어·RCA ask·이슈·웹훅·첨부파일에 더해
9
- 스프레드시트/화이트보드/폼/프레젠테이션/녹음/CRM/데이터베이스까지. 정확한 목록은 MCP `tools/list`로 확인하세요.
9
+ 스프레드시트/화이트보드/폼/프레젠테이션/페이지/녹음/CRM/데이터베이스까지. 정확한 목록은 MCP `tools/list`로 확인하세요.
10
10
 
11
11
  순수 Node.js ESM, 빌드 과정 없음. Node 18 이상 필요(내장 `fetch` 사용).
12
12
 
@@ -147,8 +147,11 @@ rootr rm /notes/todo.md # 확인 후 삭제
147
147
  rootr rm /notes/todo.md --yes # 확인 없이 (스크립트용)
148
148
  ```
149
149
 
150
- 휴지통으로 옮기는 **복구 가능한** 삭제입니다. 앱의 휴지통에서 되돌릴 수 있고,
151
- 30일이 지나면 자동으로 완전 삭제됩니다. `--yes` 없이 실행하면 확인을 묻습니다
150
+ 휴지통으로 옮기는 **복구 가능한** 삭제입니다. 30일이 지나면 자동으로 완전 삭제됩니다.
151
+ 되돌리려면 `rootr restore <id>` — 화면에서 Ctrl+Z 를 누르는 것과 같은 일입니다.
152
+ **경로가 아니라 id** 입니다(휴지통에 든 것은 이미 트리에 없어 경로로는 못 찾습니다).
153
+ `rootr rm` 이 마지막 줄에 그 id 를 찍어 주니 그대로 쓰면 됩니다. MCP 로는
154
+ `rootr_restore_node`. `--yes` 없이 실행하면 확인을 묻습니다
152
155
  (파이프/CI처럼 입력이 없는 환경에서는 "아니오"로 처리되어 안전합니다).
153
156
  `docs:write` 스코프가 필요합니다.
154
157
 
@@ -394,8 +397,8 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
394
397
  | `rootr_bulk_update_rows_each` | 여러 행을 **행마다 다른 값**으로 한 번에 수정 |
395
398
  | `rootr_delete_row` | 행 삭제 |
396
399
  | `rootr_bulk_delete_rows` | 행 여러 개를 한 번에 삭제 |
397
- | `rootr_create_view` | 보기 추가 — 테이블/보드/캘린더/타임라인/갤러리 |
398
- | `rootr_update_view` | 보기 수정 — 열 숨기기·이름·그룹·필터·정렬·열 너비 |
400
+ | `rootr_create_view` | 보기 추가 — 테이블/보드/캘린더/타임라인/갤러리 (달력은 `calendarScale`로 주·달·해) |
401
+ | `rootr_update_view` | 보기 수정 — 열 숨기기·이름·그룹·필터·정렬·열 너비·달력 단위(`calendarScale`) |
399
402
  | `rootr_delete_view` | 보기 삭제 |
400
403
  | `rootr_create_row_page` | 행에 상세 페이지 붙이기(문서 또는 서명문서) |
401
404
  | `rootr_set_row_page_type` | 앞으로 만드는 행이 무엇으로 열릴지 정하기 |
@@ -407,6 +410,9 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
407
410
  | `rootr_set_database_permissions` | 표 잠그기 — 칸·뷰 잠금 / 본인 행만 / 관리자만 고칠 칸 |
408
411
  | `rootr_read_database_notifications` | 표 알림 규칙 보기 |
409
412
  | `rootr_set_database_notifications` | 표 알림 규칙 저장 — 누구에게·언제·어떤 것만 |
413
+ | `rootr_list_database_buttons` | 표 위 **버튼** 목록 보기(고칠 수 있는지 `myLevel`도 함께) |
414
+ | `rootr_set_database_buttons` | 버튼 만들고 고치기 — **목록 통째로 교체**(남길 것도 같이 보내야 함) |
415
+ | `rootr_run_database_button` | 버튼 누르기 — 행 만들기·값 채우기·AI로 칸 채우기가 순서대로 |
410
416
  | `rootr_list_document_commits` | 공개 문서에 온 변경 제안 목록 |
411
417
  | `rootr_merge_document_commit` | 변경 제안 반영(세 벌 병합 — 덮어쓰기 아님) |
412
418
  | `rootr_reject_document_commit` | 변경 제안 거절(사유는 제안자에게 전달) |
@@ -418,6 +424,35 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
418
424
  | `rootr_delete_form_response` | 폼 응답 한 건 삭제 |
419
425
  | `rootr_create_form_collector` | 응답 모을 표·스프레드시트를 **만들어서** 연결(표는 질문 그대로 칸 생성) |
420
426
 
427
+ **표 버튼 — 매번 하는 준비 동작을 한 번에.** 새 건이 생길 때마다 상태=접수, 담당=나,
428
+ 마감=오늘+7 을 손으로 다시 채우는 일을 버튼 하나로 만듭니다. 단계는 **적힌 순서대로**
429
+ 돌고 **앞 단계가 만든 행을 뒤 단계가 받으므로**, "행을 만들고 → 그 행의 요약을 AI가 쓰고
430
+ → 그 행을 연다"가 한 번 누름으로 끝납니다. 값에는 `@today` · `@today+7` · `@me` 를 쓸 수
431
+ 있고 누르는 그 시점에 채워집니다. 버튼은 권한을 늘리지 않습니다 — **누른 사람 권한 그대로**
432
+ 돕니다(만들기·고치기는 관리자, 누르기는 편집 권한).
433
+
434
+ ```bash
435
+ rootr db buttons <표> # 지금 달린 버튼 보기
436
+ rootr db buttons <표> --file buttons.json # 목록 통째로 저장(남길 것도 같이)
437
+ rootr db button-run <표> <버튼id> # 한 번 누르기 (--rows id,id 로 고른 행 지정)
438
+ rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해 중에서
439
+ ```
440
+
441
+ ```json
442
+ [{ "label": "접수", "icon": "📥", "steps": [
443
+ { "kind": "add_row", "values": { "상태": "접수", "담당": "@me", "마감": "@today+7" } },
444
+ { "kind": "ai_fill", "target": "created", "propId": "요약", "prompt": "{제목}을 한 문장으로 요약해." },
445
+ { "kind": "open_row" }
446
+ ] }]
447
+ ```
448
+
449
+ 스캐폴드로 표를 지을 때도 `database.buttons` 에 같은 모양을 넣으면 **표와 함께** 달립니다.
450
+
451
+ **행에 댓글 달기.** 표의 한 행에 댓글을 달려면 `rootr_comment_document` 에 **표**를
452
+ `target` 으로, **행 id**를 `blockId` 으로 줍니다(읽기는 `rootr_list_document_comments`,
453
+ 명령줄은 `rootr comments <표> --block <행id>`). 상세 문서가 없는 행에도 달립니다.
454
+ `@[userId]` 로 사람을 부르면 알림이 가고, 그 알림은 **그 행**을 열면서 댓글을 짚어 줍니다.
455
+
421
456
  **사람 칸은 여러 명을 담습니다.** 회의 참석자·공동 담당처럼 여럿이 들어가는 자리가
422
457
  흔해서, 값은 이름 목록(`["김대표","이팀장"]`)입니다. 이름 하나만 보내도 받아 주고
423
458
  목록으로 맞춰 돌려줍니다. 새로 들어온 사람에게만 알림이 갑니다.
@@ -623,6 +658,44 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
623
658
  | `rootr_update_node` | 아무 노드나 이름·아이콘 변경 / 옮기기 |
624
659
  | `rootr_delete_node` | 노드를 휴지통으로 이동(복구 가능) |
625
660
 
661
+ #### 페이지 (웹페이지)
662
+
663
+ 페이지(PAGE)는 **바깥 사람이 볼 화면**입니다 — 행사 안내·제품 소개·신청 받기처럼
664
+ 링크 하나로 보여 줄 것. 전용 MCP 도구(`rootr_create_page`, `rootr_read_page`,
665
+ `rootr_append_page_blocks`, `rootr_update_page`, `rootr_update_page_block`,
666
+ `rootr_read_page_live_data`)로 저작합니다.
667
+
668
+ HTML을 짜는 것이 아니라 **정해진 블록 14종을 골라 쌓습니다** — `hero` `text`
669
+ `features` `gallery` `pricing` `testimonials` `faq` `timeline` `cta` `footer` +
670
+ 표에서 값을 끌어오는 `liveStats` `liveTable` `liveChart` + 신청을 받는 `form` + 자유 블록 `custom`.
671
+
672
+ `custom`은 **격리된 방**입니다: HTML/CSS를 마음대로 쓰되 `sandbox` 아이프레임 안에서만
673
+ 그려집니다(스크립트 없음·같은 출처 아님·바깥 통신 차단). 그래서 대담한 CSS도 바깥 화면을
674
+ 못 덮습니다 — 저장할 때 `<script>`와 `on*=`는 지워집니다. 방 안에서는 페이지 분위기 색을
675
+ `var(--pg-text)` `var(--pg-card)` `var(--pg-accent)` 처럼 쓸 수 있습니다.
676
+ 블록별 props는 **`/llms.txt`의 "Pages" 섹션**에 표로 정리돼 있습니다.
677
+
678
+ ```bash
679
+ # 페이지 만들기 (블록을 생략하면 hero/text/footer 뼈대로 시작)
680
+ curl -X POST "$BASE/workspaces/$WS/pages" -H "x-api-key: $KEY" \
681
+ -H 'Content-Type: application/json' \
682
+ -d '{"name":"가을 행사","config":{"theme":"midnight"},
683
+ "blocks":[{"type":"hero","props":{"title":"가을 개발자 밋업","subtitle":"10월 12일 · 서울"}}]}'
684
+
685
+ # 블록 덧붙이기(있는 블록은 그대로) / 통째 교체 / 라이브 값 확인
686
+ curl -X POST "$BASE/pages/$ID/blocks" -d '{"blocks":[{"type":"faq","props":{"items":[...]}}]}' ...
687
+ curl -X PATCH "$BASE/pages/$ID" -d '{"config":{"theme":"warm"}}' ...
688
+ curl "$BASE/pages/$ID/live-data" -H "x-api-key: $KEY"
689
+ ```
690
+
691
+ `liveStats`/`liveTable`/`liveChart`의 `source.nodeId`는 같은 워크스페이스의
692
+ DATABASE·LOG 노드이고, 칸은 **이름**으로 가리킵니다(칸 id가 아닙니다).
693
+ `form` 블록의 `formNodeId`에 폼 노드를 걸면 방문자가 **그 자리에서 신청**합니다 —
694
+ 폼을 따로 공개할 필요가 없습니다. 비공개(PRIVATE) 서브트리에 있는 표·폼은 공개
695
+ 페이지에서 조용히 빠집니다(일부러 그렇습니다).
696
+
697
+ 공개는 여느 노드와 같습니다: `POST /v1/nodes/:id/publish` → `rootr.io/p/{slug}`.
698
+
626
699
  #### 프레젠테이션
627
700
 
628
701
  프레젠테이션(PRESENTATION)은 전용 MCP 도구(`rootr_create_presentation`,
package/bin/rootr.js CHANGED
@@ -23,6 +23,7 @@ const USAGE = `Rootr CLI v${pkg.version}
23
23
  rootr search <query> 워크스페이스 검색
24
24
  rootr mv <path|id> <새 경로|폴더 id> 문서/폴더 이동 및/또는 이름 변경
25
25
  rootr rm <path|id> [--yes] 문서/폴더를 휴지통으로 이동 (복구 가능)
26
+ rootr restore <id> 지운 것 되돌리기 (화면의 Ctrl+Z 와 같은 일 — 경로 아닌 id)
26
27
  rootr attach <file> [--doc <path|id>] [--heading "## X"] [--alt <text>] 파일 업로드(+문서에 삽입)
27
28
  rootr ws 내 워크스페이스 목록 (id\tname, 열 수 없는 곳은 표시)
28
29
  rootr use <id|이름|주소> 활성 워크스페이스 전환 (그 워크스페이스용 키도 기억)
@@ -31,6 +32,9 @@ const USAGE = `Rootr CLI v${pkg.version}
31
32
  rootr db rows <path|id> [--body] [--backlinks] [--json] 표의 행 목록 (--body: 상세 내용까지, --backlinks: 나를 가리키는 다른 표의 행까지)
32
33
  rootr db lock <path|id> [--schema managers] [--rows own] [--locked "승인,점수"] 표 자물쇠 보기·걸기
33
34
  rootr db notify <path|id> [--file rules.json] [--clear] 표 알림 규칙 보기·저장
35
+ rootr db buttons <path|id> [--file buttons.json] [--clear] 표 위 "누르면 일하는 버튼" 보기·저장
36
+ rootr db button-run <path|id> <buttonId> [--rows id,id] 버튼 한 번 누르기 (행 만들기·값 채우기·AI 채우기)
37
+ rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] 뷰 목록·달력 주/달/해 단위
34
38
  rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] 행 추가(상세 내용 포함)
35
39
  rootr db row-body <path|id> <rowId> [--file <f>] 행의 상세 내용(본문 메모) 쓰기 — 없으면 표준입력
36
40
  rootr db rows-new <path|id> --json '[{…},…]' | --file <파일> 여러 행 한 번에 넣기 (하나라도 틀리면 전부 취소)
@@ -45,6 +49,7 @@ const USAGE = `Rootr CLI v${pkg.version}
45
49
  rootr publish <path|id> --off 공개 닫기 (딸려 나간 하위도 같이 닫힘)
46
50
  rootr publish <path|id> --status 지금 공개인지·주소·하위 포함 여부
47
51
  rootr publish <표> --layout board --skin paper 공개한 표를 무슨 꼴로 보일지 (배치 9종 × 분위기 6종)
52
+ rootr publish <문서> --comments [false] [--expires <ISO>] [--skin <분위기>] 문서 전용 — 댓글(편집 제안)·만료일·스킨 16종
48
53
  rootr readreq <path|id> [--json] 읽음 확인 현황 — 누가 읽었나·완료율
49
54
  rootr readreq <path|id> --users <id,id> [--due <ISO>] [--ack] 읽어야 할 사람 지정(명단 통째로 교체)
50
55
  rootr readreq <path|id> --clear 읽음 확인 해제
@@ -353,7 +358,32 @@ async function cmdRm(args) {
353
358
 
354
359
  await client.deleteNode(id);
355
360
  console.log(`휴지통으로 옮겼습니다: ${resolved.path || id}`);
356
- console.log('복구하려면 앱의 휴지통에서 되돌리세요 (30일 뒤 자동 삭제).');
361
+ // 되돌리려면 id 가 있어야 한다 — 휴지통에 든 것은 경로로는 못 찾는다.
362
+ console.log(`되돌리려면: rootr restore ${id} (30일 뒤 자동 삭제)`);
363
+ }
364
+
365
+ /**
366
+ * UNDO-DELETE — `rootr restore <id>`
367
+ *
368
+ * 지운 것을 제자리로 되살린다. 화면의 Ctrl+Z 와 같은 일이다.
369
+ * **경로가 아니라 id** 다 — 휴지통에 든 것은 이미 트리에 없어서 경로로는 못 찾는다.
370
+ * `rootr rm` 이 마지막 줄에 그 id 를 찍어 준다.
371
+ */
372
+ async function cmdRestore(args) {
373
+ const id = args._[0];
374
+ if (!id) {
375
+ throw new Error(
376
+ '사용법: rootr restore <id>\n' +
377
+ ' (경로가 아니라 id 입니다 — 휴지통에 든 것은 경로로 못 찾습니다. rootr rm 이 찍어 준 id 를 쓰세요.)',
378
+ );
379
+ }
380
+ const client = makeClient();
381
+ const node = await client.restoreNode(id);
382
+ if (args.json) {
383
+ console.log(JSON.stringify(node, null, 2));
384
+ return;
385
+ }
386
+ console.log(`되돌렸습니다: ${node.path ?? node.name ?? id}`);
357
387
  }
358
388
 
359
389
  /** Minimal y/N prompt. Non-interactive stdin (pipes, CI) answers "no". */
@@ -1131,20 +1161,35 @@ async function cmdDbRowsRm(args) {
1131
1161
  /** PUBLIC-DB-DESIGN — 서버가 받는 값과 **같은 목록**이어야 한다. */
1132
1162
  const DB_LAYOUTS = ['table', 'cards', 'gallery', 'list', 'board', 'timeline', 'calendar', 'chart', 'ranking'];
1133
1163
  const DB_SKINS = ['clean', 'paper', 'dark', 'vivid', 'bento', 'brutal'];
1164
+ // DOCUMENT만 갖는 스킨 목록 — 서버(`public-config.ts`의 SKIN_IDS)와 같아야 한다.
1165
+ const DOC_SKINS = [
1166
+ 'classic', 'minimal', 'magazine', 'paper', 'tech-dark', 'vivid', 'manual', 'book',
1167
+ 'editorial', 'bento', 'glass', 'mesh', 'immersive', 'brutal', 'neumorph', 'y2k',
1168
+ ];
1169
+
1170
+ /** `--comments` 단독이면 true, `--comments false`면 false, 안 주면 undefined. */
1171
+ function flagToBool(v) {
1172
+ if (v === undefined) return undefined;
1173
+ return v !== 'false' && v !== '0';
1174
+ }
1134
1175
 
1135
1176
  async function cmdPublish(args) {
1136
1177
  const target = args._[0];
1137
1178
  if (!target) {
1138
1179
  throw new Error(
1139
- '사용법: rootr publish <path|id> [--children] [--view <id>] [--layout <배치>] [--skin <분위기>] | --off | --status',
1180
+ '사용법: rootr publish <path|id> [--children] [--view <id>] [--layout <배치>] [--skin <분위기>] ' +
1181
+ '[--comments] [--expires <ISO>] | --off | --status',
1140
1182
  );
1141
1183
  }
1142
1184
  const client = makeClient();
1143
1185
  const id = await resolveDatabaseId(client, target);
1186
+ // DOCUMENT는 예전(더 풍부한) 경로를 쓴다 — 댓글(편집 제안) 허용, 문서 전용 스킨
1187
+ // 16종, 만료일이 이쪽에만 있다. 노드 공통 공개 경로(표·시트 등)는 이 셋을 모른다.
1188
+ const isDocument = (await client.nodePublicStatus(id)).nodeType === 'DOCUMENT';
1144
1189
 
1145
1190
  if (args.status) {
1146
- const st = await client.nodePublicStatus(id);
1147
- if (!st.publishable) {
1191
+ const st = isDocument ? await client.getDocumentPublicStatus(id) : await client.nodePublicStatus(id);
1192
+ if (!isDocument && !st.publishable) {
1148
1193
  console.log('이 종류는 공개할 수 없습니다(폴더).');
1149
1194
  return;
1150
1195
  }
@@ -1154,21 +1199,52 @@ async function cmdPublish(args) {
1154
1199
  }
1155
1200
  console.log(`공개 중: /p/${st.slug}`);
1156
1201
  if (st.expiresAt) console.log(` 만료: ${st.expiresAt}`);
1157
- if (st.hasPassword) console.log(' 비밀번호 걸려 있음');
1158
- console.log(` 하위 문서도 공개: ${st.includeChildren ? '예' : '아니오'}`);
1159
- console.log(` 복제 허용: ${st.allowClone === false ? '아니오' : '예'}`);
1160
- if (st.design && (st.design.layout || st.design.skin)) {
1161
- console.log(` 보이는 방식: ${st.design.layout ?? 'table'} / ${st.design.skin ?? 'clean'}`);
1202
+ if (isDocument) {
1203
+ console.log(` 댓글(편집 제안) 허용: ${st.allowCommits ? '예' : '아니오'}`);
1204
+ if (st.publicConfig?.skin) console.log(` 스킨: ${st.publicConfig.skin}`);
1205
+ } else {
1206
+ if (st.hasPassword) console.log(' 비밀번호 걸려 있음');
1207
+ console.log(` 하위 문서도 공개: ${st.includeChildren ? '예' : '아니오'}`);
1208
+ console.log(` 복제 허용: ${st.allowClone === false ? '아니오' : '예'}`);
1209
+ if (st.design && (st.design.layout || st.design.skin)) {
1210
+ console.log(` 보이는 방식: ${st.design.layout ?? 'table'} / ${st.design.skin ?? 'clean'}`);
1211
+ }
1162
1212
  }
1163
1213
  return;
1164
1214
  }
1165
1215
 
1166
1216
  if (args.off) {
1167
- await client.setNodePublic(id, { isPublic: false });
1217
+ if (isDocument) await client.setDocumentPublic(id, { isPublic: false });
1218
+ else await client.setNodePublic(id, { isPublic: false });
1168
1219
  console.log('공개를 닫았습니다. 딸려 나갔던 하위 문서도 같이 닫혔습니다(주소는 남아 있습니다).');
1169
1220
  return;
1170
1221
  }
1171
1222
 
1223
+ if (isDocument) {
1224
+ if (args.skin !== undefined && !DOC_SKINS.includes(args.skin)) {
1225
+ throw new Error(`--skin 은 다음 중 하나여야 합니다: ${DOC_SKINS.join(', ')}`);
1226
+ }
1227
+ const cur = await client.getDocumentPublicStatus(id);
1228
+ if (!cur.isPublic) {
1229
+ const res = await client.publishDocument(id);
1230
+ console.log(`공개했습니다: /p/${res.slug}`);
1231
+ }
1232
+ const patch = {};
1233
+ const comments = flagToBool(args.comments);
1234
+ if (comments !== undefined) patch.allowCommits = comments;
1235
+ if (typeof args.expires === 'string') patch.expiresAt = args.expires;
1236
+ if (typeof args.skin === 'string') patch.skin = args.skin;
1237
+ if (Object.keys(patch).length) {
1238
+ await client.setDocumentPublic(id, patch);
1239
+ if (patch.allowCommits !== undefined) {
1240
+ console.log(` 댓글(편집 제안) 허용: ${patch.allowCommits ? '예' : '아니오'}`);
1241
+ }
1242
+ if (patch.expiresAt !== undefined) console.log(` 만료: ${patch.expiresAt}`);
1243
+ if (patch.skin !== undefined) console.log(` 스킨: ${patch.skin}`);
1244
+ }
1245
+ return;
1246
+ }
1247
+
1172
1248
  // PUBLIC-DB-DESIGN — 표는 표 모양으로만 보일 이유가 없다. 준 것만 바뀐다.
1173
1249
  const design = {
1174
1250
  ...(typeof args.layout === 'string' ? { layout: args.layout } : {}),
@@ -1274,9 +1350,12 @@ async function cmdDb(args) {
1274
1350
  if (sub === 'row-page-type') return cmdDbRowPageType(args);
1275
1351
  if (sub === 'lock') return cmdDbLock(args);
1276
1352
  if (sub === 'notify') return cmdDbNotify(args);
1353
+ if (sub === 'buttons') return cmdDbButtons(args);
1354
+ if (sub === 'button-run') return cmdDbButtonRun(args);
1355
+ if (sub === 'view') return cmdDbView(args);
1277
1356
  if (sub !== 'create') {
1278
1357
  throw new Error(
1279
- '사용법: 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)',
1358
+ '사용법: rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|row-page|row-page-type|lock|notify|buttons|button-run|view … (자세한 건 rootr --help)',
1280
1359
  );
1281
1360
  }
1282
1361
  const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
@@ -1390,6 +1469,175 @@ async function cmdDbNotify(args) {
1390
1469
  }
1391
1470
  }
1392
1471
 
1472
+ /**
1473
+ * `rootr db view <path|id> <뷰이름|뷰id> [--scale week|month|year] [--name 새이름]`
1474
+ *
1475
+ * 뷰 하나만 고친다. 인자 없이 부르면 이 표의 뷰 목록을 보여 준다.
1476
+ * `--scale` 은 **달력 뷰**가 주·달·해 중 무엇으로 열릴지다(기본 달).
1477
+ * 해 보기는 열두 달을 한 번에 보여 주고, 고른 단위는 뷰에 저장돼 모두에게 그대로 보인다.
1478
+ */
1479
+ async function cmdDbView(args) {
1480
+ const target = args._[1];
1481
+ if (!target) throw new Error('사용법: rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] [--json]');
1482
+
1483
+ const client = makeClient();
1484
+ const id = await resolveDatabaseId(client, target);
1485
+ const viewRef = args._[2];
1486
+
1487
+ if (!viewRef) {
1488
+ const db = await client.getDatabase(id);
1489
+ const views = db.views ?? [];
1490
+ if (args.json) {
1491
+ console.log(JSON.stringify(views, null, 2));
1492
+ return;
1493
+ }
1494
+ if (!views.length) {
1495
+ console.log('뷰가 없습니다.');
1496
+ return;
1497
+ }
1498
+ const SPAN = { week: '주', month: '달', year: '해' };
1499
+ for (const v of views) {
1500
+ const span = v.type === 'calendar' ? ` · ${SPAN[v.config?.calendarScale] ?? '달'} 단위` : '';
1501
+ console.log(`- ${v.name} (${v.type})${span} ${v.id}`);
1502
+ }
1503
+ return;
1504
+ }
1505
+
1506
+ const patch = {};
1507
+ if (args.scale !== undefined) {
1508
+ const scale = String(args.scale);
1509
+ if (!['week', 'month', 'year'].includes(scale)) {
1510
+ throw new Error('--scale 은 week · month · year 중 하나여야 합니다.');
1511
+ }
1512
+ patch.calendarScale = scale;
1513
+ }
1514
+ if (typeof args.name === 'string') patch.name = args.name;
1515
+ if (!Object.keys(patch).length) {
1516
+ throw new Error('바꿀 것을 주세요: --scale week|month|year 또는 --name 새이름');
1517
+ }
1518
+
1519
+ const view = await client.updateDatabaseView(id, viewRef, patch);
1520
+ if (args.json) {
1521
+ console.log(JSON.stringify(view, null, 2));
1522
+ return;
1523
+ }
1524
+ const SPAN = { week: '주', month: '달', year: '해' };
1525
+ const span = view.type === 'calendar' ? ` · ${SPAN[view.config?.calendarScale] ?? '달'} 단위` : '';
1526
+ console.log(`뷰를 고쳤습니다: ${view.name} (${view.type})${span}`);
1527
+ }
1528
+
1529
+
1530
+ /**
1531
+ * DB-BUTTON — 표 위의 "누르면 일하는 버튼".
1532
+ *
1533
+ * rootr db buttons <path|id> 지금 달린 버튼 보기
1534
+ * rootr db buttons <path|id> --file b.json 버튼 목록을 통째로 갈아끼우기
1535
+ * rootr db buttons <path|id> --clear 버튼 전부 없애기
1536
+ *
1537
+ * `--file` 은 **통째로 갈아끼운다** — 남길 버튼도 파일에 같이 있어야 한다.
1538
+ * 파일 모양: [{ "label":"접수", "icon":"📥",
1539
+ * "steps":[{"kind":"add_row","values":{"상태":"접수","담당":"@me","마감":"@today+7"}}] }]
1540
+ */
1541
+ async function cmdDbButtons(args) {
1542
+ const target = args._[1];
1543
+ if (!target) throw new Error('사용법: rootr db buttons <path|id> [--file buttons.json] [--clear] [--json]');
1544
+
1545
+ const client = makeClient();
1546
+ const id = await resolveDatabaseId(client, target);
1547
+
1548
+ if (args.clear) {
1549
+ const cleared = await client.setDatabaseButtons(id, []);
1550
+ console.log('버튼을 모두 지웠습니다.');
1551
+ if (args.json) console.log(JSON.stringify(cleared, null, 2));
1552
+ return;
1553
+ }
1554
+ if (args.file) {
1555
+ const raw = readFileSync(args.file, 'utf8');
1556
+ let buttons;
1557
+ try {
1558
+ buttons = JSON.parse(raw);
1559
+ } catch {
1560
+ throw new Error('버튼 파일을 JSON으로 읽을 수 없습니다. [{ "label": "...", "steps": [...] }] 형태여야 합니다.');
1561
+ }
1562
+ if (!Array.isArray(buttons)) throw new Error('버튼 파일은 배열이어야 합니다(버튼 목록 전체).');
1563
+ const saved = await client.setDatabaseButtons(id, buttons);
1564
+ console.log(`버튼 ${saved.buttons.length}개를 저장했습니다.`);
1565
+ if (args.json) console.log(JSON.stringify(saved, null, 2));
1566
+ return;
1567
+ }
1568
+
1569
+ const { buttons, myLevel } = await client.listDatabaseButtons(id);
1570
+ if (args.json) {
1571
+ console.log(JSON.stringify(buttons, null, 2));
1572
+ return;
1573
+ }
1574
+ if (!buttons.length) {
1575
+ console.log('버튼이 없습니다.');
1576
+ return;
1577
+ }
1578
+ const WHAT = {
1579
+ add_row: '행 만들기',
1580
+ update_rows: '값 바꾸기',
1581
+ ai_fill: 'AI로 칸 채우기',
1582
+ open_row: '만든 행 열기',
1583
+ };
1584
+ const WHERE = { created: '방금 만든 행', selected: '고른 행' };
1585
+ for (const b of buttons) {
1586
+ console.log(`${b.icon ? b.icon + ' ' : ''}${b.label} (${b.id})`);
1587
+ for (const [i, st] of (b.steps ?? []).entries()) {
1588
+ const what = WHAT[st.kind] ?? st.kind;
1589
+ const where = st.target ? ` · ${WHERE[st.target] ?? st.target}` : '';
1590
+ const detail =
1591
+ st.kind === 'ai_fill'
1592
+ ? ` · ${st.propId} 칸 ← "${st.prompt}"`
1593
+ : st.values
1594
+ ? ' · ' + Object.entries(st.values).map(([k, v]) => `${k}=${v}`).join(', ')
1595
+ : '';
1596
+ console.log(` ${i + 1}. ${what}${where}${detail}`);
1597
+ }
1598
+ }
1599
+ if (myLevel && myLevel !== 'MANAGE' && myLevel !== 'FULL') {
1600
+ console.log('\n(버튼을 고치려면 이 표의 관리 권한이 필요합니다. 누르는 것은 지금 권한으로도 됩니다.)');
1601
+ }
1602
+ }
1603
+
1604
+ /**
1605
+ * `rootr db button-run <path|id> <buttonId> [--rows id1,id2]`
1606
+ *
1607
+ * 버튼을 한 번 누른다. `--rows` 는 "고른 행"을 대상으로 하는 단계가 있을 때만 쓴다.
1608
+ * 누른 사람 권한 그대로 돈다 — 잠긴 칸을 건드리는 버튼은 거기서 막힌다.
1609
+ */
1610
+ async function cmdDbButtonRun(args) {
1611
+ const target = args._[1];
1612
+ const buttonId = args._[2];
1613
+ if (!target || !buttonId) {
1614
+ throw new Error('사용법: rootr db button-run <path|id> <buttonId> [--rows id1,id2] [--json]');
1615
+ }
1616
+ const client = makeClient();
1617
+ const id = await resolveDatabaseId(client, target);
1618
+ const rowIds =
1619
+ typeof args.rows === 'string'
1620
+ ? args.rows.split(',').map((x) => x.trim()).filter(Boolean)
1621
+ : undefined;
1622
+
1623
+ const r = await client.runDatabaseButton(id, buttonId, { rowIds });
1624
+ if (args.json) {
1625
+ console.log(JSON.stringify(r, null, 2));
1626
+ return;
1627
+ }
1628
+ // created 는 **만든 행의 id 목록**이다(개수가 아니다) — 세어서 보여 준다.
1629
+ const madeCount = Array.isArray(r.created) ? r.created.length : (r.created ?? 0);
1630
+ const parts = [];
1631
+ if (madeCount) parts.push(`행 ${madeCount}개 만듦`);
1632
+ if (r.updated) parts.push(`행 ${r.updated}개 고침`);
1633
+ if (r.filled) parts.push(`AI로 칸 ${r.filled}개 채움`);
1634
+ console.log(parts.length ? parts.join(' · ') : '바뀐 것이 없습니다.');
1635
+ if (r.openRowId) console.log(`열 행: ${r.openRowId}`);
1636
+ // AI가 못 채워도 버튼은 계속 돈다 — 조용히 넘어가면 사람이 왜 빈칸인지 모른다.
1637
+ if (r.aiFailed) console.log(`AI가 못 채운 칸 ${r.aiFailed}개${r.aiError ? ` — ${r.aiError}` : ''}`);
1638
+ }
1639
+
1640
+
1393
1641
  async function cmdDbRows(args) {
1394
1642
  const target = args._[1];
1395
1643
  if (!target) throw new Error('사용법: rootr db rows <path|id> [--body] [--backlinks] [--json]');
@@ -1823,6 +2071,8 @@ async function main() {
1823
2071
  return cmdMv(args);
1824
2072
  case 'rm':
1825
2073
  return cmdRm(args);
2074
+ case 'restore':
2075
+ return cmdRestore(args);
1826
2076
  case 'ws':
1827
2077
  return cmdWs(args);
1828
2078
  case 'ask':
package/lib/client.js CHANGED
@@ -350,11 +350,19 @@ export class RootrClient {
350
350
  return this.request(`/nodes/${encodeURIComponent(id)}`, { method: 'DELETE' });
351
351
  }
352
352
 
353
+ /**
354
+ * UNDO-DELETE — 휴지통에 든 것을 제자리로 되살린다.
355
+ * 화면의 Ctrl+Z 와 같은 일을 하는 자리다. 지운 사람이 아니어도 쓰기 권한이면 된다.
356
+ */
357
+ async restoreNode(id) {
358
+ return this.request(`/nodes/${encodeURIComponent(id)}/restore`, { method: 'POST' });
359
+ }
360
+
353
361
  /** Rename and/or move a node. `parentId: null` moves it to the workspace root. */
354
- async updateNode(id, { name, icon, tags, parentId, position } = {}) {
362
+ async updateNode(id, { name, icon, tags, parentId, position, graphSyncOff } = {}) {
355
363
  return this.request(`/nodes/${encodeURIComponent(id)}`, {
356
364
  method: 'PATCH',
357
- body: { name, icon, tags, parentId, position },
365
+ body: { name, icon, tags, parentId, position, graphSyncOff },
358
366
  });
359
367
  }
360
368
 
@@ -443,6 +451,32 @@ export class RootrClient {
443
451
  });
444
452
  }
445
453
 
454
+ // ── DB-BUTTON — 표 위에 두는 "누르면 일하는 버튼" ────────────────────────
455
+ //
456
+ // 화면에서만 만들 수 있으면, 에이전트는 표를 지어 놓고도 "접수 버튼 하나 달아
457
+ // 줘"를 못 한다. 만들 수 있으면 눌러 줄 수도 있어야 한다.
458
+
459
+ /** 이 표의 버튼 목록. `myLevel` 로 내가 고칠 수 있는지도 함께 온다. */
460
+ async listDatabaseButtons(databaseId) {
461
+ return this.request(`/databases/${encodeURIComponent(databaseId)}/buttons`);
462
+ }
463
+
464
+ /** 버튼 목록을 **통째로** 갈아끼운다(MANAGE 필요). 남길 버튼도 같이 보내야 한다. */
465
+ async setDatabaseButtons(databaseId, buttons) {
466
+ return this.request(`/databases/${encodeURIComponent(databaseId)}/buttons`, {
467
+ method: 'PUT',
468
+ body: { buttons },
469
+ });
470
+ }
471
+
472
+ /** 버튼 한 번 누르기. `rowIds` 는 "고른 행"을 대상으로 하는 단계가 있을 때만 쓴다. */
473
+ async runDatabaseButton(databaseId, buttonId, { rowIds } = {}) {
474
+ return this.request(
475
+ `/databases/${encodeURIComponent(databaseId)}/buttons/${encodeURIComponent(buttonId)}/run`,
476
+ { method: 'POST', body: { rowIds } },
477
+ );
478
+ }
479
+
446
480
  // ── PUBLIC-DOCS — 발행한 문서에 올라온 변경 제안 ────────────────────────
447
481
 
448
482
  async listDocumentCommits(documentId, status = 'OPEN') {
@@ -760,10 +794,13 @@ export class RootrClient {
760
794
  * building a table by adding columns one at a time made the order depend on
761
795
  * the order of the calls, which is how migrated tables came out scrambled.
762
796
  */
763
- async createDatabase(workspace, { name, parentId, path, createParents, autoRename, icon, properties, views, rows, rowPageType } = {}) {
797
+ async createDatabase(
798
+ workspace,
799
+ { name, parentId, path, createParents, autoRename, icon, properties, views, rows, rowPageType, graphSync } = {},
800
+ ) {
764
801
  return this.request(`/workspaces/${encodeURIComponent(workspace)}/databases`, {
765
802
  method: 'POST',
766
- body: { name, parentId, path, createParents, autoRename, icon, properties, views, rows, rowPageType },
803
+ body: { name, parentId, path, createParents, autoRename, icon, properties, views, rows, rowPageType, graphSync },
767
804
  });
768
805
  }
769
806
 
@@ -784,11 +821,11 @@ export class RootrClient {
784
821
  */
785
822
  async createDatabaseView(
786
823
  databaseId,
787
- { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths } = {},
824
+ { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale } = {},
788
825
  ) {
789
826
  return this.request(`/databases/${encodeURIComponent(databaseId)}/views`, {
790
827
  method: 'POST',
791
- body: { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths },
828
+ body: { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale },
792
829
  });
793
830
  }
794
831
 
@@ -1159,6 +1196,36 @@ export class RootrClient {
1159
1196
  });
1160
1197
  }
1161
1198
 
1199
+ // ---- Pages (block-assembled web pages) ----------------------------------
1200
+
1201
+ /** PAGE 노드를 한 번에 만든다(자리 + 블록 + 테마). */
1202
+ async createPage(workspaceId, { name, parentId, path, createParents, autoRename, icon, blocks, config } = {}) {
1203
+ return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/pages`, {
1204
+ method: 'POST',
1205
+ body: { name, parentId, path, createParents, autoRename, icon, blocks, config },
1206
+ });
1207
+ }
1208
+
1209
+ /** 페이지 정본: 블록 배열 + 설정(테마·강조색). */
1210
+ async getPage(pageId) {
1211
+ return this.request(`/pages/${encodeURIComponent(pageId)}`);
1212
+ }
1213
+
1214
+ /** blocks를 주면 **통째로 교체**된다 — 남길 블록도 같이 보내야 한다. */
1215
+ async updatePage(pageId, { blocks, config } = {}) {
1216
+ return this.request(`/pages/${encodeURIComponent(pageId)}`, { method: 'PATCH', body: { blocks, config } });
1217
+ }
1218
+
1219
+ /** 맨 뒤에 덧붙이기 — 있는 블록은 그대로 둔다. */
1220
+ async appendPageBlocks(pageId, blocks) {
1221
+ return this.request(`/pages/${encodeURIComponent(pageId)}/blocks`, { method: 'POST', body: { blocks } });
1222
+ }
1223
+
1224
+ /** 라이브 블록이 지금 무엇을 보여 주는가(집계·행·차트 값). */
1225
+ async getPageLiveData(pageId) {
1226
+ return this.request(`/pages/${encodeURIComponent(pageId)}/live-data`);
1227
+ }
1228
+
1162
1229
  // ---- Presentations (slide decks) ----------------------------------------
1163
1230
 
1164
1231
  async createPresentation(workspaceId, { name, parentId, theme, slides, config, icon } = {}) {
@@ -1340,7 +1407,7 @@ export class RootrClient {
1340
1407
  });
1341
1408
  }
1342
1409
 
1343
- /** Full spreadsheet: node meta + sheets[] (each with a sparse `cells` map). */
1410
+ /** Full spreadsheet: node meta + data.sheets[] (each with a sparse `cells` map). */
1344
1411
  async getSpreadsheet(spreadsheetId) {
1345
1412
  return this.request(`/spreadsheets/${encodeURIComponent(spreadsheetId)}`);
1346
1413
  }