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/README.ko.md +38 -1
- package/README.md +56 -3
- package/bin/rootr.js +481 -9
- package/lib/client.js +92 -1
- package/lib/domain-billing.js +52 -0
- package/lib/mcp-tools/databases.js +110 -5
- package/lib/mcp-tools/documents.js +17 -1
- package/lib/mcp-tools/domains.js +236 -0
- package/lib/mcp-tools/forms.js +20 -2
- package/lib/mcp-tools/pages.js +37 -0
- package/lib/mcp-tools/public.js +13 -0
- package/lib/mcp-tools/workspaces.js +64 -1
- package/lib/mcp.js +2 -0
- package/package.json +1 -1
package/bin/rootr.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { dirname, join, basename, extname } from 'node:path';
|
|
5
5
|
|
|
@@ -9,6 +9,7 @@ import { RootrClient, RootrApiError, imageMarkdown } from '../lib/client.js';
|
|
|
9
9
|
import { setGlobalDocMailSink } from '../lib/context.js';
|
|
10
10
|
import { resolveTarget, requireResolved, resolveMoveDestination } from '../lib/resolve.js';
|
|
11
11
|
import { t as tr, lang } from '../lib/i18n.js';
|
|
12
|
+
import { domainPaymentHelp, slotLine } from '../lib/domain-billing.js';
|
|
12
13
|
|
|
13
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -23,6 +24,8 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
23
24
|
rootr embed <문서 path|id> <끼울 노드 path|id> [--heading "## X"] 문서 안에 표·시트·발표자료를 **살아 있는 채로** 끼워 넣기
|
|
24
25
|
rootr edit <path|id> --find <text> --replace <text> [--all] 본문 내 텍스트 치환
|
|
25
26
|
rootr comments <path|id> [--block <id>] [--json] 문서에 달린 댓글 읽기 (공개 페이지 댓글 포함)
|
|
27
|
+
rootr members [--workspace <id>] [--json] 워크스페이스 구성원 목록 (id\t이름\t이메일\t역할)
|
|
28
|
+
rootr message <userId> <text> [--workspace <id>] 구성원에게 바로 메시지 보내기 (문서/이슈에 매이지 않음)
|
|
26
29
|
rootr search <query> 워크스페이스 검색
|
|
27
30
|
rootr mv <path|id> <새 경로|폴더 id> 문서/폴더 이동 및/또는 이름 변경
|
|
28
31
|
rootr rm <path|id> [--yes] 문서/폴더를 휴지통으로 이동 (복구 가능)
|
|
@@ -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 서버 구동 (도구
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1809
|
-
|
|
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':
|