rootr-cli 0.25.0 → 0.29.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.ko.md +53 -3
- package/README.md +62 -4
- package/bin/rootr.js +667 -13
- package/lib/client.js +131 -7
- package/lib/domain-billing.js +52 -0
- package/lib/mcp-tools/booking.js +11 -1
- package/lib/mcp-tools/databases.js +423 -10
- package/lib/mcp-tools/documents.js +17 -1
- package/lib/mcp-tools/domains.js +236 -0
- package/lib/mcp-tools/forms.js +20 -2
- package/lib/mcp-tools/pages.js +21 -4
- package/lib/mcp-tools/public.js +13 -0
- package/lib/mcp-tools/workspaces.js +71 -1
- package/lib/mcp.js +2 -0
- package/package.json +1 -1
package/bin/rootr.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { dirname, join, basename, extname } from 'node:path';
|
|
5
5
|
|
|
@@ -9,6 +9,7 @@ import { RootrClient, RootrApiError, imageMarkdown } from '../lib/client.js';
|
|
|
9
9
|
import { setGlobalDocMailSink } from '../lib/context.js';
|
|
10
10
|
import { resolveTarget, requireResolved, resolveMoveDestination } from '../lib/resolve.js';
|
|
11
11
|
import { t as tr, lang } from '../lib/i18n.js';
|
|
12
|
+
import { domainPaymentHelp, slotLine } from '../lib/domain-billing.js';
|
|
12
13
|
|
|
13
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -23,6 +24,8 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
23
24
|
rootr embed <문서 path|id> <끼울 노드 path|id> [--heading "## X"] 문서 안에 표·시트·발표자료를 **살아 있는 채로** 끼워 넣기
|
|
24
25
|
rootr edit <path|id> --find <text> --replace <text> [--all] 본문 내 텍스트 치환
|
|
25
26
|
rootr comments <path|id> [--block <id>] [--json] 문서에 달린 댓글 읽기 (공개 페이지 댓글 포함)
|
|
27
|
+
rootr members [--workspace <id>] [--json] 워크스페이스 구성원 목록 (id\t이름\t이메일\t역할)
|
|
28
|
+
rootr message <userId> <text> [--workspace <id>] 구성원에게 바로 메시지 보내기 (문서/이슈에 매이지 않음)
|
|
26
29
|
rootr search <query> 워크스페이스 검색
|
|
27
30
|
rootr mv <path|id> <새 경로|폴더 id> 문서/폴더 이동 및/또는 이름 변경
|
|
28
31
|
rootr rm <path|id> [--yes] 문서/폴더를 휴지통으로 이동 (복구 가능)
|
|
@@ -40,7 +43,8 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
40
43
|
rootr db button-run <path|id> <buttonId> [--rows id,id] 버튼 한 번 누르기 (행 만들기·값 채우기·AI 채우기)
|
|
41
44
|
rootr db automations <path|id> [--file rules.json] [--clear] 자동 규칙 — "값이 이렇게 되면 이 일을 해라"·정해진 시각
|
|
42
45
|
rootr db import-sheets <파일.xlsx> --ws <id> [--dry] 시트 수백 장짜리 엑셀을 표 하나로 접어 가져오기
|
|
43
|
-
rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] [--cards "칸,칸"] 뷰 목록·달력 주/달/해 단위·보드 카드에 보일 칸
|
|
46
|
+
rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] [--cards "칸,칸"] 뷰 목록·달력 주/달/해 단위·보드 카드에 보일 칸 ·[--density compact|normal] [--freeze 0~3] 촘촘하게·왼쪽 칸 고정
|
|
47
|
+
rootr db column <path|id> <칸> [--align left|center|right] [--wrap on|off] [--overdue on|off] [--time on|off] [--range on|off] 칸 정렬·줄바꿈·지난 날짜 빨강·시간·기간
|
|
44
48
|
rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] 행 추가(상세 내용 포함)
|
|
45
49
|
rootr db row-body <path|id> <rowId> [--file <f>] 행의 상세 내용(본문 메모) 쓰기 — 없으면 표준입력
|
|
46
50
|
rootr db rows-new <path|id> --json '[{…},…]' | --file <파일> 여러 행 한 번에 넣기 (하나라도 틀리면 전부 취소)
|
|
@@ -50,6 +54,14 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
50
54
|
rootr db duplicates <path|id> (--row <rowId> | --values '<json>') [--json] 같은 사람(중복 감지 칸 값이 같은 행)과 그 이력
|
|
51
55
|
rootr db merge <path|id> <rowId> --into <기존 rowId> 중복 행을 기존 행에 합치기(빈 칸만 채움, rowId 행은 지움)
|
|
52
56
|
rootr db keep-duplicate <path|id> <rowId> --from <이전 rowId> 새 행으로 두되 이전 행과 서로 링크
|
|
57
|
+
rootr db rename-col <path|id> <칸 이름|id> <새 이름> [--dry] 칸 이름 바꾸기 — 수식·버튼/규칙 {칸}·다른 표 롤업·폼 질문·페이지 블록도 함께(--dry: 무엇이 바뀌는지만)
|
|
58
|
+
rootr db carry-over <path|id> <rowId> [--from <이전 rowId>] 이전 정보 가져오기 — 같은 사람의 이전 행 값을 새 행으로(이전 행은 그대로, 이름은 표의 dedupeCarry.label)
|
|
59
|
+
rootr db dedupe-preview <path|id> <rowId> [--json '<규칙 덮기>'] 중복 처리 미리 보기 — 칸별 전·후·출처, 지울 행(쓰지 않음)
|
|
60
|
+
rootr db dedupe <path|id> <rowId> [--action carry|merge_into_old|merge_all] [--json '<규칙 덮기>'] 중복 처리 적용(opId 로 되돌림)
|
|
61
|
+
rootr db dedupe-undo <path|id> <opId> 중복 처리 한 번을 통째로 되돌리기(지운 행 되살림)
|
|
62
|
+
rootr db column-set <path|id> <칸 이름|id> --json '<설정>' 칸 설정 바꾸기 — 예: '{"validate":{"kind":"pattern","pattern":"[A-Z]{2}-\\\\d{4}","message":"사번 꼴이 아닙니다"}}', '{"dedupeCarry":{…}}'
|
|
63
|
+
rootr db rule-violations <path|id> [--limit N] [--json] 지금 값 가운데 칸 검사(validate)에 안 맞는 것(고치지 않는다)
|
|
64
|
+
rootr db rule-test --json '<규칙>' <값> [<값>…] [--type number] 값 검사 규칙을 걸기 전에 시험(저장 안 함) · --presets: 예시 꼴 목록
|
|
53
65
|
rootr db history <path|id> [--row <rowId>] [--limit N] [--json] 행·표 변경 이력(누가·언제·어떻게·전후 값)
|
|
54
66
|
rootr db restore <path|id> --at <ISO 시각> [--yes] 표 전체(행+칸·보기·버튼·규칙)를 그 시각으로 — --yes 없으면 미리보기만
|
|
55
67
|
rootr db row-page <rowId> [--type RECORDING] 이 행만 원하는 타입으로 상세 붙이기(녹음·스프레드시트·서명문서…)
|
|
@@ -61,6 +73,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
61
73
|
rootr publish <path|id> --status 지금 공개인지·주소·하위 포함 여부
|
|
62
74
|
rootr publish <표> --layout board --skin paper 공개한 표를 무슨 꼴로 보일지 (배치 9종 × 분위기 6종)
|
|
63
75
|
rootr publish <문서> --comments [false] [--expires <ISO>] [--skin <분위기>] 문서 전용 — 댓글(편집 제안)·만료일·스킨 16종
|
|
76
|
+
rootr publish <path|id> --description "<글>" --cover <그림|id|none> 검색 설명문·링크 미리보기 대표 이미지 (검색엔진에 바로 알림)
|
|
64
77
|
rootr booking show <표> 방문자 일정 등록(예약) 설정 보기
|
|
65
78
|
rootr booking set <표> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 예약 켜기·규칙
|
|
66
79
|
rootr booking set <표> --ask "연락처:required" --approval manual 방문자에게 더 묻기 · 승인 후 확정
|
|
@@ -80,6 +93,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
80
93
|
rootr form collect <path|id> --to db|sheet 응답 모을 곳을 새로 만들어 연결 (표는 질문 그대로 칸 생성)
|
|
81
94
|
rootr form mapping <path|id> [--set '<{"질문id":"칸id"}>'] 질문이 표의 어느 칸으로 가는지 보기·정하기 (짝 없으면 값이 버려짐)
|
|
82
95
|
rootr form responses <path|id> [--latest] [--json] 폼에 들어온 응답 보기 (--latest: 사람마다 최신 1건만)
|
|
96
|
+
rootr form submit <path|id> [--values '<json>'] [--files '{"질문id":["a.pdf","b.pdf"]}'] 응답 내기 (파일은 올린 뒤 답으로 붙임 — 여러 개 받는 질문은 배열로)
|
|
83
97
|
rootr form response-edit <응답id> --values '<json>' 이미 낸 응답 고치기 (폼 검사 규칙 다시 통과)
|
|
84
98
|
rootr form response-rm <응답id> [--yes] 응답 한 건 삭제 (되돌릴 수 없음)
|
|
85
99
|
rootr sign new <경로> [--file <f>] [--reviewer <userId>] [--line <line.json>] 서명문서 만들기(본문·결재라인까지)
|
|
@@ -106,6 +120,14 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
106
120
|
rootr mail webhook add <url> [--events …] | list | rm <id> | test <id>
|
|
107
121
|
받은 문서·회수·읽음을 내 주소로 알려 주는 웹훅(내 계정 것, HMAC 서명)
|
|
108
122
|
※ 다른 명령 뒤 새로 받은 문서가 있으면 stderr 로 한 줄 알림(끄기: ROOTR_MAIL_NOTICE=0)
|
|
123
|
+
rootr domain list 내 도메인 목록 — 종류(내 도메인·무료 주소)·상태와 가리키는 페이지, 자리 사용(유료 n/m·무료 n/3)
|
|
124
|
+
rootr domain detect <도메인> [--provider <업체>] 등록 전에 — DNS 를 어디서 관리하는지(가비아·Cloudflare…)와 넣을 한 줄·단계 안내
|
|
125
|
+
rootr domain add <도메인> <페이지·문서 path|id> [--provider <업체>] [--confirm-charge] 발행한 페이지·문서를 내 도메인(www.회사.co.kr)으로 연결 (Team 플랜, 도메인 1개당 월 $10·연 결제 시 월 $8 — 자리가 꽉 찼으면 --confirm-charge 로 1개 추가 결제)
|
|
126
|
+
rootr domain add <이름>.rootr.site <페이지 path|id> 무료 주소로 바로 열기(Team 플랜, DNS·결제 없음, 3개까지) — --subdomain <이름> 으로도
|
|
127
|
+
rootr domain available <이름> 무료 주소 이름(이름.rootr.site)을 쓸 수 있는지
|
|
128
|
+
rootr domain check <id> 지금 다시 확인(연결 전이면 exit 2)
|
|
129
|
+
rootr domain rename <id> <새 도메인> [--provider <업체>] 연결 전 도메인의 이름 바꾸기(결제 그대로, 새 id 가 나옴)
|
|
130
|
+
rootr domain remove <id> 연결 해제
|
|
109
131
|
rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] 설정 조회/저장
|
|
110
132
|
rootr config --add-key <k> [--label <name>] [--workspace <w>] 워크스페이스별 키 추가 등록
|
|
111
133
|
rootr feedback "<무엇이 안 되는지>" [--kind bug|friction|idea|praise] [--email <주소>]
|
|
@@ -119,7 +141,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
119
141
|
rootr memory add "<내용>" [--kind 지시|사람|프로젝트|참고] [--source <경로|id>] [--related a,b] 기억 저장
|
|
120
142
|
rootr memory edit <id> [--content "<내용>"] [--kind …] [--source …] [--related a,b] 기억 고치기(준 칸만)
|
|
121
143
|
rootr memory forget <id> [--hard] 기억 폐기(--hard면 행 삭제)
|
|
122
|
-
rootr mcp MCP stdio 서버 구동 (도구
|
|
144
|
+
rootr mcp MCP stdio 서버 구동 (도구 214종 — README 참고)
|
|
123
145
|
rootr --help 도움말
|
|
124
146
|
rootr --version 버전 출력
|
|
125
147
|
|
|
@@ -140,6 +162,8 @@ usage:
|
|
|
140
162
|
rootr embed <doc path|id> <node path|id> [--heading "## X"] embed a LIVE table/sheet/deck inside a document
|
|
141
163
|
rootr edit <path|id> --find <text> --replace <text> [--all] replace text inside the body
|
|
142
164
|
rootr comments <path|id> [--block <id>] [--json] read comments (including ones left on the public page)
|
|
165
|
+
rootr members [--workspace <id>] [--json] list workspace members (id\tname\temail\trole)
|
|
166
|
+
rootr message <userId> <text> [--workspace <id>] send a direct message to a member (not tied to a doc/issue)
|
|
143
167
|
rootr search <query> search the workspace
|
|
144
168
|
rootr mv <path|id> <new path|folder id> move and/or rename a document or folder
|
|
145
169
|
rootr rm <path|id> [--yes] move to the trash (recoverable)
|
|
@@ -157,7 +181,8 @@ usage:
|
|
|
157
181
|
rootr db button-run <path|id> <buttonId> [--rows id,id] press a button once (create rows, fill values, AI fill)
|
|
158
182
|
rootr db automations <path|id> [--file rules.json] [--clear] automatic rules — "when a value becomes X, do Y", or at a set time
|
|
159
183
|
rootr db import-sheets <file.xlsx> --ws <id> [--dry] fold a one-sheet-per-record workbook into ONE table
|
|
160
|
-
rootr db view <path|id> [view name] [--scale week|month|year] [--name <new name>] list views; calendar week/month/year
|
|
184
|
+
rootr db view <path|id> [view name] [--scale week|month|year] [--name <new name>] list views; calendar week/month/year [--density compact|normal] [--freeze 0-3] compact rows · frozen left columns
|
|
185
|
+
rootr db column <path|id> <column> [--align left|center|right] [--wrap on|off] [--overdue on|off] [--time on|off] [--range on|off] column alignment · wrapping · overdue red · time · end date
|
|
161
186
|
rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] add a row (with its body)
|
|
162
187
|
rootr db row-body <path|id> <rowId> [--file <f>] write a row's body — reads stdin if no file
|
|
163
188
|
rootr db rows-new <path|id> --json '[{…},…]' | --file <file> insert many rows at once (one bad row cancels all)
|
|
@@ -165,8 +190,18 @@ usage:
|
|
|
165
190
|
rootr db rows-each <path|id> --json '[{"rowId":…,"values":{…}},…]' a different value per row, in one call
|
|
166
191
|
rootr db rows-rm <path|id> --ids <id,id> [--yes] delete chosen rows at once — one blocked row rejects all
|
|
167
192
|
rootr db duplicates <path|id> (--row <rowId> | --values '<json>') [--json] rows of the same person (same dedupe value) with history
|
|
193
|
+
rootr db pick <path|id> [name] [--limit N] [--json] find rows to link — whole table, newest first, with the identify line (phone last 4 · date)
|
|
194
|
+
rootr db identify <path|id> <col[:last4],col…> | --clear columns shown next to a row's title when another table picks it (same-name people)
|
|
168
195
|
rootr db merge <path|id> <rowId> --into <existing rowId> merge a duplicate into the existing row (fills blanks, deletes rowId)
|
|
169
196
|
rootr db keep-duplicate <path|id> <rowId> --from <earlier rowId> keep as a new row but link both rows
|
|
197
|
+
rootr db rename-col <path|id> <column name|id> <new name> [--dry] rename a column — formulas, button/rule {col}, other tables' rollups, form questions and page blocks follow (--dry: list only)
|
|
198
|
+
rootr db carry-over <path|id> <rowId> [--from <earlier rowId>] carry the person's previous row into this row (earlier row untouched)
|
|
199
|
+
rootr db dedupe-preview <path|id> <rowId> [--json '<rule overrides>'] preview duplicate handling — per-column before/after/source, rows to delete
|
|
200
|
+
rootr db dedupe <path|id> <rowId> [--action carry|merge_into_old|merge_all] [--json '<rule overrides>'] apply duplicate handling (undo by opId)
|
|
201
|
+
rootr db dedupe-undo <path|id> <opId> undo one duplicate-handling operation (recreates deleted rows)
|
|
202
|
+
rootr db column-set <path|id> <column name|id> --json '<settings>' change a column's settings, e.g. '{"validate":{"kind":"pattern","pattern":"[A-Z]{2}-\\\\d{4}","message":"not an employee id"}}'
|
|
203
|
+
rootr db rule-violations <path|id> [--limit N] [--json] current values that break a column's validate rule (nothing is changed)
|
|
204
|
+
rootr db rule-test --json '<rule>' <value> [<value>…] [--type number] try a validate rule on sample values (saves nothing) · --presets: example patterns
|
|
170
205
|
rootr db history <path|id> [--row <rowId>] [--limit N] [--json] change history (who, when, how, before/after)
|
|
171
206
|
rootr db restore <path|id> --at <ISO time> [--yes] restore the whole table (rows + columns/views/buttons/rules) — preview without --yes
|
|
172
207
|
rootr db row-page <rowId> [--type RECORDING] give this row a typed detail page (recording, spreadsheet, signed doc…)
|
|
@@ -178,6 +213,7 @@ usage:
|
|
|
178
213
|
rootr publish <path|id> --status is it public, at what URL, children included?
|
|
179
214
|
rootr publish <table> --layout board --skin paper how a published table looks (9 layouts × 6 skins)
|
|
180
215
|
rootr publish <doc> --comments [false] [--expires <ISO>] [--skin <skin>] documents only — comments, expiry, 16 skins
|
|
216
|
+
rootr publish <path|id> --description "<text>" --cover <image|id|none> search description & link-preview cover image (search engines pinged)
|
|
181
217
|
rootr booking show <table> show visitor booking settings
|
|
182
218
|
rootr booking set <table> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 turn on + rules
|
|
183
219
|
rootr booking set <table> --ask "Phone:required" --approval manual ask more · confirm after approval
|
|
@@ -196,6 +232,7 @@ usage:
|
|
|
196
232
|
rootr form set <path|id> --fields '<json>' | --config '<json>' change questions/settings (settings merge)
|
|
197
233
|
rootr form collect <path|id> --to db|sheet create and wire a destination (a table gets a column per question)
|
|
198
234
|
rootr form responses <path|id> [--latest] [--json] read responses (--latest: newest one per person)
|
|
235
|
+
rootr form submit <path|id> [--values '<json>'] [--files '{"fieldId":["a.pdf","b.pdf"]}'] submit a response (files are uploaded, then attached — several for a "multiple" file question)
|
|
199
236
|
rootr form response-edit <responseId> --values '<json>' edit a submitted response (revalidated against the form)
|
|
200
237
|
rootr form response-rm <responseId> [--yes] delete one response (cannot be undone)
|
|
201
238
|
rootr sign new <path> [--file <f>] [--reviewer <userId>] [--line <line.json>] create a signed document (body + approval line)
|
|
@@ -219,6 +256,14 @@ usage:
|
|
|
219
256
|
rootr mail access <id> read|comment|write change what the recipient of a document you sent can do
|
|
220
257
|
rootr mail revoke <id> withdraw a document you sent — the recipient can no longer open the original
|
|
221
258
|
rootr mail hide <id> [--box inbox|sent] remove from your own mailbox (a received one then stops opening for your account)
|
|
259
|
+
rootr domain list your custom domains — status (waiting for DNS / issuing certificate / connected) and page
|
|
260
|
+
rootr domain detect <domain> [--provider <id>] before adding — where the DNS is managed (Gabia, Cloudflare…), the one line to add, step by step
|
|
261
|
+
rootr domain add <domain> <page|doc path|id> [--provider <id>] [--confirm-charge] open a published page or document at your own domain (Team plan; $10/month per domain, $8 billed yearly — when slots are full, --confirm-charge buys one more)
|
|
262
|
+
rootr domain add <name>.rootr.site <page path|id> a free address, live at once (Team plan; no DNS, no payment, up to 3) — or --subdomain <name>
|
|
263
|
+
rootr domain available <name> is the free address name.rootr.site available
|
|
264
|
+
rootr domain check <id> re-check now (exit 2 while not live)
|
|
265
|
+
rootr domain rename <id> <new domain> [--provider <id>] change the name of a not-yet-live domain (billing unchanged, new id)
|
|
266
|
+
rootr domain remove <id> disconnect
|
|
222
267
|
rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] read/save configuration
|
|
223
268
|
rootr config --add-key <k> [--label <name>] [--workspace <w>] register an extra per-workspace key
|
|
224
269
|
rootr feedback "<what is not working>" [--kind bug|friction|idea|praise] [--email <address>]
|
|
@@ -232,7 +277,7 @@ usage:
|
|
|
232
277
|
rootr memory add "<content>" [--kind instruction|person|project|reference] [--source <path|id>] [--related a,b] save a memory
|
|
233
278
|
rootr memory edit <id> [--content "<content>"] [--kind …] [--source …] [--related a,b] edit a memory (only the fields you pass)
|
|
234
279
|
rootr memory forget <id> [--hard] retire a memory (--hard deletes the row)
|
|
235
|
-
rootr mcp run the MCP stdio server (
|
|
280
|
+
rootr mcp run the MCP stdio server (214 tools — see the README)
|
|
236
281
|
rootr --help this help
|
|
237
282
|
rootr --version print the version
|
|
238
283
|
|
|
@@ -359,6 +404,51 @@ async function cmdComments(args) {
|
|
|
359
404
|
}
|
|
360
405
|
}
|
|
361
406
|
|
|
407
|
+
/** `rootr members [--workspace <id>]` — list the current workspace's members (id, name, email, role). */
|
|
408
|
+
async function cmdMembers(args) {
|
|
409
|
+
const cfg = loadConfig();
|
|
410
|
+
const workspace = args.workspace || cfg.workspace;
|
|
411
|
+
if (!workspace) {
|
|
412
|
+
throw new RootrApiError(tr('워크스페이스가 설정되지 않았습니다.', `No workspace is configured.`), {
|
|
413
|
+
hint: tr('--workspace <id>로 넘기거나 rootr config --workspace <id> 또는 ROOTR_WORKSPACE 환경변수를 지정하세요.', `Pass --workspace <id>, or set it with rootr config --workspace <id> or the ROOTR_WORKSPACE environment variable.`),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
const client = new RootrClient({ ...cfg, workspace, apiKey: keyForWorkspace(cfg, workspace) });
|
|
417
|
+
const members = await client.listMembers(workspace);
|
|
418
|
+
if (args.json) {
|
|
419
|
+
console.log(JSON.stringify(members, null, 2));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
for (const m of members) {
|
|
423
|
+
console.log(`${m.userId ?? m.user?.id}\t${m.user?.name ?? ''}\t${m.user?.email ?? ''}\t${m.role}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* MEMBER-MSG — `rootr message <userId> <text> [--workspace <id>]`
|
|
429
|
+
*
|
|
430
|
+
* 2026-10-22 제보(#226): 앱에서는 구성원에게 바로 메시지를 보낼 수 있는데 CLI/MCP에는
|
|
431
|
+
* 그 길이 없어서 이슈 댓글에 @멘션을 다는 것으로 때워야 했다. 문서/이슈에 매이지 않은
|
|
432
|
+
* 1:1 알림 경로를 그대로 연다.
|
|
433
|
+
*/
|
|
434
|
+
async function cmdMessage(args) {
|
|
435
|
+
const userId = args._[0];
|
|
436
|
+
const body = args._[1];
|
|
437
|
+
if (!userId || !body) {
|
|
438
|
+
throw new Error(usage(`rootr message <userId> <text> [--workspace <id>]`));
|
|
439
|
+
}
|
|
440
|
+
const cfg = loadConfig();
|
|
441
|
+
const workspace = args.workspace || cfg.workspace;
|
|
442
|
+
if (!workspace) {
|
|
443
|
+
throw new RootrApiError(tr('워크스페이스가 설정되지 않았습니다.', `No workspace is configured.`), {
|
|
444
|
+
hint: tr('--workspace <id>로 넘기거나 rootr config --workspace <id> 또는 ROOTR_WORKSPACE 환경변수를 지정하세요.', `Pass --workspace <id>, or set it with rootr config --workspace <id> or the ROOTR_WORKSPACE environment variable.`),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
const client = new RootrClient({ ...cfg, workspace, apiKey: keyForWorkspace(cfg, workspace) });
|
|
448
|
+
await client.messageMember(workspace, userId, body);
|
|
449
|
+
console.log(tr(`${userId} 에게 메시지를 보냈습니다.`, `Message sent to ${userId}.`));
|
|
450
|
+
}
|
|
451
|
+
|
|
362
452
|
async function cmdRead(args) {
|
|
363
453
|
const target = args._[0];
|
|
364
454
|
if (!target) throw new Error(usage(`rootr read <path|id> [--json]`));
|
|
@@ -1010,10 +1100,11 @@ async function cmdForm(args) {
|
|
|
1010
1100
|
if (sub === 'collect') return cmdFormCollect(args);
|
|
1011
1101
|
if (sub === 'mapping') return cmdFormMapping(args);
|
|
1012
1102
|
if (sub === 'responses') return cmdFormResponses(args);
|
|
1103
|
+
if (sub === 'submit') return cmdFormSubmit(args);
|
|
1013
1104
|
if (sub === 'response-edit') return cmdFormResponseEdit(args);
|
|
1014
1105
|
if (sub === 'response-rm') return cmdFormResponseRm(args);
|
|
1015
1106
|
throw new Error(
|
|
1016
|
-
usage(tr(`rootr form new|show|set|collect|responses|response-edit|response-rm … (자세한 건 rootr --help)`, `rootr form new|show|set|collect|responses|response-edit|response-rm … (see rootr --help)`)),
|
|
1107
|
+
usage(tr(`rootr form new|show|set|collect|responses|submit|response-edit|response-rm … (자세한 건 rootr --help)`, `rootr form new|show|set|collect|responses|submit|response-edit|response-rm … (see rootr --help)`)),
|
|
1017
1108
|
);
|
|
1018
1109
|
}
|
|
1019
1110
|
|
|
@@ -1074,7 +1165,8 @@ async function cmdFormNew(args) {
|
|
|
1074
1165
|
console.log(` id: ${id}`);
|
|
1075
1166
|
if (args.collect === 'db' || args.collect === 'sheet') {
|
|
1076
1167
|
const to = args.collect === 'db' ? 'database' : 'spreadsheet';
|
|
1077
|
-
|
|
1168
|
+
// 서버는 `target` 만 받는다(`to` 는 검증 파이프가 지워 400 이 났다) — form collect 와 같은 길로.
|
|
1169
|
+
await client.createFormCollector(id, to);
|
|
1078
1170
|
console.log(tr(` 응답 모을 곳: ${args.collect === 'db' ? '표' : '스프레드시트'} 생성됨`, ` Responses collected into: a ${args.collect === 'db' ? 'table' : 'spreadsheet'} was created`));
|
|
1079
1171
|
}
|
|
1080
1172
|
}
|
|
@@ -1098,6 +1190,7 @@ async function cmdFormShow(args) {
|
|
|
1098
1190
|
f.hidden ? tr('숨김', `hidden`) : null,
|
|
1099
1191
|
f.page && f.page > 1 ? tr(`${f.page}쪽`, `page ${f.page}`) : null,
|
|
1100
1192
|
f.showIf ? tr('조건부', `conditional`) : null,
|
|
1193
|
+
f.type === 'file' && f.multiple ? tr(`파일 여러 개(최대 ${f.maxFiles ?? 10})`, `several files (max ${f.maxFiles ?? 10})`) : null,
|
|
1101
1194
|
].filter(Boolean);
|
|
1102
1195
|
console.log(` ${i + 1}. ${f.name} [${f.type}]${marks.length ? ' · ' + marks.join('·') : ''}`);
|
|
1103
1196
|
if (f.help) console.log(` ${f.help}`);
|
|
@@ -1249,6 +1342,83 @@ async function cmdFormResponses(args) {
|
|
|
1249
1342
|
}
|
|
1250
1343
|
}
|
|
1251
1344
|
|
|
1345
|
+
/**
|
|
1346
|
+
* FORM-FILE-MULTI — `rootr form submit <폼> --values '<json>' --files '{"질문id":["a.pdf","b.pdf"]}'`
|
|
1347
|
+
*
|
|
1348
|
+
* 파일 질문은 값으로 **이미 올라간 파일의 표**(`{ id, filename, size }`)를 받는다. 터미널에서 그걸 손으로
|
|
1349
|
+
* 만들려면 `rootr attach` 를 파일마다 부르고 id 를 옮겨 적어야 했다 — 여기서 올리기와 내기를 한 번에 한다.
|
|
1350
|
+
* 여러 개 받는 질문(`multiple`)은 배열로, 한 개 받는 질문은 객체로 붙인다(서버 약속과 같은 모양).
|
|
1351
|
+
*/
|
|
1352
|
+
async function cmdFormSubmit(args) {
|
|
1353
|
+
const target = args._[1];
|
|
1354
|
+
const usageLine = tr(
|
|
1355
|
+
`rootr form submit <path|id> [--values '<json>'] [--files '{"질문id":["a.pdf","b.pdf"]}']`,
|
|
1356
|
+
`rootr form submit <path|id> [--values '<json>'] [--files '{"fieldId":["a.pdf","b.pdf"]}']`,
|
|
1357
|
+
);
|
|
1358
|
+
if (!target) throw new Error(usage(usageLine));
|
|
1359
|
+
const parseJson = (raw, flag) => {
|
|
1360
|
+
try {
|
|
1361
|
+
return JSON.parse(raw);
|
|
1362
|
+
} catch {
|
|
1363
|
+
throw new Error(tr(`${flag} 를 JSON으로 읽을 수 없습니다.`, `Could not read ${flag} as JSON.`));
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
const values = typeof args.values === 'string' ? parseJson(args.values, '--values') : {};
|
|
1367
|
+
const files = typeof args.files === 'string' ? parseJson(args.files, '--files') : {};
|
|
1368
|
+
if (!values || typeof values !== 'object' || Array.isArray(values) || !files || typeof files !== 'object' || Array.isArray(files)) {
|
|
1369
|
+
throw new Error(usage(usageLine));
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const cfg = loadConfig();
|
|
1373
|
+
const workspace = args.workspace || cfg.workspace;
|
|
1374
|
+
const client = new RootrClient({ ...cfg, workspace, apiKey: keyForWorkspace(cfg, workspace) });
|
|
1375
|
+
const id = await resolveDatabaseId(client, target);
|
|
1376
|
+
const form = await client.getForm(id);
|
|
1377
|
+
const byKey = new Map();
|
|
1378
|
+
for (const f of form.fields ?? []) {
|
|
1379
|
+
byKey.set(f.id, f);
|
|
1380
|
+
if (!byKey.has(f.name)) byKey.set(f.name, f); // 질문 이름으로 적어도 된다
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
for (const [key, list] of Object.entries(files)) {
|
|
1384
|
+
const field = byKey.get(key);
|
|
1385
|
+
if (!field || field.type !== 'file') {
|
|
1386
|
+
throw new Error(tr(`"${key}" 은 이 폼의 파일 질문이 아닙니다.`, `"${key}" is not a file question on this form.`));
|
|
1387
|
+
}
|
|
1388
|
+
const paths = Array.isArray(list) ? list : [list];
|
|
1389
|
+
if (paths.length > 1 && !field.multiple) {
|
|
1390
|
+
throw new Error(
|
|
1391
|
+
tr(
|
|
1392
|
+
`"${field.name}" 은 파일을 한 개만 받습니다. 여러 개를 받으려면: rootr form set <폼> --fields 로 그 질문에 "multiple": true`,
|
|
1393
|
+
`"${field.name}" takes a single file. To take several, set "multiple": true on it with rootr form set <form> --fields`,
|
|
1394
|
+
),
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
if (!workspace) {
|
|
1398
|
+
throw new RootrApiError(tr('워크스페이스가 설정되지 않았습니다.', `No workspace is configured.`), {
|
|
1399
|
+
hint: tr('--workspace <id> 를 주세요.', `Pass --workspace <id>.`),
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
const uploaded = [];
|
|
1403
|
+
for (const filePath of paths) {
|
|
1404
|
+
const filename = basename(String(filePath));
|
|
1405
|
+
const mimeType = MIME_BY_EXT[extname(filename).toLowerCase()] || 'application/octet-stream';
|
|
1406
|
+
const att = await client.uploadAttachment(workspace, { filename, mimeType, nodeId: id, data: readFileSync(String(filePath)) });
|
|
1407
|
+
uploaded.push({ id: att.id, filename });
|
|
1408
|
+
console.error(tr(`올림: ${filename} (id ${att.id})`, `uploaded ${filename} (id ${att.id})`));
|
|
1409
|
+
}
|
|
1410
|
+
values[field.id] = field.multiple ? uploaded : uploaded[0];
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const result = await client.createFormResponse(id, values);
|
|
1414
|
+
if (args.json) {
|
|
1415
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
console.log(tr(`응답 접수됨: ${result.id ?? ''}`, `Response submitted: ${result.id ?? ''}`));
|
|
1419
|
+
console.log(` ${JSON.stringify(result.values ?? values)}`);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1252
1422
|
async function cmdFormResponseEdit(args) {
|
|
1253
1423
|
const responseId = args._[1];
|
|
1254
1424
|
if (!responseId) throw new Error(usage(tr("rootr form response-edit <응답id> --values '<json>'", "rootr form response-edit <responseId> --values '<json>'")));
|
|
@@ -1646,6 +1816,44 @@ async function cmdDbDuplicates(args) {
|
|
|
1646
1816
|
}
|
|
1647
1817
|
}
|
|
1648
1818
|
|
|
1819
|
+
/** REL-PICK — `rootr db pick <표> [이름]` : 관계 칸에 이을 행 찾기(표 전체, 같은 이름은 최근 행 먼저, 구분 글자). */
|
|
1820
|
+
async function cmdDbPick(args) {
|
|
1821
|
+
const [, target, ...words] = args._;
|
|
1822
|
+
if (!target) throw new Error(usage(`rootr db pick <path|id> [name] [--limit N] [--json]`));
|
|
1823
|
+
const client = makeClient();
|
|
1824
|
+
const id = await resolveDatabaseId(client, target);
|
|
1825
|
+
const res = await client.pickRows(id, { q: words.join(' '), limit: args.limit ? Number(args.limit) : undefined });
|
|
1826
|
+
if (args.json) return console.log(JSON.stringify(res, null, 2));
|
|
1827
|
+
if (!res.rows.length) return console.log(tr('맞는 행이 없습니다.', 'No matching rows.'));
|
|
1828
|
+
for (const r of res.rows) console.log(`- ${r.title || '(제목 없음)'}${r.subtitle ? ` (${r.subtitle})` : ''}\t${r.id}`);
|
|
1829
|
+
if (res.hasMore) console.log(tr(`… ${res.total}개 중 ${res.rows.length}개 — 이름을 더 적거나 전화 끝자리로 좁히세요.`, `… ${res.rows.length} of ${res.total} — narrow it down.`));
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/** REL-PICK-IDENTIFY — `rootr db identify <표> 연락처:last4,지원일` : 제목 칸의 identifyProps 를 건다(--clear 로 끈다). */
|
|
1833
|
+
async function cmdDbIdentify(args) {
|
|
1834
|
+
const [, target, spec] = args._;
|
|
1835
|
+
if (!target || (!spec && !args.clear)) throw new Error(usage(`rootr db identify <path|id> <col[:last4],col…> | --clear`));
|
|
1836
|
+
const client = makeClient();
|
|
1837
|
+
const id = await resolveDatabaseId(client, target);
|
|
1838
|
+
const db = await client.getDatabase(id);
|
|
1839
|
+
const title = (db.properties || []).find((p) => p.type === 'title');
|
|
1840
|
+
if (!title) throw new Error(tr('이 표에 제목 칸이 없습니다.', 'This table has no title column.'));
|
|
1841
|
+
const identifyProps = args.clear
|
|
1842
|
+
? null
|
|
1843
|
+
: String(spec)
|
|
1844
|
+
.split(',')
|
|
1845
|
+
.map((x) => x.trim())
|
|
1846
|
+
.filter(Boolean)
|
|
1847
|
+
.map((x) => {
|
|
1848
|
+
const [propId, mask] = x.split(':');
|
|
1849
|
+
return mask ? { propId: propId.trim(), mask: mask.trim() } : { propId: propId.trim() };
|
|
1850
|
+
});
|
|
1851
|
+
const res = await client.updateColumn(id, title.id, { identifyProps });
|
|
1852
|
+
const names = new Map((db.properties || []).map((p) => [p.id, p.name]));
|
|
1853
|
+
const shown = (res.identifyProps || []).map((it) => `${names.get(it.propId) ?? it.propId}${it.mask ? `(${it.mask})` : ''}`);
|
|
1854
|
+
console.log(shown.length ? tr(`구분 칸: ${shown.join(' · ')}`, `Identify columns: ${shown.join(' · ')}`) : tr('구분 칸을 껐습니다.', 'Identify columns cleared.'));
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1649
1857
|
/** DEDUPE — `rootr db merge <표> <rowId> --into <기존 rowId>` */
|
|
1650
1858
|
async function cmdDbMerge(args) {
|
|
1651
1859
|
const [, target, rowId] = args._;
|
|
@@ -1661,6 +1869,43 @@ async function cmdDbMerge(args) {
|
|
|
1661
1869
|
);
|
|
1662
1870
|
}
|
|
1663
1871
|
|
|
1872
|
+
/**
|
|
1873
|
+
* COL-RENAME — `rootr db rename-col <표> <칸 이름|id> <새 이름> [--dry] [--json]`
|
|
1874
|
+
* 칸 이름을 바꾸면 그 칸을 이름으로 가리키던 수식·버튼/자동 규칙 치환·다른 표 롤업·이어진 폼 질문·페이지 블록이 함께 바뀐다.
|
|
1875
|
+
* --dry 는 무엇이 바뀌는지만 보여 준다(쓰지 않는다).
|
|
1876
|
+
*/
|
|
1877
|
+
async function cmdDbRenameCol(args) {
|
|
1878
|
+
const [, target, col, ...rest] = args._;
|
|
1879
|
+
const to = rest.join(' ').trim();
|
|
1880
|
+
if (!target || !col || !to) throw new Error(usage(`rootr db rename-col <path|id> <column name|id> <new name> [--dry] [--json]`));
|
|
1881
|
+
const client = makeClient();
|
|
1882
|
+
const id = await resolveDatabaseId(client, target);
|
|
1883
|
+
const db = await client.getDatabase(id);
|
|
1884
|
+
const prop =
|
|
1885
|
+
(db.properties ?? []).find((p) => p.id === col) ??
|
|
1886
|
+
(db.properties ?? []).find((p) => p.name.trim().toLowerCase() === String(col).trim().toLowerCase());
|
|
1887
|
+
if (!prop) throw new Error(tr(`"${col}" 칸이 이 표에 없습니다.`, `No column "${col}" in this table.`));
|
|
1888
|
+
const cascade = args.dry
|
|
1889
|
+
? await client.columnRenameImpact(id, prop.id, to)
|
|
1890
|
+
: (await client.updateColumn(id, prop.id, { name: to })).renameCascade ?? null;
|
|
1891
|
+
if (args.json) return console.log(JSON.stringify({ propertyId: prop.id, from: prop.name, to, dryRun: !!args.dry, renameCascade: cascade }, null, 2));
|
|
1892
|
+
console.log(
|
|
1893
|
+
args.dry
|
|
1894
|
+
? tr(`미리보기: 「${prop.name}」 → 「${to}」`, `Preview: "${prop.name}" → "${to}"`)
|
|
1895
|
+
: tr(`바꿨습니다: 「${prop.name}」 → 「${to}」`, `Renamed "${prop.name}" → "${to}"`),
|
|
1896
|
+
);
|
|
1897
|
+
if (!cascade || !cascade.summary) console.log(tr('이 칸을 이름으로 가리키는 곳이 없습니다.', 'Nothing else refers to this column by name.'));
|
|
1898
|
+
else console.log(cascade.summary);
|
|
1899
|
+
for (const f of cascade?.formulas ?? []) console.log(` ${tr('수식', 'formula')} ${f.column}: ${f.before} → ${f.after}`);
|
|
1900
|
+
for (const b of cascade?.buttons ?? []) console.log(` ${tr('버튼', 'button')} ${b.table} / ${b.label}`);
|
|
1901
|
+
for (const a of cascade?.automations ?? []) console.log(` ${tr('자동 규칙', 'automation')} ${a.table} / ${a.label}`);
|
|
1902
|
+
for (const r of cascade?.rollups ?? []) console.log(` ${tr('롤업', 'rollup')} ${r.table} / ${r.column}`);
|
|
1903
|
+
for (const f of cascade?.forms ?? []) for (const q of f.pinned) console.log(` ${tr('폼', 'form')} ${f.form} / ${q.field} → ${q.column}`);
|
|
1904
|
+
for (const p of cascade?.pages ?? []) console.log(` ${tr('페이지', 'page')} ${p.page} (${p.blocks})`);
|
|
1905
|
+
for (const w of cascade?.warnings ?? []) console.log(`⚠ ${w}`);
|
|
1906
|
+
if (args.dry) console.log(tr('미리보기입니다. 바꾸려면 --dry 를 빼세요.', 'Preview only. Drop --dry to rename.'));
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1664
1909
|
/** ROW-HISTORY — `rootr db history <표> [--row <rowId>] [--limit N] [--json]` */
|
|
1665
1910
|
async function cmdDbHistory(args) {
|
|
1666
1911
|
const target = args._[1];
|
|
@@ -1706,6 +1951,116 @@ async function cmdDbRestore(args) {
|
|
|
1706
1951
|
console.log(tr('되돌렸습니다.', 'Restored.'));
|
|
1707
1952
|
}
|
|
1708
1953
|
|
|
1954
|
+
/** DEDUPE-CARRY(P1) — `rootr db carry-over <표> <rowId> [--from <이전 rowId>]` */
|
|
1955
|
+
async function cmdDbCarryOver(args) {
|
|
1956
|
+
const [, target, rowId] = args._;
|
|
1957
|
+
if (!target || !rowId) throw new Error(usage(`rootr db carry-over <path|id> <rowId> [--from <rowId>]`));
|
|
1958
|
+
const client = makeClient();
|
|
1959
|
+
const id = await resolveDatabaseId(client, target);
|
|
1960
|
+
const res = await client.carryOver(id, rowId, typeof args.from === 'string' ? args.from : undefined);
|
|
1961
|
+
if (args.json) return console.log(JSON.stringify(res, null, 2));
|
|
1962
|
+
console.log(
|
|
1963
|
+
tr(
|
|
1964
|
+
res.nothingChanged && !res.marked.length
|
|
1965
|
+
? `같은 값의 이전 행 ${res.fromRowIds?.length ?? 1}개를 찾았지만 채울 칸이 없었습니다(바뀐 칸 없음).`
|
|
1966
|
+
: `이전 행 ${res.fromRowIds?.length ?? 1}개(${res.fromTitle || res.fromRowId} 등)에서 가져왔습니다 — 바뀐 칸 ${res.copied.length}개: ${res.copied.join(', ') || '없음'}${res.marked.length ? ` · 표시: ${res.marked.join(', ')}` : ''}${res.opId ? ` · 되돌리기: rootr db dedupe-undo <표> ${res.opId}` : ''}`,
|
|
1967
|
+
res.nothingChanged && !res.marked.length
|
|
1968
|
+
? `Found ${res.fromRowIds?.length ?? 1} earlier row(s) but nothing to fill (no column changed).`
|
|
1969
|
+
: `Carried from ${res.fromRowIds?.length ?? 1} earlier row(s) — ${res.copied.length} column(s): ${res.copied.join(', ') || 'none'}${res.marked.length ? ` · marked: ${res.marked.join(', ')}` : ''}${res.opId ? ` · undo: rootr db dedupe-undo <table> ${res.opId}` : ''}`,
|
|
1970
|
+
),
|
|
1971
|
+
);
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
/** DEDUPE-RULES — `rootr db dedupe-preview|dedupe <표> <rowId> [--action …] [--json '<규칙 덮기>']` */
|
|
1975
|
+
async function cmdDbDedupe(args, dryRun) {
|
|
1976
|
+
const [, target, rowId] = args._;
|
|
1977
|
+
if (!target || !rowId) throw new Error(usage(`rootr db ${dryRun ? 'dedupe-preview' : 'dedupe'} <path|id> <rowId> [--action carry|merge_into_old|merge_all] [--json '<rules>']`));
|
|
1978
|
+
let rules;
|
|
1979
|
+
if (typeof args.json === 'string') {
|
|
1980
|
+
try {
|
|
1981
|
+
rules = JSON.parse(args.json);
|
|
1982
|
+
} catch {
|
|
1983
|
+
throw new Error(tr('--json 이 JSON 이 아닙니다.', '--json is not valid JSON.'));
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
const client = makeClient();
|
|
1987
|
+
const id = await resolveDatabaseId(client, target);
|
|
1988
|
+
const res = await client.dedupe(id, rowId, { action: typeof args.action === 'string' ? args.action : undefined, rules, dryRun });
|
|
1989
|
+
if (args.json === true || args.raw) return console.log(JSON.stringify(res, null, 2));
|
|
1990
|
+
console.log(
|
|
1991
|
+
tr(
|
|
1992
|
+
`${dryRun ? '미리 보기' : '적용'} — ${res.action} · 남는 행 ${res.targetRowId}${res.deleted?.length ? ` · 지움 ${res.deleted.length}행` : ''}${res.kept?.length ? ` · 이어 둠 ${res.kept.length}행` : ''}${res.opId ? ` · opId ${res.opId}` : ''}`,
|
|
1993
|
+
`${dryRun ? 'Preview' : 'Applied'} — ${res.action} · keeps ${res.targetRowId}${res.deleted?.length ? ` · deletes ${res.deleted.length}` : ''}${res.kept?.length ? ` · keeps linked ${res.kept.length}` : ''}${res.opId ? ` · opId ${res.opId}` : ''}`,
|
|
1994
|
+
),
|
|
1995
|
+
);
|
|
1996
|
+
if (!res.columns?.length) console.log(tr(' 바뀌는 칸 없음', ' no column changes'));
|
|
1997
|
+
for (const c of res.columns ?? []) console.log(` ${c.column} [${c.strategy}]: ${JSON.stringify(c.before)} → ${JSON.stringify(c.after)}`);
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
/** DEDUPE-RULES — `rootr db dedupe-undo <표> <opId>` */
|
|
2001
|
+
async function cmdDbDedupeUndo(args) {
|
|
2002
|
+
const [, target, opId] = args._;
|
|
2003
|
+
if (!target || !opId) throw new Error(usage(`rootr db dedupe-undo <path|id> <opId>`));
|
|
2004
|
+
const client = makeClient();
|
|
2005
|
+
const id = await resolveDatabaseId(client, target);
|
|
2006
|
+
const res = await client.dedupeUndo(id, opId);
|
|
2007
|
+
if (args.json) return console.log(JSON.stringify(res, null, 2));
|
|
2008
|
+
console.log(tr(`되돌렸습니다 — ${res.rows.map((r) => `${r.rowId}(${r.state})`).join(', ')}`, `Undone — ${res.rows.map((r) => `${r.rowId}(${r.state})`).join(', ')}`));
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
/** `rootr db column-set <표> <칸> --json '<PATCH 본문>'` — 칸 설정(validate·dedupe·dedupeCarry·이름·선택지…)을 바꾼다. */
|
|
2012
|
+
async function cmdDbColumnSet(args) {
|
|
2013
|
+
const [, target, column] = args._;
|
|
2014
|
+
if (!target || !column || typeof args.json !== 'string') throw new Error(usage(`rootr db column-set <path|id> <column> --json '<settings>'`));
|
|
2015
|
+
let body;
|
|
2016
|
+
try {
|
|
2017
|
+
body = JSON.parse(args.json);
|
|
2018
|
+
} catch {
|
|
2019
|
+
throw new Error(tr('--json 이 JSON 이 아닙니다.', '--json is not valid JSON.'));
|
|
2020
|
+
}
|
|
2021
|
+
const client = makeClient();
|
|
2022
|
+
const id = await resolveDatabaseId(client, target);
|
|
2023
|
+
const db = await client.getDatabase(id);
|
|
2024
|
+
const prop = (db.properties ?? []).find((p) => p.id === column) ?? (db.properties ?? []).find((p) => p.name.trim().toLowerCase() === String(column).trim().toLowerCase());
|
|
2025
|
+
if (!prop) throw new Error(tr(`"${column}" 칸이 없습니다. 칸: ${(db.properties ?? []).map((p) => p.name).join(', ')}`, `No column "${column}".`));
|
|
2026
|
+
const res = await client.updateColumn(id, prop.id, body);
|
|
2027
|
+
console.log(JSON.stringify(res, null, 2));
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
/** VALUE-RULE(P2) — `rootr db rule-violations <표> [--limit N] [--json]` */
|
|
2031
|
+
async function cmdDbRuleViolations(args) {
|
|
2032
|
+
const [, target] = args._;
|
|
2033
|
+
if (!target) throw new Error(usage(`rootr db rule-violations <path|id> [--limit N]`));
|
|
2034
|
+
const client = makeClient();
|
|
2035
|
+
const id = await resolveDatabaseId(client, target);
|
|
2036
|
+
const res = await client.ruleViolations(id, { limit: args.limit ? Number(args.limit) : undefined });
|
|
2037
|
+
if (args.json) return console.log(JSON.stringify(res, null, 2));
|
|
2038
|
+
if (!res.checkedColumns.length) return console.log(tr('검사를 건 칸이 없습니다.', 'No column has a validate rule.'));
|
|
2039
|
+
console.log(tr(`검사한 칸: ${res.checkedColumns.join(', ')} · 안 맞는 값 ${res.total}건`, `Checked: ${res.checkedColumns.join(', ')} · ${res.total} violations`));
|
|
2040
|
+
for (const it of res.items) console.log(` ${it.rowId} ${it.title || '-'} ${it.propName}: ${it.value} — ${it.message}`);
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
/** VALUE-RULE — `rootr db rule-test --json '<규칙>' <값>…` · `rootr db rule-test --presets` */
|
|
2044
|
+
async function cmdDbRuleTest(args) {
|
|
2045
|
+
const client = makeClient();
|
|
2046
|
+
if (args.presets) {
|
|
2047
|
+
const { presets } = await client.valueRulePresets();
|
|
2048
|
+
if (args.json) return console.log(JSON.stringify(presets, null, 2));
|
|
2049
|
+
for (const p of presets) console.log(` ${p.id.padEnd(12)} ${tr(p.label.ko, p.label.en)} ${p.pattern}${p.flags ? ` (flags ${p.flags})` : ''} ${tr('예', 'e.g.')} ${p.example}`);
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
const values = args._.slice(1);
|
|
2053
|
+
if (typeof args.json !== 'string' || !values.length) throw new Error(usage(`rootr db rule-test --json '{"pattern":"\\\\d{5}"}' <값> [<값>…]`));
|
|
2054
|
+
let rule;
|
|
2055
|
+
try {
|
|
2056
|
+
rule = JSON.parse(args.json);
|
|
2057
|
+
} catch {
|
|
2058
|
+
throw new Error(tr('--json 이 JSON 이 아닙니다.', '--json is not valid JSON.'));
|
|
2059
|
+
}
|
|
2060
|
+
const res = await client.testValueRule(rule, values, typeof args.type === 'string' ? args.type : undefined);
|
|
2061
|
+
for (const r of res.results) console.log(` ${r.ok ? '✓' : '✗'} ${r.value}${r.ok ? '' : ` — ${r.message}`}`);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
1709
2064
|
/** DEDUPE-KEEP — `rootr db keep-duplicate <표> <rowId> --from <이전 rowId>` */
|
|
1710
2065
|
async function cmdDbKeepDuplicate(args) {
|
|
1711
2066
|
const [, target, rowId] = args._;
|
|
@@ -1731,6 +2086,37 @@ const DOC_SKINS = [
|
|
|
1731
2086
|
'editorial', 'bento', 'glass', 'mesh', 'immersive', 'brutal', 'neumorph', 'y2k',
|
|
1732
2087
|
];
|
|
1733
2088
|
|
|
2089
|
+
/**
|
|
2090
|
+
* PUBLIC-SEO — `--description "<글>"`(검색 설명문, 빈 글이면 지움)·`--cover <그림 파일|첨부 id|none>`(링크 미리보기 대표 이미지).
|
|
2091
|
+
* 그림 파일을 주면 그 워크스페이스에 올려 id 로 바꾼다. 안 주면 빈 객체 — 아무것도 안 바뀐다.
|
|
2092
|
+
*/
|
|
2093
|
+
async function seoPatchFromArgs(client, args, nodeId) {
|
|
2094
|
+
const patch = {};
|
|
2095
|
+
if (args.description !== undefined) patch.seoDescription = typeof args.description === 'string' ? args.description : null;
|
|
2096
|
+
if (args.cover !== undefined) {
|
|
2097
|
+
const cover = typeof args.cover === 'string' ? args.cover.trim() : '';
|
|
2098
|
+
if (!cover || cover === 'none') patch.seoImage = null;
|
|
2099
|
+
else if (/^[0-9a-f-]{36}$/i.test(cover)) patch.seoImage = cover;
|
|
2100
|
+
else {
|
|
2101
|
+
if (!existsSync(cover)) throw new Error(tr(`--cover 파일을 찾을 수 없습니다: ${cover}`, `--cover file not found: ${cover}`));
|
|
2102
|
+
const mimeType = MIME_BY_EXT[extname(cover).toLowerCase()] || 'application/octet-stream';
|
|
2103
|
+
if (!mimeType.startsWith('image/')) throw new Error(tr('--cover 는 그림 파일(png·jpg·gif·webp)이어야 합니다.', '--cover must be an image file (png, jpg, gif, webp).'));
|
|
2104
|
+
const cfg = loadConfig();
|
|
2105
|
+
const workspace = args.workspace || cfg.workspace;
|
|
2106
|
+
if (!workspace) throw new Error(tr('--cover 파일을 올릴 워크스페이스가 없습니다 — --workspace <id> 를 주세요.', 'No workspace to upload the --cover file to — pass --workspace <id>.'));
|
|
2107
|
+
const { id } = await client.uploadAttachment(workspace, { filename: basename(cover), mimeType, nodeId, data: readFileSync(cover) });
|
|
2108
|
+
patch.seoImage = id;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
return patch;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
function printSeo(seo) {
|
|
2115
|
+
if (!seo || (!seo.description && !seo.imageAttachmentId)) return;
|
|
2116
|
+
if (seo.description) console.log(tr(` 검색 설명문: ${seo.description}`, ` search description: ${seo.description}`));
|
|
2117
|
+
if (seo.imageAttachmentId) console.log(tr(` 대표 이미지: ${seo.imageAttachmentId}`, ` cover image: ${seo.imageAttachmentId}`));
|
|
2118
|
+
}
|
|
2119
|
+
|
|
1734
2120
|
/** `--comments` 단독이면 true, `--comments false`면 false, 안 주면 undefined. */
|
|
1735
2121
|
function flagToBool(v) {
|
|
1736
2122
|
if (v === undefined) return undefined;
|
|
@@ -1742,7 +2128,7 @@ async function cmdPublish(args) {
|
|
|
1742
2128
|
if (!target) {
|
|
1743
2129
|
throw new Error(
|
|
1744
2130
|
usage(tr(`rootr publish <path|id> [--children] [--view <id>] [--layout <배치>] [--skin <분위기>] `, `rootr publish <path|id> [--children] [--view <id>] [--layout <layout>] [--skin <skin>] `)) +
|
|
1745
|
-
'[--comments] [--expires <ISO>] | --off | --status',
|
|
2131
|
+
'[--comments] [--expires <ISO>] [--description <text>] [--cover <image|id|none>] | --off | --status',
|
|
1746
2132
|
);
|
|
1747
2133
|
}
|
|
1748
2134
|
const client = makeClient();
|
|
@@ -1792,6 +2178,7 @@ async function cmdPublish(args) {
|
|
|
1792
2178
|
if (isDocument) {
|
|
1793
2179
|
console.log(tr(` 댓글(편집 제안) 허용: ${st.allowCommits ? '예' : '아니오'}`, ` comments (edit suggestions) allowed: ${st.allowCommits ? 'yes' : 'no'}`));
|
|
1794
2180
|
if (st.publicConfig?.skin) console.log(tr(` 스킨: ${st.publicConfig.skin}`, ` skin: ${st.publicConfig.skin}`));
|
|
2181
|
+
printSeo(st.publicConfig?.seo);
|
|
1795
2182
|
} else {
|
|
1796
2183
|
if (st.hasPassword) console.log(tr(' 비밀번호 걸려 있음', ` password protected`));
|
|
1797
2184
|
console.log(tr(` 하위 문서도 공개: ${st.includeChildren ? '예' : '아니오'}`, ` children published too: ${st.includeChildren ? 'yes' : 'no'}`));
|
|
@@ -1799,6 +2186,7 @@ async function cmdPublish(args) {
|
|
|
1799
2186
|
if (st.design && (st.design.layout || st.design.skin)) {
|
|
1800
2187
|
console.log(tr(` 보이는 방식: ${st.design.layout ?? 'table'} / ${st.design.skin ?? 'clean'}`, ` looks like: ${st.design.layout ?? 'table'} / ${st.design.skin ?? 'clean'}`));
|
|
1801
2188
|
}
|
|
2189
|
+
printSeo(st.seo);
|
|
1802
2190
|
}
|
|
1803
2191
|
return;
|
|
1804
2192
|
}
|
|
@@ -1824,8 +2212,10 @@ async function cmdPublish(args) {
|
|
|
1824
2212
|
if (comments !== undefined) patch.allowCommits = comments;
|
|
1825
2213
|
if (typeof args.expires === 'string') patch.expiresAt = args.expires;
|
|
1826
2214
|
if (typeof args.skin === 'string') patch.skin = args.skin;
|
|
2215
|
+
Object.assign(patch, await seoPatchFromArgs(client, args, id));
|
|
1827
2216
|
if (Object.keys(patch).length) {
|
|
1828
|
-
await client.setDocumentPublic(id, patch);
|
|
2217
|
+
const after = await client.setDocumentPublic(id, patch);
|
|
2218
|
+
if (patch.seoDescription !== undefined || patch.seoImage !== undefined) printSeo(after?.publicConfig?.seo);
|
|
1829
2219
|
if (patch.allowCommits !== undefined) {
|
|
1830
2220
|
console.log(tr(` 댓글(편집 제안) 허용: ${patch.allowCommits ? '예' : '아니오'}`, ` comments (edit suggestions) allowed: ${patch.allowCommits ? 'yes' : 'no'}`));
|
|
1831
2221
|
}
|
|
@@ -1865,6 +2255,8 @@ async function cmdPublish(args) {
|
|
|
1865
2255
|
if (Object.keys(design).length) {
|
|
1866
2256
|
console.log(tr(` 보이는 방식: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`, ` looks like: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`));
|
|
1867
2257
|
}
|
|
2258
|
+
const seo = await seoPatchFromArgs(client, args, id);
|
|
2259
|
+
if (Object.keys(seo).length) printSeo((await client.setNodePublic(id, seo))?.seo);
|
|
1868
2260
|
}
|
|
1869
2261
|
|
|
1870
2262
|
/**
|
|
@@ -1985,7 +2377,7 @@ async function cmdBooking(args) {
|
|
|
1985
2377
|
` [--duration 30] [--buffer 10] [--calendar-buffer 60] [--notice 2] [--ahead 60] [--per-day 4]\n` +
|
|
1986
2378
|
` [--block 2026-10-03,2026-10-09] [--approval instant|manual] [--tz Asia/Seoul]\n` +
|
|
1987
2379
|
` [--block-time "daily 12:00-13:00=점심, mon 09:00-11:00, fri"]\n` +
|
|
1988
|
-
` [--meet on|off] [--notify-visitor on|off] [--notify-email a@b.com,c@d.com] [--self-cancel on|off]\n` +
|
|
2380
|
+
` [--meet on|off] [--all-day-busy on|off] [--notify-visitor on|off] [--notify-email a@b.com,c@d.com] [--self-cancel on|off]\n` +
|
|
1989
2381
|
` [--self-move on|off] [--remind 1d,1h (안 보내려면 --remind off)]\n` +
|
|
1990
2382
|
` [--types "30분 상담=30m,데모=60m|화면 보며 설명"] [--guests on|off] [--max-guests 5] [--track-source on|off]\n` +
|
|
1991
2383
|
` [--after https://example.com/thanks]\n` +
|
|
@@ -1997,7 +2389,7 @@ async function cmdBooking(args) {
|
|
|
1997
2389
|
` [--duration 30] [--buffer 10] [--calendar-buffer 60] [--notice 2] [--ahead 60] [--per-day 4]\n` +
|
|
1998
2390
|
` [--block 2026-10-03,2026-10-09] [--approval instant|manual] [--tz Asia/Seoul]\n` +
|
|
1999
2391
|
` [--block-time "daily 12:00-13:00=lunch, mon 09:00-11:00, fri"]\n` +
|
|
2000
|
-
` [--meet on|off] [--notify-visitor on|off] [--notify-email a@b.com,c@d.com] [--self-cancel on|off]\n` +
|
|
2392
|
+
` [--meet on|off] [--all-day-busy on|off] [--notify-visitor on|off] [--notify-email a@b.com,c@d.com] [--self-cancel on|off]\n` +
|
|
2001
2393
|
` [--self-move on|off] [--remind 1d,1h (--remind off to stop)]\n` +
|
|
2002
2394
|
` [--types "Intro=30m,Demo=60m|walk through the product"] [--guests on|off] [--max-guests 5] [--track-source on|off]\n` +
|
|
2003
2395
|
` [--after https://example.com/thanks]\n` +
|
|
@@ -2051,6 +2443,7 @@ async function cmdBooking(args) {
|
|
|
2051
2443
|
: tr(' 미리 알림: 안 보냄', ' reminders: off'),
|
|
2052
2444
|
);
|
|
2053
2445
|
if (b.google?.meet) console.log(tr(' 구글 밋: 예약마다 회의 만듦', ' Google Meet: a room per booking'));
|
|
2446
|
+
if (b.google && b.google.allDayBusy === false) console.log(tr(' 하루 종일 일정: 안 막음', ' All-day events: do not block'));
|
|
2054
2447
|
const mailTo = [
|
|
2055
2448
|
...(b.notifyVisitor === false ? [] : [tr('방문자', 'visitor')]),
|
|
2056
2449
|
...(b.notifyEmails ?? []),
|
|
@@ -2117,6 +2510,9 @@ async function cmdBooking(args) {
|
|
|
2117
2510
|
if (args.block !== undefined) body.blockedDates = String(args.block).split(',').map((x) => x.trim()).filter(Boolean);
|
|
2118
2511
|
// 목록을 통째로 바꾼다 — 빈 값을 주면 다 지운다.
|
|
2119
2512
|
if (args.meet !== undefined) body.google = { ...(body.google ?? {}), meet: args.meet !== 'off' && args.meet !== false };
|
|
2513
|
+
if (args['all-day-busy'] !== undefined) {
|
|
2514
|
+
body.google = { ...(body.google ?? {}), allDayBusy: args['all-day-busy'] !== 'off' && args['all-day-busy'] !== false };
|
|
2515
|
+
}
|
|
2120
2516
|
if (args['notify-visitor'] !== undefined) body.notifyVisitor = args['notify-visitor'] !== 'off' && args['notify-visitor'] !== false;
|
|
2121
2517
|
if (args['self-cancel'] !== undefined) body.allowVisitorCancel = args['self-cancel'] !== 'off' && args['self-cancel'] !== false;
|
|
2122
2518
|
if (args['self-move'] !== undefined) body.allowVisitorReschedule = args['self-move'] !== 'off' && args['self-move'] !== false;
|
|
@@ -2359,8 +2755,18 @@ async function cmdDb(args) {
|
|
|
2359
2755
|
if (sub === 'rows-set') return cmdDbRowsSet(args);
|
|
2360
2756
|
if (sub === 'rows-rm') return cmdDbRowsRm(args);
|
|
2361
2757
|
if (sub === 'duplicates') return cmdDbDuplicates(args);
|
|
2758
|
+
if (sub === 'pick') return cmdDbPick(args);
|
|
2759
|
+
if (sub === 'identify') return cmdDbIdentify(args);
|
|
2362
2760
|
if (sub === 'merge') return cmdDbMerge(args);
|
|
2363
2761
|
if (sub === 'keep-duplicate') return cmdDbKeepDuplicate(args);
|
|
2762
|
+
if (sub === 'rename-col') return cmdDbRenameCol(args);
|
|
2763
|
+
if (sub === 'carry-over') return cmdDbCarryOver(args);
|
|
2764
|
+
if (sub === 'dedupe-preview') return cmdDbDedupe(args, true);
|
|
2765
|
+
if (sub === 'dedupe') return cmdDbDedupe(args, false);
|
|
2766
|
+
if (sub === 'dedupe-undo') return cmdDbDedupeUndo(args);
|
|
2767
|
+
if (sub === 'column-set') return cmdDbColumnSet(args);
|
|
2768
|
+
if (sub === 'rule-violations') return cmdDbRuleViolations(args);
|
|
2769
|
+
if (sub === 'rule-test') return cmdDbRuleTest(args);
|
|
2364
2770
|
if (sub === 'history') return cmdDbHistory(args);
|
|
2365
2771
|
if (sub === 'restore') return cmdDbRestore(args);
|
|
2366
2772
|
if (sub === 'row-page') return cmdDbRowPage(args);
|
|
@@ -2372,9 +2778,10 @@ async function cmdDb(args) {
|
|
|
2372
2778
|
if (sub === 'automations') return cmdDbAutomations(args);
|
|
2373
2779
|
if (sub === 'import-sheets') return cmdDbImportSheets(args);
|
|
2374
2780
|
if (sub === 'view') return cmdDbView(args);
|
|
2781
|
+
if (sub === 'column') return cmdDbColumn(args);
|
|
2375
2782
|
if (sub !== 'create') {
|
|
2376
2783
|
throw new Error(
|
|
2377
|
-
usage(tr(`rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|duplicates|merge|keep-duplicate|history|restore|row-page|row-page-type|lock|notify|buttons|button-run|automations|import-sheets|view … (자세한 건 rootr --help)`, `rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|duplicates|merge|keep-duplicate|history|restore|row-page|row-page-type|lock|notify|buttons|button-run|view … (see rootr --help)`)),
|
|
2784
|
+
usage(tr(`rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|duplicates|merge|keep-duplicate|history|restore|row-page|row-page-type|lock|notify|buttons|button-run|automations|import-sheets|view|column … (자세한 건 rootr --help)`, `rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|duplicates|merge|keep-duplicate|history|restore|row-page|row-page-type|lock|notify|buttons|button-run|view|column … (see rootr --help)`)),
|
|
2378
2785
|
);
|
|
2379
2786
|
}
|
|
2380
2787
|
const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
|
|
@@ -2536,8 +2943,19 @@ async function cmdDbView(args) {
|
|
|
2536
2943
|
const cards = args.cards.split(',').map((c) => c.trim()).filter(Boolean);
|
|
2537
2944
|
patch.cardProps = cards.length ? cards : null;
|
|
2538
2945
|
}
|
|
2946
|
+
// VIEW-DENSITY · FREEZE-COLS — 표 보기를 한눈에(촘촘한 줄·왼쪽 칸 고정).
|
|
2947
|
+
if (args.density !== undefined) {
|
|
2948
|
+
const density = String(args.density);
|
|
2949
|
+
if (!['compact', 'normal'].includes(density)) throw new Error(tr('--density 는 compact · normal 중 하나여야 합니다.', `--density must be compact or normal.`));
|
|
2950
|
+
patch.density = density;
|
|
2951
|
+
}
|
|
2952
|
+
if (args.freeze !== undefined) {
|
|
2953
|
+
const n = Number(args.freeze);
|
|
2954
|
+
if (!Number.isInteger(n) || n < 0 || n > 3) throw new Error(tr('--freeze 는 0~3 (왼쪽에 붙여 둘 칸 수)', `--freeze must be 0-3 (columns kept on the left)`));
|
|
2955
|
+
patch.frozenColumns = n;
|
|
2956
|
+
}
|
|
2539
2957
|
if (!Object.keys(patch).length) {
|
|
2540
|
-
throw new Error(tr('바꿀 것을 주세요: --scale week|month|year, --name
|
|
2958
|
+
throw new Error(tr('바꿀 것을 주세요: --scale week|month|year, --name 새이름, --cards "칸,칸", --density compact|normal 또는 --freeze 0~3', `Give something to change: --scale week|month|year, --name <new name>, --cards "field,field", --density compact|normal, or --freeze 0-3`));
|
|
2541
2959
|
}
|
|
2542
2960
|
|
|
2543
2961
|
const view = await client.updateDatabaseView(id, viewRef, patch);
|
|
@@ -2551,6 +2969,66 @@ async function cmdDbView(args) {
|
|
|
2551
2969
|
}
|
|
2552
2970
|
|
|
2553
2971
|
|
|
2972
|
+
/**
|
|
2973
|
+
* `rootr db column <path|id> <칸 이름|id> [--align left|center|right|auto] [--wrap on|off] [--overdue on|off] [--time on|off] [--range on|off]`
|
|
2974
|
+
*
|
|
2975
|
+
* 칸 하나의 표시 설정(COL-ALIGN·COL-WRAP·DATE-OVERDUE·DATE-NO-TIME/RANGE). 준 것만 바뀐다. `--align auto` 는 기본(숫자 오른쪽).
|
|
2976
|
+
* 옵션 없이 부르면 지금 설정을 보여 준다. 2026-10-21 클린베테랑 P4·P8·P9·P10.
|
|
2977
|
+
*/
|
|
2978
|
+
async function cmdDbColumn(args) {
|
|
2979
|
+
const target = args._[1];
|
|
2980
|
+
const ref = args._[2];
|
|
2981
|
+
const USAGE = tr(
|
|
2982
|
+
`rootr db column <path|id> <칸> [--align left|center|right|auto] [--wrap on|off] [--overdue on|off] [--time on|off] [--range on|off] [--json]`,
|
|
2983
|
+
`rootr db column <path|id> <column> [--align left|center|right|auto] [--wrap on|off] [--overdue on|off] [--time on|off] [--range on|off] [--json]`,
|
|
2984
|
+
);
|
|
2985
|
+
if (!target || !ref) throw new Error(usage(USAGE));
|
|
2986
|
+
const client = makeClient();
|
|
2987
|
+
const id = await resolveDatabaseId(client, target);
|
|
2988
|
+
const db = await client.getDatabase(id);
|
|
2989
|
+
const prop = (db.properties ?? []).find((p) => p.id === ref || p.name.trim().toLowerCase() === String(ref).trim().toLowerCase());
|
|
2990
|
+
if (!prop) throw new Error(tr(`"${ref}" 칸이 없습니다. 칸: ${(db.properties ?? []).map((p) => p.name).join(', ')}`, `No column "${ref}". Columns: ${(db.properties ?? []).map((p) => p.name).join(', ')}`));
|
|
2991
|
+
const onOff = (key) => {
|
|
2992
|
+
const v = args[key];
|
|
2993
|
+
if (v === undefined) return undefined;
|
|
2994
|
+
if (v === 'on' || v === true || v === 'true') return true;
|
|
2995
|
+
if (v === 'off' || v === 'false') return false;
|
|
2996
|
+
throw new Error(tr(`--${key} 는 on · off 중 하나여야 합니다.`, `--${key} must be on or off.`));
|
|
2997
|
+
};
|
|
2998
|
+
const patch = {};
|
|
2999
|
+
if (args.align !== undefined) {
|
|
3000
|
+
const a = String(args.align);
|
|
3001
|
+
if (!['left', 'center', 'right', 'auto'].includes(a)) throw new Error(tr('--align 은 left · center · right · auto', `--align must be left, center, right or auto`));
|
|
3002
|
+
patch.align = a === 'auto' ? null : a;
|
|
3003
|
+
}
|
|
3004
|
+
const wrap = onOff('wrap');
|
|
3005
|
+
if (wrap !== undefined) patch.wrap = wrap;
|
|
3006
|
+
const overdue = onOff('overdue');
|
|
3007
|
+
if (overdue !== undefined) patch.highlightOverdue = overdue;
|
|
3008
|
+
const time = onOff('time');
|
|
3009
|
+
if (time !== undefined) patch.allowTime = time;
|
|
3010
|
+
const range = onOff('range');
|
|
3011
|
+
if (range !== undefined) patch.allowRange = range;
|
|
3012
|
+
const show = (p) => {
|
|
3013
|
+
const parts = [
|
|
3014
|
+
`${tr('정렬', 'align')}=${p.align ?? tr('기본', 'auto')}`,
|
|
3015
|
+
`${tr('줄바꿈', 'wrap')}=${p.wrap ? 'on' : 'off'}`,
|
|
3016
|
+
];
|
|
3017
|
+
if (p.type === 'date') {
|
|
3018
|
+
parts.push(`${tr('지난 날짜 빨강', 'overdue')}=${p.highlightOverdue ? 'on' : 'off'}`);
|
|
3019
|
+
parts.push(`${tr('시간', 'time')}=${p.allowTime === false ? 'off' : 'on'}`);
|
|
3020
|
+
parts.push(`${tr('기간', 'range')}=${p.allowRange === false ? 'off' : 'on'}`);
|
|
3021
|
+
}
|
|
3022
|
+
return `${p.name} (${p.type}) ${parts.join(' ')}`;
|
|
3023
|
+
};
|
|
3024
|
+
if (!Object.keys(patch).length) {
|
|
3025
|
+
console.log(args.json ? JSON.stringify(prop, null, 2) : show(prop));
|
|
3026
|
+
return;
|
|
3027
|
+
}
|
|
3028
|
+
const updated = await client.updateColumn(id, prop.id, patch);
|
|
3029
|
+
console.log(args.json ? JSON.stringify(updated, null, 2) : tr(`칸을 고쳤습니다: ${show(updated)}`, `Column updated: ${show(updated)}`));
|
|
3030
|
+
}
|
|
3031
|
+
|
|
2554
3032
|
/**
|
|
2555
3033
|
* DB-BUTTON — 표 위의 "누르면 일하는 버튼".
|
|
2556
3034
|
*
|
|
@@ -3317,6 +3795,175 @@ async function cmdMailWebhook(client, args) {
|
|
|
3317
3795
|
}
|
|
3318
3796
|
}
|
|
3319
3797
|
|
|
3798
|
+
// ---- custom domains (CUSTOM-DOMAIN — 발행한 페이지를 내 도메인으로) ----------------
|
|
3799
|
+
|
|
3800
|
+
const DOMAIN_USAGE = () =>
|
|
3801
|
+
usage(
|
|
3802
|
+
tr(
|
|
3803
|
+
`rootr domain list | detect <도메인> [--provider <업체>] | add <도메인|이름.rootr.site> <페이지 path|id> [--provider <업체>] [--confirm-charge] | add --subdomain <이름> <페이지> | available <이름> | check <id> | rename <id> <새 도메인> | remove <id>`,
|
|
3804
|
+
`rootr domain list | detect <domain> [--provider <id>] | add <domain|name.rootr.site> <page path|id> [--provider <id>] [--confirm-charge] | add --subdomain <name> <page> | available <name> | check <id> | rename <id> <new domain> | remove <id>`,
|
|
3805
|
+
),
|
|
3806
|
+
);
|
|
3807
|
+
|
|
3808
|
+
const DOMAIN_STATUS = {
|
|
3809
|
+
PENDING_DNS: tr('DNS 확인 중', 'waiting for DNS'),
|
|
3810
|
+
PENDING_CERT: tr('인증서 발급 중', 'issuing certificate'),
|
|
3811
|
+
ACTIVE: tr('연결됨', 'connected'),
|
|
3812
|
+
ERROR: tr('오류', 'error'),
|
|
3813
|
+
SUSPENDED: tr('일시 중지(결제 필요)', 'paused (payment needed)'),
|
|
3814
|
+
REMOVED: tr('해제됨', 'removed'),
|
|
3815
|
+
};
|
|
3816
|
+
const DOMAIN_KIND = { PAID: tr('내 도메인', 'own'), FREE: tr('무료 주소', 'free') };
|
|
3817
|
+
|
|
3818
|
+
function domainWorkspace() {
|
|
3819
|
+
const ws = loadConfig().workspace;
|
|
3820
|
+
if (!ws) throw new Error(tr('워크스페이스가 설정되지 않았습니다. rootr use <워크스페이스> 또는 ROOTR_WORKSPACE', 'No workspace set. rootr use <workspace> or ROOTR_WORKSPACE'));
|
|
3821
|
+
return ws;
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3824
|
+
function printDomainRecords(records) {
|
|
3825
|
+
console.log(tr('DNS 에 이 한 줄을 넣으세요:', 'Add this one DNS line:'));
|
|
3826
|
+
for (const r of records ?? []) {
|
|
3827
|
+
console.log(` ${tr('타입', 'type')} ${r.type} ${tr('이름(호스트)', 'name (host)')} ${r.name} ${tr('값', 'value')} ${r.value ?? tr('(서버에 연결 주소가 아직 없음)', '(not configured on the server yet)')} TTL ${tr('기본값', 'default')}`);
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
function printDomainGuide(provider) {
|
|
3832
|
+
if (!provider) return;
|
|
3833
|
+
const l = lang() === 'ko' ? 'ko' : 'en';
|
|
3834
|
+
console.log(`\n${provider.name?.[l] ?? provider.id}${provider.dnsUrl ? ` — ${provider.dnsUrl}` : ''}`);
|
|
3835
|
+
(provider.steps?.[l] ?? []).forEach((s, i) => console.log(` ${i + 1}. ${s}`));
|
|
3836
|
+
if (provider.quirks?.[l]?.length) {
|
|
3837
|
+
console.log(tr(' 주의:', ' Watch out:'));
|
|
3838
|
+
for (const q of provider.quirks[l]) console.log(` - ${q}`);
|
|
3839
|
+
}
|
|
3840
|
+
}
|
|
3841
|
+
|
|
3842
|
+
function printDomainStatus(d) {
|
|
3843
|
+
const st = DOMAIN_STATUS[d.status] ?? d.status;
|
|
3844
|
+
console.log(`${d.hostname}\t${st}${d.status === 'ACTIVE' ? `\t${d.siteUrl}` : ''}`);
|
|
3845
|
+
if (d.message || d.lastError) console.log(` ${d.message || d.lastError}`);
|
|
3846
|
+
}
|
|
3847
|
+
|
|
3848
|
+
async function cmdDomain(args) {
|
|
3849
|
+
const sub = args._[0];
|
|
3850
|
+
const client = makeClient();
|
|
3851
|
+
switch (sub) {
|
|
3852
|
+
case undefined:
|
|
3853
|
+
case 'list':
|
|
3854
|
+
case 'ls': {
|
|
3855
|
+
const out = await client.listCustomDomains(domainWorkspace());
|
|
3856
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3857
|
+
if (out.slots) console.log(slotLine(out.slots, tr));
|
|
3858
|
+
if (!out.domains?.length) return console.log(tr('연결된 도메인이 없습니다. rootr domain add <도메인> <페이지> (무료: rootr domain add --subdomain <이름> <페이지>)', 'No custom domains. rootr domain add <domain> <page> (free: rootr domain add --subdomain <name> <page>)'));
|
|
3859
|
+
for (const d of out.domains) {
|
|
3860
|
+
console.log(`${d.id}\t${d.hostname}\t${DOMAIN_KIND[d.kind] ?? DOMAIN_KIND.PAID}\t${DOMAIN_STATUS[d.status] ?? d.status}\t${d.rootNode ? d.rootNode.name : tr('(페이지 없음)', '(page missing)')}`);
|
|
3861
|
+
if (d.status !== 'ACTIVE' && d.lastError) console.log(` ${d.lastError}`);
|
|
3862
|
+
}
|
|
3863
|
+
return;
|
|
3864
|
+
}
|
|
3865
|
+
case 'available': {
|
|
3866
|
+
const name = args._[1];
|
|
3867
|
+
if (!name) throw new Error(DOMAIN_USAGE());
|
|
3868
|
+
const out = await client.customSubdomainAvailability(domainWorkspace(), name);
|
|
3869
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3870
|
+
const why = {
|
|
3871
|
+
invalid: tr('영어 소문자·숫자·하이픈 3~30자, 앞뒤 하이픈 없이', 'lowercase a-z 0-9 and hyphen, 3–30 chars, no leading/trailing hyphen'),
|
|
3872
|
+
reserved: tr('예약된 이름', 'reserved name'),
|
|
3873
|
+
taken: tr('이미 다른 곳에서 씀', 'already taken'),
|
|
3874
|
+
mine: tr('이미 이 워크스페이스 것', 'already yours'),
|
|
3875
|
+
limit: tr(`워크스페이스당 ${out.limit}개까지`, `limit of ${out.limit} per workspace reached`),
|
|
3876
|
+
};
|
|
3877
|
+
console.log(out.available ? tr(`${out.hostname} — 쓸 수 있습니다`, `${out.hostname} — available`) : `${out.hostname} — ${tr('안 됩니다', 'not available')}: ${why[out.reason] ?? out.reason}`);
|
|
3878
|
+
if (!out.available) process.exitCode = 2;
|
|
3879
|
+
return;
|
|
3880
|
+
}
|
|
3881
|
+
case 'detect': {
|
|
3882
|
+
const host = args._[1];
|
|
3883
|
+
if (!host) throw new Error(DOMAIN_USAGE());
|
|
3884
|
+
const out = await client.detectCustomDomain(domainWorkspace(), host, typeof args.provider === 'string' ? args.provider : undefined);
|
|
3885
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3886
|
+
if (out.isApex) console.log(tr(`${out.hostname} 은(는) 루트 도메인이라 CNAME 을 못 거는 곳이 많습니다 — ${out.suggestedHostname} 로 연결하고 루트는 포워딩하세요.`, `${out.hostname} is a root domain; most providers cannot put a CNAME there — connect ${out.suggestedHostname} and forward the root.`));
|
|
3887
|
+
console.log(out.detectedProvider ? tr(`DNS 관리: ${out.provider.name.ko} (이름서버 ${out.nameservers.join(', ')})`, `DNS managed at: ${out.provider.name.en} (name servers ${out.nameservers.join(', ')})`) : tr('DNS 관리 업체를 알아내지 못했습니다 — 일반 안내를 보여 드립니다.', 'Could not tell where the DNS is managed — showing the generic guide.'));
|
|
3888
|
+
printDomainRecords([out.record]);
|
|
3889
|
+
printDomainGuide(out.provider);
|
|
3890
|
+
return;
|
|
3891
|
+
}
|
|
3892
|
+
case 'add': {
|
|
3893
|
+
// 무료 주소는 `--subdomain <이름> <페이지>` 로도 받는다(그때는 페이지가 첫 칸).
|
|
3894
|
+
const subdomain = typeof args.subdomain === 'string' ? args.subdomain : undefined;
|
|
3895
|
+
const host = subdomain ? undefined : args._[1];
|
|
3896
|
+
const page = subdomain ? args._[1] : args._[2];
|
|
3897
|
+
if ((!host && !subdomain) || !page) throw new Error(DOMAIN_USAGE());
|
|
3898
|
+
const rootNodeId = requireResolved(await resolveTarget(client, page));
|
|
3899
|
+
let out;
|
|
3900
|
+
try {
|
|
3901
|
+
out = await client.addCustomDomain(domainWorkspace(), {
|
|
3902
|
+
hostname: host,
|
|
3903
|
+
subdomain,
|
|
3904
|
+
rootNodeId,
|
|
3905
|
+
provider: typeof args.provider === 'string' ? args.provider : undefined,
|
|
3906
|
+
confirmCharge: args['confirm-charge'] === true || args.yes === true,
|
|
3907
|
+
});
|
|
3908
|
+
} catch (err) {
|
|
3909
|
+
const help = err instanceof RootrApiError ? domainPaymentHelp(err, tr) : null;
|
|
3910
|
+
if (help) err.hint = help;
|
|
3911
|
+
throw err;
|
|
3912
|
+
}
|
|
3913
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3914
|
+
if (out.kind === 'FREE') {
|
|
3915
|
+
console.log(tr(`무료 주소가 바로 열렸습니다: https://${out.hostname} (id ${out.id}) — DNS 설정이 필요 없습니다.`, `The free address is live: https://${out.hostname} (id ${out.id}) — no DNS setup needed.`));
|
|
3916
|
+
return;
|
|
3917
|
+
}
|
|
3918
|
+
if (out.billing?.charged) console.log(tr(`도메인 연결 애드온을 ${out.billing.quantity}개로 늘렸습니다(일할 계산 청구).`, `Custom-domain add-on increased to ${out.billing.quantity} (prorated charge).`));
|
|
3919
|
+
console.log(tr(`등록했습니다: ${out.hostname} (id ${out.id})`, `Registered ${out.hostname} (id ${out.id})`));
|
|
3920
|
+
printDomainRecords(out.records);
|
|
3921
|
+
printDomainGuide(out.guide?.provider);
|
|
3922
|
+
console.log(tr(`\n넣은 뒤: rootr domain check ${out.id} (5분마다 저절로도 확인하고, 연결되면 알림이 갑니다)`, `\nThen: rootr domain check ${out.id} (also re-checked every 5 minutes; you get a notification when it is live)`));
|
|
3923
|
+
return;
|
|
3924
|
+
}
|
|
3925
|
+
case 'check': {
|
|
3926
|
+
const id = args._[1];
|
|
3927
|
+
if (!id) throw new Error(DOMAIN_USAGE());
|
|
3928
|
+
const out = await client.checkCustomDomain(domainWorkspace(), id);
|
|
3929
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3930
|
+
printDomainStatus(out);
|
|
3931
|
+
if (out.status !== 'ACTIVE') process.exitCode = 2;
|
|
3932
|
+
return;
|
|
3933
|
+
}
|
|
3934
|
+
case 'rename':
|
|
3935
|
+
case 'mv': {
|
|
3936
|
+
const id = args._[1];
|
|
3937
|
+
const host = args._[2];
|
|
3938
|
+
if (!id || !host) throw new Error(DOMAIN_USAGE());
|
|
3939
|
+
const out = await client.renameCustomDomain(domainWorkspace(), id, { hostname: host, provider: typeof args.provider === 'string' ? args.provider : undefined });
|
|
3940
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3941
|
+
console.log(tr(`이름을 바꿨습니다: ${out.renamedFrom?.hostname ?? id} → ${out.hostname} (새 id ${out.id}, 결제 그대로)`, `Renamed ${out.renamedFrom?.hostname ?? id} → ${out.hostname} (new id ${out.id}, billing unchanged)`));
|
|
3942
|
+
printDomainRecords(out.records);
|
|
3943
|
+
printDomainGuide(out.guide?.provider);
|
|
3944
|
+
console.log(tr(`\n넣은 뒤: rootr domain check ${out.id}`, `\nThen: rootr domain check ${out.id}`));
|
|
3945
|
+
return;
|
|
3946
|
+
}
|
|
3947
|
+
case 'remove':
|
|
3948
|
+
case 'rm':
|
|
3949
|
+
case 'delete': {
|
|
3950
|
+
const id = args._[1];
|
|
3951
|
+
if (!id) throw new Error(DOMAIN_USAGE());
|
|
3952
|
+
const out = await client.removeCustomDomain(domainWorkspace(), id);
|
|
3953
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3954
|
+
if (out.kind === 'FREE') console.log(tr(`${out.hostname} 무료 주소를 해제했습니다.`, `Released the free address ${out.hostname}.`));
|
|
3955
|
+
else console.log(tr(`${out.hostname} 연결을 해제했습니다. DNS 의 CNAME 줄도 지워 주세요.`, `Disconnected ${out.hostname}. Remove the CNAME line at your DNS provider too.`));
|
|
3956
|
+
const b = out.billing;
|
|
3957
|
+
if (b?.action === 'decreased') console.log(tr(`도메인 연결 애드온을 ${b.quantity}개로 줄였습니다(남은 기간은 일할 계산으로 돌려받습니다).`, `Custom-domain add-on reduced to ${b.quantity} (prorated credit).`));
|
|
3958
|
+
if (b?.action === 'cancel_scheduled') console.log(tr(`마지막 도메인이라 도메인 연결 애드온이 ${String(b.cancelAt).slice(0, 10)} 에 끝나도록 예약했습니다(그때까지 자리는 남습니다).`, `That was the last one — the custom-domain add-on ends on ${String(b.cancelAt).slice(0, 10)} (the slot stays until then).`));
|
|
3959
|
+
if (b?.action === 'failed') console.log(tr('결제 수량 조정이 실패했습니다 — 설정 › 결제에서 확인해 주세요.', 'Could not adjust the add-on quantity — check Settings → Billing.'));
|
|
3960
|
+
return;
|
|
3961
|
+
}
|
|
3962
|
+
default:
|
|
3963
|
+
throw new Error(DOMAIN_USAGE());
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3320
3967
|
async function main() {
|
|
3321
3968
|
const argv = process.argv.slice(2);
|
|
3322
3969
|
const args = parseArgs(argv);
|
|
@@ -3336,6 +3983,10 @@ async function main() {
|
|
|
3336
3983
|
return cmdLs(args);
|
|
3337
3984
|
case 'comments':
|
|
3338
3985
|
return cmdComments(args);
|
|
3986
|
+
case 'members':
|
|
3987
|
+
return cmdMembers(args);
|
|
3988
|
+
case 'message':
|
|
3989
|
+
return cmdMessage(args);
|
|
3339
3990
|
case 'read':
|
|
3340
3991
|
return cmdRead(args);
|
|
3341
3992
|
case 'write':
|
|
@@ -3386,6 +4037,9 @@ async function main() {
|
|
|
3386
4037
|
return cmdSend(args);
|
|
3387
4038
|
case 'mail':
|
|
3388
4039
|
return cmdMail(args);
|
|
4040
|
+
case 'domain':
|
|
4041
|
+
case 'domains':
|
|
4042
|
+
return cmdDomain(args);
|
|
3389
4043
|
case 'feedback':
|
|
3390
4044
|
return cmdFeedback(args);
|
|
3391
4045
|
case 'scaffold':
|