rootr-cli 0.32.0 → 0.34.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 CHANGED
@@ -405,7 +405,7 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
405
405
 
406
406
  ## MCP 서버로 사용하기
407
407
 
408
- `rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 214종:
408
+ `rootr mcp`는 stdio로 MCP(Model Context Protocol) 서버를 구동합니다. 제공 도구 221종:
409
409
 
410
410
  노드를 가리키는 인자(`target`, `databaseId`, `spreadsheetId`, `formId`, `crmId`, `trackerId`,
411
411
  `recordingId`, `presentationId`, `whiteboardId`, `logId`, `parentId` 등)는 **노드 id와 경로를 모두**
@@ -487,6 +487,16 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
487
487
  | `rootr_get_issue` | 이슈 상세 조회 |
488
488
  | `rootr_update_issue` | 이슈 갱신(닫기: state=CLOSED + stateReason) |
489
489
  | `rootr_comment_issue` | 이슈에 댓글 추가 |
490
+ | `rootr_update_issue_tracker` | 트래커 설정 — **외부 참여**(`read` 보기만 · `login` 로그인한 사람만 작성 · `anyone` 누구나 작성), 라벨·유형 |
491
+ | `rootr_submit_public_issue` | **웹게시한** 트래커에 밖에서 이슈 올리기(공개 주소로, `asGuest`+`guestName` 이면 손님으로, 페이지 블록은 `blockId`, `files:[{filename, contentBase64}]` 첨부) |
492
+ | `rootr_comment_public_issue` | 웹게시한 트래커의 이슈에 댓글(`files` 첨부, `read:true` 면 공개 댓글 읽기) |
493
+ | `rootr_search_public_issues` | 웹게시한 트래커를 밖에서 찾기 — `q`(제목·본문·#번호·쓴 사람), `labels`(모두 가진 것), `state`, `sort`(newest·oldest·comments) |
494
+
495
+ CLI: `rootr issue tracker <트래커> --participation anyone` · `rootr issue list|create|comment …` ·
496
+ `rootr issue list <공개 주소> --q "결제" --label 버그` · `rootr issue submit <공개 주소> --title "…" --name <이름> --file 화면.png --file 로그.txt` ·
497
+ `rootr issue reply <공개 주소> <#번호> "<댓글>" [--file <경로>]`.
498
+ 첨부는 그림(png·jpg·gif·webp)·pdf·txt·csv·log·docx·xlsx·pptx·zip, 한 파일 10MB·글 하나에 10개까지입니다(확장자와 파일 첫 바이트가 맞아야 함).
499
+ 외부 참여는 **트래커 설정**이라 페이지(PAGE)에 `issues` 블록으로 끼운 트래커도 같은 규칙을 따릅니다.
490
500
 
491
501
  #### 웹훅 (webhooks:manage 스코프)
492
502
 
@@ -713,7 +723,7 @@ rootr db column <표> 특이사항 --wrap on --align center # 긴 글 줄바꿈
713
723
  | 도구 | 설명 |
714
724
  |---|---|
715
725
  | `rootr_publish_node` | **아무 노드나** 공개 — 문서·표·폼·시트·화이트보드·덱·페이지, 하위까지. **페이지(PAGE)는 Team 플랜부터**(무료는 403 `PLAN_UPGRADE_REQUIRED`, 하위 포함 시 페이지는 건너뛰고 개수를 알려 줌) |
716
- | `rootr_set_node_public` | 이미 공개한 것의 설정 변경(하위 포함 여부·검색 노출·만료) |
726
+ | `rootr_set_node_public` | 이미 공개한 것의 설정 변경(하위 포함 여부·검색 노출·만료·비밀번호, 이슈트래커는 `participation` 외부 참여) |
717
727
  | `rootr_node_public_status` | 지금 공개돼 있는지, 주소는 무엇인지 |
718
728
  | `rootr_publish_document` | 문서 전용 발행(구형 — 새로 짤 때는 `rootr_publish_node`) |
719
729
  | `rootr_set_document_public` | 문서 전용 공개 설정 변경 |
package/README.md CHANGED
@@ -11,7 +11,7 @@ citations back to the exact paragraph.
11
11
  *(한국어 문서: [README.ko.md](https://github.com/gwmage/rootr-cli/blob/main/README.ko.md))*
12
12
 
13
13
  - `rootr` CLI — documents, databases, forms, signed documents, publishing, permissions, memory
14
- - `rootr mcp` — stdio MCP server, **217 tools** (see [MCP tools](#mcp-tools))
14
+ - `rootr mcp` — stdio MCP server, **221 tools** (see [MCP tools](#mcp-tools))
15
15
 
16
16
  Plain Node.js ESM, no build step. Node 18+ (uses the built-in `fetch`).
17
17
 
@@ -296,8 +296,31 @@ rootr publish /handbook --status # is it public, at what URL, chi
296
296
  rootr publish /projects/tasks --layout board --skin paper # 9 layouts × 6 skins
297
297
  rootr publish /handbook/policy.md --comments --expires 2026-12-31T00:00:00Z
298
298
  rootr publish /handbook --off
299
+ rootr publish /support/feedback --participation anyone # issue tracker: visitors file issues & comment with just a name
300
+ rootr publish /support/feedback --password s3cret # lock the address (Team plan) — writing is locked too
299
301
  ```
300
302
 
303
+ **Issue trackers take outside participation** (외부 참여): `read` (default, read-only), `login`
304
+ (any signed-in Rootr account files issues and comments) or `anyone` (no account — just a name, shown as
305
+ "name (guest)"). It is the tracker's own setting, so a tracker placed on a published page (`issues`
306
+ block) follows the same rule.
307
+
308
+ ```bash
309
+ rootr issue tracker /support/feedback --participation login # change it (MANAGE)
310
+ rootr issue list /support/feedback --state ALL
311
+ rootr issue create /support/feedback --title "Export is slow" --body "…"
312
+ rootr issue comment /support/feedback 12 "Fixed in 1.4"
313
+ rootr issue submit https://rootr.io/ko/p/AbCd123 --title "Button does nothing" --name "Kim" # from outside, as a guest
314
+ rootr issue submit https://rootr.io/ko/p/AbCd123 --title "Crash" --file shot.png --file app.log --name "Kim" # with files
315
+ rootr issue reply https://rootr.io/ko/p/AbCd123 12 "Same here" --name "Lee"
316
+ rootr issue list https://rootr.io/ko/p/AbCd123 --q "payment" --label bug --sort comments # search a published tracker
317
+ ```
318
+
319
+ Outside attachments: images (png·jpg·gif·webp), pdf, txt·csv·log, docx·xlsx·pptx, zip — 10MB per file, 10 per
320
+ issue/comment; the extension and the file's first bytes must agree. MCP: `rootr_submit_public_issue` /
321
+ `rootr_comment_public_issue` take `files: [{ filename, contentBase64 }]` (contents, never a path), and
322
+ `rootr_search_public_issues` searches a published tracker the way a visitor does.
323
+
301
324
  Every node type publishes on the Free plan **except PAGE** (the block-built web page): publishing a
302
325
  page to the web needs the **Team plan**. On Free, `rootr publish <page>` fails with
303
326
  `PLAN_UPGRADE_REQUIRED` and nothing changes; `--children` skips pages underneath and says how many.
@@ -517,7 +540,7 @@ is refused and says when the grace was used.
517
540
 
518
541
  ## MCP tools
519
542
 
520
- `rootr mcp` speaks MCP over stdio. 217 tools:
543
+ `rootr mcp` speaks MCP over stdio. 221 tools:
521
544
 
522
545
  | Group | Count | Tools |
523
546
  |---|---|---|
@@ -532,7 +555,7 @@ is refused and says when the grace was used.
532
555
  | Pages | 7 | HTML page nodes: create, read, update, append and patch blocks, live data, visit tracking (GA-style visits / scroll / conversions stored as a LOG, a daily table and a dashboard) |
533
556
  | Spreadsheets | 7 | create, read, update, per-sheet create/update/delete, patch cells & formulas |
534
557
  | Recordings | 7 | create, upload & download audio, transcribe, summarize, rename speakers |
535
- | Issue tracker | 6 | create tracker, list / create / get / update issue, comment |
558
+ | Issue tracker | 9 | create tracker, change tracker settings (outside participation, labels, types), list / create / get / update issue, comment, file an issue / comment on a PUBLISHED tracker from outside |
536
559
  | Permissions & visibility | 10 | grant / revoke node permissions, read access, visibility, public status, read requirements |
537
560
  | LOG datastores | 5 | create store, update fields, add entries, query, stats |
538
561
  | Templates | 4 | list, apply, save as template, update |
package/bin/rootr.js CHANGED
@@ -5,7 +5,16 @@ import { dirname, join, basename, extname } from 'node:path';
5
5
 
6
6
  import { loadConfig, saveConfig, keyForWorkspace, maskKey, upsertKey, CONFIG_PATH, DEFAULT_BASE_URL } from '../lib/config.js';
7
7
  import { candidateClients, rememberWorkspace, resolveWorkspaceSwitch, surveyWorkspaces } from '../lib/credentials.js';
8
- import { RootrClient, RootrApiError, imageMarkdown } from '../lib/client.js';
8
+ import {
9
+ RootrClient,
10
+ RootrApiError,
11
+ imageMarkdown,
12
+ publicIssueMimeOf,
13
+ withPublicAttachments,
14
+ PUBLIC_ISSUE_FILE_EXTENSIONS,
15
+ PUBLIC_ISSUE_FILE_MAX_BYTES,
16
+ PUBLIC_ISSUE_FILES_PER_POST,
17
+ } from '../lib/client.js';
9
18
  import { setGlobalDocMailSink } from '../lib/context.js';
10
19
  import { resolveTarget, requireResolved, resolveMoveDestination } from '../lib/resolve.js';
11
20
  import { t as tr, lang } from '../lib/i18n.js';
@@ -43,7 +52,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
43
52
  rootr db button-run <path|id> <buttonId> [--rows id,id] [--input 글|--input-file f|--input -] 버튼 한 번 누르기 (행 만들기·값 채우기·AI 채우기 · 글 받는 버튼이면 --input)
44
53
  rootr db automations <path|id> [--file rules.json] [--clear] 자동 규칙 — "값이 이렇게 되면 이 일을 해라"·정해진 시각
45
54
  rootr db import-sheets <파일.xlsx> --ws <id> [--dry] 시트 수백 장짜리 엑셀을 표 하나로 접어 가져오기
46
- rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] [--cards "칸,칸"] 뷰 목록·달력 주/달/해 단위·보드 카드에 보일 칸 ·[--density compact|normal] [--freeze 0~3] 촘촘하게·왼쪽 칸 고정
55
+ rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] [--cards "칸,칸"] 뷰 목록·달력 주/달/해 단위·보드 카드에 보일 칸 ·[--density compact|normal] [--freeze 0~3] [--lines rows|all|none] [--line-strength light|strong] 촘촘하게·왼쪽 칸 고정·칸 선
47
56
  rootr db view <path|id> <뷰이름> [--row-hidden on|off] [--quick "칸,칸"] [--entry-row on|off|top|bottom] [--personal-filters on|off] [--day-pager 날짜칸|off] 행 열면 감춘 칸 보이기·필터 맨 위 고정 칸·맨 아래 입력 줄·하루씩 보기
48
57
  rootr db column <path|id> <칸> [--align left|center|right] [--wrap on|off] [--overdue on|off] [--time on|off] [--range on|off] 칸 정렬·줄바꿈·지난 날짜 빨강·시간·기간
49
58
  rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] 행 추가(상세 내용 포함)
@@ -75,6 +84,17 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
75
84
  rootr publish <표> --layout board --skin paper 공개한 표를 무슨 꼴로 보일지 (배치 9종 × 분위기 6종)
76
85
  rootr publish <문서> --comments [false] [--expires <ISO>] [--skin <분위기>] 문서 전용 — 댓글(편집 제안)·만료일·스킨 16종
77
86
  rootr publish <path|id> --description "<글>" --cover <그림|id|none> 검색 설명문·링크 미리보기 대표 이미지 (검색엔진에 바로 알림)
87
+ rootr publish <path|id> --password <비번> | --no-password 공개 주소 잠그기(Team 부터)·풀기 — 이슈트래커는 쓰기도 같이 잠김
88
+ rootr publish <이슈트래커> --participation read|login|anyone 외부 참여 — 보기만·로그인한 사람만 작성·누구나 작성(이름만 적고)
89
+ rootr issue tracker <트래커> [--participation read|login|anyone] [--json] 트래커 설정 보기·외부 참여 바꾸기(MANAGE)
90
+ rootr issue list <트래커|공개 주소> [--state OPEN|CLOSED|ALL] [--q <검색어>] [--label <라벨>]… [--sort newest|oldest|comments] [--json]
91
+ 이슈 목록·검색(공개 주소면 밖에서 — 제목·본문·#번호·쓴 사람, 페이지 블록은 --block)
92
+ rootr issue create <트래커> --title "<제목>" [--body "<본문>"] 이슈 올리기(내 계정으로)
93
+ rootr issue comment <트래커> <#번호> "<댓글>" 이슈에 댓글
94
+ rootr issue submit <공개 주소> --title "<제목>" [--body …] [--file <경로>]… [--name <이름>] [--block <블록 id>] [--password <비번>]
95
+ 웹게시한 트래커에 밖에서 이슈 올리기(--name 이면 키 없이 손님으로, 페이지 블록은 --block)
96
+ --file: 그림·pdf·txt·csv·log·docx·xlsx·pptx·zip, 한 파일 10MB·글 하나에 10개
97
+ rootr issue reply <공개 주소> <#번호> "<댓글>" [--file <경로>]… [--name <이름>] [--block <블록 id>] [--password <비번>] 웹게시한 트래커 이슈에 댓글
78
98
  rootr booking show <표> 방문자 일정 등록(예약) 설정 보기
79
99
  rootr booking set <표> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 예약 켜기·규칙
80
100
  rootr booking set <표> --ask "연락처:required" --approval manual 방문자에게 더 묻기 · 승인 후 확정
@@ -146,7 +166,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
146
166
  rootr memory add "<내용>" [--kind 지시|사람|프로젝트|참고] [--source <경로|id>] [--related a,b] 기억 저장
147
167
  rootr memory edit <id> [--content "<내용>"] [--kind …] [--source …] [--related a,b] 기억 고치기(준 칸만)
148
168
  rootr memory forget <id> [--hard] 기억 폐기(--hard면 행 삭제)
149
- rootr mcp MCP stdio 서버 구동 (도구 214종 — README 참고)
169
+ rootr mcp MCP stdio 서버 구동 (도구 220종 — README 참고)
150
170
  rootr --help 도움말
151
171
  rootr --version 버전 출력
152
172
 
@@ -186,7 +206,7 @@ usage:
186
206
  rootr db button-run <path|id> <buttonId> [--rows id,id] [--input text|--input-file f|--input -] press a button once (create rows, fill values, AI fill · --input for a button that asks for text)
187
207
  rootr db automations <path|id> [--file rules.json] [--clear] automatic rules — "when a value becomes X, do Y", or at a set time
188
208
  rootr db import-sheets <file.xlsx> --ws <id> [--dry] fold a one-sheet-per-record workbook into ONE table
189
- rootr db view <path|id> [view name] [--scale week|month|year] [--name <new name>] list views; calendar week/month/year [--density compact|normal] [--freeze 0-3] compact rows · frozen left columns
209
+ rootr db view <path|id> [view name] [--scale week|month|year] [--name <new name>] list views; calendar week/month/year [--density compact|normal] [--freeze 0-3] [--lines rows|all|none] [--line-strength light|strong] compact rows · frozen left columns · grid lines
190
210
  rootr db view <path|id> <view name> [--row-hidden on|off] [--quick "field,field"] [--entry-row on|off|top|bottom] [--personal-filters on|off] [--day-pager <date field>|off] hidden fields shown in the row page · pinned filters · entry row · one day at a time
191
211
  rootr db column <path|id> <column> [--align left|center|right] [--wrap on|off] [--overdue on|off] [--time on|off] [--range on|off] column alignment · wrapping · overdue red · time · end date
192
212
  rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] add a row (with its body)
@@ -220,6 +240,17 @@ usage:
220
240
  rootr publish <table> --layout board --skin paper how a published table looks (9 layouts × 6 skins)
221
241
  rootr publish <doc> --comments [false] [--expires <ISO>] [--skin <skin>] documents only — comments, expiry, 16 skins
222
242
  rootr publish <path|id> --description "<text>" --cover <image|id|none> search description & link-preview cover image (search engines pinged)
243
+ rootr publish <path|id> --password <pw> | --no-password lock (Team plan) / unlock the public address — on a tracker writing is locked too
244
+ rootr publish <issue tracker> --participation read|login|anyone outside participation — read-only · signed-in users write · anyone writes (just a name)
245
+ rootr issue tracker <tracker> [--participation read|login|anyone] [--json] show tracker settings / change outside participation (MANAGE)
246
+ rootr issue list <tracker|public URL> [--state OPEN|CLOSED|ALL] [--q <text>] [--label <name>]… [--sort newest|oldest|comments] [--json]
247
+ list/search issues (a public URL searches a PUBLISHED tracker from outside — title, body, #number, author; --block for a page)
248
+ rootr issue create <tracker> --title "<title>" [--body "<body>"] file an issue (as your account)
249
+ rootr issue comment <tracker> <#number> "<comment>" comment on an issue
250
+ rootr issue submit <public URL> --title "<title>" [--body …] [--file <path>]… [--name <name>] [--block <block id>] [--password <pw>]
251
+ file an issue on a PUBLISHED tracker from outside (--name = as a guest, no key; --block for a tracker on a page)
252
+ --file: images·pdf·txt·csv·log·docx·xlsx·pptx·zip, 10MB per file, 10 per issue/comment
253
+ rootr issue reply <public URL> <#number> "<comment>" [--file <path>]… [--name <name>] [--block <block id>] [--password <pw>] comment on an issue of a published tracker
223
254
  rootr booking show <table> show visitor booking settings
224
255
  rootr booking set <table> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 turn on + rules
225
256
  rootr booking set <table> --ask "Phone:required" --approval manual ask more · confirm after approval
@@ -287,7 +318,7 @@ usage:
287
318
  rootr memory add "<content>" [--kind instruction|person|project|reference] [--source <path|id>] [--related a,b] save a memory
288
319
  rootr memory edit <id> [--content "<content>"] [--kind …] [--source …] [--related a,b] edit a memory (only the fields you pass)
289
320
  rootr memory forget <id> [--hard] retire a memory (--hard deletes the row)
290
- rootr mcp run the MCP stdio server (214 tools — see the README)
321
+ rootr mcp run the MCP stdio server (220 tools — see the README)
291
322
  rootr --help this help
292
323
  rootr --version print the version
293
324
 
@@ -306,7 +337,8 @@ function usage(syntax) {
306
337
  }
307
338
 
308
339
  function parseArgs(argv) {
309
- const args = { _: [] };
340
+ // `__all[key]` — 같은 깃발을 여러 번 준 값 전부(`--file a --file b`). 나머지 명령은 예전처럼 마지막 값(args[key])을 쓴다.
341
+ const args = { _: [], __all: {} };
310
342
  for (let i = 0; i < argv.length; i++) {
311
343
  const tok = argv[i];
312
344
  if (tok.startsWith('--')) {
@@ -316,6 +348,7 @@ function parseArgs(argv) {
316
348
  args[key] = true;
317
349
  } else {
318
350
  args[key] = next;
351
+ (args.__all[key] = args.__all[key] || []).push(next);
319
352
  i++;
320
353
  }
321
354
  } else {
@@ -2161,6 +2194,188 @@ function flagToBool(v) {
2161
2194
  return v !== 'false' && v !== '0';
2162
2195
  }
2163
2196
 
2197
+ /** ISSUE-PUBLIC — 외부 참여(서버 issue-schema.ts PUBLIC_PARTICIPATION_MODES 와 같은 값). */
2198
+ const PARTICIPATION_MODES = ['read', 'login', 'anyone'];
2199
+
2200
+ function participationLabel(mode) {
2201
+ return {
2202
+ read: tr('보기만', 'read only'),
2203
+ login: tr('로그인한 사람만 작성', 'signed-in users write'),
2204
+ anyone: tr('누구나 작성(이름만 적고)', 'anyone writes (just a name)'),
2205
+ }[mode] ?? mode;
2206
+ }
2207
+
2208
+ /** 공개 주소(rootr.io/…/p/{slug}) 또는 slug 그대로. */
2209
+ function publicSlugOf(target) {
2210
+ const m = /\/p\/([^/?#\s]+)/.exec(String(target || ''));
2211
+ return m ? decodeURIComponent(m[1]) : String(target || '').trim();
2212
+ }
2213
+
2214
+ /** GET /issue-trackers/:id/issues 는 `{ issues: [...] }` 로 답한다(배열이 아니다 — 3100 실측에서 `find is not a function`). */
2215
+ function issuesOf(res) {
2216
+ return Array.isArray(res) ? res : Array.isArray(res?.issues) ? res.issues : [];
2217
+ }
2218
+
2219
+ function issueNumberOf(raw) {
2220
+ const n = Number(String(raw ?? '').replace(/^#/, ''));
2221
+ if (!Number.isSafeInteger(n) || n < 1) throw new Error(tr(`이슈 번호가 아닙니다: ${raw}`, `Not an issue number: ${raw}`));
2222
+ return n;
2223
+ }
2224
+
2225
+ /** `--file <경로>`(여러 번) → 올릴 파일들. 개수·크기·형식을 보내기 전에 거른다 — 서버 안전선(issue-public-attachments.ts)과 같은 값. */
2226
+ function publicIssueFilesOf(args) {
2227
+ const paths = args.__all.file || (typeof args.file === 'string' ? [args.file] : []);
2228
+ if (paths.length > PUBLIC_ISSUE_FILES_PER_POST) {
2229
+ throw new Error(tr(`파일은 글 하나에 ${PUBLIC_ISSUE_FILES_PER_POST}개까지 붙일 수 있습니다.`, `At most ${PUBLIC_ISSUE_FILES_PER_POST} files per issue/comment.`));
2230
+ }
2231
+ return paths.map((p) => {
2232
+ const filename = basename(p);
2233
+ const mimeType = publicIssueMimeOf(filename);
2234
+ if (!mimeType) {
2235
+ throw new Error(
2236
+ tr(`이 형식은 올릴 수 없습니다: ${filename} — ${PUBLIC_ISSUE_FILE_EXTENSIONS.join('·')} 만`, `File type not allowed: ${filename} — only ${PUBLIC_ISSUE_FILE_EXTENSIONS.join(', ')}`),
2237
+ );
2238
+ }
2239
+ const data = readFileSync(p);
2240
+ if (data.length > PUBLIC_ISSUE_FILE_MAX_BYTES) {
2241
+ throw new Error(tr(`파일이 너무 큽니다: ${filename} — 한 파일에 10MB까지`, `File too large: ${filename} — 10MB per file`));
2242
+ }
2243
+ return { filename, mimeType, data };
2244
+ });
2245
+ }
2246
+
2247
+ /**
2248
+ * ISSUE-PUBLIC — 이슈트래커 명령(창업자 지시 2026-11-02 "api cli mcp scaffold 에도 적용"). 예전엔 CLI 에 이슈 명령이 아예 없었다.
2249
+ * · 안에서: tracker(설정·외부 참여) · list · create · comment
2250
+ * · 밖에서(웹게시한 트래커): submit · reply — 키가 있으면 그 계정으로, --name 이면 키 없이 손님으로
2251
+ */
2252
+ async function cmdIssue(args) {
2253
+ const sub = args._[0];
2254
+ const client = makeClient();
2255
+ if (sub === 'tracker') {
2256
+ const target = args._[1];
2257
+ if (!target) throw new Error(usage('rootr issue tracker <path|id> [--participation read|login|anyone] [--json]'));
2258
+ const id = await resolveNodeId(client, target);
2259
+ let t;
2260
+ if (args.participation !== undefined) {
2261
+ if (!PARTICIPATION_MODES.includes(args.participation)) {
2262
+ throw new Error(tr(`--participation 은 다음 중 하나여야 합니다: ${PARTICIPATION_MODES.join(', ')}`, `--participation must be one of: ${PARTICIPATION_MODES.join(', ')}`));
2263
+ }
2264
+ t = await client.updateIssueTracker(id, { publicParticipation: args.participation });
2265
+ } else {
2266
+ t = await client.getIssueTracker(id);
2267
+ }
2268
+ if (args.json) return console.log(JSON.stringify(t, null, 2));
2269
+ const mode = (t.config && t.config.publicParticipation) || 'read';
2270
+ console.log(`${t.name} (${t.path})`);
2271
+ console.log(tr(` 열림 ${t.openCount} · 닫힘 ${t.closedCount}`, ` open ${t.openCount} · closed ${t.closedCount}`));
2272
+ console.log(tr(` 외부 참여: ${participationLabel(mode)}`, ` outside participation: ${participationLabel(mode)}`));
2273
+ console.log(tr(` 라벨: ${(t.labels || []).map((l) => l.name).join(', ') || '없음'}`, ` labels: ${(t.labels || []).map((l) => l.name).join(', ') || 'none'}`));
2274
+ if (mode !== 'read') {
2275
+ const st = await client.nodePublicStatus(id);
2276
+ if (!st.isPublic) {
2277
+ console.log(tr(' ※ 아직 웹게시 전입니다 — `rootr publish <트래커>` 로 공개하거나 발행한 페이지에 issues 블록으로 넣어야 밖에서 씁니다.', ' Note: not published yet — `rootr publish <tracker>` or put it on a published page (issues block) so outsiders can write.'));
2278
+ } else {
2279
+ console.log(tr(` 공개 주소: /p/${st.slug}`, ` public address: /p/${st.slug}`));
2280
+ }
2281
+ }
2282
+ return;
2283
+ }
2284
+ if (sub === 'list') {
2285
+ const target = args._[1];
2286
+ if (!target) throw new Error(usage('rootr issue list <path|id|공개 주소> [--state OPEN|CLOSED|ALL] [--q <text>] [--label <이름>]… [--sort newest|oldest|comments] [--json]'));
2287
+ // ISSUE-PUBLIC-SEARCH — 공개 주소(…/p/{slug})나 --block 이면 웹게시한 트래커를 **밖에서** 찾는다(키 없이, 서버 검색).
2288
+ if (/\/p\//.test(String(target)) || typeof args.block === 'string') {
2289
+ const slug = publicSlugOf(target);
2290
+ const pass = typeof args.password === 'string' ? await client.unlockPublicNode(slug, args.password) : undefined;
2291
+ const res = await client.searchPublicIssues(slug, {
2292
+ blockId: typeof args.block === 'string' ? args.block : undefined,
2293
+ q: typeof args.q === 'string' ? args.q : undefined,
2294
+ state: typeof args.state === 'string' ? args.state : undefined,
2295
+ labels: args.__all.label || [],
2296
+ sort: typeof args.sort === 'string' ? args.sort : undefined,
2297
+ pass,
2298
+ });
2299
+ if (args.json) return console.log(JSON.stringify(res, null, 2));
2300
+ const list = res?.issues || [];
2301
+ if (!list.length) return console.log(tr('조건에 맞는 이슈가 없습니다.', 'No matching issues.'));
2302
+ for (const i of list) {
2303
+ const who = i.author ? (i.author.guest ? tr(`${i.author.name} (외부)`, `${i.author.name} (guest)`) : i.author.name) : '';
2304
+ const labels = (i.labels || []).map((l) => l.name).join(',');
2305
+ console.log(`#${i.number}\t${i.state}\t${i.title}${labels ? `\t[${labels}]` : ''}${who ? `\t${who}` : ''}${i.commentCount ? `\t💬${i.commentCount}` : ''}`);
2306
+ }
2307
+ if (res.truncated) console.log(tr(`… ${res.truncated.total}건 중 ${res.truncated.shown}건`, `… ${res.truncated.shown} of ${res.truncated.total}`));
2308
+ return;
2309
+ }
2310
+ const id = await resolveNodeId(client, target);
2311
+ const list = issuesOf(await client.listIssues(id, { state: args.state, q: typeof args.q === 'string' ? args.q : undefined }));
2312
+ if (args.json) return console.log(JSON.stringify(list, null, 2));
2313
+ if (!list.length) return console.log(tr('이슈가 없습니다.', 'No issues.'));
2314
+ for (const i of list) {
2315
+ const who = i.guestName ? tr(`${i.guestName} (외부)`, `${i.guestName} (guest)`) : i.author?.name ?? '';
2316
+ console.log(`#${i.number}\t${i.state}\t${i.title}${who ? `\t${who}` : ''}${i.commentCount ? `\t💬${i.commentCount}` : ''}`);
2317
+ }
2318
+ return;
2319
+ }
2320
+ if (sub === 'create') {
2321
+ const target = args._[1];
2322
+ if (!target || typeof args.title !== 'string') throw new Error(usage('rootr issue create <path|id> --title "<title>" [--body "<body>"]'));
2323
+ const id = await resolveNodeId(client, target);
2324
+ const out = await client.createIssue(id, { title: args.title, body: typeof args.body === 'string' ? args.body : undefined });
2325
+ console.log(tr(`올렸습니다: #${out.number} ${out.title}`, `Filed: #${out.number} ${out.title}`));
2326
+ return;
2327
+ }
2328
+ if (sub === 'comment') {
2329
+ const [, target, num, ...rest] = args._;
2330
+ const text = rest.join(' ').trim();
2331
+ if (!target || !num || !text) throw new Error(usage('rootr issue comment <path|id> <#number> "<comment>"'));
2332
+ const id = await resolveNodeId(client, target);
2333
+ const n = issueNumberOf(num);
2334
+ const hit = issuesOf(await client.listIssues(id, { state: 'ALL', q: `#${n}` })).find((i) => i.number === n);
2335
+ if (!hit) throw new Error(tr(`#${n} 이슈가 없습니다.`, `Issue #${n} not found.`));
2336
+ await client.commentIssue(id, hit.id, text);
2337
+ console.log(tr(`#${n} 에 댓글을 달았습니다.`, `Commented on #${n}.`));
2338
+ return;
2339
+ }
2340
+ if (sub === 'submit' || sub === 'reply') {
2341
+ const slug = publicSlugOf(args._[1]);
2342
+ if (!slug) throw new Error(usage(`rootr issue ${sub} <public URL> …`));
2343
+ const asGuest = typeof args.name === 'string';
2344
+ const opts = {
2345
+ blockId: typeof args.block === 'string' ? args.block : undefined,
2346
+ guestName: asGuest ? args.name : undefined,
2347
+ asGuest,
2348
+ pass: typeof args.password === 'string' ? await client.unlockPublicNode(slug, args.password) : undefined,
2349
+ };
2350
+ // ISSUE-PUBLIC-ATTACH — `--file <경로>` 여러 번. 보내기 전에 크기·형식을 걸러(서버가 다시 판정) 올리고, 받은 줄을 본문 끝에 붙인다.
2351
+ const files = publicIssueFilesOf(args);
2352
+ const attach = async () => {
2353
+ const lines = [];
2354
+ for (const f of files) {
2355
+ const out = await client.uploadPublicIssueFile(slug, { ...opts, filename: f.filename, mimeType: f.mimeType, data: f.data });
2356
+ console.log(tr(`첨부: ${out.filename} (${out.size}B)`, `attached: ${out.filename} (${out.size}B)`));
2357
+ lines.push(out.markdown);
2358
+ }
2359
+ return lines;
2360
+ };
2361
+ if (sub === 'submit') {
2362
+ if (typeof args.title !== 'string') throw new Error(usage('rootr issue submit <public URL> --title "<title>" [--body …] [--file <경로>]… [--name <name>] [--block <id>]'));
2363
+ const body = withPublicAttachments(typeof args.body === 'string' ? args.body : '', await attach());
2364
+ const out = await client.submitPublicIssue(slug, { ...opts, title: args.title, body: body || undefined });
2365
+ console.log(out?.number ? tr(`올렸습니다: #${out.number}`, `Filed: #${out.number}`) : tr('올렸습니다.', 'Filed.'));
2366
+ return;
2367
+ }
2368
+ const [, , num, ...rest] = args._;
2369
+ const text = rest.join(' ').trim();
2370
+ if (!num || (!text && !files.length)) throw new Error(usage('rootr issue reply <public URL> <#number> "<comment>" [--file <경로>]… [--name <name>] [--block <id>]'));
2371
+ const n = issueNumberOf(num);
2372
+ await client.commentPublicIssue(slug, n, { ...opts, body: withPublicAttachments(text, await attach()) });
2373
+ console.log(tr('댓글을 달았습니다.', 'Comment posted.'));
2374
+ return;
2375
+ }
2376
+ throw new Error(usage('rootr issue tracker|list|create|comment|submit|reply …'));
2377
+ }
2378
+
2164
2379
  async function cmdPublish(args) {
2165
2380
  const target = args._[0];
2166
2381
  if (!target) {
@@ -2219,6 +2434,7 @@ async function cmdPublish(args) {
2219
2434
  printSeo(st.publicConfig?.seo);
2220
2435
  } else {
2221
2436
  if (st.hasPassword) console.log(tr(' 비밀번호 걸려 있음', ` password protected`));
2437
+ if (st.participation) console.log(tr(` 외부 참여: ${participationLabel(st.participation)}`, ` outside participation: ${participationLabel(st.participation)}`));
2222
2438
  console.log(tr(` 하위 문서도 공개: ${st.includeChildren ? '예' : '아니오'}`, ` children published too: ${st.includeChildren ? 'yes' : 'no'}`));
2223
2439
  console.log(tr(` 복제 허용: ${st.allowClone === false ? '아니오' : '예'}`, ` cloning allowed: ${st.allowClone === false ? 'no' : 'yes'}`));
2224
2440
  if (st.design && (st.design.layout || st.design.skin)) {
@@ -2274,12 +2490,22 @@ async function cmdPublish(args) {
2274
2490
  if (design.skin && !DB_SKINS.includes(design.skin)) {
2275
2491
  throw new Error(tr(`--skin 은 다음 중 하나여야 합니다: ${DB_SKINS.join(', ')}`, `--skin must be one of: ${DB_SKINS.join(', ')}`));
2276
2492
  }
2493
+ // ISSUE-PUBLIC — 이슈트래커의 외부 참여(트래커 설정에 저장 — 페이지에 끼운 블록도 같은 값을 따른다)
2494
+ if (args.participation !== undefined && !PARTICIPATION_MODES.includes(args.participation)) {
2495
+ throw new Error(tr(`--participation 은 다음 중 하나여야 합니다: ${PARTICIPATION_MODES.join(', ')}`, `--participation must be one of: ${PARTICIPATION_MODES.join(', ')}`));
2496
+ }
2277
2497
  const res = await client.publishNode(id, {
2278
2498
  ...(args.children ? { includeChildren: true } : {}),
2279
2499
  ...(typeof args.view === 'string' ? { view: args.view } : {}),
2280
2500
  ...(Object.keys(design).length ? { design } : {}),
2501
+ ...(typeof args.participation === 'string' ? { participation: args.participation } : {}),
2281
2502
  });
2282
2503
  console.log(tr(`공개했습니다: /p/${res.slug}`, `Published: /p/${res.slug}`));
2504
+ if (res.participation) console.log(tr(` 외부 참여: ${participationLabel(res.participation)}`, ` outside participation: ${participationLabel(res.participation)}`));
2505
+ if (typeof args.password === 'string' || args['no-password']) {
2506
+ await client.setNodePublic(id, { password: typeof args.password === 'string' ? args.password : null });
2507
+ console.log(typeof args.password === 'string' ? tr(' 비밀번호를 걸었습니다(쓰기도 같이 잠김).', ' Locked with a password (writing too).') : tr(' 비밀번호를 풀었습니다.', ' Password removed.'));
2508
+ }
2283
2509
  if (args.children) console.log(tr(' 이 문서 아래 전부(손자·증손자 포함)도 각자 주소로 공개됩니다.', ` Everything under this document (grandchildren included) gets its own public address.`));
2284
2510
  if (res.skippedPagesRequireTeam) {
2285
2511
  // PAGE-PUBLISH-TEAM — 무료 워크스페이스에서는 아래의 페이지(PAGE)는 따라 나가지 않는다.
@@ -2987,6 +3213,17 @@ async function cmdDbView(args) {
2987
3213
  if (!['compact', 'normal'].includes(density)) throw new Error(tr('--density 는 compact · normal 중 하나여야 합니다.', `--density must be compact or normal.`));
2988
3214
  patch.density = density;
2989
3215
  }
3216
+ // VIEW-GRID-LINES — 칸 선(rows 가로선만 · all 칸마다 · none)·진하기(light · strong).
3217
+ if (args.lines !== undefined) {
3218
+ const lines = String(args.lines);
3219
+ if (!['rows', 'all', 'none'].includes(lines)) throw new Error(tr('--lines 는 rows · all · none 중 하나여야 합니다.', `--lines must be rows, all or none.`));
3220
+ patch.gridLines = lines;
3221
+ }
3222
+ if (args['line-strength'] !== undefined) {
3223
+ const strength = String(args['line-strength']);
3224
+ if (!['light', 'strong'].includes(strength)) throw new Error(tr('--line-strength 는 light · strong 중 하나여야 합니다.', `--line-strength must be light or strong.`));
3225
+ patch.lineStrength = strength;
3226
+ }
2990
3227
  if (args.freeze !== undefined) {
2991
3228
  const n = Number(args.freeze);
2992
3229
  if (!Number.isInteger(n) || n < 0 || n > 3) throw new Error(tr('--freeze 는 0~3 (왼쪽에 붙여 둘 칸 수)', `--freeze must be 0-3 (columns kept on the left)`));
@@ -3015,7 +3252,7 @@ async function cmdDbView(args) {
3015
3252
  patch.dayPager = !v || v === 'off' || v === 'false' ? null : { dateProp: v };
3016
3253
  }
3017
3254
  if (!Object.keys(patch).length) {
3018
- throw new Error(tr('바꿀 것을 주세요: --scale week|month|year, --name 새이름, --cards "칸,칸", --density compact|normal, --freeze 0~3, --row-hidden on|off, --quick "칸,칸", --entry-row on|off|top|bottom, --personal-filters on|off 또는 --day-pager 날짜칸|off', `Give something to change: --scale week|month|year, --name <new name>, --cards "field,field", --density compact|normal, --freeze 0-3, --row-hidden on|off, --quick "field,field", --entry-row on|off|top|bottom, --personal-filters on|off, or --day-pager <date field>|off`));
3255
+ throw new Error(tr('바꿀 것을 주세요: --scale week|month|year, --name 새이름, --cards "칸,칸", --density compact|normal, --lines rows|all|none, --line-strength light|strong, --freeze 0~3, --row-hidden on|off, --quick "칸,칸", --entry-row on|off|top|bottom, --personal-filters on|off 또는 --day-pager 날짜칸|off', `Give something to change: --scale week|month|year, --name <new name>, --cards "field,field", --density compact|normal, --lines rows|all|none, --line-strength light|strong, --freeze 0-3, --row-hidden on|off, --quick "field,field", --entry-row on|off|top|bottom, --personal-filters on|off, or --day-pager <date field>|off`));
3019
3256
  }
3020
3257
 
3021
3258
  const view = await client.updateDatabaseView(id, viewRef, patch);
@@ -4152,6 +4389,9 @@ async function main() {
4152
4389
  return cmdForm(args);
4153
4390
  case 'publish':
4154
4391
  return cmdPublish(args);
4392
+ case 'issue':
4393
+ case 'issues':
4394
+ return cmdIssue(args);
4155
4395
  case 'booking':
4156
4396
  return cmdBooking(args);
4157
4397
  case 'gcal':
package/lib/client.js CHANGED
@@ -115,6 +115,43 @@ function hintFor(status, body) {
115
115
  return undefined;
116
116
  }
117
117
 
118
+ /**
119
+ * ISSUE-PUBLIC-ATTACH — 공개 이슈트래커 첨부의 안전선(서버 issue-public-attachments.ts 와 같은 값). 서버가 다시 판정하지만, 보내기 전에
120
+ * 걸러 큰 파일을 헛되이 싣지 않게 한다.
121
+ */
122
+ export const PUBLIC_ISSUE_FILE_MAX_BYTES = 10 * 1024 * 1024;
123
+ export const PUBLIC_ISSUE_FILES_PER_POST = 10;
124
+ const PUBLIC_ISSUE_MIME = {
125
+ png: 'image/png',
126
+ jpg: 'image/jpeg',
127
+ jpeg: 'image/jpeg',
128
+ gif: 'image/gif',
129
+ webp: 'image/webp',
130
+ pdf: 'application/pdf',
131
+ txt: 'text/plain',
132
+ log: 'text/plain',
133
+ csv: 'text/csv',
134
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
135
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
136
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
137
+ zip: 'application/zip',
138
+ };
139
+ export const PUBLIC_ISSUE_FILE_EXTENSIONS = Object.keys(PUBLIC_ISSUE_MIME);
140
+
141
+ /** 파일 이름 → 보낼 mimeType(모르는 확장자면 null — 서버가 415 로 거절할 것을 미리 알린다). */
142
+ export function publicIssueMimeOf(filename) {
143
+ const ext = String(filename || '').split('.').pop().toLowerCase();
144
+ return PUBLIC_ISSUE_MIME[ext] || null;
145
+ }
146
+
147
+ /** 본문 끝에 첨부 줄(서버가 준 `markdown`)을 붙인다 — 화면(PublicIssueBoard)과 같은 모양. */
148
+ export function withPublicAttachments(body, markdowns) {
149
+ const lines = (markdowns || []).filter(Boolean);
150
+ if (!lines.length) return body || '';
151
+ const text = String(body || '').replace(/\s+$/, '');
152
+ return `${text}${text ? '\n\n' : ''}${lines.join('\n\n')}`;
153
+ }
154
+
118
155
  export class RootrClient {
119
156
  constructor({ apiKey, workspace, baseUrl, publicBaseUrl, defaultHeaders }) {
120
157
  this.apiKey = apiKey;
@@ -165,11 +202,12 @@ export class RootrClient {
165
202
  * @param {boolean} [opts.returnResponse] - if true, return the raw Response (for header access)
166
203
  */
167
204
  async request(path, opts = {}) {
168
- this._requireApiKey();
205
+ // ISSUE-PUBLIC — `noAuth` 는 공개 주소에 **손님으로** 부를 때(키를 보내면 그 키 주인의 계정으로 쓰인다).
206
+ if (!opts.noAuth) this._requireApiKey();
169
207
 
170
208
  const url = opts.url || `${this.baseUrl}${path}`;
171
209
  const headers = {
172
- 'x-api-key': this.apiKey,
210
+ ...(opts.noAuth ? {} : { 'x-api-key': this.apiKey }),
173
211
  ...this.defaultHeaders,
174
212
  ...(opts.headers || {}),
175
213
  };
@@ -936,10 +974,10 @@ export class RootrClient {
936
974
 
937
975
  // ---- Issues ---------------------------------------------------------------
938
976
 
939
- async createIssueTracker(workspace, name) {
977
+ async createIssueTracker(workspace, name, extra = {}) {
940
978
  return this.request(`/workspaces/${encodeURIComponent(workspace)}/issue-trackers`, {
941
979
  method: 'POST',
942
- body: { name },
980
+ body: { name, ...extra },
943
981
  });
944
982
  }
945
983
 
@@ -947,6 +985,93 @@ export class RootrClient {
947
985
  return this.request(`/issue-trackers/${encodeURIComponent(trackerId)}`);
948
986
  }
949
987
 
988
+ /**
989
+ * 트래커 설정을 고친다 — `{ publicParticipation }`(외부 참여: read·login·anyone)만 보내면 그 값만 바뀐다.
990
+ * `labels`·`types` 는 통째로 바꾼다. 외부 참여를 **바꾸는 것**은 MANAGE(웹게시와 같은 주인 몫)다.
991
+ */
992
+ async updateIssueTracker(trackerId, patch = {}) {
993
+ return this.request(`/issue-trackers/${encodeURIComponent(trackerId)}`, {
994
+ method: 'PATCH',
995
+ body: patch,
996
+ });
997
+ }
998
+
999
+ // ── ISSUE-PUBLIC — 남의(또는 내) **웹게시한** 이슈트래커에 밖에서 쓰기 ──────────────
1000
+ // 공개 주소(rootr.io/p/{slug}) 위의 길이다. 키를 보내면 그 키 주인(계정)으로 쓰이고, `asGuest` 면 키 없이 손님 이름으로.
1001
+ // 페이지에 끼운 트래커는 페이지 주소 + `blockId` 로 닿는다.
1002
+
1003
+ /** 잠긴 공개 주소의 비밀번호 → 통과증(`?pass=`). */
1004
+ async unlockPublicNode(slug, password) {
1005
+ const out = await this.request(`/public/nodes/${encodeURIComponent(slug)}/unlock`, {
1006
+ method: 'POST',
1007
+ body: { password },
1008
+ noAuth: true,
1009
+ });
1010
+ return out?.token;
1011
+ }
1012
+
1013
+ _publicIssuesBase(slug, blockId) {
1014
+ const s = encodeURIComponent(slug);
1015
+ return blockId ? `/public/nodes/${s}/issue-blocks/${encodeURIComponent(blockId)}/issues` : `/public/nodes/${s}/issues`;
1016
+ }
1017
+
1018
+ async submitPublicIssue(slug, { blockId, title, body, guestName, pass, asGuest } = {}) {
1019
+ const qs = pass ? `?pass=${encodeURIComponent(pass)}` : '';
1020
+ return this.request(`${this._publicIssuesBase(slug, blockId)}${qs}`, {
1021
+ method: 'POST',
1022
+ body: { title, body, ...(guestName ? { guestName } : {}) },
1023
+ noAuth: Boolean(asGuest) || !this.apiKey,
1024
+ });
1025
+ }
1026
+
1027
+ async commentPublicIssue(slug, number, { blockId, body, guestName, pass, asGuest } = {}) {
1028
+ const qs = pass ? `?pass=${encodeURIComponent(pass)}` : '';
1029
+ return this.request(`${this._publicIssuesBase(slug, blockId)}/${encodeURIComponent(String(number))}/comments${qs}`, {
1030
+ method: 'POST',
1031
+ body: { body, ...(guestName ? { guestName } : {}) },
1032
+ noAuth: Boolean(asGuest) || !this.apiKey,
1033
+ });
1034
+ }
1035
+
1036
+ /**
1037
+ * ISSUE-PUBLIC-SEARCH — 공개 트래커(또는 페이지 블록)의 목록을 서버에서 찾는다. `q` 는 제목·본문·번호(#12)·(이름을 싣는 모드면) 쓴 사람,
1038
+ * `labels` 는 이름(모두 가진 것), `state` OPEN|CLOSED, `sort` newest|oldest|comments. 키 없이 부른다(공개 읽기).
1039
+ */
1040
+ async searchPublicIssues(slug, { blockId, q, state, labels, sort, pass } = {}) {
1041
+ const qs = new URLSearchParams();
1042
+ if (q) qs.set('q', q);
1043
+ if (state && String(state).toUpperCase() !== 'ALL') qs.set('state', String(state).toUpperCase());
1044
+ for (const l of [].concat(labels || [])) if (l) qs.append('label', l);
1045
+ if (sort) qs.set('sort', sort);
1046
+ if (pass) qs.set('pass', pass);
1047
+ const tail = qs.toString();
1048
+ return this.request(`${this._publicIssuesBase(slug, blockId)}${tail ? `?${tail}` : ''}`, { noAuth: true });
1049
+ }
1050
+
1051
+ /**
1052
+ * ISSUE-PUBLIC-ATTACH — 공개 트래커(또는 페이지 블록)에 새 이슈·댓글용 파일을 올린다(본문 = 바이트). 10MB·허용 형식은 서버가 판정한다.
1053
+ * 돌려받은 `markdown` 을 이슈·댓글 본문 끝에 붙여 보낸다(`withPublicAttachments`).
1054
+ */
1055
+ async uploadPublicIssueFile(slug, { blockId, filename, mimeType, data, guestName, pass, asGuest } = {}) {
1056
+ const s = encodeURIComponent(slug);
1057
+ const base = blockId ? `/public/nodes/${s}/issue-blocks/${encodeURIComponent(blockId)}/attachments` : `/public/nodes/${s}/issues/attachments`;
1058
+ const qs = new URLSearchParams({ filename, mimeType: mimeType || publicIssueMimeOf(filename) || 'application/octet-stream' });
1059
+ if (guestName) qs.set('guestName', guestName);
1060
+ if (pass) qs.set('pass', pass);
1061
+ return this.request(`${base}?${qs.toString()}`, {
1062
+ method: 'POST',
1063
+ headers: { 'Content-Type': 'application/octet-stream' },
1064
+ body: data,
1065
+ noAuth: Boolean(asGuest) || !this.apiKey,
1066
+ });
1067
+ }
1068
+
1069
+ /** 공개 트래커(또는 페이지 블록)의 이슈 하나 — 댓글까지. */
1070
+ async readPublicIssue(slug, number, { blockId, pass } = {}) {
1071
+ const qs = pass ? `?pass=${encodeURIComponent(pass)}` : '';
1072
+ return this.request(`${this._publicIssuesBase(slug, blockId)}/${encodeURIComponent(String(number))}${qs}`, { noAuth: true });
1073
+ }
1074
+
950
1075
  async listIssues(trackerId, { state, label, type, assigneeId, q } = {}) {
951
1076
  const params = new URLSearchParams();
952
1077
  if (state) params.set('state', state);
@@ -1133,11 +1258,11 @@ export class RootrClient {
1133
1258
  */
1134
1259
  async createDatabaseView(
1135
1260
  databaseId,
1136
- { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns, rowPaneShowHidden, quickFilters, entryRow, filtersPersonalOnly, dayPager } = {},
1261
+ { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, gridLines, lineStrength, frozenColumns, rowPaneShowHidden, quickFilters, entryRow, filtersPersonalOnly, dayPager } = {},
1137
1262
  ) {
1138
1263
  return this.request(`/databases/${encodeURIComponent(databaseId)}/views`, {
1139
1264
  method: 'POST',
1140
- body: { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns, rowPaneShowHidden, quickFilters, entryRow, filtersPersonalOnly, dayPager },
1265
+ body: { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, gridLines, lineStrength, frozenColumns, rowPaneShowHidden, quickFilters, entryRow, filtersPersonalOnly, dayPager },
1141
1266
  });
1142
1267
  }
1143
1268
 
@@ -1454,10 +1579,10 @@ export class RootrClient {
1454
1579
  });
1455
1580
  }
1456
1581
 
1457
- async queryVectors(databaseId, column, { vector, topK, minScore } = {}) {
1582
+ async queryVectors(databaseId, column, { vector, topK, minScore, filters } = {}) {
1458
1583
  return this.request(`/databases/${encodeURIComponent(databaseId)}/vectors/${encodeURIComponent(column)}/query`, {
1459
1584
  method: 'POST',
1460
- body: { vector, topK, minScore },
1585
+ body: { vector, topK, minScore, ...(filters?.length ? { filters } : {}) },
1461
1586
  });
1462
1587
  }
1463
1588
 
@@ -2126,10 +2251,10 @@ export class RootrClient {
2126
2251
  }
2127
2252
 
2128
2253
  /** 연구노트 프리셋: 기재자(나) → 점검자 두 단계. */
2129
- async applyResearchNotePreset(nodeId, { reviewerUserId, noteMeta } = {}) {
2254
+ async applyResearchNotePreset(nodeId, { reviewerUserId, noteMeta, authorUserId } = {}) {
2130
2255
  return this.request(`/signed-docs/${encodeURIComponent(nodeId)}/research-note-preset`, {
2131
2256
  method: 'POST',
2132
- body: { reviewerUserId, noteMeta },
2257
+ body: { reviewerUserId, noteMeta, authorUserId },
2133
2258
  });
2134
2259
  }
2135
2260
 
@@ -74,12 +74,16 @@ const FILTER_DESC =
74
74
  'still show them. A new row added while a filter is on is seeded with the filtered values so it does not ' +
75
75
  'vanish the moment it is created.';
76
76
 
77
- const SORT_SHAPE = z.object({
77
+ const SORT_ONE = z.object({
78
78
  propId: z.string().describe('Column NAME or id to sort by'),
79
79
  dir: z.enum(['asc', 'desc']).describe('asc = 오름차순, desc = 내림차순'),
80
80
  });
81
+ const SORT_SHAPE = z.union([SORT_ONE, z.array(SORT_ONE).min(1).max(3)]);
81
82
 
82
- const SORT_DESC = 'Row order in this view.';
83
+ const SORT_DESC =
84
+ 'Row order in this view — one { propId, dir } or a list of up to 3 (the next one breaks ties of the previous, e.g. ' +
85
+ '[{ "propId": "지원일", "dir": "desc" }, { "propId": "등록일시", "dir": "desc" }]). Rows still tied are ordered by creation ' +
86
+ 'time in the direction of the first criterion (descending → the newest row first).';
83
87
 
84
88
  /**
85
89
  * VIEW-ROW-SHOW-HIDDEN · VIEW-QUICK-FILTER · VIEW-ENTRY-ROW · VIEW-DAY-PAGER — 표 보기 설정 넷. 만들기(rootr_create_view)와
@@ -178,7 +182,8 @@ export function registerDatabaseTools(server) {
178
182
  title: 'Create a Rootr database (table / board / kanban)',
179
183
  description:
180
184
  'Create a DATABASE node — a typed table with columns, views and rows — in ONE call, optionally seeded with ' +
181
- 'its first rows. **Use this whenever you need a table**: moving a Notion database over, building a kanban ' +
185
+ 'its first rows. Column names must be unique in the table (trimmed, case-insensitive) — duplicates are ' +
186
+ 'rejected with a 400 so rows can be addressed by column name unambiguously. **Use this whenever you need a table**: moving a Notion database over, building a kanban ' +
182
187
  'board, a task/requirement/risk tracker, anything with trackable rows. Do NOT fake a table with a markdown ' +
183
188
  'document, and do NOT create an empty database and then add columns one by one — `properties` order IS the ' +
184
189
  'column order, so one call keeps the columns in exactly the order you list them. ' +
@@ -386,6 +391,16 @@ export function registerDatabaseTools(server) {
386
391
  .nullable()
387
392
  .optional()
388
393
  .describe('table view: "compact" = lower rows and smaller text so more rows fit on one screen. null/"normal" = default.'),
394
+ gridLines: z
395
+ .enum(['rows', 'all', 'none'])
396
+ .nullable()
397
+ .optional()
398
+ .describe('table view lines: "rows" = lines between rows only (default), "all" = a border around every cell (spreadsheet-like), "none" = no lines. null = default.'),
399
+ lineStrength: z
400
+ .enum(['light', 'strong'])
401
+ .nullable()
402
+ .optional()
403
+ .describe('table view: "strong" = darker grid lines so rows are easier to tell apart. null/"light" = default.'),
389
404
  frozenColumns: z
390
405
  .number()
391
406
  .int()
@@ -400,7 +415,7 @@ export function registerDatabaseTools(server) {
400
415
  ...VIEW_EXTRA_SHAPE,
401
416
  },
402
417
  },
403
- async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns, rowPaneShowHidden, quickFilters, entryRow, filtersPersonalOnly, dayPager }) => {
418
+ async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, gridLines, lineStrength, frozenColumns, rowPaneShowHidden, quickFilters, entryRow, filtersPersonalOnly, dayPager }) => {
404
419
  try {
405
420
  const client = makeClient();
406
421
  const id = await resolveNodeId(client, databaseId);
@@ -415,6 +430,8 @@ export function registerDatabaseTools(server) {
415
430
  colWidths,
416
431
  calendarScale,
417
432
  density,
433
+ gridLines,
434
+ lineStrength,
418
435
  frozenColumns,
419
436
  rowPaneShowHidden,
420
437
  quickFilters,
@@ -525,6 +542,16 @@ export function registerDatabaseTools(server) {
525
542
  .nullable()
526
543
  .optional()
527
544
  .describe('table view: "compact" = lower rows and smaller text so more rows fit on one screen. null/"normal" = default.'),
545
+ gridLines: z
546
+ .enum(['rows', 'all', 'none'])
547
+ .nullable()
548
+ .optional()
549
+ .describe('table view lines: "rows" = lines between rows only (default), "all" = a border around every cell (spreadsheet-like), "none" = no lines. null = default.'),
550
+ lineStrength: z
551
+ .enum(['light', 'strong'])
552
+ .nullable()
553
+ .optional()
554
+ .describe('table view: "strong" = darker grid lines so rows are easier to tell apart. null/"light" = default.'),
528
555
  frozenColumns: z
529
556
  .number()
530
557
  .int()
@@ -554,6 +581,8 @@ export function registerDatabaseTools(server) {
554
581
  cardProps,
555
582
  rowColors,
556
583
  density,
584
+ gridLines,
585
+ lineStrength,
557
586
  frozenColumns,
558
587
  rowPaneShowHidden,
559
588
  quickFilters,
@@ -577,6 +606,8 @@ export function registerDatabaseTools(server) {
577
606
  cardProps,
578
607
  rowColors,
579
608
  density,
609
+ gridLines,
610
+ lineStrength,
580
611
  frozenColumns,
581
612
  rowPaneShowHidden,
582
613
  quickFilters,
@@ -1989,7 +2020,10 @@ export function registerDatabaseTools(server) {
1989
2020
  'Remove ONE column from a Rootr (루터) DATABASE. The values stored under it are NOT erased from the rows, ' +
1990
2021
  'so re-adding a column is not enough to bring them back in the UI — confirm with the user before calling this. ' +
1991
2022
  'Exception: values of a vector column are erased (see below). ' +
1992
- 'The title column cannot be deleted. Views grouped/dated by the column are cleared automatically. ' +
2023
+ 'The title column cannot be deleted. Views grouped/dated by the column are cleared automatically, and so are the ' +
2024
+ 'value lines that point at it in buttons/automations of THIS and OTHER tables (an add_row into this table, an update_rows ' +
2025
+ 'of its rows, an ai_fill of that column); automations whose condition watches it stop. The result lists them under ' +
2026
+ '`deleteCascade` — show it to the user. ' +
1993
2027
  'For a vector column the stored vectors are deleted with it (not kept for re-adding — re-add the column and ' +
1994
2028
  'store the vectors again with rootr_set_row_vectors).',
1995
2029
  annotations: { readOnlyHint: false, destructiveHint: true },
@@ -2418,7 +2452,10 @@ export function registerDatabaseTools(server) {
2418
2452
  'query with the same model you used for rootr_set_row_vectors. `minScore` drops weak matches. Rows the caller ' +
2419
2453
  'cannot see (own-rows-only tables) are never returned. Rows without a stored vector are skipped. ' +
2420
2454
  'The column can hold at most 50,000 vectors — a bigger column is refused, not searched partially — and the scan has ' +
2421
- 'no index, so latency grows with the number of rows in the column.',
2455
+ 'no index, so latency grows with the number of rows compared (1536 dims: ~0.7s at 2,000 rows, ~3s at 10,000). ' +
2456
+ 'Pass `filters` (same shape as rootr_create_view filters, columns by NAME or id) to compare only the rows that match — ' +
2457
+ 'e.g. [{"propId":"상태","op":"is","values":["진행"]}]. When more than 5,000 vectors were compared without filters the ' +
2458
+ 'answer carries a `hint` saying so; `scanned` is how many vectors were compared.',
2422
2459
  annotations: { readOnlyHint: true, openWorldHint: false },
2423
2460
  inputSchema: {
2424
2461
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
@@ -2426,13 +2463,21 @@ export function registerDatabaseTools(server) {
2426
2463
  vector: z.array(z.number()).describe('Query vector — same length as the column\'s dimensions'),
2427
2464
  topK: z.number().int().min(1).max(100).optional().default(10).describe('How many rows (default 10, max 100)'),
2428
2465
  minScore: z.number().min(-1).max(1).optional().describe('Only rows with cosine ≥ this'),
2466
+ filters: z
2467
+ .array(z.object({ propId: z.string(), op: z.string(), values: z.array(z.string()).optional() }))
2468
+ .optional()
2469
+ .describe(
2470
+ 'Compare only rows matching EVERY condition (AND), checked before any vector is compared — the way to keep a big ' +
2471
+ 'column fast. Same conditions as a view filter: propId = column NAME or id, op = is / is_not / contains / ' +
2472
+ 'is_empty / is_not_empty / before / after / on / between / eq / gt / lt / checked / unchecked / today / this_week …',
2473
+ ),
2429
2474
  },
2430
2475
  },
2431
- async ({ databaseId, column, vector, topK, minScore }) => {
2476
+ async ({ databaseId, column, vector, topK, minScore, filters }) => {
2432
2477
  try {
2433
2478
  const client = makeClient();
2434
2479
  const id = await resolveNodeId(client, databaseId);
2435
- return jsonResult(await client.queryVectors(id, column, { vector, topK, minScore }));
2480
+ return jsonResult(await client.queryVectors(id, column, { vector, topK, minScore, filters }));
2436
2481
  } catch (err) {
2437
2482
  return errorResult(err);
2438
2483
  }
@@ -693,7 +693,10 @@ export function registerDocumentTools(server) {
693
693
  title: 'Duplicate a Rootr node (deep copy)',
694
694
  description:
695
695
  'Deep-copy a Rootr (루터) node — a document or a whole folder tree — next to the original, or into ' +
696
- 'another workspace the user can access (targetWorkspaceId). Returns the new root node.',
696
+ 'another workspace the user can access (targetWorkspaceId). Returns the new root node. If an older table ' +
697
+ '(or one inside the copied folder) has two columns with the same name, the COPY gets the later one renamed ' +
698
+ 'to "Name (2)" (values and views follow — columns are kept by id; the original is not changed) and the ' +
699
+ 'result lists it in `renamedColumns`.',
697
700
  annotations: { readOnlyHint: false, destructiveHint: false },
698
701
  inputSchema: {
699
702
  target: z.string().describe('Node path (starting with "/") or node id to copy'),
@@ -60,7 +60,10 @@ export function registerFeedbackTools(server) {
60
60
  .string()
61
61
  .email()
62
62
  .optional()
63
- .describe('Only if the user asked for a reply and gave you their address'),
63
+ .describe(
64
+ 'Only if the user asked for a reply and gave you their address. Without it, the "fixed" notice goes to the ' +
65
+ 'signed-in user, or — for a workspace API key — to the person who created that key.',
66
+ ),
64
67
  reporter: z
65
68
  .string()
66
69
  .max(120)
@@ -1,6 +1,35 @@
1
1
  import { z } from 'zod';
2
2
 
3
3
  import { textResult, jsonResult, errorResult, makeClient, requireWorkspace, resolveNodeId, NODE_TARGET_HINT, NODE_URL_HINT } from './shared.js';
4
+ import {
5
+ RootrApiError,
6
+ publicIssueMimeOf,
7
+ withPublicAttachments,
8
+ PUBLIC_ISSUE_FILE_EXTENSIONS,
9
+ PUBLIC_ISSUE_FILE_MAX_BYTES,
10
+ PUBLIC_ISSUE_FILES_PER_POST,
11
+ } from '../client.js';
12
+
13
+ /**
14
+ * ISSUE-PUBLIC — 외부 참여(웹게시한 트래커·페이지에 끼운 트래커에 밖의 사람이 무엇을 할 수 있나). 서버 값과 같아야 한다
15
+ * (issues/issue-schema.ts PUBLIC_PARTICIPATION_MODES).
16
+ */
17
+ const PARTICIPATION = z
18
+ .enum(['read', 'login', 'anyone'])
19
+ .optional()
20
+ .describe(
21
+ 'Outside participation (외부 참여) on the tracker\'s PUBLIC page and on any published PAGE that shows it (`issues` block): ' +
22
+ '"read" 보기만 (default) · "login" 로그인한 사람만 작성 (any Rootr account may file issues and comment) · ' +
23
+ '"anyone" 누구나 작성 (no account — visitors type a name, shown as "이름 (외부)"). Takes effect once the tracker ' +
24
+ '(or the page holding it) is published — rootr_publish_node.',
25
+ );
26
+
27
+ /** 공개 주소 글자(slug · rootr.io/…/p/{slug} 주소)에서 slug 만. */
28
+ function slugOf(target) {
29
+ const t = String(target || '').trim();
30
+ const m = /\/p\/([^/?#\s]+)/.exec(t);
31
+ return m ? decodeURIComponent(m[1]) : t;
32
+ }
4
33
 
5
34
  /** Issue tracker tools: create tracker, list/create/get/update issues, comment. */
6
35
  export function registerIssueTools(server) {
@@ -19,13 +48,14 @@ export function registerIssueTools(server) {
19
48
  .string()
20
49
  .optional()
21
50
  .describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
51
+ publicParticipation: PARTICIPATION,
22
52
  },
23
53
  },
24
- async ({ name, workspace }) => {
54
+ async ({ name, workspace, publicParticipation }) => {
25
55
  try {
26
56
  const ws = requireWorkspace(workspace);
27
57
  const client = makeClient(ws);
28
- const result = await client.createIssueTracker(ws, name);
58
+ const result = await client.createIssueTracker(ws, name, publicParticipation ? { publicParticipation } : {});
29
59
  return jsonResult(result);
30
60
  } catch (err) {
31
61
  return errorResult(err);
@@ -170,4 +200,205 @@ export function registerIssueTools(server) {
170
200
  }
171
201
  },
172
202
  );
203
+
204
+ server.registerTool(
205
+ 'rootr_update_issue_tracker',
206
+ {
207
+ title: 'Change an issue tracker\'s settings (outside participation, labels, types)',
208
+ description:
209
+ 'Change settings of a Rootr (루터) issue tracker. `publicParticipation` decides what people OUTSIDE the workspace may ' +
210
+ 'do on its public page (rootr.io/p/…) and on published PAGEs that show it: "read" 보기만 · "login" 로그인한 사람만 ' +
211
+ '작성 · "anyone" 누구나 작성 (just a name). Only the keys you send change; `labels`/`types` REPLACE the whole list ' +
212
+ '([{ id?, name, color? }] — read the current ones with rootr_list_issues / the tracker first). Changing ' +
213
+ 'publicParticipation is an owner-level action (MANAGE, same as publishing) — use an account key (PAT). ' +
214
+ 'To also publish in one step use rootr_publish_node with `participation`.',
215
+ annotations: { readOnlyHint: false, destructiveHint: false },
216
+ inputSchema: {
217
+ trackerId: z.string().describe(`Issue tracker node id${NODE_TARGET_HINT}`),
218
+ publicParticipation: PARTICIPATION,
219
+ labels: z
220
+ .array(z.object({ id: z.string().optional(), name: z.string(), color: z.string().optional() }))
221
+ .optional()
222
+ .describe('Full label list (replaces the current one)'),
223
+ types: z
224
+ .array(z.object({ id: z.string().optional(), name: z.string(), color: z.string().optional() }))
225
+ .optional()
226
+ .describe('Full issue type list (replaces the current one)'),
227
+ },
228
+ },
229
+ async ({ trackerId, ...patch }) => {
230
+ try {
231
+ const client = makeClient();
232
+ trackerId = await resolveNodeId(client, trackerId);
233
+ const body = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined));
234
+ return jsonResult(await client.updateIssueTracker(trackerId, body));
235
+ } catch (err) {
236
+ return errorResult(err);
237
+ }
238
+ },
239
+ );
240
+
241
+ const PUBLIC_TARGET = {
242
+ url: z
243
+ .string()
244
+ .describe(
245
+ 'The PUBLIC address of the tracker — "https://rootr.io/ko/p/<slug>" or just the slug. For a tracker shown on a ' +
246
+ 'published PAGE, the page\'s address plus `blockId`.',
247
+ ),
248
+ blockId: z.string().optional().describe('Only when the tracker sits on a published PAGE: that `issues` block\'s id'),
249
+ guestName: z
250
+ .string()
251
+ .max(40)
252
+ .optional()
253
+ .describe('Name to show when writing as a guest (누구나 작성 mode with asGuest). Ignored when writing as your account.'),
254
+ asGuest: z
255
+ .boolean()
256
+ .optional()
257
+ .describe(
258
+ 'Write WITHOUT your API key, as a guest with `guestName` (only works when participation is "anyone"). ' +
259
+ 'Default false: the key\'s owner account is the author (works for "login" and "anyone").',
260
+ ),
261
+ password: z.string().optional().describe('If the public page is password-locked'),
262
+ };
263
+
264
+ /**
265
+ * ISSUE-PUBLIC-ATTACH — 공개 이슈·댓글에 붙일 파일. **내용(base64)만** 받는다 — 파일 경로는 받지 않는다: 원격 커넥터는 이 도구를 API 서버
266
+ * 안에서 돌리므로 경로를 읽으면 서버 파일을 남의 공개 트래커로 내보내는 구멍이 된다(rootr-remote-mcp-runs-in-api-process). 로컬에서도
267
+ * 같은 모양이라 원격/로컬을 가를 필요가 없다. 크기 상한은 서버와 같은 10MB(base64 로 약 13.4MB), 글 하나에 10개.
268
+ */
269
+ const PUBLIC_FILES = z
270
+ .array(
271
+ z.object({
272
+ filename: z.string().min(1).max(255).describe('File name with extension, e.g. "screenshot.png"'),
273
+ contentBase64: z
274
+ .string()
275
+ .max(Math.ceil(PUBLIC_ISSUE_FILE_MAX_BYTES / 3) * 4 + 4)
276
+ .describe('The file bytes, base64-encoded (≤10MB decoded)'),
277
+ mimeType: z.string().max(120).optional().describe('Optional — guessed from the extension'),
278
+ }),
279
+ )
280
+ .max(PUBLIC_ISSUE_FILES_PER_POST)
281
+ .optional()
282
+ .describe(
283
+ `Files to attach (≤${PUBLIC_ISSUE_FILES_PER_POST}, ≤10MB each): ${PUBLIC_ISSUE_FILE_EXTENSIONS.join(', ')}. ` +
284
+ 'Images show inside the text, other files as a download link. svg/html/executables/video are refused, and the file\'s ' +
285
+ 'first bytes must match its extension. Content only — no file paths.',
286
+ );
287
+
288
+ /** 파일들을 그 트래커에 올리고, 본문 끝에 붙일 줄들을 돌려준다. 보내기 전에 크기·형식을 거른다(서버가 다시 판정). */
289
+ async function uploadPublicFiles(client, slug, files, opts) {
290
+ const lines = [];
291
+ for (const f of files || []) {
292
+ const data = Buffer.from(f.contentBase64, 'base64');
293
+ if (!data.length) throw new RootrApiError(`빈 파일입니다: ${f.filename}`);
294
+ if (data.length > PUBLIC_ISSUE_FILE_MAX_BYTES) throw new RootrApiError(`파일이 너무 큽니다: ${f.filename} — 한 파일에 10MB까지`);
295
+ const mimeType = f.mimeType || publicIssueMimeOf(f.filename);
296
+ if (!mimeType) {
297
+ throw new RootrApiError(`이 형식은 올릴 수 없습니다: ${f.filename}`, { hint: `허용: ${PUBLIC_ISSUE_FILE_EXTENSIONS.join(', ')}` });
298
+ }
299
+ const out = await client.uploadPublicIssueFile(slug, { ...opts, filename: f.filename, mimeType, data });
300
+ lines.push(out.markdown);
301
+ }
302
+ return lines;
303
+ }
304
+
305
+ server.registerTool(
306
+ 'rootr_search_public_issues',
307
+ {
308
+ title: 'Search the issues of a PUBLISHED tracker (outside view)',
309
+ description:
310
+ 'Search/filter the issues of someone\'s (or your own) PUBLISHED Rootr (루터) issue tracker through its public address — ' +
311
+ 'what an outside visitor sees. `q` matches title, body, number ("#12" or "12") and, when the tracker shows authors ' +
312
+ '(participation login/anyone), the author name; `labels` = label names (issues having all of them); `state` OPEN|CLOSED; ' +
313
+ '`sort` newest (default) | oldest | comments. Returns { participation, issues:[{number,title,state,body,labels,createdAt,' +
314
+ 'closedAt,commentCount,author}], truncated? } (≤500 per call). No key needed. For a tracker in YOUR workspace prefer ' +
315
+ 'rootr_list_issues (internal fields).',
316
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
317
+ inputSchema: {
318
+ url: PUBLIC_TARGET.url,
319
+ blockId: PUBLIC_TARGET.blockId,
320
+ password: PUBLIC_TARGET.password,
321
+ q: z.string().max(200).optional().describe('Search text'),
322
+ state: z.enum(['OPEN', 'CLOSED', 'ALL']).optional().describe('Default ALL'),
323
+ labels: z.array(z.string().max(100)).max(20).optional().describe('Label names — issues having all of them'),
324
+ sort: z.enum(['newest', 'oldest', 'comments']).optional(),
325
+ },
326
+ },
327
+ async ({ url, blockId, password, q, state, labels, sort }) => {
328
+ try {
329
+ const client = makeClient();
330
+ const slug = slugOf(url);
331
+ const pass = password ? await client.unlockPublicNode(slug, password) : undefined;
332
+ return jsonResult(await client.searchPublicIssues(slug, { blockId, q, state, labels, sort, pass }));
333
+ } catch (err) {
334
+ return errorResult(err);
335
+ }
336
+ },
337
+ );
338
+
339
+ server.registerTool(
340
+ 'rootr_submit_public_issue',
341
+ {
342
+ title: 'File an issue on a PUBLISHED tracker (outside participation)',
343
+ description:
344
+ 'File an issue on someone\'s (or your own) PUBLISHED Rootr (루터) issue tracker through its public address — the ' +
345
+ 'way an outside person or an external agent reports a bug/request. Works only when the tracker\'s participation ' +
346
+ 'allows it: "login" (your account key counts as signed in) or "anyone". Errors: 403 PUBLIC_READ_ONLY (보기만), ' +
347
+ '401 LOGIN_REQUIRED, 400 GUEST_NAME_REQUIRED, 401 PUBLIC_DOC_PASSWORD (pass `password`). Text and `files` only — labels, ' +
348
+ 'assignee and state cannot be set from outside, and @mentions are stripped. `files` are uploaded to that tracker first ' +
349
+ 'and appended to the body (415 ATTACHMENT_TYPE_NOT_ALLOWED / ATTACHMENT_CONTENT_MISMATCH, 413 ATTACHMENT_TOO_LARGE, ' +
350
+ '403 TRACKER_STORAGE_FULL). Returns { ok, number }. For a tracker in YOUR workspace prefer rootr_create_issue (full fields).',
351
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
352
+ inputSchema: {
353
+ ...PUBLIC_TARGET,
354
+ title: z.string().max(200).describe('Issue title (≤200)'),
355
+ body: z.string().max(20000).optional().describe('Issue body, markdown (≤20,000)'),
356
+ files: PUBLIC_FILES,
357
+ },
358
+ },
359
+ async ({ url, blockId, guestName, asGuest, password, title, body, files }) => {
360
+ try {
361
+ const client = makeClient();
362
+ const slug = slugOf(url);
363
+ const pass = password ? await client.unlockPublicNode(slug, password) : undefined;
364
+ const lines = await uploadPublicFiles(client, slug, files, { blockId, guestName, pass, asGuest });
365
+ const full = withPublicAttachments(body || '', lines);
366
+ return jsonResult(await client.submitPublicIssue(slug, { blockId, title, body: full || undefined, guestName, pass, asGuest }));
367
+ } catch (err) {
368
+ return errorResult(err);
369
+ }
370
+ },
371
+ );
372
+
373
+ server.registerTool(
374
+ 'rootr_comment_public_issue',
375
+ {
376
+ title: 'Comment on an issue of a PUBLISHED tracker (outside participation)',
377
+ description:
378
+ 'Comment on issue #number of a PUBLISHED Rootr (루터) issue tracker through its public address (same rules as ' +
379
+ 'rootr_submit_public_issue, `files` too). Pass `read: true` instead of `body` to just read that issue\'s public comments. ' +
380
+ 'Returns { ok, id } (or { number, comments } when reading).',
381
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
382
+ inputSchema: {
383
+ ...PUBLIC_TARGET,
384
+ number: z.number().int().positive().describe('Issue number (#N)'),
385
+ body: z.string().max(10000).optional().describe('Comment, markdown (≤10,000). Required unless read is true or files are given'),
386
+ files: PUBLIC_FILES,
387
+ read: z.boolean().optional().describe('Only read the public comments of this issue'),
388
+ },
389
+ },
390
+ async ({ url, blockId, guestName, asGuest, password, number, body, files, read }) => {
391
+ try {
392
+ const client = makeClient();
393
+ const slug = slugOf(url);
394
+ const pass = password ? await client.unlockPublicNode(slug, password) : undefined;
395
+ if (read) return jsonResult(await client.readPublicIssue(slug, number, { blockId, pass }));
396
+ if (!body && !(files && files.length)) throw new Error('body is required (or files, or read: true)');
397
+ const lines = await uploadPublicFiles(client, slug, files, { blockId, guestName, pass, asGuest });
398
+ return jsonResult(await client.commentPublicIssue(slug, number, { blockId, body: withPublicAttachments(body || '', lines), guestName, pass, asGuest }));
399
+ } catch (err) {
400
+ return errorResult(err);
401
+ }
402
+ },
403
+ );
173
404
  }
@@ -49,6 +49,8 @@ const BLOCK_GUIDE =
49
49
  '· booking {bookingNodeId(DATABASE node id — a table that is published with visitor booking turned on, see rootr_set_booking), headline?, type?} — ' +
50
50
  'a visitor scheduling calendar right on the page; `type` shows only one of the table\'s booking types. ' +
51
51
  'bookingNodeId must be the node ID (names/paths are not resolved here); a table that is not published or has booking off renders as "not taking bookings"\n' +
52
+ '· issues {trackerNodeId(ISSUE_TRACKER id, unique name or "/path"), headline?} — an issue tracker on the page: visitors read issues and comments and, ' +
53
+ 'when the tracker\'s publicParticipation (rootr_update_issue_tracker) is "login" or "anyone", file issues and comment right there — the tracker itself need not be published\n' +
52
54
  '· custom {html, height?(40-4000, default 260), minHeight?:"none"|"screen", bleed?:boolean} — free HTML/CSS, rendered inside a locked-down sandboxed iframe ' +
53
55
  '(no scripts, no same-origin, no network). Use it for a look the other blocks cannot make. Page theme colors are ' +
54
56
  'available inside as var(--pg-bg|text|muted|faint|card|line|accent|on-accent) and var(--pg-font). Scripts and ' +
@@ -14,6 +14,16 @@ const DB_DESIGN_SCHEMA = z.object({
14
14
  skin: z.enum(['clean', 'paper', 'dark', 'vivid', 'bento', 'brutal']).optional(),
15
15
  });
16
16
 
17
+ const PARTICIPATION = z
18
+ .enum(['read', 'login', 'anyone'])
19
+ .optional()
20
+ .describe(
21
+ 'ISSUE_TRACKER only — who OUTSIDE the workspace may write on the public page (외부 참여): ' +
22
+ '"read" 보기만 (default), "login" 로그인한 사람만 작성 (any Rootr account files issues and comments), ' +
23
+ '"anyone" 누구나 작성 (no account — visitors type a name, shown as "이름 (외부)"). Stored on the tracker itself, ' +
24
+ 'so a tracker placed on a published PAGE (`issues` block) follows the same setting. A password lock locks writing too.',
25
+ );
26
+
17
27
  /**
18
28
  * PUBLIC-ANY — **어떤 타입이든 공개 발행한다**(창업자 지시 2026-08-27).
19
29
  *
@@ -45,7 +55,8 @@ export function registerPublicTools(server) {
45
55
  'its rows, form responses, recording audio, CRM contacts and deal amounts, and signer names on a signed ' +
46
56
  'document. Rows/entries are capped at 500 per page and the page says so when it truncates. ' +
47
57
  'Owner-level action (MANAGE): use an ACCOUNT key (PAT), not a workspace-scoped/connector token. ' +
48
- 'Safe to call again — the slug never changes, so links already shared stay valid.',
58
+ 'Safe to call again — the slug never changes, so links already shared stay valid. ' +
59
+ 'ISSUE_TRACKER: pass `participation` ("anyone" = outside feedback box) to let visitors file issues and comment.',
49
60
  annotations: { readOnlyHint: false, destructiveHint: false },
50
61
  inputSchema: {
51
62
  target: z.string().describe(`Node id${NODE_TARGET_HINT}`),
@@ -64,13 +75,14 @@ export function registerPublicTools(server) {
64
75
  'leaves the layout alone. Layouts that need a column (calendar/timeline need a date, board a ' +
65
76
  'select/status, ranking a number, chart either) fall back to the table and say why.',
66
77
  ),
78
+ participation: PARTICIPATION,
67
79
  },
68
80
  },
69
- async ({ target, includeChildren, view, design }) => {
81
+ async ({ target, includeChildren, view, design, participation }) => {
70
82
  try {
71
83
  const client = makeClient();
72
84
  const id = await resolveNodeId(client, target);
73
- return jsonResult(await client.publishNode(id, { includeChildren, view, design }));
85
+ return jsonResult(await client.publishNode(id, { includeChildren, view, design, participation }));
74
86
  } catch (err) {
75
87
  return errorResult(err);
76
88
  }
@@ -97,6 +109,8 @@ export function registerPublicTools(server) {
97
109
  '`{"seoDescription": "...", "seoImage": "<attachment id>"}` sets the search-result description and link-preview ' +
98
110
  'image (search engines are pinged right away). ' +
99
111
  '`{"isPublic": true}` on a PAGE needs the Team plan (Free → 403 PLAN_UPGRADE_REQUIRED); closing it works on any plan. ' +
112
+ '`{"participation": "anyone"}` (ISSUE_TRACKER only) opens the public page to outside issues/comments — ' +
113
+ '"read" | "login" | "anyone"; on a tracker a password lock also locks writing. ' +
100
114
  'For DOCUMENT nodes use rootr_set_document_public instead — documents keep their own settings (skins, ' +
101
115
  'edit proposals) on the older path. Owner-level action (MANAGE): use an account key (PAT).',
102
116
  annotations: { readOnlyHint: false, destructiveHint: false },
@@ -125,6 +139,7 @@ export function registerPublicTools(server) {
125
139
  'board, timeline, calendar, chart, ranking. `skin` — clean, paper, dark, vivid, bento, brutal. ' +
126
140
  'Only the keys you send change.',
127
141
  ),
142
+ participation: PARTICIPATION,
128
143
  },
129
144
  },
130
145
  async ({ target, ...settings }) => {
@@ -152,7 +167,8 @@ export function registerPublicTools(server) {
152
167
  'address is closed right now — do NOT hand that URL out; tell the user why and how to reopen it. ' +
153
168
  '`closed: "PAGE_PUBLISH_REQUIRES_TEAM"` means a PAGE was published but its workspace is on the Free plan, so ' +
154
169
  'visitors see a "not published" screen — upgrading to Team reopens the same URL without publishing again. ' +
155
- 'Call this before publishing so you do not tell the user a page is new when it already had a URL.',
170
+ 'Call this before publishing so you do not tell the user a page is new when it already had a URL. ' +
171
+ 'An ISSUE_TRACKER also returns `participation` (read 보기만 | login 로그인한 사람만 작성 | anyone 누구나 작성).',
156
172
  annotations: { readOnlyHint: true, destructiveHint: false },
157
173
  inputSchema: {
158
174
  target: z.string().describe(`Node id${NODE_TARGET_HINT}`),
@@ -184,22 +184,33 @@ export function registerSignedDocTools(server) {
184
184
  title: 'Turn a signed document into a research note',
185
185
  description:
186
186
  'Switch a SIGNED document to RESEARCH_NOTE mode and build the two-step line the Korean national R&D ' +
187
- 'guideline requires: 기재자(the caller) → 점검자(the reviewer/연구책임자). Optionally stores the cover ' +
188
- 'metadata (과제번호·과제명·기관). Use this instead of hand-building the approval line for 연구노트.',
187
+ 'guideline requires: 기재자 → 점검자(the reviewer/연구책임자). Optionally stores the cover ' +
188
+ 'metadata (과제번호·과제명·기관). Use this instead of hand-building the approval line for 연구노트. ' +
189
+ 'When calling with a session login, 기재자 defaults to the caller and authorUserId can be omitted. ' +
190
+ 'When calling with a WORKSPACE API KEY (no logged-in user), authorUserId is REQUIRED — pass the ' +
191
+ '기재자의 userId explicitly (it may be the same person as reviewerUserId).',
189
192
  annotations: { readOnlyHint: false, destructiveHint: true },
190
193
  inputSchema: {
191
194
  nodeId: z.string().describe(`서명문서 노드 id${NODE_TARGET_HINT}`),
192
195
  reviewerUserId: z.string().describe('점검자(연구책임자)의 userId'),
196
+ authorUserId: z
197
+ .string()
198
+ .optional()
199
+ .describe(
200
+ '기재자의 userId. 세션 로그인이면 생략 시 호출자 본인이 들어간다. ' +
201
+ 'API 키로 부를 때는 필수 — 점검자와 같은 사람이어도 된다.',
202
+ ),
193
203
  noteMeta: z.record(z.any()).optional().describe('표지 정보: 과제번호·과제명·연구책임자·기관 등'),
194
204
  workspace: z.string().optional().describe('워크스페이스 id (생략하면 설정값)'),
195
205
  },
196
206
  },
197
- async ({ nodeId, reviewerUserId, noteMeta, workspace }) => {
207
+ async ({ nodeId, reviewerUserId, authorUserId, noteMeta, workspace }) => {
198
208
  try {
199
209
  const client = makeClient(workspace);
200
210
  return jsonResult(
201
211
  await client.applyResearchNotePreset(await resolveNodeId(client, nodeId), {
202
212
  reviewerUserId,
213
+ authorUserId,
203
214
  noteMeta,
204
215
  }),
205
216
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.32.0",
3
+ "version": "0.34.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": {