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/lib/client.js
CHANGED
|
@@ -754,6 +754,19 @@ export class RootrClient {
|
|
|
754
754
|
return [];
|
|
755
755
|
}
|
|
756
756
|
|
|
757
|
+
/** List members of a workspace (id, email, name, role). */
|
|
758
|
+
async listMembers(workspace) {
|
|
759
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/members`);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** MEMBER-MSG — send a direct notification-style message to another member of this workspace. */
|
|
763
|
+
async messageMember(workspace, userId, body) {
|
|
764
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/members/${encodeURIComponent(userId)}/message`, {
|
|
765
|
+
method: 'POST',
|
|
766
|
+
body: { body },
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
|
|
757
770
|
// ---- Scaffolding ----------------------------------------------------------
|
|
758
771
|
|
|
759
772
|
/** Plan a scaffold tree from a natural-language intent. No workspace required. */
|
|
@@ -970,6 +983,54 @@ export class RootrClient {
|
|
|
970
983
|
});
|
|
971
984
|
}
|
|
972
985
|
|
|
986
|
+
// ---- Custom domains (CUSTOM-DOMAIN) -------------------------------------
|
|
987
|
+
// 발행한 페이지(PAGE)를 고객 도메인(www.customer.co.kr)으로 연다. 워크스페이스 관리자 이상, 등록·확인은 Team 플랜부터.
|
|
988
|
+
|
|
989
|
+
async listCustomDomains(workspace) {
|
|
990
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/domains`);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/** 등록 전에 — 이 도메인의 DNS 를 어디서 관리하는지(가비아·Cloudflare…)와 그 화면 기준 안내. */
|
|
994
|
+
async detectCustomDomain(workspace, host, provider) {
|
|
995
|
+
const qs = new URLSearchParams({ host });
|
|
996
|
+
if (provider) qs.set('provider', provider);
|
|
997
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/domains/detect?${qs}`);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** hostname(www.회사.co.kr·acme.rootr.site) 또는 subdomain(acme). confirmCharge = 애드온 수량 +1 청구에 사람이 동의함. */
|
|
1001
|
+
async addCustomDomain(workspace, { hostname, subdomain, rootNodeId, provider, confirmCharge } = {}) {
|
|
1002
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/domains`, {
|
|
1003
|
+
method: 'POST',
|
|
1004
|
+
body: { hostname, subdomain, rootNodeId, provider, ...(confirmCharge ? { confirmCharge: true } : {}) },
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** 무료 주소 이름을 쓸 수 있나(acme → acme.rootr.site). */
|
|
1009
|
+
async customSubdomainAvailability(workspace, name) {
|
|
1010
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/domains/subdomain?${new URLSearchParams({ name })}`);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async checkCustomDomain(workspace, domainId) {
|
|
1014
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/domains/${encodeURIComponent(domainId)}/check`, {
|
|
1015
|
+
method: 'POST',
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async renameCustomDomain(workspace, domainId, { hostname, provider } = {}) {
|
|
1020
|
+
const body = { hostname };
|
|
1021
|
+
if (provider) body.provider = provider;
|
|
1022
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/domains/${encodeURIComponent(domainId)}`, {
|
|
1023
|
+
method: 'PATCH',
|
|
1024
|
+
body,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
async removeCustomDomain(workspace, domainId) {
|
|
1029
|
+
return this.request(`/workspaces/${encodeURIComponent(workspace)}/domains/${encodeURIComponent(domainId)}`, {
|
|
1030
|
+
method: 'DELETE',
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
973
1034
|
// ---- Webhooks ---------------------------------------------------------
|
|
974
1035
|
|
|
975
1036
|
async listWebhooks(workspace) {
|
|
@@ -1277,6 +1338,22 @@ export class RootrClient {
|
|
|
1277
1338
|
});
|
|
1278
1339
|
}
|
|
1279
1340
|
|
|
1341
|
+
/**
|
|
1342
|
+
* DEDUPE-RULES — 이 행과 같은 값의 행들을 표 규칙대로 처리(dryRun 이면 미리 보기만). `rules` 는 이번만 덮는 dedupeCarry 꼴.
|
|
1343
|
+
*/
|
|
1344
|
+
async dedupe(databaseId, rowId, { action, rules, fromRowIds, targetRowId, dryRun } = {}) {
|
|
1345
|
+
const path = `/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}/${dryRun ? 'dedupe-preview' : 'dedupe'}`;
|
|
1346
|
+
return this.request(path, {
|
|
1347
|
+
method: 'POST',
|
|
1348
|
+
body: { ...(action ? { action } : {}), ...(rules ? { rules } : {}), ...(fromRowIds?.length ? { fromRowIds } : {}), ...(targetRowId ? { targetRowId } : {}) },
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/** DEDUPE-RULES — 처리 한 번(opId)을 통째로 되돌린다(고친 칸은 전 값으로, 지운 행은 되살린다). */
|
|
1353
|
+
async dedupeUndo(databaseId, opId) {
|
|
1354
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/dedupe-ops/${encodeURIComponent(opId)}/undo`, { method: 'POST', body: {} });
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1280
1357
|
/** VALUE-RULE(P2) — 지금 들어 있는 값 가운데 칸 검사에 안 맞는 것(고치지 않는다). */
|
|
1281
1358
|
async ruleViolations(databaseId, { limit } = {}) {
|
|
1282
1359
|
const q = limit ? `?limit=${encodeURIComponent(limit)}` : '';
|
|
@@ -1325,7 +1402,7 @@ export class RootrClient {
|
|
|
1325
1402
|
});
|
|
1326
1403
|
}
|
|
1327
1404
|
|
|
1328
|
-
/** DEDUPE — `rowId` 행을 `into` 행에 합친다(빈 칸만 채우고
|
|
1405
|
+
/** DEDUPE — `rowId` 행을 `into` 행에 합친다(빈 칸만 채우고 합친 댓글, `rowId` 행은 지운다). */
|
|
1329
1406
|
async mergeRows(databaseId, rowId, into) {
|
|
1330
1407
|
return this.request(`/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}/merge`, {
|
|
1331
1408
|
method: 'POST',
|
|
@@ -1623,6 +1700,20 @@ export class RootrClient {
|
|
|
1623
1700
|
return this.request(`/pages/${encodeURIComponent(pageId)}/live-data`);
|
|
1624
1701
|
}
|
|
1625
1702
|
|
|
1703
|
+
// PAGE-ANALYTICS — 방문 기록(켜면 LOG·일별 표·대시보드가 페이지 옆에 선다)
|
|
1704
|
+
async getPageAnalytics(pageId) {
|
|
1705
|
+
return this.request(`/pages/${encodeURIComponent(pageId)}/analytics`);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
async setPageAnalytics(pageId, { enabled, locale } = {}) {
|
|
1709
|
+
return this.request(`/pages/${encodeURIComponent(pageId)}/analytics`, { method: 'POST', body: { enabled, ...(locale ? { locale } : {}) } });
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
async refreshPageAnalytics(pageId, days) {
|
|
1713
|
+
const q = days ? `?days=${encodeURIComponent(days)}` : '';
|
|
1714
|
+
return this.request(`/pages/${encodeURIComponent(pageId)}/analytics/refresh${q}`, { method: 'POST' });
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1626
1717
|
// ---- Presentations (slide decks) ----------------------------------------
|
|
1627
1718
|
|
|
1628
1719
|
async createPresentation(workspaceId, { name, parentId, theme, slides, config, icon } = {}) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CUSTOM-DOMAIN 과금 — 서버의 402(도메인 결제 필요)를 사람이 할 일로 바꾼다. MCP 도구(domains.js)와 CLI(`rootr domain add`)가 같이 쓴다.
|
|
3
|
+
* 에이전트·CLI 는 결제를 할 수 없다(결제창은 사람이 연다) — 그래서 "어디서 무엇을 누르면 되는지"와 "다시 부르는 법"을 말한다.
|
|
4
|
+
* `tr(ko, en)` 을 주면 그 언어로, 안 주면 영어(MCP 는 영어 설명을 쓴다).
|
|
5
|
+
*/
|
|
6
|
+
export function domainPaymentHelp(err, tr = (_ko, en) => en) {
|
|
7
|
+
const code = err?.body?.code;
|
|
8
|
+
const d = err?.body?.details ?? {};
|
|
9
|
+
const url = d.billingUrl ?? tr('rootr.io › 설정 › 결제', 'rootr.io → Settings → Billing');
|
|
10
|
+
const month = d.priceUsd?.month ?? 10;
|
|
11
|
+
const year = d.priceUsd?.year ?? 96;
|
|
12
|
+
if (code === 'CUSTOM_DOMAIN_PAYMENT_REQUIRED') {
|
|
13
|
+
return tr(
|
|
14
|
+
`결제가 필요합니다: 내 도메인은 1개당 월 $${month}(연 결제 시 월 $${year / 12})입니다. 유료 자리 ${d.slots ?? 0}개 중 ${d.used ?? 0}개를 쓰는 중. ` +
|
|
15
|
+
`${url} 에서 "도메인 연결"을 시작하거나(또는 페이지 공유 창 › 도메인 연결에서 결제창을 열고) 다시 실행하세요. ` +
|
|
16
|
+
'결제 없이 쓰려면 무료 주소: --subdomain <이름> (이름.rootr.site)',
|
|
17
|
+
`Payment needed: own domains cost $${month}/month each ($${year / 12}/month billed yearly). ${d.used ?? 0} of ${d.slots ?? 0} paid slot(s) in use. ` +
|
|
18
|
+
`Ask the person to open ${url} and start the "Custom domain" add-on (or connect it once from the page's Share → "Connect domain" ` +
|
|
19
|
+
'window, which opens the checkout), then call again. A free address (subdomain: "name" → name.rootr.site) needs no payment.',
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
if (code === 'CUSTOM_DOMAIN_CHARGE_CONFIRM_REQUIRED') {
|
|
23
|
+
const price = d.interval === 'year' ? `$${year}/year` : `$${month}/month`;
|
|
24
|
+
return tr(
|
|
25
|
+
`도메인 연결 애드온이 ${d.quantity ?? '?'}개로 1개 늘어납니다(도메인당 ${price}, 이번 주기는 일할 계산). 동의하면 --confirm-charge 를 붙여 다시 실행하세요.`,
|
|
26
|
+
`This adds one slot to the existing custom-domain add-on (new quantity ${d.quantity ?? '?'}, ${price} per domain, prorated now). ` +
|
|
27
|
+
'Tell the person the price; only after they agree, call again with confirmCharge: true.',
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (code === 'CUSTOM_DOMAIN_PAYMENT_PAST_DUE') {
|
|
31
|
+
return tr(
|
|
32
|
+
`도메인 연결 결제가 실패한 상태입니다. ${url} 에서 카드를 먼저 바꾼 뒤 다시 실행하세요.`,
|
|
33
|
+
`The custom-domain add-on payment failed. Ask the person to update the card at ${url}, then try again.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 목록 위에 붙는 자리 한 줄(사람이 읽는 글). */
|
|
40
|
+
export function slotLine(slots, tr = (_ko, en) => en) {
|
|
41
|
+
if (!slots) return '';
|
|
42
|
+
const addon = slots.addon?.active
|
|
43
|
+
? tr(
|
|
44
|
+
` · 애드온 ${slots.addon.interval === 'year' ? '연' : '월'} 결제${slots.addon.cancelAt ? `(${String(slots.addon.cancelAt).slice(0, 10)} 종료 예약)` : ''}`,
|
|
45
|
+
` · add-on billed ${slots.addon.interval === 'year' ? 'yearly' : 'monthly'}${slots.addon.cancelAt ? ` (ends ${String(slots.addon.cancelAt).slice(0, 10)})` : ''}`,
|
|
46
|
+
)
|
|
47
|
+
: '';
|
|
48
|
+
return tr(
|
|
49
|
+
`내 도메인 ${slots.used}/${slots.total}자리${slots.granted ? `(부여 ${slots.granted})` : ''}${addon} · 무료 주소 ${slots.free?.used ?? 0}/${slots.free?.limit ?? 3} · 가격 월 $${slots.price?.monthUsd ?? 10}(연 $${slots.price?.yearUsd ?? 96})`,
|
|
50
|
+
`own domains ${slots.used}/${slots.total} slot(s)${slots.granted ? ` (${slots.granted} granted)` : ''}${addon} · free addresses ${slots.free?.used ?? 0}/${slots.free?.limit ?? 3} · price $${slots.price?.monthUsd ?? 10}/month ($${slots.price?.yearUsd ?? 96}/year)`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -524,7 +524,8 @@ export function registerDatabaseTools(server) {
|
|
|
524
524
|
title: 'Read a database schema and rows',
|
|
525
525
|
description:
|
|
526
526
|
'Read a Rootr (루터) DATABASE node — its schema (properties with ids/types and select options) plus all rows ' +
|
|
527
|
-
'(each with its rowId and values
|
|
527
|
+
'(each with its rowId and values; a row whose current value breaks a column check also carries ' +
|
|
528
|
+
'`issues: { [propId]: { message, askDeleteRow? } }` — saved as typed, just flagged). ALWAYS call this before rootr_add_row / rootr_update_row so you know the exact ' +
|
|
528
529
|
'property ids/names and, for board/kanban views, which select column holds the status. Only works on DATABASE nodes.',
|
|
529
530
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
530
531
|
inputSchema: {
|
|
@@ -562,6 +563,7 @@ export function registerDatabaseTools(server) {
|
|
|
562
563
|
title: 'List database rows',
|
|
563
564
|
description:
|
|
564
565
|
'List the rows of a Rootr (루터) DATABASE (id + values + position), without the schema. ' +
|
|
566
|
+
'Rows whose current values break a column check carry `issues: { [propId]: { message, askDeleteRow? } }` (omitted when none). ' +
|
|
565
567
|
'Use rootr_read_database instead when you also need the property/column definitions.',
|
|
566
568
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
567
569
|
inputSchema: {
|
|
@@ -1290,7 +1292,25 @@ export function registerDatabaseTools(server) {
|
|
|
1290
1292
|
'row: { "재등록 여부": true, "구분": { map: { "A": "A(재등록)" }, else: "재등록" } } — map looks at the value the ' +
|
|
1291
1293
|
'NEW row came in with; else covers any value not in map; if that column is still EMPTY when carried, the mark waits and is ' +
|
|
1292
1294
|
'applied once to the first value written there later (e.g. a later button step); missing select options are added }. ' +
|
|
1293
|
-
'Names or ids. null turns it off.'
|
|
1295
|
+
'Names or ids. null turns it off. ' +
|
|
1296
|
+
'DUPLICATE RULES (all optional, same object): action?: "ask" (dialog; askDefault?: carry|merge_into_old|merge_all preselected) | ' +
|
|
1297
|
+
'"carry" (= auto:true; copy into the new row) | "merge_into_old" (fill the representative earlier row, delete the new row unless ' +
|
|
1298
|
+
'keepNew:true which keeps it linked) | "merge_all" (all same-value rows → one representative row; others deleted or kept linked); ' +
|
|
1299
|
+
'sources?: "all" (default — per column take the most recent earlier row that HAS a value) | "latest" (only the latest earlier row); ' +
|
|
1300
|
+
'strategy?: default per column, one of fill_empty (default) | prefer_new | prefer_old | latest | skip; columns?: { "<column>": ' +
|
|
1301
|
+
'fill_empty | prefer_new | prefer_old | latest | skip | max | min | sum (number) | earliest | latest_date (date) | union ' +
|
|
1302
|
+
'(multi_select/relation/files/person) | { append: { sep?: " / ", datePrefix?: true } } (text) } — a strategy on the wrong column ' +
|
|
1303
|
+
'type is a 400; exclude = skip, overwrite = prefer_old; representative?: "latest" | "oldest", representativeWhen?: [{ propId, op, ' +
|
|
1304
|
+
'values }] (first row matching wins). Every operation returns an opId (undo with rootr_dedupe_undo); preview with ' +
|
|
1305
|
+
'rootr_dedupe_preview. ' +
|
|
1306
|
+
'BY CONDITION: cases?: [{ when?: { within?: { column: "<date column>", days: 0 }, same?: ["<column>"], filters?: [{ propId, op, ' +
|
|
1307
|
+
'values }] (on the earlier row) }, action, …overrides (askDefault/sources/strategy/columns/keepNew/representative/' +
|
|
1308
|
+
'representativeWhen/mark/label; "mark": {} = no marks) }] (≤10) — each earlier row goes to the FIRST matching case, rows ' +
|
|
1309
|
+
'matching none are untouched; any row in an ask case → nothing automatic; merge cases run first, then carry cases fill the ' +
|
|
1310
|
+
'surviving row, which gets the first carry case\'s mark/label (else the merge case\'s). e.g. { cases: [{ when: { within: ' +
|
|
1311
|
+
'{ column: "등록일", days: 0 } }, action: "merge_all", mark: {} }, { action: "carry" }] }. TEMPLATES: map/else values may ' +
|
|
1312
|
+
'use {value} = the value the new row came in with: { "경로": { else: "{value} (다시)" } }; a value already in that shape is ' +
|
|
1313
|
+
'kept; new values become select options.',
|
|
1294
1314
|
),
|
|
1295
1315
|
validate: z
|
|
1296
1316
|
.record(z.any())
|
|
@@ -1394,7 +1414,7 @@ export function registerDatabaseTools(server) {
|
|
|
1394
1414
|
|
|
1395
1415
|
// ── DEDUPE(F5) — 같은 사람 찾기·합치기·새 행으로 두기 ─────────────────────
|
|
1396
1416
|
//
|
|
1397
|
-
// 화면의 중복 창과 같은 일을 에이전트도 할 수 있어야 한다. 그렇지 않으면 "
|
|
1417
|
+
// 화면의 중복 창과 같은 일을 에이전트도 할 수 있어야 한다. 그렇지 않으면 "명단 붙여 넣으면서 이미 있는 사람은 합쳐 줘"
|
|
1398
1418
|
// 같은 부탁에서 에이전트가 표 전체를 읽어 전화번호를 손으로 견주게 된다(9천 행이면 수 MB).
|
|
1399
1419
|
|
|
1400
1420
|
server.registerTool(
|
|
@@ -1487,8 +1507,9 @@ export function registerDatabaseTools(server) {
|
|
|
1487
1507
|
'The "이전 정보 가져오기" choice: copy the values of an EARLIER row of the same person (same dedupe value, e.g. phone) ' +
|
|
1488
1508
|
'INTO row `rowId`, following the dedupe column\'s dedupeCarry settings (excluded columns such as the entry date, ' +
|
|
1489
1509
|
'marks such as 재등록 여부=true / 구분 A→A(재등록)). The earlier row is NOT changed and nothing is linked. ' +
|
|
1490
|
-
'Without `from`
|
|
1491
|
-
'
|
|
1510
|
+
'Without `from` every earlier row with the same value is used (sources "all": per column the most recent row that has a value; ' +
|
|
1511
|
+
'sources "latest": only the latest row). The response lists the columns that actually changed (copied, changedCount, ' +
|
|
1512
|
+
'nothingChanged) and an opId for rootr_dedupe_undo. Tables whose dedupeCarry action is carry (or auto) do this by themselves on every write path — use this for tables without it or to pick a ' +
|
|
1492
1513
|
'specific earlier row. Only rows the caller can see are used.',
|
|
1493
1514
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1494
1515
|
inputSchema: {
|
|
@@ -1508,6 +1529,90 @@ export function registerDatabaseTools(server) {
|
|
|
1508
1529
|
},
|
|
1509
1530
|
);
|
|
1510
1531
|
|
|
1532
|
+
server.registerTool(
|
|
1533
|
+
'rootr_dedupe_preview',
|
|
1534
|
+
{
|
|
1535
|
+
title: 'Preview how a row and its same-value rows would be carried/merged',
|
|
1536
|
+
description:
|
|
1537
|
+
'Read-only. For row `rowId` of a Rootr (루터) DATABASE with a dedupe column, shows what the table\'s duplicate rule (dedupeCarry on ' +
|
|
1538
|
+
'the dedupe column — see rootr_update_column) would do: { action, targetRowId (row that stays), deleted, kept, fromRowIds, columns: ' +
|
|
1539
|
+
'[{ column, strategy, before, after, from }], marked, pending, nothingChanged }. `rules` overrides the table rule for this preview ' +
|
|
1540
|
+
'only (same shape: action, sources, strategy, columns, keepNew, representative, representativeWhen …). 404 when no other row has ' +
|
|
1541
|
+
'the same value. Apply with rootr_dedupe_apply.',
|
|
1542
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
1543
|
+
inputSchema: {
|
|
1544
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1545
|
+
rowId: z.string().describe('The (new) row whose duplicates to handle'),
|
|
1546
|
+
rules: z.record(z.any()).optional().describe('Override the table rule for this call (dedupeCarry shape)'),
|
|
1547
|
+
fromRowIds: z.array(z.string()).optional().describe('Limit the earlier rows used as sources'),
|
|
1548
|
+
},
|
|
1549
|
+
},
|
|
1550
|
+
async ({ databaseId, rowId, rules, fromRowIds }) => {
|
|
1551
|
+
try {
|
|
1552
|
+
const client = makeClient();
|
|
1553
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1554
|
+
return jsonResult(await client.dedupe(id, rowId, { rules, fromRowIds, dryRun: true }));
|
|
1555
|
+
} catch (err) {
|
|
1556
|
+
return errorResult(err);
|
|
1557
|
+
}
|
|
1558
|
+
},
|
|
1559
|
+
);
|
|
1560
|
+
|
|
1561
|
+
server.registerTool(
|
|
1562
|
+
'rootr_dedupe_apply',
|
|
1563
|
+
{
|
|
1564
|
+
title: "Apply the table's duplicate rule to a row (carry / merge into the earlier row / merge all)",
|
|
1565
|
+
description:
|
|
1566
|
+
'Writes. Same input as rootr_dedupe_preview; applies it: carry copies into the row, merge_into_old fills the representative earlier ' +
|
|
1567
|
+
'row and deletes this row (unless keepNew), merge_all keeps one representative row and deletes the rest (unless keepNew). Returns ' +
|
|
1568
|
+
'the same fields plus `opId` — undo the whole operation (restores cells, recreates deleted rows) with rootr_dedupe_undo. Preview ' +
|
|
1569
|
+
'first when deleting rows. The caller must be able to edit the kept row and delete the others.',
|
|
1570
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1571
|
+
inputSchema: {
|
|
1572
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1573
|
+
rowId: z.string().describe('The (new) row whose duplicates to handle'),
|
|
1574
|
+
action: z.enum(['carry', 'merge_into_old', 'merge_all']).optional().describe('Override the action (default: the table rule; ask → askDefault or carry)'),
|
|
1575
|
+
rules: z.record(z.any()).optional().describe('Override the table rule for this call (dedupeCarry shape)'),
|
|
1576
|
+
fromRowIds: z.array(z.string()).optional().describe('Limit the earlier rows used as sources'),
|
|
1577
|
+
targetRowId: z.string().optional().describe('merge_into_old: the earlier row that stays'),
|
|
1578
|
+
},
|
|
1579
|
+
},
|
|
1580
|
+
async ({ databaseId, rowId, action, rules, fromRowIds, targetRowId }) => {
|
|
1581
|
+
try {
|
|
1582
|
+
const client = makeClient();
|
|
1583
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1584
|
+
return jsonResult(await client.dedupe(id, rowId, { action, rules, fromRowIds, targetRowId }));
|
|
1585
|
+
} catch (err) {
|
|
1586
|
+
return errorResult(err);
|
|
1587
|
+
}
|
|
1588
|
+
},
|
|
1589
|
+
);
|
|
1590
|
+
|
|
1591
|
+
server.registerTool(
|
|
1592
|
+
'rootr_dedupe_undo',
|
|
1593
|
+
{
|
|
1594
|
+
title: 'Undo one duplicate-handling operation',
|
|
1595
|
+
description:
|
|
1596
|
+
'Undo a carry/merge by its `opId` (returned by rootr_dedupe_apply, rootr_carry_over, rootr_merge_rows, row writes that ' +
|
|
1597
|
+
'auto-handled a duplicate as `deduped.opId`, and recorded in row history as via "dedupe:<opId>"). Cells it changed go back to ' +
|
|
1598
|
+
'their values before the operation and rows it deleted are recreated with the same id. A second undo is refused.',
|
|
1599
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1600
|
+
inputSchema: {
|
|
1601
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1602
|
+
opId: z.string().describe('The operation id'),
|
|
1603
|
+
},
|
|
1604
|
+
},
|
|
1605
|
+
async ({ databaseId, opId }) => {
|
|
1606
|
+
try {
|
|
1607
|
+
const client = makeClient();
|
|
1608
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1609
|
+
return jsonResult(await client.dedupeUndo(id, opId));
|
|
1610
|
+
} catch (err) {
|
|
1611
|
+
return errorResult(err);
|
|
1612
|
+
}
|
|
1613
|
+
},
|
|
1614
|
+
);
|
|
1615
|
+
|
|
1511
1616
|
server.registerTool(
|
|
1512
1617
|
'rootr_rule_violations',
|
|
1513
1618
|
{
|
|
@@ -480,7 +480,9 @@ export function registerDocumentTools(server) {
|
|
|
480
480
|
'Partially update a published Rootr (루터) document\'s public settings — only the fields you pass are ' +
|
|
481
481
|
'changed, everything else (including slug) is left as-is. Examples: {"isPublic": false} to unpublish; ' +
|
|
482
482
|
'{"allowCommits": true} to let public readers propose edits; {"skin": "tech-dark"} or ' +
|
|
483
|
-
'{"layout": {"cover": true}} to restyle the public page; {"
|
|
483
|
+
'{"layout": {"cover": true}} to restyle the public page; {"seoDescription": "...", "seoImage": "<attachment id>"} ' +
|
|
484
|
+
'to set the search-result description and link-preview image (search engines are pinged right away); ' +
|
|
485
|
+
'{"expiresAt": "2026-12-31T00:00:00Z"} to set an ' +
|
|
484
486
|
'auto-close date, or {"expiresAt": null} to clear it. ' +
|
|
485
487
|
'Owner-level action (MANAGE): works with an account key (PAT), not with workspace-scoped/connector tokens.',
|
|
486
488
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
@@ -510,6 +512,20 @@ export function registerDocumentTools(server) {
|
|
|
510
512
|
.boolean()
|
|
511
513
|
.optional()
|
|
512
514
|
.describe('true hides the page from search engines (Team plan and up)'),
|
|
515
|
+
seoDescription: z
|
|
516
|
+
.union([z.string().max(300), z.null()])
|
|
517
|
+
.optional()
|
|
518
|
+
.describe(
|
|
519
|
+
'Search-result / link-preview description (meta description, og:description). Aim for ~160 characters ' +
|
|
520
|
+
'that say what the page covers with the words people search for. null or "" falls back to the opening text.',
|
|
521
|
+
),
|
|
522
|
+
seoImage: z
|
|
523
|
+
.union([z.string(), z.null()])
|
|
524
|
+
.optional()
|
|
525
|
+
.describe(
|
|
526
|
+
'Cover image for link previews (og:image): an image attachment id from the same workspace — upload one ' +
|
|
527
|
+
'with rootr_upload_attachment first. 1200×630 landscape fits best. null removes it (the Rootr title card is used).',
|
|
528
|
+
),
|
|
513
529
|
skin: z
|
|
514
530
|
.enum([
|
|
515
531
|
'classic',
|
|
@@ -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
|
+
}
|