rootr-cli 0.26.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +38 -1
- package/README.md +40 -2
- package/bin/rootr.js +422 -9
- package/lib/client.js +78 -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/public.js +13 -0
- package/lib/mcp-tools/workspaces.js +63 -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,14 @@ 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 domain list 내 도메인 목록 — 종류(내 도메인·무료 주소)·상태와 가리키는 페이지, 자리 사용(유료 n/m·무료 n/3)
|
|
124
|
+
rootr domain detect <도메인> [--provider <업체>] 등록 전에 — DNS 를 어디서 관리하는지(가비아·Cloudflare…)와 넣을 한 줄·단계 안내
|
|
125
|
+
rootr domain add <도메인> <페이지·문서 path|id> [--provider <업체>] [--confirm-charge] 발행한 페이지·문서를 내 도메인(www.회사.co.kr)으로 연결 (Team 플랜, 도메인 1개당 월 $10·연 결제 시 월 $8 — 자리가 꽉 찼으면 --confirm-charge 로 1개 추가 결제)
|
|
126
|
+
rootr domain add <이름>.rootr.site <페이지 path|id> 무료 주소로 바로 열기(Team 플랜, DNS·결제 없음, 3개까지) — --subdomain <이름> 으로도
|
|
127
|
+
rootr domain available <이름> 무료 주소 이름(이름.rootr.site)을 쓸 수 있는지
|
|
128
|
+
rootr domain check <id> 지금 다시 확인(연결 전이면 exit 2)
|
|
129
|
+
rootr domain rename <id> <새 도메인> [--provider <업체>] 연결 전 도메인의 이름 바꾸기(결제 그대로, 새 id 가 나옴)
|
|
130
|
+
rootr domain remove <id> 연결 해제
|
|
115
131
|
rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] 설정 조회/저장
|
|
116
132
|
rootr config --add-key <k> [--label <name>] [--workspace <w>] 워크스페이스별 키 추가 등록
|
|
117
133
|
rootr feedback "<무엇이 안 되는지>" [--kind bug|friction|idea|praise] [--email <주소>]
|
|
@@ -125,7 +141,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
125
141
|
rootr memory add "<내용>" [--kind 지시|사람|프로젝트|참고] [--source <경로|id>] [--related a,b] 기억 저장
|
|
126
142
|
rootr memory edit <id> [--content "<내용>"] [--kind …] [--source …] [--related a,b] 기억 고치기(준 칸만)
|
|
127
143
|
rootr memory forget <id> [--hard] 기억 폐기(--hard면 행 삭제)
|
|
128
|
-
rootr mcp MCP stdio 서버 구동 (도구
|
|
144
|
+
rootr mcp MCP stdio 서버 구동 (도구 214종 — README 참고)
|
|
129
145
|
rootr --help 도움말
|
|
130
146
|
rootr --version 버전 출력
|
|
131
147
|
|
|
@@ -146,6 +162,8 @@ usage:
|
|
|
146
162
|
rootr embed <doc path|id> <node path|id> [--heading "## X"] embed a LIVE table/sheet/deck inside a document
|
|
147
163
|
rootr edit <path|id> --find <text> --replace <text> [--all] replace text inside the body
|
|
148
164
|
rootr comments <path|id> [--block <id>] [--json] read comments (including ones left on the public page)
|
|
165
|
+
rootr members [--workspace <id>] [--json] list workspace members (id\tname\temail\trole)
|
|
166
|
+
rootr message <userId> <text> [--workspace <id>] send a direct message to a member (not tied to a doc/issue)
|
|
149
167
|
rootr search <query> search the workspace
|
|
150
168
|
rootr mv <path|id> <new path|folder id> move and/or rename a document or folder
|
|
151
169
|
rootr rm <path|id> [--yes] move to the trash (recoverable)
|
|
@@ -178,6 +196,9 @@ usage:
|
|
|
178
196
|
rootr db keep-duplicate <path|id> <rowId> --from <earlier rowId> keep as a new row but link both rows
|
|
179
197
|
rootr db rename-col <path|id> <column name|id> <new name> [--dry] rename a column — formulas, button/rule {col}, other tables' rollups, form questions and page blocks follow (--dry: list only)
|
|
180
198
|
rootr db carry-over <path|id> <rowId> [--from <earlier rowId>] carry the person's previous row into this row (earlier row untouched)
|
|
199
|
+
rootr db dedupe-preview <path|id> <rowId> [--json '<rule overrides>'] preview duplicate handling — per-column before/after/source, rows to delete
|
|
200
|
+
rootr db dedupe <path|id> <rowId> [--action carry|merge_into_old|merge_all] [--json '<rule overrides>'] apply duplicate handling (undo by opId)
|
|
201
|
+
rootr db dedupe-undo <path|id> <opId> undo one duplicate-handling operation (recreates deleted rows)
|
|
181
202
|
rootr db column-set <path|id> <column name|id> --json '<settings>' change a column's settings, e.g. '{"validate":{"kind":"pattern","pattern":"[A-Z]{2}-\\\\d{4}","message":"not an employee id"}}'
|
|
182
203
|
rootr db rule-violations <path|id> [--limit N] [--json] current values that break a column's validate rule (nothing is changed)
|
|
183
204
|
rootr db rule-test --json '<rule>' <value> [<value>…] [--type number] try a validate rule on sample values (saves nothing) · --presets: example patterns
|
|
@@ -192,6 +213,7 @@ usage:
|
|
|
192
213
|
rootr publish <path|id> --status is it public, at what URL, children included?
|
|
193
214
|
rootr publish <table> --layout board --skin paper how a published table looks (9 layouts × 6 skins)
|
|
194
215
|
rootr publish <doc> --comments [false] [--expires <ISO>] [--skin <skin>] documents only — comments, expiry, 16 skins
|
|
216
|
+
rootr publish <path|id> --description "<text>" --cover <image|id|none> search description & link-preview cover image (search engines pinged)
|
|
195
217
|
rootr booking show <table> show visitor booking settings
|
|
196
218
|
rootr booking set <table> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 turn on + rules
|
|
197
219
|
rootr booking set <table> --ask "Phone:required" --approval manual ask more · confirm after approval
|
|
@@ -210,6 +232,7 @@ usage:
|
|
|
210
232
|
rootr form set <path|id> --fields '<json>' | --config '<json>' change questions/settings (settings merge)
|
|
211
233
|
rootr form collect <path|id> --to db|sheet create and wire a destination (a table gets a column per question)
|
|
212
234
|
rootr form responses <path|id> [--latest] [--json] read responses (--latest: newest one per person)
|
|
235
|
+
rootr form submit <path|id> [--values '<json>'] [--files '{"fieldId":["a.pdf","b.pdf"]}'] submit a response (files are uploaded, then attached — several for a "multiple" file question)
|
|
213
236
|
rootr form response-edit <responseId> --values '<json>' edit a submitted response (revalidated against the form)
|
|
214
237
|
rootr form response-rm <responseId> [--yes] delete one response (cannot be undone)
|
|
215
238
|
rootr sign new <path> [--file <f>] [--reviewer <userId>] [--line <line.json>] create a signed document (body + approval line)
|
|
@@ -233,6 +256,14 @@ usage:
|
|
|
233
256
|
rootr mail access <id> read|comment|write change what the recipient of a document you sent can do
|
|
234
257
|
rootr mail revoke <id> withdraw a document you sent — the recipient can no longer open the original
|
|
235
258
|
rootr mail hide <id> [--box inbox|sent] remove from your own mailbox (a received one then stops opening for your account)
|
|
259
|
+
rootr domain list your custom domains — status (waiting for DNS / issuing certificate / connected) and page
|
|
260
|
+
rootr domain detect <domain> [--provider <id>] before adding — where the DNS is managed (Gabia, Cloudflare…), the one line to add, step by step
|
|
261
|
+
rootr domain add <domain> <page|doc path|id> [--provider <id>] [--confirm-charge] open a published page or document at your own domain (Team plan; $10/month per domain, $8 billed yearly — when slots are full, --confirm-charge buys one more)
|
|
262
|
+
rootr domain add <name>.rootr.site <page path|id> a free address, live at once (Team plan; no DNS, no payment, up to 3) — or --subdomain <name>
|
|
263
|
+
rootr domain available <name> is the free address name.rootr.site available
|
|
264
|
+
rootr domain check <id> re-check now (exit 2 while not live)
|
|
265
|
+
rootr domain rename <id> <new domain> [--provider <id>] change the name of a not-yet-live domain (billing unchanged, new id)
|
|
266
|
+
rootr domain remove <id> disconnect
|
|
236
267
|
rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] read/save configuration
|
|
237
268
|
rootr config --add-key <k> [--label <name>] [--workspace <w>] register an extra per-workspace key
|
|
238
269
|
rootr feedback "<what is not working>" [--kind bug|friction|idea|praise] [--email <address>]
|
|
@@ -246,7 +277,7 @@ usage:
|
|
|
246
277
|
rootr memory add "<content>" [--kind instruction|person|project|reference] [--source <path|id>] [--related a,b] save a memory
|
|
247
278
|
rootr memory edit <id> [--content "<content>"] [--kind …] [--source …] [--related a,b] edit a memory (only the fields you pass)
|
|
248
279
|
rootr memory forget <id> [--hard] retire a memory (--hard deletes the row)
|
|
249
|
-
rootr mcp run the MCP stdio server (
|
|
280
|
+
rootr mcp run the MCP stdio server (214 tools — see the README)
|
|
250
281
|
rootr --help this help
|
|
251
282
|
rootr --version print the version
|
|
252
283
|
|
|
@@ -373,6 +404,51 @@ async function cmdComments(args) {
|
|
|
373
404
|
}
|
|
374
405
|
}
|
|
375
406
|
|
|
407
|
+
/** `rootr members [--workspace <id>]` — list the current workspace's members (id, name, email, role). */
|
|
408
|
+
async function cmdMembers(args) {
|
|
409
|
+
const cfg = loadConfig();
|
|
410
|
+
const workspace = args.workspace || cfg.workspace;
|
|
411
|
+
if (!workspace) {
|
|
412
|
+
throw new RootrApiError(tr('워크스페이스가 설정되지 않았습니다.', `No workspace is configured.`), {
|
|
413
|
+
hint: tr('--workspace <id>로 넘기거나 rootr config --workspace <id> 또는 ROOTR_WORKSPACE 환경변수를 지정하세요.', `Pass --workspace <id>, or set it with rootr config --workspace <id> or the ROOTR_WORKSPACE environment variable.`),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
const client = new RootrClient({ ...cfg, workspace, apiKey: keyForWorkspace(cfg, workspace) });
|
|
417
|
+
const members = await client.listMembers(workspace);
|
|
418
|
+
if (args.json) {
|
|
419
|
+
console.log(JSON.stringify(members, null, 2));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
for (const m of members) {
|
|
423
|
+
console.log(`${m.userId ?? m.user?.id}\t${m.user?.name ?? ''}\t${m.user?.email ?? ''}\t${m.role}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* MEMBER-MSG — `rootr message <userId> <text> [--workspace <id>]`
|
|
429
|
+
*
|
|
430
|
+
* 2026-10-22 제보(#226): 앱에서는 구성원에게 바로 메시지를 보낼 수 있는데 CLI/MCP에는
|
|
431
|
+
* 그 길이 없어서 이슈 댓글에 @멘션을 다는 것으로 때워야 했다. 문서/이슈에 매이지 않은
|
|
432
|
+
* 1:1 알림 경로를 그대로 연다.
|
|
433
|
+
*/
|
|
434
|
+
async function cmdMessage(args) {
|
|
435
|
+
const userId = args._[0];
|
|
436
|
+
const body = args._[1];
|
|
437
|
+
if (!userId || !body) {
|
|
438
|
+
throw new Error(usage(`rootr message <userId> <text> [--workspace <id>]`));
|
|
439
|
+
}
|
|
440
|
+
const cfg = loadConfig();
|
|
441
|
+
const workspace = args.workspace || cfg.workspace;
|
|
442
|
+
if (!workspace) {
|
|
443
|
+
throw new RootrApiError(tr('워크스페이스가 설정되지 않았습니다.', `No workspace is configured.`), {
|
|
444
|
+
hint: tr('--workspace <id>로 넘기거나 rootr config --workspace <id> 또는 ROOTR_WORKSPACE 환경변수를 지정하세요.', `Pass --workspace <id>, or set it with rootr config --workspace <id> or the ROOTR_WORKSPACE environment variable.`),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
const client = new RootrClient({ ...cfg, workspace, apiKey: keyForWorkspace(cfg, workspace) });
|
|
448
|
+
await client.messageMember(workspace, userId, body);
|
|
449
|
+
console.log(tr(`${userId} 에게 메시지를 보냈습니다.`, `Message sent to ${userId}.`));
|
|
450
|
+
}
|
|
451
|
+
|
|
376
452
|
async function cmdRead(args) {
|
|
377
453
|
const target = args._[0];
|
|
378
454
|
if (!target) throw new Error(usage(`rootr read <path|id> [--json]`));
|
|
@@ -1024,10 +1100,11 @@ async function cmdForm(args) {
|
|
|
1024
1100
|
if (sub === 'collect') return cmdFormCollect(args);
|
|
1025
1101
|
if (sub === 'mapping') return cmdFormMapping(args);
|
|
1026
1102
|
if (sub === 'responses') return cmdFormResponses(args);
|
|
1103
|
+
if (sub === 'submit') return cmdFormSubmit(args);
|
|
1027
1104
|
if (sub === 'response-edit') return cmdFormResponseEdit(args);
|
|
1028
1105
|
if (sub === 'response-rm') return cmdFormResponseRm(args);
|
|
1029
1106
|
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)`)),
|
|
1107
|
+
usage(tr(`rootr form new|show|set|collect|responses|submit|response-edit|response-rm … (자세한 건 rootr --help)`, `rootr form new|show|set|collect|responses|submit|response-edit|response-rm … (see rootr --help)`)),
|
|
1031
1108
|
);
|
|
1032
1109
|
}
|
|
1033
1110
|
|
|
@@ -1088,7 +1165,8 @@ async function cmdFormNew(args) {
|
|
|
1088
1165
|
console.log(` id: ${id}`);
|
|
1089
1166
|
if (args.collect === 'db' || args.collect === 'sheet') {
|
|
1090
1167
|
const to = args.collect === 'db' ? 'database' : 'spreadsheet';
|
|
1091
|
-
|
|
1168
|
+
// 서버는 `target` 만 받는다(`to` 는 검증 파이프가 지워 400 이 났다) — form collect 와 같은 길로.
|
|
1169
|
+
await client.createFormCollector(id, to);
|
|
1092
1170
|
console.log(tr(` 응답 모을 곳: ${args.collect === 'db' ? '표' : '스프레드시트'} 생성됨`, ` Responses collected into: a ${args.collect === 'db' ? 'table' : 'spreadsheet'} was created`));
|
|
1093
1171
|
}
|
|
1094
1172
|
}
|
|
@@ -1112,6 +1190,7 @@ async function cmdFormShow(args) {
|
|
|
1112
1190
|
f.hidden ? tr('숨김', `hidden`) : null,
|
|
1113
1191
|
f.page && f.page > 1 ? tr(`${f.page}쪽`, `page ${f.page}`) : null,
|
|
1114
1192
|
f.showIf ? tr('조건부', `conditional`) : null,
|
|
1193
|
+
f.type === 'file' && f.multiple ? tr(`파일 여러 개(최대 ${f.maxFiles ?? 10})`, `several files (max ${f.maxFiles ?? 10})`) : null,
|
|
1115
1194
|
].filter(Boolean);
|
|
1116
1195
|
console.log(` ${i + 1}. ${f.name} [${f.type}]${marks.length ? ' · ' + marks.join('·') : ''}`);
|
|
1117
1196
|
if (f.help) console.log(` ${f.help}`);
|
|
@@ -1263,6 +1342,83 @@ async function cmdFormResponses(args) {
|
|
|
1263
1342
|
}
|
|
1264
1343
|
}
|
|
1265
1344
|
|
|
1345
|
+
/**
|
|
1346
|
+
* FORM-FILE-MULTI — `rootr form submit <폼> --values '<json>' --files '{"질문id":["a.pdf","b.pdf"]}'`
|
|
1347
|
+
*
|
|
1348
|
+
* 파일 질문은 값으로 **이미 올라간 파일의 표**(`{ id, filename, size }`)를 받는다. 터미널에서 그걸 손으로
|
|
1349
|
+
* 만들려면 `rootr attach` 를 파일마다 부르고 id 를 옮겨 적어야 했다 — 여기서 올리기와 내기를 한 번에 한다.
|
|
1350
|
+
* 여러 개 받는 질문(`multiple`)은 배열로, 한 개 받는 질문은 객체로 붙인다(서버 약속과 같은 모양).
|
|
1351
|
+
*/
|
|
1352
|
+
async function cmdFormSubmit(args) {
|
|
1353
|
+
const target = args._[1];
|
|
1354
|
+
const usageLine = tr(
|
|
1355
|
+
`rootr form submit <path|id> [--values '<json>'] [--files '{"질문id":["a.pdf","b.pdf"]}']`,
|
|
1356
|
+
`rootr form submit <path|id> [--values '<json>'] [--files '{"fieldId":["a.pdf","b.pdf"]}']`,
|
|
1357
|
+
);
|
|
1358
|
+
if (!target) throw new Error(usage(usageLine));
|
|
1359
|
+
const parseJson = (raw, flag) => {
|
|
1360
|
+
try {
|
|
1361
|
+
return JSON.parse(raw);
|
|
1362
|
+
} catch {
|
|
1363
|
+
throw new Error(tr(`${flag} 를 JSON으로 읽을 수 없습니다.`, `Could not read ${flag} as JSON.`));
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
const values = typeof args.values === 'string' ? parseJson(args.values, '--values') : {};
|
|
1367
|
+
const files = typeof args.files === 'string' ? parseJson(args.files, '--files') : {};
|
|
1368
|
+
if (!values || typeof values !== 'object' || Array.isArray(values) || !files || typeof files !== 'object' || Array.isArray(files)) {
|
|
1369
|
+
throw new Error(usage(usageLine));
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const cfg = loadConfig();
|
|
1373
|
+
const workspace = args.workspace || cfg.workspace;
|
|
1374
|
+
const client = new RootrClient({ ...cfg, workspace, apiKey: keyForWorkspace(cfg, workspace) });
|
|
1375
|
+
const id = await resolveDatabaseId(client, target);
|
|
1376
|
+
const form = await client.getForm(id);
|
|
1377
|
+
const byKey = new Map();
|
|
1378
|
+
for (const f of form.fields ?? []) {
|
|
1379
|
+
byKey.set(f.id, f);
|
|
1380
|
+
if (!byKey.has(f.name)) byKey.set(f.name, f); // 질문 이름으로 적어도 된다
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
for (const [key, list] of Object.entries(files)) {
|
|
1384
|
+
const field = byKey.get(key);
|
|
1385
|
+
if (!field || field.type !== 'file') {
|
|
1386
|
+
throw new Error(tr(`"${key}" 은 이 폼의 파일 질문이 아닙니다.`, `"${key}" is not a file question on this form.`));
|
|
1387
|
+
}
|
|
1388
|
+
const paths = Array.isArray(list) ? list : [list];
|
|
1389
|
+
if (paths.length > 1 && !field.multiple) {
|
|
1390
|
+
throw new Error(
|
|
1391
|
+
tr(
|
|
1392
|
+
`"${field.name}" 은 파일을 한 개만 받습니다. 여러 개를 받으려면: rootr form set <폼> --fields 로 그 질문에 "multiple": true`,
|
|
1393
|
+
`"${field.name}" takes a single file. To take several, set "multiple": true on it with rootr form set <form> --fields`,
|
|
1394
|
+
),
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
if (!workspace) {
|
|
1398
|
+
throw new RootrApiError(tr('워크스페이스가 설정되지 않았습니다.', `No workspace is configured.`), {
|
|
1399
|
+
hint: tr('--workspace <id> 를 주세요.', `Pass --workspace <id>.`),
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
const uploaded = [];
|
|
1403
|
+
for (const filePath of paths) {
|
|
1404
|
+
const filename = basename(String(filePath));
|
|
1405
|
+
const mimeType = MIME_BY_EXT[extname(filename).toLowerCase()] || 'application/octet-stream';
|
|
1406
|
+
const att = await client.uploadAttachment(workspace, { filename, mimeType, nodeId: id, data: readFileSync(String(filePath)) });
|
|
1407
|
+
uploaded.push({ id: att.id, filename });
|
|
1408
|
+
console.error(tr(`올림: ${filename} (id ${att.id})`, `uploaded ${filename} (id ${att.id})`));
|
|
1409
|
+
}
|
|
1410
|
+
values[field.id] = field.multiple ? uploaded : uploaded[0];
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const result = await client.createFormResponse(id, values);
|
|
1414
|
+
if (args.json) {
|
|
1415
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
console.log(tr(`응답 접수됨: ${result.id ?? ''}`, `Response submitted: ${result.id ?? ''}`));
|
|
1419
|
+
console.log(` ${JSON.stringify(result.values ?? values)}`);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1266
1422
|
async function cmdFormResponseEdit(args) {
|
|
1267
1423
|
const responseId = args._[1];
|
|
1268
1424
|
if (!responseId) throw new Error(usage(tr("rootr form response-edit <응답id> --values '<json>'", "rootr form response-edit <responseId> --values '<json>'")));
|
|
@@ -1805,12 +1961,53 @@ async function cmdDbCarryOver(args) {
|
|
|
1805
1961
|
if (args.json) return console.log(JSON.stringify(res, null, 2));
|
|
1806
1962
|
console.log(
|
|
1807
1963
|
tr(
|
|
1808
|
-
|
|
1809
|
-
|
|
1964
|
+
res.nothingChanged && !res.marked.length
|
|
1965
|
+
? `같은 값의 이전 행 ${res.fromRowIds?.length ?? 1}개를 찾았지만 채울 칸이 없었습니다(바뀐 칸 없음).`
|
|
1966
|
+
: `이전 행 ${res.fromRowIds?.length ?? 1}개(${res.fromTitle || res.fromRowId} 등)에서 가져왔습니다 — 바뀐 칸 ${res.copied.length}개: ${res.copied.join(', ') || '없음'}${res.marked.length ? ` · 표시: ${res.marked.join(', ')}` : ''}${res.opId ? ` · 되돌리기: rootr db dedupe-undo <표> ${res.opId}` : ''}`,
|
|
1967
|
+
res.nothingChanged && !res.marked.length
|
|
1968
|
+
? `Found ${res.fromRowIds?.length ?? 1} earlier row(s) but nothing to fill (no column changed).`
|
|
1969
|
+
: `Carried from ${res.fromRowIds?.length ?? 1} earlier row(s) — ${res.copied.length} column(s): ${res.copied.join(', ') || 'none'}${res.marked.length ? ` · marked: ${res.marked.join(', ')}` : ''}${res.opId ? ` · undo: rootr db dedupe-undo <table> ${res.opId}` : ''}`,
|
|
1810
1970
|
),
|
|
1811
1971
|
);
|
|
1812
1972
|
}
|
|
1813
1973
|
|
|
1974
|
+
/** DEDUPE-RULES — `rootr db dedupe-preview|dedupe <표> <rowId> [--action …] [--json '<규칙 덮기>']` */
|
|
1975
|
+
async function cmdDbDedupe(args, dryRun) {
|
|
1976
|
+
const [, target, rowId] = args._;
|
|
1977
|
+
if (!target || !rowId) throw new Error(usage(`rootr db ${dryRun ? 'dedupe-preview' : 'dedupe'} <path|id> <rowId> [--action carry|merge_into_old|merge_all] [--json '<rules>']`));
|
|
1978
|
+
let rules;
|
|
1979
|
+
if (typeof args.json === 'string') {
|
|
1980
|
+
try {
|
|
1981
|
+
rules = JSON.parse(args.json);
|
|
1982
|
+
} catch {
|
|
1983
|
+
throw new Error(tr('--json 이 JSON 이 아닙니다.', '--json is not valid JSON.'));
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
const client = makeClient();
|
|
1987
|
+
const id = await resolveDatabaseId(client, target);
|
|
1988
|
+
const res = await client.dedupe(id, rowId, { action: typeof args.action === 'string' ? args.action : undefined, rules, dryRun });
|
|
1989
|
+
if (args.json === true || args.raw) return console.log(JSON.stringify(res, null, 2));
|
|
1990
|
+
console.log(
|
|
1991
|
+
tr(
|
|
1992
|
+
`${dryRun ? '미리 보기' : '적용'} — ${res.action} · 남는 행 ${res.targetRowId}${res.deleted?.length ? ` · 지움 ${res.deleted.length}행` : ''}${res.kept?.length ? ` · 이어 둠 ${res.kept.length}행` : ''}${res.opId ? ` · opId ${res.opId}` : ''}`,
|
|
1993
|
+
`${dryRun ? 'Preview' : 'Applied'} — ${res.action} · keeps ${res.targetRowId}${res.deleted?.length ? ` · deletes ${res.deleted.length}` : ''}${res.kept?.length ? ` · keeps linked ${res.kept.length}` : ''}${res.opId ? ` · opId ${res.opId}` : ''}`,
|
|
1994
|
+
),
|
|
1995
|
+
);
|
|
1996
|
+
if (!res.columns?.length) console.log(tr(' 바뀌는 칸 없음', ' no column changes'));
|
|
1997
|
+
for (const c of res.columns ?? []) console.log(` ${c.column} [${c.strategy}]: ${JSON.stringify(c.before)} → ${JSON.stringify(c.after)}`);
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
/** DEDUPE-RULES — `rootr db dedupe-undo <표> <opId>` */
|
|
2001
|
+
async function cmdDbDedupeUndo(args) {
|
|
2002
|
+
const [, target, opId] = args._;
|
|
2003
|
+
if (!target || !opId) throw new Error(usage(`rootr db dedupe-undo <path|id> <opId>`));
|
|
2004
|
+
const client = makeClient();
|
|
2005
|
+
const id = await resolveDatabaseId(client, target);
|
|
2006
|
+
const res = await client.dedupeUndo(id, opId);
|
|
2007
|
+
if (args.json) return console.log(JSON.stringify(res, null, 2));
|
|
2008
|
+
console.log(tr(`되돌렸습니다 — ${res.rows.map((r) => `${r.rowId}(${r.state})`).join(', ')}`, `Undone — ${res.rows.map((r) => `${r.rowId}(${r.state})`).join(', ')}`));
|
|
2009
|
+
}
|
|
2010
|
+
|
|
1814
2011
|
/** `rootr db column-set <표> <칸> --json '<PATCH 본문>'` — 칸 설정(validate·dedupe·dedupeCarry·이름·선택지…)을 바꾼다. */
|
|
1815
2012
|
async function cmdDbColumnSet(args) {
|
|
1816
2013
|
const [, target, column] = args._;
|
|
@@ -1889,6 +2086,37 @@ const DOC_SKINS = [
|
|
|
1889
2086
|
'editorial', 'bento', 'glass', 'mesh', 'immersive', 'brutal', 'neumorph', 'y2k',
|
|
1890
2087
|
];
|
|
1891
2088
|
|
|
2089
|
+
/**
|
|
2090
|
+
* PUBLIC-SEO — `--description "<글>"`(검색 설명문, 빈 글이면 지움)·`--cover <그림 파일|첨부 id|none>`(링크 미리보기 대표 이미지).
|
|
2091
|
+
* 그림 파일을 주면 그 워크스페이스에 올려 id 로 바꾼다. 안 주면 빈 객체 — 아무것도 안 바뀐다.
|
|
2092
|
+
*/
|
|
2093
|
+
async function seoPatchFromArgs(client, args, nodeId) {
|
|
2094
|
+
const patch = {};
|
|
2095
|
+
if (args.description !== undefined) patch.seoDescription = typeof args.description === 'string' ? args.description : null;
|
|
2096
|
+
if (args.cover !== undefined) {
|
|
2097
|
+
const cover = typeof args.cover === 'string' ? args.cover.trim() : '';
|
|
2098
|
+
if (!cover || cover === 'none') patch.seoImage = null;
|
|
2099
|
+
else if (/^[0-9a-f-]{36}$/i.test(cover)) patch.seoImage = cover;
|
|
2100
|
+
else {
|
|
2101
|
+
if (!existsSync(cover)) throw new Error(tr(`--cover 파일을 찾을 수 없습니다: ${cover}`, `--cover file not found: ${cover}`));
|
|
2102
|
+
const mimeType = MIME_BY_EXT[extname(cover).toLowerCase()] || 'application/octet-stream';
|
|
2103
|
+
if (!mimeType.startsWith('image/')) throw new Error(tr('--cover 는 그림 파일(png·jpg·gif·webp)이어야 합니다.', '--cover must be an image file (png, jpg, gif, webp).'));
|
|
2104
|
+
const cfg = loadConfig();
|
|
2105
|
+
const workspace = args.workspace || cfg.workspace;
|
|
2106
|
+
if (!workspace) throw new Error(tr('--cover 파일을 올릴 워크스페이스가 없습니다 — --workspace <id> 를 주세요.', 'No workspace to upload the --cover file to — pass --workspace <id>.'));
|
|
2107
|
+
const { id } = await client.uploadAttachment(workspace, { filename: basename(cover), mimeType, nodeId, data: readFileSync(cover) });
|
|
2108
|
+
patch.seoImage = id;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
return patch;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
function printSeo(seo) {
|
|
2115
|
+
if (!seo || (!seo.description && !seo.imageAttachmentId)) return;
|
|
2116
|
+
if (seo.description) console.log(tr(` 검색 설명문: ${seo.description}`, ` search description: ${seo.description}`));
|
|
2117
|
+
if (seo.imageAttachmentId) console.log(tr(` 대표 이미지: ${seo.imageAttachmentId}`, ` cover image: ${seo.imageAttachmentId}`));
|
|
2118
|
+
}
|
|
2119
|
+
|
|
1892
2120
|
/** `--comments` 단독이면 true, `--comments false`면 false, 안 주면 undefined. */
|
|
1893
2121
|
function flagToBool(v) {
|
|
1894
2122
|
if (v === undefined) return undefined;
|
|
@@ -1900,7 +2128,7 @@ async function cmdPublish(args) {
|
|
|
1900
2128
|
if (!target) {
|
|
1901
2129
|
throw new Error(
|
|
1902
2130
|
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',
|
|
2131
|
+
'[--comments] [--expires <ISO>] [--description <text>] [--cover <image|id|none>] | --off | --status',
|
|
1904
2132
|
);
|
|
1905
2133
|
}
|
|
1906
2134
|
const client = makeClient();
|
|
@@ -1950,6 +2178,7 @@ async function cmdPublish(args) {
|
|
|
1950
2178
|
if (isDocument) {
|
|
1951
2179
|
console.log(tr(` 댓글(편집 제안) 허용: ${st.allowCommits ? '예' : '아니오'}`, ` comments (edit suggestions) allowed: ${st.allowCommits ? 'yes' : 'no'}`));
|
|
1952
2180
|
if (st.publicConfig?.skin) console.log(tr(` 스킨: ${st.publicConfig.skin}`, ` skin: ${st.publicConfig.skin}`));
|
|
2181
|
+
printSeo(st.publicConfig?.seo);
|
|
1953
2182
|
} else {
|
|
1954
2183
|
if (st.hasPassword) console.log(tr(' 비밀번호 걸려 있음', ` password protected`));
|
|
1955
2184
|
console.log(tr(` 하위 문서도 공개: ${st.includeChildren ? '예' : '아니오'}`, ` children published too: ${st.includeChildren ? 'yes' : 'no'}`));
|
|
@@ -1957,6 +2186,7 @@ async function cmdPublish(args) {
|
|
|
1957
2186
|
if (st.design && (st.design.layout || st.design.skin)) {
|
|
1958
2187
|
console.log(tr(` 보이는 방식: ${st.design.layout ?? 'table'} / ${st.design.skin ?? 'clean'}`, ` looks like: ${st.design.layout ?? 'table'} / ${st.design.skin ?? 'clean'}`));
|
|
1959
2188
|
}
|
|
2189
|
+
printSeo(st.seo);
|
|
1960
2190
|
}
|
|
1961
2191
|
return;
|
|
1962
2192
|
}
|
|
@@ -1982,8 +2212,10 @@ async function cmdPublish(args) {
|
|
|
1982
2212
|
if (comments !== undefined) patch.allowCommits = comments;
|
|
1983
2213
|
if (typeof args.expires === 'string') patch.expiresAt = args.expires;
|
|
1984
2214
|
if (typeof args.skin === 'string') patch.skin = args.skin;
|
|
2215
|
+
Object.assign(patch, await seoPatchFromArgs(client, args, id));
|
|
1985
2216
|
if (Object.keys(patch).length) {
|
|
1986
|
-
await client.setDocumentPublic(id, patch);
|
|
2217
|
+
const after = await client.setDocumentPublic(id, patch);
|
|
2218
|
+
if (patch.seoDescription !== undefined || patch.seoImage !== undefined) printSeo(after?.publicConfig?.seo);
|
|
1987
2219
|
if (patch.allowCommits !== undefined) {
|
|
1988
2220
|
console.log(tr(` 댓글(편집 제안) 허용: ${patch.allowCommits ? '예' : '아니오'}`, ` comments (edit suggestions) allowed: ${patch.allowCommits ? 'yes' : 'no'}`));
|
|
1989
2221
|
}
|
|
@@ -2023,6 +2255,8 @@ async function cmdPublish(args) {
|
|
|
2023
2255
|
if (Object.keys(design).length) {
|
|
2024
2256
|
console.log(tr(` 보이는 방식: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`, ` looks like: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`));
|
|
2025
2257
|
}
|
|
2258
|
+
const seo = await seoPatchFromArgs(client, args, id);
|
|
2259
|
+
if (Object.keys(seo).length) printSeo((await client.setNodePublic(id, seo))?.seo);
|
|
2026
2260
|
}
|
|
2027
2261
|
|
|
2028
2262
|
/**
|
|
@@ -2527,6 +2761,9 @@ async function cmdDb(args) {
|
|
|
2527
2761
|
if (sub === 'keep-duplicate') return cmdDbKeepDuplicate(args);
|
|
2528
2762
|
if (sub === 'rename-col') return cmdDbRenameCol(args);
|
|
2529
2763
|
if (sub === 'carry-over') return cmdDbCarryOver(args);
|
|
2764
|
+
if (sub === 'dedupe-preview') return cmdDbDedupe(args, true);
|
|
2765
|
+
if (sub === 'dedupe') return cmdDbDedupe(args, false);
|
|
2766
|
+
if (sub === 'dedupe-undo') return cmdDbDedupeUndo(args);
|
|
2530
2767
|
if (sub === 'column-set') return cmdDbColumnSet(args);
|
|
2531
2768
|
if (sub === 'rule-violations') return cmdDbRuleViolations(args);
|
|
2532
2769
|
if (sub === 'rule-test') return cmdDbRuleTest(args);
|
|
@@ -3558,6 +3795,175 @@ async function cmdMailWebhook(client, args) {
|
|
|
3558
3795
|
}
|
|
3559
3796
|
}
|
|
3560
3797
|
|
|
3798
|
+
// ---- custom domains (CUSTOM-DOMAIN — 발행한 페이지를 내 도메인으로) ----------------
|
|
3799
|
+
|
|
3800
|
+
const DOMAIN_USAGE = () =>
|
|
3801
|
+
usage(
|
|
3802
|
+
tr(
|
|
3803
|
+
`rootr domain list | detect <도메인> [--provider <업체>] | add <도메인|이름.rootr.site> <페이지 path|id> [--provider <업체>] [--confirm-charge] | add --subdomain <이름> <페이지> | available <이름> | check <id> | rename <id> <새 도메인> | remove <id>`,
|
|
3804
|
+
`rootr domain list | detect <domain> [--provider <id>] | add <domain|name.rootr.site> <page path|id> [--provider <id>] [--confirm-charge] | add --subdomain <name> <page> | available <name> | check <id> | rename <id> <new domain> | remove <id>`,
|
|
3805
|
+
),
|
|
3806
|
+
);
|
|
3807
|
+
|
|
3808
|
+
const DOMAIN_STATUS = {
|
|
3809
|
+
PENDING_DNS: tr('DNS 확인 중', 'waiting for DNS'),
|
|
3810
|
+
PENDING_CERT: tr('인증서 발급 중', 'issuing certificate'),
|
|
3811
|
+
ACTIVE: tr('연결됨', 'connected'),
|
|
3812
|
+
ERROR: tr('오류', 'error'),
|
|
3813
|
+
SUSPENDED: tr('일시 중지(결제 필요)', 'paused (payment needed)'),
|
|
3814
|
+
REMOVED: tr('해제됨', 'removed'),
|
|
3815
|
+
};
|
|
3816
|
+
const DOMAIN_KIND = { PAID: tr('내 도메인', 'own'), FREE: tr('무료 주소', 'free') };
|
|
3817
|
+
|
|
3818
|
+
function domainWorkspace() {
|
|
3819
|
+
const ws = loadConfig().workspace;
|
|
3820
|
+
if (!ws) throw new Error(tr('워크스페이스가 설정되지 않았습니다. rootr use <워크스페이스> 또는 ROOTR_WORKSPACE', 'No workspace set. rootr use <workspace> or ROOTR_WORKSPACE'));
|
|
3821
|
+
return ws;
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3824
|
+
function printDomainRecords(records) {
|
|
3825
|
+
console.log(tr('DNS 에 이 한 줄을 넣으세요:', 'Add this one DNS line:'));
|
|
3826
|
+
for (const r of records ?? []) {
|
|
3827
|
+
console.log(` ${tr('타입', 'type')} ${r.type} ${tr('이름(호스트)', 'name (host)')} ${r.name} ${tr('값', 'value')} ${r.value ?? tr('(서버에 연결 주소가 아직 없음)', '(not configured on the server yet)')} TTL ${tr('기본값', 'default')}`);
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
function printDomainGuide(provider) {
|
|
3832
|
+
if (!provider) return;
|
|
3833
|
+
const l = lang() === 'ko' ? 'ko' : 'en';
|
|
3834
|
+
console.log(`\n${provider.name?.[l] ?? provider.id}${provider.dnsUrl ? ` — ${provider.dnsUrl}` : ''}`);
|
|
3835
|
+
(provider.steps?.[l] ?? []).forEach((s, i) => console.log(` ${i + 1}. ${s}`));
|
|
3836
|
+
if (provider.quirks?.[l]?.length) {
|
|
3837
|
+
console.log(tr(' 주의:', ' Watch out:'));
|
|
3838
|
+
for (const q of provider.quirks[l]) console.log(` - ${q}`);
|
|
3839
|
+
}
|
|
3840
|
+
}
|
|
3841
|
+
|
|
3842
|
+
function printDomainStatus(d) {
|
|
3843
|
+
const st = DOMAIN_STATUS[d.status] ?? d.status;
|
|
3844
|
+
console.log(`${d.hostname}\t${st}${d.status === 'ACTIVE' ? `\t${d.siteUrl}` : ''}`);
|
|
3845
|
+
if (d.message || d.lastError) console.log(` ${d.message || d.lastError}`);
|
|
3846
|
+
}
|
|
3847
|
+
|
|
3848
|
+
async function cmdDomain(args) {
|
|
3849
|
+
const sub = args._[0];
|
|
3850
|
+
const client = makeClient();
|
|
3851
|
+
switch (sub) {
|
|
3852
|
+
case undefined:
|
|
3853
|
+
case 'list':
|
|
3854
|
+
case 'ls': {
|
|
3855
|
+
const out = await client.listCustomDomains(domainWorkspace());
|
|
3856
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3857
|
+
if (out.slots) console.log(slotLine(out.slots, tr));
|
|
3858
|
+
if (!out.domains?.length) return console.log(tr('연결된 도메인이 없습니다. rootr domain add <도메인> <페이지> (무료: rootr domain add --subdomain <이름> <페이지>)', 'No custom domains. rootr domain add <domain> <page> (free: rootr domain add --subdomain <name> <page>)'));
|
|
3859
|
+
for (const d of out.domains) {
|
|
3860
|
+
console.log(`${d.id}\t${d.hostname}\t${DOMAIN_KIND[d.kind] ?? DOMAIN_KIND.PAID}\t${DOMAIN_STATUS[d.status] ?? d.status}\t${d.rootNode ? d.rootNode.name : tr('(페이지 없음)', '(page missing)')}`);
|
|
3861
|
+
if (d.status !== 'ACTIVE' && d.lastError) console.log(` ${d.lastError}`);
|
|
3862
|
+
}
|
|
3863
|
+
return;
|
|
3864
|
+
}
|
|
3865
|
+
case 'available': {
|
|
3866
|
+
const name = args._[1];
|
|
3867
|
+
if (!name) throw new Error(DOMAIN_USAGE());
|
|
3868
|
+
const out = await client.customSubdomainAvailability(domainWorkspace(), name);
|
|
3869
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3870
|
+
const why = {
|
|
3871
|
+
invalid: tr('영어 소문자·숫자·하이픈 3~30자, 앞뒤 하이픈 없이', 'lowercase a-z 0-9 and hyphen, 3–30 chars, no leading/trailing hyphen'),
|
|
3872
|
+
reserved: tr('예약된 이름', 'reserved name'),
|
|
3873
|
+
taken: tr('이미 다른 곳에서 씀', 'already taken'),
|
|
3874
|
+
mine: tr('이미 이 워크스페이스 것', 'already yours'),
|
|
3875
|
+
limit: tr(`워크스페이스당 ${out.limit}개까지`, `limit of ${out.limit} per workspace reached`),
|
|
3876
|
+
};
|
|
3877
|
+
console.log(out.available ? tr(`${out.hostname} — 쓸 수 있습니다`, `${out.hostname} — available`) : `${out.hostname} — ${tr('안 됩니다', 'not available')}: ${why[out.reason] ?? out.reason}`);
|
|
3878
|
+
if (!out.available) process.exitCode = 2;
|
|
3879
|
+
return;
|
|
3880
|
+
}
|
|
3881
|
+
case 'detect': {
|
|
3882
|
+
const host = args._[1];
|
|
3883
|
+
if (!host) throw new Error(DOMAIN_USAGE());
|
|
3884
|
+
const out = await client.detectCustomDomain(domainWorkspace(), host, typeof args.provider === 'string' ? args.provider : undefined);
|
|
3885
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3886
|
+
if (out.isApex) console.log(tr(`${out.hostname} 은(는) 루트 도메인이라 CNAME 을 못 거는 곳이 많습니다 — ${out.suggestedHostname} 로 연결하고 루트는 포워딩하세요.`, `${out.hostname} is a root domain; most providers cannot put a CNAME there — connect ${out.suggestedHostname} and forward the root.`));
|
|
3887
|
+
console.log(out.detectedProvider ? tr(`DNS 관리: ${out.provider.name.ko} (이름서버 ${out.nameservers.join(', ')})`, `DNS managed at: ${out.provider.name.en} (name servers ${out.nameservers.join(', ')})`) : tr('DNS 관리 업체를 알아내지 못했습니다 — 일반 안내를 보여 드립니다.', 'Could not tell where the DNS is managed — showing the generic guide.'));
|
|
3888
|
+
printDomainRecords([out.record]);
|
|
3889
|
+
printDomainGuide(out.provider);
|
|
3890
|
+
return;
|
|
3891
|
+
}
|
|
3892
|
+
case 'add': {
|
|
3893
|
+
// 무료 주소는 `--subdomain <이름> <페이지>` 로도 받는다(그때는 페이지가 첫 칸).
|
|
3894
|
+
const subdomain = typeof args.subdomain === 'string' ? args.subdomain : undefined;
|
|
3895
|
+
const host = subdomain ? undefined : args._[1];
|
|
3896
|
+
const page = subdomain ? args._[1] : args._[2];
|
|
3897
|
+
if ((!host && !subdomain) || !page) throw new Error(DOMAIN_USAGE());
|
|
3898
|
+
const rootNodeId = requireResolved(await resolveTarget(client, page));
|
|
3899
|
+
let out;
|
|
3900
|
+
try {
|
|
3901
|
+
out = await client.addCustomDomain(domainWorkspace(), {
|
|
3902
|
+
hostname: host,
|
|
3903
|
+
subdomain,
|
|
3904
|
+
rootNodeId,
|
|
3905
|
+
provider: typeof args.provider === 'string' ? args.provider : undefined,
|
|
3906
|
+
confirmCharge: args['confirm-charge'] === true || args.yes === true,
|
|
3907
|
+
});
|
|
3908
|
+
} catch (err) {
|
|
3909
|
+
const help = err instanceof RootrApiError ? domainPaymentHelp(err, tr) : null;
|
|
3910
|
+
if (help) err.hint = help;
|
|
3911
|
+
throw err;
|
|
3912
|
+
}
|
|
3913
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3914
|
+
if (out.kind === 'FREE') {
|
|
3915
|
+
console.log(tr(`무료 주소가 바로 열렸습니다: https://${out.hostname} (id ${out.id}) — DNS 설정이 필요 없습니다.`, `The free address is live: https://${out.hostname} (id ${out.id}) — no DNS setup needed.`));
|
|
3916
|
+
return;
|
|
3917
|
+
}
|
|
3918
|
+
if (out.billing?.charged) console.log(tr(`도메인 연결 애드온을 ${out.billing.quantity}개로 늘렸습니다(일할 계산 청구).`, `Custom-domain add-on increased to ${out.billing.quantity} (prorated charge).`));
|
|
3919
|
+
console.log(tr(`등록했습니다: ${out.hostname} (id ${out.id})`, `Registered ${out.hostname} (id ${out.id})`));
|
|
3920
|
+
printDomainRecords(out.records);
|
|
3921
|
+
printDomainGuide(out.guide?.provider);
|
|
3922
|
+
console.log(tr(`\n넣은 뒤: rootr domain check ${out.id} (5분마다 저절로도 확인하고, 연결되면 알림이 갑니다)`, `\nThen: rootr domain check ${out.id} (also re-checked every 5 minutes; you get a notification when it is live)`));
|
|
3923
|
+
return;
|
|
3924
|
+
}
|
|
3925
|
+
case 'check': {
|
|
3926
|
+
const id = args._[1];
|
|
3927
|
+
if (!id) throw new Error(DOMAIN_USAGE());
|
|
3928
|
+
const out = await client.checkCustomDomain(domainWorkspace(), id);
|
|
3929
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3930
|
+
printDomainStatus(out);
|
|
3931
|
+
if (out.status !== 'ACTIVE') process.exitCode = 2;
|
|
3932
|
+
return;
|
|
3933
|
+
}
|
|
3934
|
+
case 'rename':
|
|
3935
|
+
case 'mv': {
|
|
3936
|
+
const id = args._[1];
|
|
3937
|
+
const host = args._[2];
|
|
3938
|
+
if (!id || !host) throw new Error(DOMAIN_USAGE());
|
|
3939
|
+
const out = await client.renameCustomDomain(domainWorkspace(), id, { hostname: host, provider: typeof args.provider === 'string' ? args.provider : undefined });
|
|
3940
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3941
|
+
console.log(tr(`이름을 바꿨습니다: ${out.renamedFrom?.hostname ?? id} → ${out.hostname} (새 id ${out.id}, 결제 그대로)`, `Renamed ${out.renamedFrom?.hostname ?? id} → ${out.hostname} (new id ${out.id}, billing unchanged)`));
|
|
3942
|
+
printDomainRecords(out.records);
|
|
3943
|
+
printDomainGuide(out.guide?.provider);
|
|
3944
|
+
console.log(tr(`\n넣은 뒤: rootr domain check ${out.id}`, `\nThen: rootr domain check ${out.id}`));
|
|
3945
|
+
return;
|
|
3946
|
+
}
|
|
3947
|
+
case 'remove':
|
|
3948
|
+
case 'rm':
|
|
3949
|
+
case 'delete': {
|
|
3950
|
+
const id = args._[1];
|
|
3951
|
+
if (!id) throw new Error(DOMAIN_USAGE());
|
|
3952
|
+
const out = await client.removeCustomDomain(domainWorkspace(), id);
|
|
3953
|
+
if (args.json) return console.log(JSON.stringify(out, null, 2));
|
|
3954
|
+
if (out.kind === 'FREE') console.log(tr(`${out.hostname} 무료 주소를 해제했습니다.`, `Released the free address ${out.hostname}.`));
|
|
3955
|
+
else console.log(tr(`${out.hostname} 연결을 해제했습니다. DNS 의 CNAME 줄도 지워 주세요.`, `Disconnected ${out.hostname}. Remove the CNAME line at your DNS provider too.`));
|
|
3956
|
+
const b = out.billing;
|
|
3957
|
+
if (b?.action === 'decreased') console.log(tr(`도메인 연결 애드온을 ${b.quantity}개로 줄였습니다(남은 기간은 일할 계산으로 돌려받습니다).`, `Custom-domain add-on reduced to ${b.quantity} (prorated credit).`));
|
|
3958
|
+
if (b?.action === 'cancel_scheduled') console.log(tr(`마지막 도메인이라 도메인 연결 애드온이 ${String(b.cancelAt).slice(0, 10)} 에 끝나도록 예약했습니다(그때까지 자리는 남습니다).`, `That was the last one — the custom-domain add-on ends on ${String(b.cancelAt).slice(0, 10)} (the slot stays until then).`));
|
|
3959
|
+
if (b?.action === 'failed') console.log(tr('결제 수량 조정이 실패했습니다 — 설정 › 결제에서 확인해 주세요.', 'Could not adjust the add-on quantity — check Settings → Billing.'));
|
|
3960
|
+
return;
|
|
3961
|
+
}
|
|
3962
|
+
default:
|
|
3963
|
+
throw new Error(DOMAIN_USAGE());
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3561
3967
|
async function main() {
|
|
3562
3968
|
const argv = process.argv.slice(2);
|
|
3563
3969
|
const args = parseArgs(argv);
|
|
@@ -3577,6 +3983,10 @@ async function main() {
|
|
|
3577
3983
|
return cmdLs(args);
|
|
3578
3984
|
case 'comments':
|
|
3579
3985
|
return cmdComments(args);
|
|
3986
|
+
case 'members':
|
|
3987
|
+
return cmdMembers(args);
|
|
3988
|
+
case 'message':
|
|
3989
|
+
return cmdMessage(args);
|
|
3580
3990
|
case 'read':
|
|
3581
3991
|
return cmdRead(args);
|
|
3582
3992
|
case 'write':
|
|
@@ -3627,6 +4037,9 @@ async function main() {
|
|
|
3627
4037
|
return cmdSend(args);
|
|
3628
4038
|
case 'mail':
|
|
3629
4039
|
return cmdMail(args);
|
|
4040
|
+
case 'domain':
|
|
4041
|
+
case 'domains':
|
|
4042
|
+
return cmdDomain(args);
|
|
3630
4043
|
case 'feedback':
|
|
3631
4044
|
return cmdFeedback(args);
|
|
3632
4045
|
case 'scaffold':
|