rootr-cli 0.26.0 → 0.30.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] 문서/폴더를 휴지통으로 이동 (복구 가능)
@@ -53,6 +56,9 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
53
56
  rootr db keep-duplicate <path|id> <rowId> --from <이전 rowId> 새 행으로 두되 이전 행과 서로 링크
54
57
  rootr db rename-col <path|id> <칸 이름|id> <새 이름> [--dry] 칸 이름 바꾸기 — 수식·버튼/규칙 {칸}·다른 표 롤업·폼 질문·페이지 블록도 함께(--dry: 무엇이 바뀌는지만)
55
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> 중복 처리 한 번을 통째로 되돌리기(지운 행 되살림)
56
62
  rootr db column-set <path|id> <칸 이름|id> --json '<설정>' 칸 설정 바꾸기 — 예: '{"validate":{"kind":"pattern","pattern":"[A-Z]{2}-\\\\d{4}","message":"사번 꼴이 아닙니다"}}', '{"dedupeCarry":{…}}'
57
63
  rootr db rule-violations <path|id> [--limit N] [--json] 지금 값 가운데 칸 검사(validate)에 안 맞는 것(고치지 않는다)
58
64
  rootr db rule-test --json '<규칙>' <값> [<값>…] [--type number] 값 검사 규칙을 걸기 전에 시험(저장 안 함) · --presets: 예시 꼴 목록
@@ -67,6 +73,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
67
73
  rootr publish <path|id> --status 지금 공개인지·주소·하위 포함 여부
68
74
  rootr publish <표> --layout board --skin paper 공개한 표를 무슨 꼴로 보일지 (배치 9종 × 분위기 6종)
69
75
  rootr publish <문서> --comments [false] [--expires <ISO>] [--skin <분위기>] 문서 전용 — 댓글(편집 제안)·만료일·스킨 16종
76
+ rootr publish <path|id> --description "<글>" --cover <그림|id|none> 검색 설명문·링크 미리보기 대표 이미지 (검색엔진에 바로 알림)
70
77
  rootr booking show <표> 방문자 일정 등록(예약) 설정 보기
71
78
  rootr booking set <표> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 예약 켜기·규칙
72
79
  rootr booking set <표> --ask "연락처:required" --approval manual 방문자에게 더 묻기 · 승인 후 확정
@@ -86,6 +93,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
86
93
  rootr form collect <path|id> --to db|sheet 응답 모을 곳을 새로 만들어 연결 (표는 질문 그대로 칸 생성)
87
94
  rootr form mapping <path|id> [--set '<{"질문id":"칸id"}>'] 질문이 표의 어느 칸으로 가는지 보기·정하기 (짝 없으면 값이 버려짐)
88
95
  rootr form responses <path|id> [--latest] [--json] 폼에 들어온 응답 보기 (--latest: 사람마다 최신 1건만)
96
+ rootr form submit <path|id> [--values '<json>'] [--files '{"질문id":["a.pdf","b.pdf"]}'] 응답 내기 (파일은 올린 뒤 답으로 붙임 — 여러 개 받는 질문은 배열로)
89
97
  rootr form response-edit <응답id> --values '<json>' 이미 낸 응답 고치기 (폼 검사 규칙 다시 통과)
90
98
  rootr form response-rm <응답id> [--yes] 응답 한 건 삭제 (되돌릴 수 없음)
91
99
  rootr sign new <경로> [--file <f>] [--reviewer <userId>] [--line <line.json>] 서명문서 만들기(본문·결재라인까지)
@@ -112,6 +120,17 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
112
120
  rootr mail webhook add <url> [--events …] | list | rm <id> | test <id>
113
121
  받은 문서·회수·읽음을 내 주소로 알려 주는 웹훅(내 계정 것, HMAC 서명)
114
122
  ※ 다른 명령 뒤 새로 받은 문서가 있으면 stderr 로 한 줄 알림(끄기: ROOTR_MAIL_NOTICE=0)
123
+ rootr page stats <페이지 path|id> [--json] 방문 기록 상태 — 최근 7일 방문자·방문·전환, 기록(LOG)·일별 표·대시보드 위치
124
+ rootr page track <페이지 path|id> on|off [--locale ko|en] 방문 기록 켜기(페이지 옆에 「방문 분석」 폴더: 방문 기록 LOG·일별 방문 표·대시보드)·끄기(노드는 남음)
125
+ rootr page recount <페이지 path|id> [--days 90] 일별 방문 표를 지금 다시 세기(보통은 15분마다 자동)
126
+ rootr domain list 내 도메인 목록 — 종류(내 도메인·무료 주소)·상태와 가리키는 페이지, 자리 사용(유료 n/m·무료 n/3)
127
+ rootr domain detect <도메인> [--provider <업체>] 등록 전에 — DNS 를 어디서 관리하는지(가비아·Cloudflare…)와 넣을 한 줄·단계 안내
128
+ rootr domain add <도메인> <페이지·문서 path|id> [--provider <업체>] [--confirm-charge] 발행한 페이지·문서를 내 도메인(www.회사.co.kr)으로 연결 (Team 플랜, 도메인 1개당 월 $10·연 결제 시 월 $8 — 자리가 꽉 찼으면 --confirm-charge 로 1개 추가 결제)
129
+ rootr domain add <이름>.rootr.site <페이지 path|id> 무료 주소로 바로 열기(Team 플랜, DNS·결제 없음, 3개까지) — --subdomain <이름> 으로도
130
+ rootr domain available <이름> 무료 주소 이름(이름.rootr.site)을 쓸 수 있는지
131
+ rootr domain check <id> 지금 다시 확인(연결 전이면 exit 2)
132
+ rootr domain rename <id> <새 도메인> [--provider <업체>] 연결 전 도메인의 이름 바꾸기(결제 그대로, 새 id 가 나옴)
133
+ rootr domain remove <id> 연결 해제
115
134
  rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] 설정 조회/저장
116
135
  rootr config --add-key <k> [--label <name>] [--workspace <w>] 워크스페이스별 키 추가 등록
117
136
  rootr feedback "<무엇이 안 되는지>" [--kind bug|friction|idea|praise] [--email <주소>]
@@ -125,7 +144,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
125
144
  rootr memory add "<내용>" [--kind 지시|사람|프로젝트|참고] [--source <경로|id>] [--related a,b] 기억 저장
126
145
  rootr memory edit <id> [--content "<내용>"] [--kind …] [--source …] [--related a,b] 기억 고치기(준 칸만)
127
146
  rootr memory forget <id> [--hard] 기억 폐기(--hard면 행 삭제)
128
- rootr mcp MCP stdio 서버 구동 (도구 197종 — README 참고)
147
+ rootr mcp MCP stdio 서버 구동 (도구 214종 — README 참고)
129
148
  rootr --help 도움말
130
149
  rootr --version 버전 출력
131
150
 
@@ -146,6 +165,8 @@ usage:
146
165
  rootr embed <doc path|id> <node path|id> [--heading "## X"] embed a LIVE table/sheet/deck inside a document
147
166
  rootr edit <path|id> --find <text> --replace <text> [--all] replace text inside the body
148
167
  rootr comments <path|id> [--block <id>] [--json] read comments (including ones left on the public page)
168
+ rootr members [--workspace <id>] [--json] list workspace members (id\tname\temail\trole)
169
+ rootr message <userId> <text> [--workspace <id>] send a direct message to a member (not tied to a doc/issue)
149
170
  rootr search <query> search the workspace
150
171
  rootr mv <path|id> <new path|folder id> move and/or rename a document or folder
151
172
  rootr rm <path|id> [--yes] move to the trash (recoverable)
@@ -178,6 +199,9 @@ usage:
178
199
  rootr db keep-duplicate <path|id> <rowId> --from <earlier rowId> keep as a new row but link both rows
179
200
  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)
180
201
  rootr db carry-over <path|id> <rowId> [--from <earlier rowId>] carry the person's previous row into this row (earlier row untouched)
202
+ rootr db dedupe-preview <path|id> <rowId> [--json '<rule overrides>'] preview duplicate handling — per-column before/after/source, rows to delete
203
+ rootr db dedupe <path|id> <rowId> [--action carry|merge_into_old|merge_all] [--json '<rule overrides>'] apply duplicate handling (undo by opId)
204
+ rootr db dedupe-undo <path|id> <opId> undo one duplicate-handling operation (recreates deleted rows)
181
205
  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"}}'
182
206
  rootr db rule-violations <path|id> [--limit N] [--json] current values that break a column's validate rule (nothing is changed)
183
207
  rootr db rule-test --json '<rule>' <value> [<value>…] [--type number] try a validate rule on sample values (saves nothing) · --presets: example patterns
@@ -192,6 +216,7 @@ usage:
192
216
  rootr publish <path|id> --status is it public, at what URL, children included?
193
217
  rootr publish <table> --layout board --skin paper how a published table looks (9 layouts × 6 skins)
194
218
  rootr publish <doc> --comments [false] [--expires <ISO>] [--skin <skin>] documents only — comments, expiry, 16 skins
219
+ rootr publish <path|id> --description "<text>" --cover <image|id|none> search description & link-preview cover image (search engines pinged)
195
220
  rootr booking show <table> show visitor booking settings
196
221
  rootr booking set <table> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 turn on + rules
197
222
  rootr booking set <table> --ask "Phone:required" --approval manual ask more · confirm after approval
@@ -210,6 +235,7 @@ usage:
210
235
  rootr form set <path|id> --fields '<json>' | --config '<json>' change questions/settings (settings merge)
211
236
  rootr form collect <path|id> --to db|sheet create and wire a destination (a table gets a column per question)
212
237
  rootr form responses <path|id> [--latest] [--json] read responses (--latest: newest one per person)
238
+ 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)
213
239
  rootr form response-edit <responseId> --values '<json>' edit a submitted response (revalidated against the form)
214
240
  rootr form response-rm <responseId> [--yes] delete one response (cannot be undone)
215
241
  rootr sign new <path> [--file <f>] [--reviewer <userId>] [--line <line.json>] create a signed document (body + approval line)
@@ -233,6 +259,17 @@ usage:
233
259
  rootr mail access <id> read|comment|write change what the recipient of a document you sent can do
234
260
  rootr mail revoke <id> withdraw a document you sent — the recipient can no longer open the original
235
261
  rootr mail hide <id> [--box inbox|sent] remove from your own mailbox (a received one then stops opening for your account)
262
+ rootr page stats <page path|id> [--json] visit tracking status — last 7 days visitors/sessions/conversions, where the log, daily table and dashboard are
263
+ rootr page track <page path|id> on|off [--locale ko|en] turn visit tracking on (an "Analytics" folder next to the page: visit LOG, daily visits table, dashboard) or off (nodes stay)
264
+ rootr page recount <page path|id> [--days 90] recount the daily visits table now (normally every 15 minutes)
265
+ rootr domain list your custom domains — status (waiting for DNS / issuing certificate / connected) and page
266
+ rootr domain detect <domain> [--provider <id>] before adding — where the DNS is managed (Gabia, Cloudflare…), the one line to add, step by step
267
+ 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)
268
+ 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>
269
+ rootr domain available <name> is the free address name.rootr.site available
270
+ rootr domain check <id> re-check now (exit 2 while not live)
271
+ rootr domain rename <id> <new domain> [--provider <id>] change the name of a not-yet-live domain (billing unchanged, new id)
272
+ rootr domain remove <id> disconnect
236
273
  rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] read/save configuration
237
274
  rootr config --add-key <k> [--label <name>] [--workspace <w>] register an extra per-workspace key
238
275
  rootr feedback "<what is not working>" [--kind bug|friction|idea|praise] [--email <address>]
@@ -246,7 +283,7 @@ usage:
246
283
  rootr memory add "<content>" [--kind instruction|person|project|reference] [--source <path|id>] [--related a,b] save a memory
247
284
  rootr memory edit <id> [--content "<content>"] [--kind …] [--source …] [--related a,b] edit a memory (only the fields you pass)
248
285
  rootr memory forget <id> [--hard] retire a memory (--hard deletes the row)
249
- rootr mcp run the MCP stdio server (197 tools — see the README)
286
+ rootr mcp run the MCP stdio server (214 tools — see the README)
250
287
  rootr --help this help
251
288
  rootr --version print the version
252
289
 
@@ -373,6 +410,51 @@ async function cmdComments(args) {
373
410
  }
374
411
  }
375
412
 
413
+ /** `rootr members [--workspace <id>]` — list the current workspace's members (id, name, email, role). */
414
+ async function cmdMembers(args) {
415
+ const cfg = loadConfig();
416
+ const workspace = args.workspace || cfg.workspace;
417
+ if (!workspace) {
418
+ throw new RootrApiError(tr('워크스페이스가 설정되지 않았습니다.', `No workspace is configured.`), {
419
+ 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.`),
420
+ });
421
+ }
422
+ const client = new RootrClient({ ...cfg, workspace, apiKey: keyForWorkspace(cfg, workspace) });
423
+ const members = await client.listMembers(workspace);
424
+ if (args.json) {
425
+ console.log(JSON.stringify(members, null, 2));
426
+ return;
427
+ }
428
+ for (const m of members) {
429
+ console.log(`${m.userId ?? m.user?.id}\t${m.user?.name ?? ''}\t${m.user?.email ?? ''}\t${m.role}`);
430
+ }
431
+ }
432
+
433
+ /**
434
+ * MEMBER-MSG — `rootr message <userId> <text> [--workspace <id>]`
435
+ *
436
+ * 2026-10-22 제보(#226): 앱에서는 구성원에게 바로 메시지를 보낼 수 있는데 CLI/MCP에는
437
+ * 그 길이 없어서 이슈 댓글에 @멘션을 다는 것으로 때워야 했다. 문서/이슈에 매이지 않은
438
+ * 1:1 알림 경로를 그대로 연다.
439
+ */
440
+ async function cmdMessage(args) {
441
+ const userId = args._[0];
442
+ const body = args._[1];
443
+ if (!userId || !body) {
444
+ throw new Error(usage(`rootr message <userId> <text> [--workspace <id>]`));
445
+ }
446
+ const cfg = loadConfig();
447
+ const workspace = args.workspace || cfg.workspace;
448
+ if (!workspace) {
449
+ throw new RootrApiError(tr('워크스페이스가 설정되지 않았습니다.', `No workspace is configured.`), {
450
+ 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.`),
451
+ });
452
+ }
453
+ const client = new RootrClient({ ...cfg, workspace, apiKey: keyForWorkspace(cfg, workspace) });
454
+ await client.messageMember(workspace, userId, body);
455
+ console.log(tr(`${userId} 에게 메시지를 보냈습니다.`, `Message sent to ${userId}.`));
456
+ }
457
+
376
458
  async function cmdRead(args) {
377
459
  const target = args._[0];
378
460
  if (!target) throw new Error(usage(`rootr read <path|id> [--json]`));
@@ -1024,10 +1106,11 @@ async function cmdForm(args) {
1024
1106
  if (sub === 'collect') return cmdFormCollect(args);
1025
1107
  if (sub === 'mapping') return cmdFormMapping(args);
1026
1108
  if (sub === 'responses') return cmdFormResponses(args);
1109
+ if (sub === 'submit') return cmdFormSubmit(args);
1027
1110
  if (sub === 'response-edit') return cmdFormResponseEdit(args);
1028
1111
  if (sub === 'response-rm') return cmdFormResponseRm(args);
1029
1112
  throw new Error(
1030
- 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)`)),
1113
+ 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)`)),
1031
1114
  );
1032
1115
  }
1033
1116
 
@@ -1088,7 +1171,8 @@ async function cmdFormNew(args) {
1088
1171
  console.log(` id: ${id}`);
1089
1172
  if (args.collect === 'db' || args.collect === 'sheet') {
1090
1173
  const to = args.collect === 'db' ? 'database' : 'spreadsheet';
1091
- await client.request(`/forms/${encodeURIComponent(id)}/collect`, { method: 'POST', body: { to } });
1174
+ // 서버는 `target` 만 받는다(`to` 는 검증 파이프가 지워 400 이 났다) — form collect 와 같은 길로.
1175
+ await client.createFormCollector(id, to);
1092
1176
  console.log(tr(` 응답 모을 곳: ${args.collect === 'db' ? '표' : '스프레드시트'} 생성됨`, ` Responses collected into: a ${args.collect === 'db' ? 'table' : 'spreadsheet'} was created`));
1093
1177
  }
1094
1178
  }
@@ -1112,6 +1196,7 @@ async function cmdFormShow(args) {
1112
1196
  f.hidden ? tr('숨김', `hidden`) : null,
1113
1197
  f.page && f.page > 1 ? tr(`${f.page}쪽`, `page ${f.page}`) : null,
1114
1198
  f.showIf ? tr('조건부', `conditional`) : null,
1199
+ f.type === 'file' && f.multiple ? tr(`파일 여러 개(최대 ${f.maxFiles ?? 10})`, `several files (max ${f.maxFiles ?? 10})`) : null,
1115
1200
  ].filter(Boolean);
1116
1201
  console.log(` ${i + 1}. ${f.name} [${f.type}]${marks.length ? ' · ' + marks.join('·') : ''}`);
1117
1202
  if (f.help) console.log(` ${f.help}`);
@@ -1263,6 +1348,83 @@ async function cmdFormResponses(args) {
1263
1348
  }
1264
1349
  }
1265
1350
 
1351
+ /**
1352
+ * FORM-FILE-MULTI — `rootr form submit <폼> --values '<json>' --files '{"질문id":["a.pdf","b.pdf"]}'`
1353
+ *
1354
+ * 파일 질문은 값으로 **이미 올라간 파일의 표**(`{ id, filename, size }`)를 받는다. 터미널에서 그걸 손으로
1355
+ * 만들려면 `rootr attach` 를 파일마다 부르고 id 를 옮겨 적어야 했다 — 여기서 올리기와 내기를 한 번에 한다.
1356
+ * 여러 개 받는 질문(`multiple`)은 배열로, 한 개 받는 질문은 객체로 붙인다(서버 약속과 같은 모양).
1357
+ */
1358
+ async function cmdFormSubmit(args) {
1359
+ const target = args._[1];
1360
+ const usageLine = tr(
1361
+ `rootr form submit <path|id> [--values '<json>'] [--files '{"질문id":["a.pdf","b.pdf"]}']`,
1362
+ `rootr form submit <path|id> [--values '<json>'] [--files '{"fieldId":["a.pdf","b.pdf"]}']`,
1363
+ );
1364
+ if (!target) throw new Error(usage(usageLine));
1365
+ const parseJson = (raw, flag) => {
1366
+ try {
1367
+ return JSON.parse(raw);
1368
+ } catch {
1369
+ throw new Error(tr(`${flag} 를 JSON으로 읽을 수 없습니다.`, `Could not read ${flag} as JSON.`));
1370
+ }
1371
+ };
1372
+ const values = typeof args.values === 'string' ? parseJson(args.values, '--values') : {};
1373
+ const files = typeof args.files === 'string' ? parseJson(args.files, '--files') : {};
1374
+ if (!values || typeof values !== 'object' || Array.isArray(values) || !files || typeof files !== 'object' || Array.isArray(files)) {
1375
+ throw new Error(usage(usageLine));
1376
+ }
1377
+
1378
+ const cfg = loadConfig();
1379
+ const workspace = args.workspace || cfg.workspace;
1380
+ const client = new RootrClient({ ...cfg, workspace, apiKey: keyForWorkspace(cfg, workspace) });
1381
+ const id = await resolveDatabaseId(client, target);
1382
+ const form = await client.getForm(id);
1383
+ const byKey = new Map();
1384
+ for (const f of form.fields ?? []) {
1385
+ byKey.set(f.id, f);
1386
+ if (!byKey.has(f.name)) byKey.set(f.name, f); // 질문 이름으로 적어도 된다
1387
+ }
1388
+
1389
+ for (const [key, list] of Object.entries(files)) {
1390
+ const field = byKey.get(key);
1391
+ if (!field || field.type !== 'file') {
1392
+ throw new Error(tr(`"${key}" 은 이 폼의 파일 질문이 아닙니다.`, `"${key}" is not a file question on this form.`));
1393
+ }
1394
+ const paths = Array.isArray(list) ? list : [list];
1395
+ if (paths.length > 1 && !field.multiple) {
1396
+ throw new Error(
1397
+ tr(
1398
+ `"${field.name}" 은 파일을 한 개만 받습니다. 여러 개를 받으려면: rootr form set <폼> --fields 로 그 질문에 "multiple": true`,
1399
+ `"${field.name}" takes a single file. To take several, set "multiple": true on it with rootr form set <form> --fields`,
1400
+ ),
1401
+ );
1402
+ }
1403
+ if (!workspace) {
1404
+ throw new RootrApiError(tr('워크스페이스가 설정되지 않았습니다.', `No workspace is configured.`), {
1405
+ hint: tr('--workspace <id> 를 주세요.', `Pass --workspace <id>.`),
1406
+ });
1407
+ }
1408
+ const uploaded = [];
1409
+ for (const filePath of paths) {
1410
+ const filename = basename(String(filePath));
1411
+ const mimeType = MIME_BY_EXT[extname(filename).toLowerCase()] || 'application/octet-stream';
1412
+ const att = await client.uploadAttachment(workspace, { filename, mimeType, nodeId: id, data: readFileSync(String(filePath)) });
1413
+ uploaded.push({ id: att.id, filename });
1414
+ console.error(tr(`올림: ${filename} (id ${att.id})`, `uploaded ${filename} (id ${att.id})`));
1415
+ }
1416
+ values[field.id] = field.multiple ? uploaded : uploaded[0];
1417
+ }
1418
+
1419
+ const result = await client.createFormResponse(id, values);
1420
+ if (args.json) {
1421
+ console.log(JSON.stringify(result, null, 2));
1422
+ return;
1423
+ }
1424
+ console.log(tr(`응답 접수됨: ${result.id ?? ''}`, `Response submitted: ${result.id ?? ''}`));
1425
+ console.log(` ${JSON.stringify(result.values ?? values)}`);
1426
+ }
1427
+
1266
1428
  async function cmdFormResponseEdit(args) {
1267
1429
  const responseId = args._[1];
1268
1430
  if (!responseId) throw new Error(usage(tr("rootr form response-edit <응답id> --values '<json>'", "rootr form response-edit <responseId> --values '<json>'")));
@@ -1805,12 +1967,53 @@ async function cmdDbCarryOver(args) {
1805
1967
  if (args.json) return console.log(JSON.stringify(res, null, 2));
1806
1968
  console.log(
1807
1969
  tr(
1808
- `이전 행(${res.fromTitle || res.fromRowId})에서 가져왔습니다 — 옮긴 칸: ${res.copied.join(', ') || '없음'}${res.marked.length ? ` · 표시: ${res.marked.join(', ')}` : ''}`,
1809
- `Carried from ${res.fromTitle || res.fromRowId} — copied: ${res.copied.join(', ') || 'none'}${res.marked.length ? ` · marked: ${res.marked.join(', ')}` : ''}`,
1970
+ res.nothingChanged && !res.marked.length
1971
+ ? `같은 값의 이전 행 ${res.fromRowIds?.length ?? 1}개를 찾았지만 채울 칸이 없었습니다(바뀐 칸 없음).`
1972
+ : `이전 행 ${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}` : ''}`,
1973
+ res.nothingChanged && !res.marked.length
1974
+ ? `Found ${res.fromRowIds?.length ?? 1} earlier row(s) but nothing to fill (no column changed).`
1975
+ : `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}` : ''}`,
1810
1976
  ),
1811
1977
  );
1812
1978
  }
1813
1979
 
1980
+ /** DEDUPE-RULES — `rootr db dedupe-preview|dedupe <표> <rowId> [--action …] [--json '<규칙 덮기>']` */
1981
+ async function cmdDbDedupe(args, dryRun) {
1982
+ const [, target, rowId] = args._;
1983
+ 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>']`));
1984
+ let rules;
1985
+ if (typeof args.json === 'string') {
1986
+ try {
1987
+ rules = JSON.parse(args.json);
1988
+ } catch {
1989
+ throw new Error(tr('--json 이 JSON 이 아닙니다.', '--json is not valid JSON.'));
1990
+ }
1991
+ }
1992
+ const client = makeClient();
1993
+ const id = await resolveDatabaseId(client, target);
1994
+ const res = await client.dedupe(id, rowId, { action: typeof args.action === 'string' ? args.action : undefined, rules, dryRun });
1995
+ if (args.json === true || args.raw) return console.log(JSON.stringify(res, null, 2));
1996
+ console.log(
1997
+ tr(
1998
+ `${dryRun ? '미리 보기' : '적용'} — ${res.action} · 남는 행 ${res.targetRowId}${res.deleted?.length ? ` · 지움 ${res.deleted.length}행` : ''}${res.kept?.length ? ` · 이어 둠 ${res.kept.length}행` : ''}${res.opId ? ` · opId ${res.opId}` : ''}`,
1999
+ `${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}` : ''}`,
2000
+ ),
2001
+ );
2002
+ if (!res.columns?.length) console.log(tr(' 바뀌는 칸 없음', ' no column changes'));
2003
+ for (const c of res.columns ?? []) console.log(` ${c.column} [${c.strategy}]: ${JSON.stringify(c.before)} → ${JSON.stringify(c.after)}`);
2004
+ }
2005
+
2006
+ /** DEDUPE-RULES — `rootr db dedupe-undo <표> <opId>` */
2007
+ async function cmdDbDedupeUndo(args) {
2008
+ const [, target, opId] = args._;
2009
+ if (!target || !opId) throw new Error(usage(`rootr db dedupe-undo <path|id> <opId>`));
2010
+ const client = makeClient();
2011
+ const id = await resolveDatabaseId(client, target);
2012
+ const res = await client.dedupeUndo(id, opId);
2013
+ if (args.json) return console.log(JSON.stringify(res, null, 2));
2014
+ console.log(tr(`되돌렸습니다 — ${res.rows.map((r) => `${r.rowId}(${r.state})`).join(', ')}`, `Undone — ${res.rows.map((r) => `${r.rowId}(${r.state})`).join(', ')}`));
2015
+ }
2016
+
1814
2017
  /** `rootr db column-set <표> <칸> --json '<PATCH 본문>'` — 칸 설정(validate·dedupe·dedupeCarry·이름·선택지…)을 바꾼다. */
1815
2018
  async function cmdDbColumnSet(args) {
1816
2019
  const [, target, column] = args._;
@@ -1889,6 +2092,37 @@ const DOC_SKINS = [
1889
2092
  'editorial', 'bento', 'glass', 'mesh', 'immersive', 'brutal', 'neumorph', 'y2k',
1890
2093
  ];
1891
2094
 
2095
+ /**
2096
+ * PUBLIC-SEO — `--description "<글>"`(검색 설명문, 빈 글이면 지움)·`--cover <그림 파일|첨부 id|none>`(링크 미리보기 대표 이미지).
2097
+ * 그림 파일을 주면 그 워크스페이스에 올려 id 로 바꾼다. 안 주면 빈 객체 — 아무것도 안 바뀐다.
2098
+ */
2099
+ async function seoPatchFromArgs(client, args, nodeId) {
2100
+ const patch = {};
2101
+ if (args.description !== undefined) patch.seoDescription = typeof args.description === 'string' ? args.description : null;
2102
+ if (args.cover !== undefined) {
2103
+ const cover = typeof args.cover === 'string' ? args.cover.trim() : '';
2104
+ if (!cover || cover === 'none') patch.seoImage = null;
2105
+ else if (/^[0-9a-f-]{36}$/i.test(cover)) patch.seoImage = cover;
2106
+ else {
2107
+ if (!existsSync(cover)) throw new Error(tr(`--cover 파일을 찾을 수 없습니다: ${cover}`, `--cover file not found: ${cover}`));
2108
+ const mimeType = MIME_BY_EXT[extname(cover).toLowerCase()] || 'application/octet-stream';
2109
+ if (!mimeType.startsWith('image/')) throw new Error(tr('--cover 는 그림 파일(png·jpg·gif·webp)이어야 합니다.', '--cover must be an image file (png, jpg, gif, webp).'));
2110
+ const cfg = loadConfig();
2111
+ const workspace = args.workspace || cfg.workspace;
2112
+ if (!workspace) throw new Error(tr('--cover 파일을 올릴 워크스페이스가 없습니다 — --workspace <id> 를 주세요.', 'No workspace to upload the --cover file to — pass --workspace <id>.'));
2113
+ const { id } = await client.uploadAttachment(workspace, { filename: basename(cover), mimeType, nodeId, data: readFileSync(cover) });
2114
+ patch.seoImage = id;
2115
+ }
2116
+ }
2117
+ return patch;
2118
+ }
2119
+
2120
+ function printSeo(seo) {
2121
+ if (!seo || (!seo.description && !seo.imageAttachmentId)) return;
2122
+ if (seo.description) console.log(tr(` 검색 설명문: ${seo.description}`, ` search description: ${seo.description}`));
2123
+ if (seo.imageAttachmentId) console.log(tr(` 대표 이미지: ${seo.imageAttachmentId}`, ` cover image: ${seo.imageAttachmentId}`));
2124
+ }
2125
+
1892
2126
  /** `--comments` 단독이면 true, `--comments false`면 false, 안 주면 undefined. */
1893
2127
  function flagToBool(v) {
1894
2128
  if (v === undefined) return undefined;
@@ -1900,7 +2134,7 @@ async function cmdPublish(args) {
1900
2134
  if (!target) {
1901
2135
  throw new Error(
1902
2136
  usage(tr(`rootr publish <path|id> [--children] [--view <id>] [--layout <배치>] [--skin <분위기>] `, `rootr publish <path|id> [--children] [--view <id>] [--layout <layout>] [--skin <skin>] `)) +
1903
- '[--comments] [--expires <ISO>] | --off | --status',
2137
+ '[--comments] [--expires <ISO>] [--description <text>] [--cover <image|id|none>] | --off | --status',
1904
2138
  );
1905
2139
  }
1906
2140
  const client = makeClient();
@@ -1950,6 +2184,7 @@ async function cmdPublish(args) {
1950
2184
  if (isDocument) {
1951
2185
  console.log(tr(` 댓글(편집 제안) 허용: ${st.allowCommits ? '예' : '아니오'}`, ` comments (edit suggestions) allowed: ${st.allowCommits ? 'yes' : 'no'}`));
1952
2186
  if (st.publicConfig?.skin) console.log(tr(` 스킨: ${st.publicConfig.skin}`, ` skin: ${st.publicConfig.skin}`));
2187
+ printSeo(st.publicConfig?.seo);
1953
2188
  } else {
1954
2189
  if (st.hasPassword) console.log(tr(' 비밀번호 걸려 있음', ` password protected`));
1955
2190
  console.log(tr(` 하위 문서도 공개: ${st.includeChildren ? '예' : '아니오'}`, ` children published too: ${st.includeChildren ? 'yes' : 'no'}`));
@@ -1957,6 +2192,7 @@ async function cmdPublish(args) {
1957
2192
  if (st.design && (st.design.layout || st.design.skin)) {
1958
2193
  console.log(tr(` 보이는 방식: ${st.design.layout ?? 'table'} / ${st.design.skin ?? 'clean'}`, ` looks like: ${st.design.layout ?? 'table'} / ${st.design.skin ?? 'clean'}`));
1959
2194
  }
2195
+ printSeo(st.seo);
1960
2196
  }
1961
2197
  return;
1962
2198
  }
@@ -1982,8 +2218,10 @@ async function cmdPublish(args) {
1982
2218
  if (comments !== undefined) patch.allowCommits = comments;
1983
2219
  if (typeof args.expires === 'string') patch.expiresAt = args.expires;
1984
2220
  if (typeof args.skin === 'string') patch.skin = args.skin;
2221
+ Object.assign(patch, await seoPatchFromArgs(client, args, id));
1985
2222
  if (Object.keys(patch).length) {
1986
- await client.setDocumentPublic(id, patch);
2223
+ const after = await client.setDocumentPublic(id, patch);
2224
+ if (patch.seoDescription !== undefined || patch.seoImage !== undefined) printSeo(after?.publicConfig?.seo);
1987
2225
  if (patch.allowCommits !== undefined) {
1988
2226
  console.log(tr(` 댓글(편집 제안) 허용: ${patch.allowCommits ? '예' : '아니오'}`, ` comments (edit suggestions) allowed: ${patch.allowCommits ? 'yes' : 'no'}`));
1989
2227
  }
@@ -2023,6 +2261,8 @@ async function cmdPublish(args) {
2023
2261
  if (Object.keys(design).length) {
2024
2262
  console.log(tr(` 보이는 방식: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`, ` looks like: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`));
2025
2263
  }
2264
+ const seo = await seoPatchFromArgs(client, args, id);
2265
+ if (Object.keys(seo).length) printSeo((await client.setNodePublic(id, seo))?.seo);
2026
2266
  }
2027
2267
 
2028
2268
  /**
@@ -2527,6 +2767,9 @@ async function cmdDb(args) {
2527
2767
  if (sub === 'keep-duplicate') return cmdDbKeepDuplicate(args);
2528
2768
  if (sub === 'rename-col') return cmdDbRenameCol(args);
2529
2769
  if (sub === 'carry-over') return cmdDbCarryOver(args);
2770
+ if (sub === 'dedupe-preview') return cmdDbDedupe(args, true);
2771
+ if (sub === 'dedupe') return cmdDbDedupe(args, false);
2772
+ if (sub === 'dedupe-undo') return cmdDbDedupeUndo(args);
2530
2773
  if (sub === 'column-set') return cmdDbColumnSet(args);
2531
2774
  if (sub === 'rule-violations') return cmdDbRuleViolations(args);
2532
2775
  if (sub === 'rule-test') return cmdDbRuleTest(args);
@@ -3050,6 +3293,16 @@ async function cmdDbRowPageType(args) {
3050
3293
  }
3051
3294
 
3052
3295
  /** 경로로 줘도 되게 — 표 인자도 문서 도구들과 같은 규칙을 따른다. */
3296
+ /**
3297
+ * 노드 id·"/경로"·루터 주소 어느 것이든 노드 id 로. (예전엔 이 이름을 부르기만 하고 정의가 없어 `rootr form mapping` 이
3298
+ * "resolveNodeId is not defined" 로 죽었다 — 2026-10-25 page 명령을 실제로 불러 보다 발견.)
3299
+ */
3300
+ async function resolveNodeId(client, target) {
3301
+ const resolved = await resolveTarget(client, target);
3302
+ if (resolved.existed === false) throw new Error(tr(`찾을 수 없습니다: ${target}`, `Not found: ${target}`));
3303
+ return resolved.id ?? target;
3304
+ }
3305
+
3053
3306
  async function resolveDatabaseId(client, target) {
3054
3307
  if (typeof target === 'string' && target.startsWith('/')) {
3055
3308
  const resolved = await resolveTarget(client, target);
@@ -3558,6 +3811,215 @@ async function cmdMailWebhook(client, args) {
3558
3811
  }
3559
3812
  }
3560
3813
 
3814
+ // ---- custom domains (CUSTOM-DOMAIN — 발행한 페이지를 내 도메인으로) ----------------
3815
+
3816
+ const DOMAIN_USAGE = () =>
3817
+ usage(
3818
+ tr(
3819
+ `rootr domain list | detect <도메인> [--provider <업체>] | add <도메인|이름.rootr.site> <페이지 path|id> [--provider <업체>] [--confirm-charge] | add --subdomain <이름> <페이지> | available <이름> | check <id> | rename <id> <새 도메인> | remove <id>`,
3820
+ `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>`,
3821
+ ),
3822
+ );
3823
+
3824
+ const DOMAIN_STATUS = {
3825
+ PENDING_DNS: tr('DNS 확인 중', 'waiting for DNS'),
3826
+ PENDING_CERT: tr('인증서 발급 중', 'issuing certificate'),
3827
+ ACTIVE: tr('연결됨', 'connected'),
3828
+ ERROR: tr('오류', 'error'),
3829
+ SUSPENDED: tr('일시 중지(결제 필요)', 'paused (payment needed)'),
3830
+ REMOVED: tr('해제됨', 'removed'),
3831
+ };
3832
+ const DOMAIN_KIND = { PAID: tr('내 도메인', 'own'), FREE: tr('무료 주소', 'free') };
3833
+
3834
+ function domainWorkspace() {
3835
+ const ws = loadConfig().workspace;
3836
+ if (!ws) throw new Error(tr('워크스페이스가 설정되지 않았습니다. rootr use <워크스페이스> 또는 ROOTR_WORKSPACE', 'No workspace set. rootr use <workspace> or ROOTR_WORKSPACE'));
3837
+ return ws;
3838
+ }
3839
+
3840
+ function printDomainRecords(records) {
3841
+ console.log(tr('DNS 에 이 한 줄을 넣으세요:', 'Add this one DNS line:'));
3842
+ for (const r of records ?? []) {
3843
+ 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')}`);
3844
+ }
3845
+ }
3846
+
3847
+ function printDomainGuide(provider) {
3848
+ if (!provider) return;
3849
+ const l = lang() === 'ko' ? 'ko' : 'en';
3850
+ console.log(`\n${provider.name?.[l] ?? provider.id}${provider.dnsUrl ? ` — ${provider.dnsUrl}` : ''}`);
3851
+ (provider.steps?.[l] ?? []).forEach((s, i) => console.log(` ${i + 1}. ${s}`));
3852
+ if (provider.quirks?.[l]?.length) {
3853
+ console.log(tr(' 주의:', ' Watch out:'));
3854
+ for (const q of provider.quirks[l]) console.log(` - ${q}`);
3855
+ }
3856
+ }
3857
+
3858
+ function printDomainStatus(d) {
3859
+ const st = DOMAIN_STATUS[d.status] ?? d.status;
3860
+ console.log(`${d.hostname}\t${st}${d.status === 'ACTIVE' ? `\t${d.siteUrl}` : ''}`);
3861
+ if (d.message || d.lastError) console.log(` ${d.message || d.lastError}`);
3862
+ }
3863
+
3864
+ /**
3865
+ * PAGE-ANALYTICS — `rootr page stats|track|recount <page>`. 방문 기록은 워크스페이스 노드(LOG·일별 표·대시보드)에 쌓인다 —
3866
+ * 숫자를 더 깊게 보려면 `rootr db rows <일별 표>`·`rootr log query <방문 기록>` 을 그대로 쓴다.
3867
+ */
3868
+ async function cmdPage(args) {
3869
+ const sub = args._[0];
3870
+ const target = args._[1];
3871
+ const usageText = tr(
3872
+ 'rootr page stats <페이지> | rootr page track <페이지> on|off [--locale ko|en] | rootr page recount <페이지> [--days 90]',
3873
+ 'rootr page stats <page> | rootr page track <page> on|off [--locale ko|en] | rootr page recount <page> [--days 90]',
3874
+ );
3875
+ if (!target || !['stats', 'track', 'recount'].includes(sub)) throw new Error(usageText);
3876
+ const client = makeClient();
3877
+ const id = await resolveNodeId(client, target);
3878
+ let out;
3879
+ if (sub === 'track') {
3880
+ const mode = args._[2];
3881
+ if (mode !== 'on' && mode !== 'off') throw new Error(usageText);
3882
+ out = await client.setPageAnalytics(id, { enabled: mode === 'on', locale: args.locale });
3883
+ } else if (sub === 'recount') {
3884
+ out = (await client.refreshPageAnalytics(id, args.days)).status;
3885
+ } else {
3886
+ out = await client.getPageAnalytics(id);
3887
+ }
3888
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3889
+ console.log(out.enabled ? tr('방문 기록: 켜짐', 'Visit tracking: on') : tr('방문 기록: 꺼짐', 'Visit tracking: off'));
3890
+ if (out.last7d) {
3891
+ console.log(tr(
3892
+ `최근 7일 방문자 ${out.last7d.visitors} · 방문 ${out.last7d.sessions} · 전환 ${out.last7d.conversions}`,
3893
+ `Last 7 days visitors ${out.last7d.visitors} · sessions ${out.last7d.sessions} · conversions ${out.last7d.conversions}`,
3894
+ ));
3895
+ }
3896
+ for (const [label, n] of [[tr('대시보드', 'Dashboard'), out.dashboard], [tr('일별 표', 'Daily table'), out.daily], [tr('방문 기록', 'Visit log'), out.log]]) {
3897
+ if (n) console.log(` ${label}\t${n.name}\t${n.id}${n.url ? `\t${n.url}` : ''}`);
3898
+ }
3899
+ if (out.enabled && !out.lastEventAt) {
3900
+ console.log(tr(' 아직 기록 없음 — 웹에 게시된 주소에서만 기록됩니다(rootr publish).', ' No visits yet — only the published address records (rootr publish).'));
3901
+ }
3902
+ }
3903
+
3904
+ async function cmdDomain(args) {
3905
+ const sub = args._[0];
3906
+ const client = makeClient();
3907
+ switch (sub) {
3908
+ case undefined:
3909
+ case 'list':
3910
+ case 'ls': {
3911
+ const out = await client.listCustomDomains(domainWorkspace());
3912
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3913
+ if (out.slots) console.log(slotLine(out.slots, tr));
3914
+ 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>)'));
3915
+ for (const d of out.domains) {
3916
+ 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)')}`);
3917
+ if (d.status !== 'ACTIVE' && d.lastError) console.log(` ${d.lastError}`);
3918
+ }
3919
+ return;
3920
+ }
3921
+ case 'available': {
3922
+ const name = args._[1];
3923
+ if (!name) throw new Error(DOMAIN_USAGE());
3924
+ const out = await client.customSubdomainAvailability(domainWorkspace(), name);
3925
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3926
+ const why = {
3927
+ invalid: tr('영어 소문자·숫자·하이픈 3~30자, 앞뒤 하이픈 없이', 'lowercase a-z 0-9 and hyphen, 3–30 chars, no leading/trailing hyphen'),
3928
+ reserved: tr('예약된 이름', 'reserved name'),
3929
+ taken: tr('이미 다른 곳에서 씀', 'already taken'),
3930
+ mine: tr('이미 이 워크스페이스 것', 'already yours'),
3931
+ limit: tr(`워크스페이스당 ${out.limit}개까지`, `limit of ${out.limit} per workspace reached`),
3932
+ };
3933
+ console.log(out.available ? tr(`${out.hostname} — 쓸 수 있습니다`, `${out.hostname} — available`) : `${out.hostname} — ${tr('안 됩니다', 'not available')}: ${why[out.reason] ?? out.reason}`);
3934
+ if (!out.available) process.exitCode = 2;
3935
+ return;
3936
+ }
3937
+ case 'detect': {
3938
+ const host = args._[1];
3939
+ if (!host) throw new Error(DOMAIN_USAGE());
3940
+ const out = await client.detectCustomDomain(domainWorkspace(), host, typeof args.provider === 'string' ? args.provider : undefined);
3941
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3942
+ 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.`));
3943
+ 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.'));
3944
+ printDomainRecords([out.record]);
3945
+ printDomainGuide(out.provider);
3946
+ return;
3947
+ }
3948
+ case 'add': {
3949
+ // 무료 주소는 `--subdomain <이름> <페이지>` 로도 받는다(그때는 페이지가 첫 칸).
3950
+ const subdomain = typeof args.subdomain === 'string' ? args.subdomain : undefined;
3951
+ const host = subdomain ? undefined : args._[1];
3952
+ const page = subdomain ? args._[1] : args._[2];
3953
+ if ((!host && !subdomain) || !page) throw new Error(DOMAIN_USAGE());
3954
+ const rootNodeId = requireResolved(await resolveTarget(client, page));
3955
+ let out;
3956
+ try {
3957
+ out = await client.addCustomDomain(domainWorkspace(), {
3958
+ hostname: host,
3959
+ subdomain,
3960
+ rootNodeId,
3961
+ provider: typeof args.provider === 'string' ? args.provider : undefined,
3962
+ confirmCharge: args['confirm-charge'] === true || args.yes === true,
3963
+ });
3964
+ } catch (err) {
3965
+ const help = err instanceof RootrApiError ? domainPaymentHelp(err, tr) : null;
3966
+ if (help) err.hint = help;
3967
+ throw err;
3968
+ }
3969
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3970
+ if (out.kind === 'FREE') {
3971
+ 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.`));
3972
+ return;
3973
+ }
3974
+ if (out.billing?.charged) console.log(tr(`도메인 연결 애드온을 ${out.billing.quantity}개로 늘렸습니다(일할 계산 청구).`, `Custom-domain add-on increased to ${out.billing.quantity} (prorated charge).`));
3975
+ console.log(tr(`등록했습니다: ${out.hostname} (id ${out.id})`, `Registered ${out.hostname} (id ${out.id})`));
3976
+ printDomainRecords(out.records);
3977
+ printDomainGuide(out.guide?.provider);
3978
+ 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)`));
3979
+ return;
3980
+ }
3981
+ case 'check': {
3982
+ const id = args._[1];
3983
+ if (!id) throw new Error(DOMAIN_USAGE());
3984
+ const out = await client.checkCustomDomain(domainWorkspace(), id);
3985
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3986
+ printDomainStatus(out);
3987
+ if (out.status !== 'ACTIVE') process.exitCode = 2;
3988
+ return;
3989
+ }
3990
+ case 'rename':
3991
+ case 'mv': {
3992
+ const id = args._[1];
3993
+ const host = args._[2];
3994
+ if (!id || !host) throw new Error(DOMAIN_USAGE());
3995
+ const out = await client.renameCustomDomain(domainWorkspace(), id, { hostname: host, provider: typeof args.provider === 'string' ? args.provider : undefined });
3996
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3997
+ 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)`));
3998
+ printDomainRecords(out.records);
3999
+ printDomainGuide(out.guide?.provider);
4000
+ console.log(tr(`\n넣은 뒤: rootr domain check ${out.id}`, `\nThen: rootr domain check ${out.id}`));
4001
+ return;
4002
+ }
4003
+ case 'remove':
4004
+ case 'rm':
4005
+ case 'delete': {
4006
+ const id = args._[1];
4007
+ if (!id) throw new Error(DOMAIN_USAGE());
4008
+ const out = await client.removeCustomDomain(domainWorkspace(), id);
4009
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
4010
+ if (out.kind === 'FREE') console.log(tr(`${out.hostname} 무료 주소를 해제했습니다.`, `Released the free address ${out.hostname}.`));
4011
+ else console.log(tr(`${out.hostname} 연결을 해제했습니다. DNS 의 CNAME 줄도 지워 주세요.`, `Disconnected ${out.hostname}. Remove the CNAME line at your DNS provider too.`));
4012
+ const b = out.billing;
4013
+ if (b?.action === 'decreased') console.log(tr(`도메인 연결 애드온을 ${b.quantity}개로 줄였습니다(남은 기간은 일할 계산으로 돌려받습니다).`, `Custom-domain add-on reduced to ${b.quantity} (prorated credit).`));
4014
+ 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).`));
4015
+ if (b?.action === 'failed') console.log(tr('결제 수량 조정이 실패했습니다 — 설정 › 결제에서 확인해 주세요.', 'Could not adjust the add-on quantity — check Settings → Billing.'));
4016
+ return;
4017
+ }
4018
+ default:
4019
+ throw new Error(DOMAIN_USAGE());
4020
+ }
4021
+ }
4022
+
3561
4023
  async function main() {
3562
4024
  const argv = process.argv.slice(2);
3563
4025
  const args = parseArgs(argv);
@@ -3577,6 +4039,10 @@ async function main() {
3577
4039
  return cmdLs(args);
3578
4040
  case 'comments':
3579
4041
  return cmdComments(args);
4042
+ case 'members':
4043
+ return cmdMembers(args);
4044
+ case 'message':
4045
+ return cmdMessage(args);
3580
4046
  case 'read':
3581
4047
  return cmdRead(args);
3582
4048
  case 'write':
@@ -3627,6 +4093,12 @@ async function main() {
3627
4093
  return cmdSend(args);
3628
4094
  case 'mail':
3629
4095
  return cmdMail(args);
4096
+ case 'domain':
4097
+ case 'domains':
4098
+ return cmdDomain(args);
4099
+ case 'page':
4100
+ case 'pages':
4101
+ return cmdPage(args);
3630
4102
  case 'feedback':
3631
4103
  return cmdFeedback(args);
3632
4104
  case 'scaffold':