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/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 서버 구동 (도구 197종 — README 참고)
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 (197 tools — see the README)
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
- await client.request(`/forms/${encodeURIComponent(id)}/collect`, { method: 'POST', body: { to } });
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 새이름 또는 --cards "칸,칸"', `Give something to change: --scale week|month|year, --name <new name>, or --cards "field,field"`));
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':