rootr-cli 0.25.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ import { z } from 'zod';
2
+
3
+ import { RootrApiError } from '../client.js';
4
+ import { domainPaymentHelp } from '../domain-billing.js';
5
+ import { jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT } from './shared.js';
6
+
7
+ /**
8
+ * CUSTOM-DOMAIN — 발행한 페이지(PAGE)를 **고객 도메인**(www.customer.co.kr)으로 연다.
9
+ *
10
+ * 고객은 DNS 에 한 줄(CNAME `www` → connect.rootr.site)만 넣는다. 등록하면 서버가 그 도메인의 DNS 를 어디서 관리하는지
11
+ * (가비아·카페24·Cloudflare…)를 이름서버로 짚어, 그 업체 화면 기준의 단계를 같이 준다 — 에이전트는 그 단계를 사람에게
12
+ * 그대로 전하면 된다. 원격 커넥터에서도 그대로 돈다(파일·환경을 쓰지 않는다).
13
+ *
14
+ * 3단계(과금): 내 도메인은 1개당 월 $10(연 결제 월 $8) — "도메인 연결" 애드온의 자리를 하나 쓴다. 자리가 없으면 서버가 402 를 주고,
15
+ * 여기서는 그 까닭과 할 일(결제 탭 주소)을 사람 말로 붙여 돌려준다. 무료 주소 `{이름}.rootr.site`(Team 부터, 3개까지)는 결제 없이 바로 열린다.
16
+ */
17
+
18
+ const WS_ARG = z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted');
19
+
20
+ const STATUS_HINT =
21
+ 'kind: PAID (own domain, uses one paid slot) or FREE (name.rootr.site, no DNS, live at once). ' +
22
+ 'status: SUSPENDED (the custom-domain payment lapsed — paying again in Settings → Billing reopens it), PENDING_DNS (the DNS line is not visible yet — propagation usually takes 10 min–1 h), PENDING_CERT ' +
23
+ '(DNS is right, the HTTPS certificate is being issued — minutes), ACTIVE (live), ERROR (see lastError). ' +
24
+ 'lastErrorCode is one of DNS_NOT_FOUND, DNS_WRONG_TARGET (observedTarget = what is there now), TXT_MISSING, ' +
25
+ 'CERT_PENDING, CERT_FAILED, CF_BLOCKED, CF_ERROR, CF_OFF; lastError is the same in plain Korean.';
26
+
27
+ /** 등록 응답의 안내를 사람이 읽는 글로(서버 dns-providers.ts guideText 와 같은 모양). */
28
+ function guideText(guide, lang) {
29
+ if (!guide?.provider) return '';
30
+ const p = guide.provider;
31
+ const head = `${p.name?.[lang] ?? p.id}${p.dnsUrl ? ` — ${p.dnsUrl}` : ''}`;
32
+ const steps = (p.steps?.[lang] ?? []).map((s, i) => `${i + 1}. ${s}`);
33
+ const quirks = (p.quirks?.[lang] ?? []).map((s) => `- ${s}`);
34
+ return [head, ...steps, ...(quirks.length ? [lang === 'ko' ? '주의:' : 'Watch out:', ...quirks] : [])].join('\n');
35
+ }
36
+
37
+ function recordLine(r) {
38
+ return `${r.type} name(host)=${r.name} value=${r.value ?? '(not configured on this server yet)'} TTL=default`;
39
+ }
40
+
41
+ export function registerDomainTools(server) {
42
+ server.registerTool(
43
+ 'rootr_list_custom_domains',
44
+ {
45
+ title: 'List custom domains of a workspace',
46
+ description:
47
+ 'List the custom domains (e.g. www.customer.co.kr) connected to published PAGE or DOCUMENT nodes in a Rootr (루터) ' +
48
+ 'workspace: hostname, kind (PAID own domain / FREE name.rootr.site), which page it opens (rootNode), status, the DNS ' +
49
+ 'line(s) to add, and the live URL — plus `slots`: paid-domain slots (total = bought + granted, used, available), ' +
50
+ 'the add-on state (interval, cancelAt, pastDue), free addresses used of 3, and the price ($10/month, $96/year). ' +
51
+ STATUS_HINT +
52
+ ' Workspace ADMIN or OWNER only.',
53
+ annotations: { readOnlyHint: true, destructiveHint: false },
54
+ inputSchema: { workspace: WS_ARG },
55
+ },
56
+ async ({ workspace }) => {
57
+ try {
58
+ const ws = requireWorkspace(workspace);
59
+ return jsonResult(await makeClient(ws).listCustomDomains(ws));
60
+ } catch (err) {
61
+ return errorResult(err);
62
+ }
63
+ },
64
+ );
65
+
66
+ server.registerTool(
67
+ 'rootr_add_custom_domain',
68
+ {
69
+ title: 'Connect a custom domain to a published page or document',
70
+ description:
71
+ 'Connect the customer\'s own domain (e.g. www.customer.co.kr) — or a FREE address name.rootr.site — to a PAGE or ' +
72
+ 'DOCUMENT node (a document makes a blog-like site, e.g. blog.customer.co.kr) so it opens as a normal website: the root ' +
73
+ 'path shows it, `/{child}` shows its published child pages and documents, no ' +
74
+ '"Rootr" signature. The page should already be published to the web (rootr_publish_node). Team plan and up — a Free ' +
75
+ 'workspace gets 403 PLAN_UPGRADE_REQUIRED. Workspace ADMIN/OWNER only. ' +
76
+ 'FREE address: pass `subdomain: "acme"` (or hostname acme.rootr.site) — no DNS, no payment, live immediately; ' +
77
+ 'lowercase a-z 0-9 and hyphen, 3–30 chars, globally unique, up to 3 per workspace, names like www/api/admin/mail and ' +
78
+ 'anything containing "rootr" are reserved. ' +
79
+ 'OWN domain: $10/month per domain, $8/month billed yearly ($96/year) — the "Custom domain" add-on. With a free slot it ' +
80
+ 'just connects. If the add-on exists but is full you get 402 CUSTOM_DOMAIN_CHARGE_CONFIRM_REQUIRED: tell the person ' +
81
+ 'the price and, only after they agree, call again with confirmCharge: true (adds one slot, prorated). With no add-on ' +
82
+ 'you get 402 CUSTOM_DOMAIN_PAYMENT_REQUIRED: the person must start it in Settings → Billing (the error says where). ' +
83
+ 'The customer then adds ONE DNS line (CNAME, name `www`, value connect.rootr.site). The result includes ' +
84
+ '`records` (the exact line), `guide.provider` (the DNS provider detected from the name servers — Gabia, Whois, ' +
85
+ 'Cafe24, Hosting.kr, iNames, Dotname, Cloudflare, GoDaddy, Namecheap, Route 53, Squarespace, OnlyDomains, Wix, ' +
86
+ 'or a generic guide — with click-by-click steps in Korean and English) and `instructions` (the same as text). ' +
87
+ 'Relay those steps to the human as they are. A bare root domain (customer.co.kr) cannot hold a CNAME at most ' +
88
+ 'providers — connect www.customer.co.kr and forward the root to it (guide.provider.forwarding). ' +
89
+ 'Afterwards call rootr_check_custom_domain; the server also re-checks every 5 minutes and notifies the person ' +
90
+ 'who registered it when it goes live.',
91
+ annotations: { readOnlyHint: false, destructiveHint: false },
92
+ inputSchema: {
93
+ target: z.string().describe(`The PAGE or DOCUMENT node to open at the domain root: node id${NODE_TARGET_HINT}`),
94
+ hostname: z
95
+ .string()
96
+ .optional()
97
+ .describe('The domain, e.g. www.customer.co.kr (a pasted URL is fine), or acme.rootr.site for a free address'),
98
+ subdomain: z
99
+ .string()
100
+ .optional()
101
+ .describe('Free address name only: "acme" → acme.rootr.site (no DNS, no payment). Use instead of hostname'),
102
+ confirmCharge: z
103
+ .boolean()
104
+ .optional()
105
+ .describe('Set true ONLY after the person agreed to pay for one more domain slot ($10/month or $96/year, prorated)'),
106
+ provider: z
107
+ .string()
108
+ .optional()
109
+ .describe('Override the detected DNS provider: gabia, whois, cafe24, hostingkr, inames, dotname, cloudflare, godaddy, namecheap, route53, squarespace, onlydomains, wix, other'),
110
+ lang: z.enum(['ko', 'en']).optional().describe('Language of `instructions` (default ko)'),
111
+ workspace: WS_ARG,
112
+ },
113
+ },
114
+ async ({ target, hostname, subdomain, confirmCharge, provider, lang, workspace }) => {
115
+ try {
116
+ if (!hostname && !subdomain) throw new Error('Pass hostname (www.customer.co.kr) or subdomain (acme → acme.rootr.site).');
117
+ const ws = requireWorkspace(workspace);
118
+ const client = makeClient(ws);
119
+ const rootNodeId = await resolveNodeId(client, target);
120
+ const out = await client.addCustomDomain(ws, { hostname, subdomain, rootNodeId, provider, confirmCharge });
121
+ const l = lang === 'en' ? 'en' : 'ko';
122
+ if (out.kind === 'FREE') {
123
+ const instructions =
124
+ l === 'ko'
125
+ ? `무료 주소가 바로 열렸습니다: https://${out.hostname} — DNS 설정이 필요 없습니다.`
126
+ : `The free address is live now: https://${out.hostname} — no DNS setup needed.`;
127
+ return jsonResult({ ...out, instructions });
128
+ }
129
+ const instructions = [
130
+ l === 'ko' ? `DNS 에 이 한 줄을 넣으세요:` : 'Add this one DNS line:',
131
+ ...(out.records ?? []).map(recordLine),
132
+ '',
133
+ guideText(out.guide, l),
134
+ ...(out.guide?.isApex
135
+ ? ['', l === 'ko' ? `루트 도메인은 CNAME 을 못 거는 곳이 많습니다 — www.${out.guide.apex} 로 연결하고 루트는 포워딩하세요.` : `Most providers cannot put a CNAME on the root — connect www.${out.guide.apex} and forward the root to it.`]
136
+ : []),
137
+ ].join('\n');
138
+ return jsonResult({ ...out, instructions });
139
+ } catch (err) {
140
+ const help = err instanceof RootrApiError ? domainPaymentHelp(err) : null;
141
+ if (help) err.hint = help;
142
+ return errorResult(err);
143
+ }
144
+ },
145
+ );
146
+
147
+ server.registerTool(
148
+ 'rootr_check_custom_domain',
149
+ {
150
+ title: 'Check a custom domain now',
151
+ description:
152
+ 'Re-check a custom domain right now (DNS + HTTPS certificate) and return its status with a plain-language ' +
153
+ 'reason. ' +
154
+ STATUS_HINT +
155
+ ' Calling it again within 10 seconds returns the previous result. Team plan and up; ADMIN/OWNER only.',
156
+ annotations: { readOnlyHint: false, destructiveHint: false },
157
+ inputSchema: {
158
+ domainId: z.string().describe('Domain id from rootr_list_custom_domains / rootr_add_custom_domain'),
159
+ workspace: WS_ARG,
160
+ },
161
+ },
162
+ async ({ domainId, workspace }) => {
163
+ try {
164
+ const ws = requireWorkspace(workspace);
165
+ return jsonResult(await makeClient(ws).checkCustomDomain(ws, domainId));
166
+ } catch (err) {
167
+ return errorResult(err);
168
+ }
169
+ },
170
+ );
171
+
172
+ server.registerTool(
173
+ 'rootr_rename_custom_domain',
174
+ {
175
+ title: 'Change the name of a not-yet-connected custom domain',
176
+ description:
177
+ 'Fix the hostname of an own (PAID) custom domain that is not live yet (status PENDING_DNS, PENDING_CERT or ' +
178
+ 'ERROR) — e.g. a typo, or a different name instead of www. The paid slot moves to the new name: no charge, no ' +
179
+ 'refund. The old name is disconnected (status REMOVED) and the result is a NEW domain id with the new DNS line ' +
180
+ 'and provider guide — tell the human the new CNAME line. A live (ACTIVE) domain or a free name.rootr.site ' +
181
+ 'address cannot be renamed: remove it and add a new one. Team plan and up; ADMIN/OWNER only.',
182
+ annotations: { readOnlyHint: false, destructiveHint: false },
183
+ inputSchema: {
184
+ domainId: z.string().describe('Domain id from rootr_list_custom_domains / rootr_add_custom_domain'),
185
+ hostname: z.string().describe('The new hostname, e.g. "www.customer.co.kr"'),
186
+ provider: z.string().optional().describe('DNS provider id for the new name (gabia, cafe24, cloudflare, …); omit to auto-detect'),
187
+ lang: z.enum(['ko', 'en']).optional().describe('Language of the returned step-by-step guide (default ko)'),
188
+ workspace: WS_ARG,
189
+ },
190
+ },
191
+ async ({ domainId, hostname, provider, lang, workspace }) => {
192
+ try {
193
+ const ws = requireWorkspace(workspace);
194
+ const out = await makeClient(ws).renameCustomDomain(ws, domainId, { hostname, provider });
195
+ const l = lang === 'en' ? 'en' : 'ko';
196
+ const instructions = [
197
+ l === 'ko'
198
+ ? `${out.renamedFrom?.hostname ?? hostname} → ${out.hostname} 로 바꿨습니다(결제 그대로). DNS 에 이 한 줄을 넣으세요:`
199
+ : `Renamed ${out.renamedFrom?.hostname ?? hostname} → ${out.hostname} (billing unchanged). Add this one DNS line:`,
200
+ ...(out.records ?? []).map(recordLine),
201
+ '',
202
+ guideText(out.guide, l),
203
+ ].join('\n');
204
+ return jsonResult({ ...out, instructions });
205
+ } catch (err) {
206
+ return errorResult(err);
207
+ }
208
+ },
209
+ );
210
+
211
+ server.registerTool(
212
+ 'rootr_remove_custom_domain',
213
+ {
214
+ title: 'Disconnect a custom domain',
215
+ description:
216
+ 'Disconnect a custom domain: the site stops answering at that domain immediately and the certificate host is ' +
217
+ 'deleted. The page itself and its rootr.io/p/… address are untouched. Tell the human to also remove the CNAME ' +
218
+ 'line at their DNS provider. For an own (paid) domain the add-on quantity goes down by one (prorated credit); ' +
219
+ 'removing the last one schedules the add-on to end at the end of the billing period (`billing` in the result). ' +
220
+ 'Free addresses have no billing. Works on any plan; ADMIN/OWNER only.',
221
+ annotations: { readOnlyHint: false, destructiveHint: true },
222
+ inputSchema: {
223
+ domainId: z.string().describe('Domain id from rootr_list_custom_domains'),
224
+ workspace: WS_ARG,
225
+ },
226
+ },
227
+ async ({ domainId, workspace }) => {
228
+ try {
229
+ const ws = requireWorkspace(workspace);
230
+ return jsonResult(await makeClient(ws).removeCustomDomain(ws, domainId));
231
+ } catch (err) {
232
+ return errorResult(err);
233
+ }
234
+ },
235
+ );
236
+ }
@@ -35,6 +35,18 @@ const fieldShape = z
35
35
  .boolean()
36
36
  .optional()
37
37
  .describe('date only: false = a single date (no end-date input). Omitted/true = start + optional end.'),
38
+ multiple: z
39
+ .boolean()
40
+ .optional()
41
+ .describe('file only: true = accept SEVERAL files; the answer becomes an array of { id, filename, size }. Default: one file.'),
42
+ maxFiles: z
43
+ .number()
44
+ .optional()
45
+ .describe('file + multiple only: most files one person may attach (1–20, default 10).'),
46
+ allowImages: z
47
+ .boolean()
48
+ .optional()
49
+ .describe('text/textarea only: let respondents insert images into the answer (stored as ![name](url) markdown inside the text).'),
38
50
  showIf: z
39
51
  .object({ fieldId: z.string(), equals: z.string() })
40
52
  .optional()
@@ -46,7 +58,9 @@ const FIELDS_GUIDE =
46
58
  'Fields are typed inputs shown to respondents. Basic: text | textarea | number | select | multi_select | ' +
47
59
  'checkbox | date. Contact: email | phone | url (each is format-checked server-side). Survey: rating (stars) | ' +
48
60
  'scale (numbered buttons) | csat (1-5) | nps (0-10) | matrix (rows x options, Likert) | ranking (drag order). ' +
49
- 'Also: time (HH:MM) and file (uploaded on the published page only). ' +
61
+ 'Also: time (HH:MM) and file (one file by default; `multiple: true` + optional `maxFiles` (1-20, default 10) ' +
62
+ 'takes several). `allowImages: true` on a text/textarea question lets people paste/insert images into the ' +
63
+ 'answer (kept as ![name](url) inside the text). ' +
50
64
  'select/multi_select/matrix/ranking need `options` ({name,color}[]); matrix also needs `rows`. ' +
51
65
  'Per-field extras: `help` (a hint line), `placeholder`, `page` (split into multiple pages), `hidden` + URL ' +
52
66
  'params (record the traffic source), `showIf` (show only when an earlier answer matches), `range: false` on a ' +
@@ -217,7 +231,10 @@ export function registerFormTools(server) {
217
231
  description:
218
232
  'Submit one response to a Rootr (루터) FORM on behalf of the caller (authenticated submission, not the ' +
219
233
  'public/anonymous share-link flow). `values` is keyed by field id — call rootr_read_form first to get the ' +
220
- 'exact field ids and types.',
234
+ 'exact field ids and types. File questions: upload each file first (rootr_upload_attachment, or ' +
235
+ 'rootr_create_upload_url → PUT → rootr_complete_upload) and pass `{ id, filename }` — an ARRAY of those when ' +
236
+ 'the field has `multiple: true` (more than `maxFiles` is refused; several files on a single-file question is ' +
237
+ 'refused). Images in a text answer with `allowImages` go in as markdown: `![name](url)` using the upload\'s url.',
221
238
  annotations: { readOnlyHint: false, destructiveHint: false },
222
239
  inputSchema: {
223
240
  formId: z.string().describe(`FORM node id${NODE_TARGET_HINT}`),
@@ -273,6 +290,7 @@ export function registerFormTools(server) {
273
290
  title: 'Fix an already-submitted Rootr form response',
274
291
  description:
275
292
  'Correct one response that was already submitted to a Rootr (루터) FORM. `values` REPLACES the whole ' +
293
+ '(file answers too — to add a file to a `multiple` question send the old array plus the new { id, filename }) ' +
276
294
  'answer set and is re-checked against the form\'s current fields (required/type rules), so send every ' +
277
295
  'answer you want to keep — call rootr_list_form_responses first. Use this instead of deleting and ' +
278
296
  're-submitting when you want the original submitter and submission time preserved.',
@@ -26,7 +26,11 @@ import {
26
26
  const BLOCK_GUIDE =
27
27
  'A page is `blocks[]` (ordered, max 200) + `config`. Each block is {id?, type, props}. ' +
28
28
  'You assemble a page from these block types:\n' +
29
- '· hero {title, subtitle?, align?:"left"|"center", size?:"normal"|"screen", background?:{kind:"color",color}|{kind:"image",attachmentId}, button?:{label,target}} — the opening banner. ' +
29
+ '· hero {title, subtitle?, align?:"left"|"center", size?:"normal"|"screen", background?:{kind:"color",color}|{kind:"image",attachmentId}|{kind:"video",attachmentId,posterAttachmentId?,portraitAttachmentId?,portraitPosterAttachmentId?}, button?:{label,target}} — the opening banner. ' +
30
+ 'background kind:"video" = a looping MOTION-GRAPHIC background (autoplay, muted, loop, inline, no controls; same dark shade and white text as an image; ' +
31
+ 'covers the full-screen hero too). posterAttachmentId (an image) is shown while loading and INSTEAD of the video for visitors who ask for reduced motion or whose browser blocks autoplay — ' +
32
+ 'make the poster the video\'s first frame. portraitAttachmentId (+ portraitPosterAttachmentId) is an optional PHONE/PORTRAIT cut (e.g. 1080×1920): ' +
33
+ 'a portrait screen plays it and a landscape screen plays the default one — only ONE file is downloaded. Without it, phones crop the middle strip of a 16:9 video. ' +
30
34
  'size:"screen" makes it a FULL-SCREEN landing hero: edge to edge and one viewport tall (minus the top name bar, capped at 1200px), big headline, content centred vertically ' +
31
35
  '(default "normal" = a compact banner inside the text column)\n' +
32
36
  '· text {body (markdown), title?} — prose section; markdown is sanitized when rendered\n' +
@@ -57,6 +61,14 @@ const BLOCK_GUIDE =
57
61
  'needs on a short phone and nothing is clipped. Inside, take the frame height with `height:100%` (html and body already are 100%) ' +
58
62
  'and centre the content with flexbox. bleed:true + minHeight:"screen" = a full-screen section; stack several for a ' +
59
63
  'section-per-screen landing page.\n' +
64
+ '· video {attachmentId, posterAttachmentId?, caption?, aspect?:"16:9"|"9:16"|"1:1"|"4:5" (default "16:9"), bleed?:boolean} — a MOTION-GRAPHIC clip ' +
65
+ '(always autoplay, muted, loop, playsinline, no controls — it is a moving picture, not a talk with sound). bleed:true spans the full page width. ' +
66
+ 'Same poster / reduced-motion rule as the hero video background.\n' +
67
+ 'VIDEOS (hero video background, video block): upload the file first — POST /v1/attachments/upload?workspaceId=<ws>&nodeId=<page>&filename=a.mp4&mimeType=video/mp4 ' +
68
+ 'with the raw bytes as the body and Content-Type application/octet-stream (H.264 MP4 with +faststart plays everywhere incl. iPhone). ' +
69
+ 'The attachment must belong to the page\'s workspace (else 400 PAGE_MEDIA_INVALID; a poster must be an image). Videos on pages need the TEAM plan ' +
70
+ '(Free → 403 PLAN_UPGRADE_REQUIRED, same as slide videos). Read responses carry a short-lived `vt` playback signature on each video spot; ' +
71
+ 'the bare /raw address of a video answers 403 (hotlink protection) — never store `vt`, it is dropped on save.\n' +
60
72
  'LAYOUT: any block takes `width:"full"|"twoThirds"|"half"|"third"` — neighbouring blocks sit side by side (phones stack them). ' +
61
73
  'That is how a page becomes a DASHBOARD: a row of `chart:"kpi"` blocks at width "third", then charts at width "half". ' +
62
74
  'A hero with size:"screen" and a custom block with bleed:true always take the whole row — `width` is ignored (not stored) on them.\n' +
@@ -90,10 +102,15 @@ const BLOCK_GUIDE =
90
102
  'saved; it is never stored as a silently empty block. `column`/`labelColumn`/' +
91
103
  '`valueColumn`/`orderBy.column` are the column NAMES as shown in the table (not column ids). A table or form that ' +
92
104
  'sits in a PRIVATE subtree is silently skipped on the published page — that is deliberate, never a bug to work around.\n' +
93
- 'LINKS: every `target` is {kind:"url", href} (absolute http(s)://, mailto: or tel: only) or {kind:"node", nodeId} ' +
94
- 'for a document in the same workspace. Internal path strings are rejected. A node link renders as the in-app ' +
105
+ 'LINKS: every `target` is {kind:"url", href} (absolute http(s)://, mailto: or tel: only), {kind:"node", nodeId} ' +
106
+ 'for a document in the same workspace, or {kind:"block", blockId} to JUMP TO ANOTHER BLOCK ON THIS SAME PAGE ' +
107
+ '(e.g. a hero button "Book a free consult" → the booking block further down: smooth scroll in the same tab, no new tab — ' +
108
+ 'use this for landing pages opened in Instagram/Facebook in-app browsers). blockId must be the id of a block on this page ' +
109
+ '(else 400) — when you create the page, give the target block an explicit `id` (e.g. "booking") so you can point at it. ' +
110
+ 'Internal path strings are rejected. A url link opens in a new tab; a node link renders as the in-app ' +
95
111
  'document for members, as the public address on the published page, and is hidden entirely if that document is ' +
96
- 'not published — so link to nodes rather than pasting addresses.\n' +
112
+ 'not published — so link to nodes rather than pasting addresses. Inside a text block\'s markdown, `[label](#<blockId>)` ' +
113
+ 'is a same-page link to that block.\n' +
97
114
  'CONFIG: {theme, accentColor?, navEnabled?, metaPixelId?}. theme is one of classic | minimal | warm | pastel | editorial | mono | ' +
98
115
  'dark | midnight. accentColor is a hex like "#1A4A7A"; leave it out and the theme picks a matching accent.' +
99
116
  ' metaPixelId is the numeric Meta (Facebook/Instagram) Pixel ID: when set, the PUBLISHED page reports PageView and, when a visitor books through a booking block, Schedule to that pixel (never in the editor); send "" to turn it off. ' +
@@ -94,6 +94,8 @@ export function registerPublicTools(server) {
94
94
  'PLAN_UPGRADE_REQUIRED; a password-locked page is always hidden and cannot be re-exposed until unlocked); ' +
95
95
  '`{"design": {"layout": "board", "skin": "paper"}}` changes how a published TABLE looks — it does not ' +
96
96
  'have to be a table (cards, gallery, list, board, timeline, calendar, chart, ranking). ' +
97
+ '`{"seoDescription": "...", "seoImage": "<attachment id>"}` sets the search-result description and link-preview ' +
98
+ 'image (search engines are pinged right away). ' +
97
99
  '`{"isPublic": true}` on a PAGE needs the Team plan (Free → 403 PLAN_UPGRADE_REQUIRED); closing it works on any plan. ' +
98
100
  'For DOCUMENT nodes use rootr_set_document_public instead — documents keep their own settings (skins, ' +
99
101
  'edit proposals) on the older path. Owner-level action (MANAGE): use an account key (PAT).',
@@ -106,6 +108,17 @@ export function registerPublicTools(server) {
106
108
  noIndex: z.boolean().optional(),
107
109
  expiresAt: z.string().nullable().optional().describe('ISO date, or null to clear'),
108
110
  password: z.string().nullable().optional().describe('null or "" removes the lock'),
111
+ seoDescription: z
112
+ .string()
113
+ .max(300)
114
+ .nullable()
115
+ .optional()
116
+ .describe('Search-result / link-preview description (~160 chars recommended). null or "" falls back to the opening text.'),
117
+ seoImage: z
118
+ .string()
119
+ .nullable()
120
+ .optional()
121
+ .describe('Link-preview cover image: an image attachment id from the same workspace (rootr_upload_attachment). null removes it.'),
109
122
  view: z.string().optional().describe('DATABASE only: view id the public page opens with'),
110
123
  design: DB_DESIGN_SCHEMA.optional().describe(
111
124
  'DATABASE only: how the public page presents the table. `layout` — table, cards, gallery, list, ' +
@@ -42,10 +42,18 @@ const ACCESS_HINT =
42
42
  '`searchExcluded: true` (values stay out of the search index behind ask / AI explore / similar rows — not a read permission) and a `type: "vector"` ' +
43
43
  'property needs `dimensions` (an embedding column agents fill with rootr_set_row_vectors). ' +
44
44
  'A phone/email/name property may carry `dedupe: true` (warn when the same person is entered again) with ' +
45
- '`dedupeShow: ["진행상태","특이사항","콜 이력"]` (fields of the existing row the warning shows — a relation shows its latest 5 rows); ' +
45
+ '`dedupeShow: ["상태","메모","통화 기록"]` (fields of the existing row the warning shows — a relation shows its latest 5 rows) and ' +
46
+ '`dedupeCarry: { label: "재등록", action: "carry"|"merge_into_old"|"merge_all"|"ask", sources: "all", columns: { "메모": { append: { sep: " / " } } }, exclude: ["접수일"], mark: { "재등록 여부": true, "구분": { map: { "A": "A(재등록)" }, else: "{value} (재등록)" } }, cases: [{ when: { within: { column: "접수일", days: 0 } }, action: "merge_all", mark: {} }, { action: "carry" }] }` ' +
47
+ '(same entry came back: copy the earlier row into the new one — see rootr_update_column); any property may carry ' +
48
+ '`validate: { kind: "pattern"|"range"|"date"|"birthdate", pattern?, flags?, negate?, min?, max?, message? }` (flags a wrong value on every new write, never blocks); ' +
46
49
  'a relation property may carry `relationFilter: [{ propId, op: "is"|"is_not_empty", values? }]` (which linked rows may be picked). ' +
50
+ 'The title property may carry `identifyProps: [{ propId: "연락처", mask: "last4" }, { propId: "지원일" }]` (shown next to the name ' +
51
+ 'wherever another table picks these rows, so same-name people can be told apart). ' +
47
52
  'A board view config may carry `cardProps: ["면접예정일","연락처"]` (fields on each card, rollups too) and any view `rowColors` ' +
48
53
  '(`[{ propId, op: "is"|"is_not_empty", values?, color }]`). ' +
54
+ 'Any property may carry `align: "left"|"center"|"right"` and `wrap: true`; a date property `highlightOverdue: true` ' +
55
+ '(past dates red — off by default), `allowTime: false` (no time) and `allowRange: false` (no end date). A table view ' +
56
+ 'config may carry `density: "compact"` and `frozenColumns: 1` (first N visible columns fixed while scrolling sideways). ' +
49
57
  'A DOCUMENT\'s `content` may embed a live ' +
50
58
  'table/sheet/deck with a line `[[embed:<name of a node in this same tree>]]` or `[[embed:/absolute/path]]` — it is ' +
51
59
  'linked to the real node after the whole tree is built (an unknown name rolls the whole tree back).';
@@ -228,6 +236,68 @@ export function registerWorkspaceTools(server) {
228
236
  },
229
237
  );
230
238
 
239
+ server.registerTool(
240
+ 'rootr_workspace_members',
241
+ {
242
+ title: 'List members of a Rootr workspace',
243
+ description:
244
+ 'List the members of a Rootr (루터) workspace — each with their user id, name, email, and role. Use the ' +
245
+ 'id to address rootr_message_member, or to pass as assigneeId/userId elsewhere.',
246
+ annotations: { readOnlyHint: true, destructiveHint: false },
247
+ inputSchema: {
248
+ workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
249
+ },
250
+ },
251
+ async ({ workspace }) => {
252
+ try {
253
+ const ws = requireWorkspace(workspace);
254
+ const client = makeClient(ws);
255
+ const members = await client.listMembers(ws);
256
+ return jsonResult(
257
+ members.map((m) => ({
258
+ userId: m.userId ?? m.user?.id,
259
+ name: m.user?.name ?? '',
260
+ email: m.user?.email ?? '',
261
+ role: m.role,
262
+ })),
263
+ );
264
+ } catch (err) {
265
+ return errorResult(err);
266
+ }
267
+ },
268
+ );
269
+
270
+ server.registerTool(
271
+ 'rootr_message_member',
272
+ {
273
+ title: 'Send a direct message to a workspace member',
274
+ description:
275
+ 'Send a direct notification-style message to another member of a Rootr (루터) workspace — not tied to ' +
276
+ 'any document, issue, or comment. The message is posted as the person the ROOTR_API_KEY belongs to and ' +
277
+ 'shows up for the recipient in their Rootr notification bell (and by email). Use rootr_workspace_members ' +
278
+ 'first to find the recipient\'s userId.',
279
+ annotations: { readOnlyHint: false, destructiveHint: false },
280
+ inputSchema: {
281
+ userId: z.string().describe('User id of the workspace member to message (see rootr_workspace_members)'),
282
+ body: z.string().describe('Message text'),
283
+ workspace: z
284
+ .string()
285
+ .optional()
286
+ .describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
287
+ },
288
+ },
289
+ async ({ userId, body, workspace }) => {
290
+ try {
291
+ const ws = requireWorkspace(workspace);
292
+ const client = makeClient(ws);
293
+ await client.messageMember(ws, userId, body);
294
+ return textResult(`${userId} 에게 메시지를 보냈습니다.`);
295
+ } catch (err) {
296
+ return errorResult(err);
297
+ }
298
+ },
299
+ );
300
+
231
301
  server.registerTool(
232
302
  'rootr_scaffold_plan',
233
303
  {
package/lib/mcp.js CHANGED
@@ -25,6 +25,7 @@ import { registerSignedDocTools } from './mcp-tools/signed.js';
25
25
  import { registerFeedbackTools } from './mcp-tools/feedback.js';
26
26
  import { registerMemoryTools } from './mcp-tools/memory.js';
27
27
  import { registerDocMailTools } from './mcp-tools/doc-mail.js';
28
+ import { registerDomainTools } from './mcp-tools/domains.js';
28
29
  import { withDocMailSink, withRateLimitSink } from './context.js';
29
30
  import { makeClient } from './mcp-tools/shared.js';
30
31
  import { t as tr } from './i18n.js';
@@ -330,6 +331,7 @@ export function buildMcpServer({ name, version }) {
330
331
  registerFeedbackTools(server);
331
332
  registerMemoryTools(server);
332
333
  registerDocMailTools(server);
334
+ registerDomainTools(server);
333
335
 
334
336
  return server;
335
337
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.25.0",
3
+ "version": "0.29.0",
4
4
  "description": "Official Rootr CLI + MCP server — read, write, edit and search Rootr documents from the shell or from an AI agent.",
5
5
  "type": "module",
6
6
  "bin": {