rootr-cli 0.31.0 → 0.33.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) 서버를 구동합니다. 제공 도구 220종:
409
409
 
410
410
  노드를 가리키는 인자(`target`, `databaseId`, `spreadsheetId`, `formId`, `crmId`, `trackerId`,
411
411
  `recordingId`, `presentationId`, `whiteboardId`, `logId`, `parentId` 등)는 **노드 id와 경로를 모두**
@@ -487,6 +487,13 @@ 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`) |
492
+ | `rootr_comment_public_issue` | 웹게시한 트래커의 이슈에 댓글(`read:true` 면 공개 댓글 읽기) |
493
+
494
+ CLI: `rootr issue tracker <트래커> --participation anyone` · `rootr issue list|create|comment …` ·
495
+ `rootr issue submit <공개 주소> --title "…" --name <이름>` · `rootr issue reply <공개 주소> <#번호> "<댓글>"`.
496
+ 외부 참여는 **트래커 설정**이라 페이지(PAGE)에 `issues` 블록으로 끼운 트래커도 같은 규칙을 따릅니다.
490
497
 
491
498
  #### 웹훅 (webhooks:manage 스코프)
492
499
 
@@ -713,7 +720,7 @@ rootr db column <표> 특이사항 --wrap on --align center # 긴 글 줄바꿈
713
720
  | 도구 | 설명 |
714
721
  |---|---|
715
722
  | `rootr_publish_node` | **아무 노드나** 공개 — 문서·표·폼·시트·화이트보드·덱·페이지, 하위까지. **페이지(PAGE)는 Team 플랜부터**(무료는 403 `PLAN_UPGRADE_REQUIRED`, 하위 포함 시 페이지는 건너뛰고 개수를 알려 줌) |
716
- | `rootr_set_node_public` | 이미 공개한 것의 설정 변경(하위 포함 여부·검색 노출·만료) |
723
+ | `rootr_set_node_public` | 이미 공개한 것의 설정 변경(하위 포함 여부·검색 노출·만료·비밀번호, 이슈트래커는 `participation` 외부 참여) |
717
724
  | `rootr_node_public_status` | 지금 공개돼 있는지, 주소는 무엇인지 |
718
725
  | `rootr_publish_document` | 문서 전용 발행(구형 — 새로 짤 때는 `rootr_publish_node`) |
719
726
  | `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, **220 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,6 +296,22 @@ 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
301
+ ```
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 reply https://rootr.io/ko/p/AbCd123 12 "Same here" --name "Lee"
299
315
  ```
300
316
 
301
317
  Every node type publishes on the Free plan **except PAGE** (the block-built web page): publishing a
@@ -517,7 +533,7 @@ is refused and says when the grace was used.
517
533
 
518
534
  ## MCP tools
519
535
 
520
- `rootr mcp` speaks MCP over stdio. 217 tools:
536
+ `rootr mcp` speaks MCP over stdio. 220 tools:
521
537
 
522
538
  | Group | Count | Tools |
523
539
  |---|---|---|
@@ -532,7 +548,7 @@ is refused and says when the grace was used.
532
548
  | 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
549
  | Spreadsheets | 7 | create, read, update, per-sheet create/update/delete, patch cells & formulas |
534
550
  | Recordings | 7 | create, upload & download audio, transcribe, summarize, rename speakers |
535
- | Issue tracker | 6 | create tracker, list / create / get / update issue, comment |
551
+ | 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
552
  | Permissions & visibility | 10 | grant / revoke node permissions, read access, visibility, public status, read requirements |
537
553
  | LOG datastores | 5 | create store, update fields, add entries, query, stats |
538
554
  | Templates | 4 | list, apply, save as template, update |
package/bin/rootr.js CHANGED
@@ -43,8 +43,8 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
43
43
  rootr db button-run <path|id> <buttonId> [--rows id,id] [--input 글|--input-file f|--input -] 버튼 한 번 누르기 (행 만들기·값 채우기·AI 채우기 · 글 받는 버튼이면 --input)
44
44
  rootr db automations <path|id> [--file rules.json] [--clear] 자동 규칙 — "값이 이렇게 되면 이 일을 해라"·정해진 시각
45
45
  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] 촘촘하게·왼쪽 칸 고정
47
- rootr db view <path|id> <뷰이름> [--row-hidden on|off] [--quick "칸,칸"] [--entry-row on|off] [--day-pager 날짜칸|off] 행 열면 감춘 칸 보이기·필터 맨 위 고정 칸·맨 아래 입력 줄·하루씩 보기
46
+ 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
+ rootr db view <path|id> <뷰이름> [--row-hidden on|off] [--quick "칸,칸"] [--entry-row on|off|top|bottom] [--personal-filters on|off] [--day-pager 날짜칸|off] 행 열면 감춘 칸 보이기·필터 맨 위 고정 칸·맨 아래 입력 줄·하루씩 보기
48
48
  rootr db column <path|id> <칸> [--align left|center|right] [--wrap on|off] [--overdue on|off] [--time on|off] [--range on|off] 칸 정렬·줄바꿈·지난 날짜 빨강·시간·기간
49
49
  rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] 행 추가(상세 내용 포함)
50
50
  rootr db row-body <path|id> <rowId> [--file <f>] 행의 상세 내용(본문 메모) 쓰기 — 없으면 표준입력
@@ -75,6 +75,15 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
75
75
  rootr publish <표> --layout board --skin paper 공개한 표를 무슨 꼴로 보일지 (배치 9종 × 분위기 6종)
76
76
  rootr publish <문서> --comments [false] [--expires <ISO>] [--skin <분위기>] 문서 전용 — 댓글(편집 제안)·만료일·스킨 16종
77
77
  rootr publish <path|id> --description "<글>" --cover <그림|id|none> 검색 설명문·링크 미리보기 대표 이미지 (검색엔진에 바로 알림)
78
+ rootr publish <path|id> --password <비번> | --no-password 공개 주소 잠그기(Team 부터)·풀기 — 이슈트래커는 쓰기도 같이 잠김
79
+ rootr publish <이슈트래커> --participation read|login|anyone 외부 참여 — 보기만·로그인한 사람만 작성·누구나 작성(이름만 적고)
80
+ rootr issue tracker <트래커> [--participation read|login|anyone] [--json] 트래커 설정 보기·외부 참여 바꾸기(MANAGE)
81
+ rootr issue list <트래커> [--state OPEN|CLOSED|ALL] [--q <검색어>] [--json] 이슈 목록
82
+ rootr issue create <트래커> --title "<제목>" [--body "<본문>"] 이슈 올리기(내 계정으로)
83
+ rootr issue comment <트래커> <#번호> "<댓글>" 이슈에 댓글
84
+ rootr issue submit <공개 주소> --title "<제목>" [--body …] [--name <이름>] [--block <블록 id>] [--password <비번>]
85
+ 웹게시한 트래커에 밖에서 이슈 올리기(--name 이면 키 없이 손님으로, 페이지 블록은 --block)
86
+ rootr issue reply <공개 주소> <#번호> "<댓글>" [--name <이름>] [--block <블록 id>] [--password <비번>] 웹게시한 트래커 이슈에 댓글
78
87
  rootr booking show <표> 방문자 일정 등록(예약) 설정 보기
79
88
  rootr booking set <표> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 예약 켜기·규칙
80
89
  rootr booking set <표> --ask "연락처:required" --approval manual 방문자에게 더 묻기 · 승인 후 확정
@@ -146,7 +155,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
146
155
  rootr memory add "<내용>" [--kind 지시|사람|프로젝트|참고] [--source <경로|id>] [--related a,b] 기억 저장
147
156
  rootr memory edit <id> [--content "<내용>"] [--kind …] [--source …] [--related a,b] 기억 고치기(준 칸만)
148
157
  rootr memory forget <id> [--hard] 기억 폐기(--hard면 행 삭제)
149
- rootr mcp MCP stdio 서버 구동 (도구 214종 — README 참고)
158
+ rootr mcp MCP stdio 서버 구동 (도구 220종 — README 참고)
150
159
  rootr --help 도움말
151
160
  rootr --version 버전 출력
152
161
 
@@ -186,8 +195,8 @@ usage:
186
195
  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
196
  rootr db automations <path|id> [--file rules.json] [--clear] automatic rules — "when a value becomes X, do Y", or at a set time
188
197
  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
190
- rootr db view <path|id> <view name> [--row-hidden on|off] [--quick "field,field"] [--entry-row on|off] [--day-pager <date field>|off] hidden fields shown in the row page · pinned filters · entry row · one day at a time
198
+ 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
199
+ 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
200
  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
201
  rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] add a row (with its body)
193
202
  rootr db row-body <path|id> <rowId> [--file <f>] write a row's body — reads stdin if no file
@@ -220,6 +229,15 @@ usage:
220
229
  rootr publish <table> --layout board --skin paper how a published table looks (9 layouts × 6 skins)
221
230
  rootr publish <doc> --comments [false] [--expires <ISO>] [--skin <skin>] documents only — comments, expiry, 16 skins
222
231
  rootr publish <path|id> --description "<text>" --cover <image|id|none> search description & link-preview cover image (search engines pinged)
232
+ rootr publish <path|id> --password <pw> | --no-password lock (Team plan) / unlock the public address — on a tracker writing is locked too
233
+ rootr publish <issue tracker> --participation read|login|anyone outside participation — read-only · signed-in users write · anyone writes (just a name)
234
+ rootr issue tracker <tracker> [--participation read|login|anyone] [--json] show tracker settings / change outside participation (MANAGE)
235
+ rootr issue list <tracker> [--state OPEN|CLOSED|ALL] [--q <text>] [--json] list issues
236
+ rootr issue create <tracker> --title "<title>" [--body "<body>"] file an issue (as your account)
237
+ rootr issue comment <tracker> <#number> "<comment>" comment on an issue
238
+ rootr issue submit <public URL> --title "<title>" [--body …] [--name <name>] [--block <block id>] [--password <pw>]
239
+ file an issue on a PUBLISHED tracker from outside (--name = as a guest, no key; --block for a tracker on a page)
240
+ rootr issue reply <public URL> <#number> "<comment>" [--name <name>] [--block <block id>] [--password <pw>] comment on an issue of a published tracker
223
241
  rootr booking show <table> show visitor booking settings
224
242
  rootr booking set <table> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 turn on + rules
225
243
  rootr booking set <table> --ask "Phone:required" --approval manual ask more · confirm after approval
@@ -287,7 +305,7 @@ usage:
287
305
  rootr memory add "<content>" [--kind instruction|person|project|reference] [--source <path|id>] [--related a,b] save a memory
288
306
  rootr memory edit <id> [--content "<content>"] [--kind …] [--source …] [--related a,b] edit a memory (only the fields you pass)
289
307
  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)
308
+ rootr mcp run the MCP stdio server (220 tools — see the README)
291
309
  rootr --help this help
292
310
  rootr --version print the version
293
311
 
@@ -2161,6 +2179,130 @@ function flagToBool(v) {
2161
2179
  return v !== 'false' && v !== '0';
2162
2180
  }
2163
2181
 
2182
+ /** ISSUE-PUBLIC — 외부 참여(서버 issue-schema.ts PUBLIC_PARTICIPATION_MODES 와 같은 값). */
2183
+ const PARTICIPATION_MODES = ['read', 'login', 'anyone'];
2184
+
2185
+ function participationLabel(mode) {
2186
+ return {
2187
+ read: tr('보기만', 'read only'),
2188
+ login: tr('로그인한 사람만 작성', 'signed-in users write'),
2189
+ anyone: tr('누구나 작성(이름만 적고)', 'anyone writes (just a name)'),
2190
+ }[mode] ?? mode;
2191
+ }
2192
+
2193
+ /** 공개 주소(rootr.io/…/p/{slug}) 또는 slug 그대로. */
2194
+ function publicSlugOf(target) {
2195
+ const m = /\/p\/([^/?#\s]+)/.exec(String(target || ''));
2196
+ return m ? decodeURIComponent(m[1]) : String(target || '').trim();
2197
+ }
2198
+
2199
+ /** GET /issue-trackers/:id/issues 는 `{ issues: [...] }` 로 답한다(배열이 아니다 — 3100 실측에서 `find is not a function`). */
2200
+ function issuesOf(res) {
2201
+ return Array.isArray(res) ? res : Array.isArray(res?.issues) ? res.issues : [];
2202
+ }
2203
+
2204
+ function issueNumberOf(raw) {
2205
+ const n = Number(String(raw ?? '').replace(/^#/, ''));
2206
+ if (!Number.isSafeInteger(n) || n < 1) throw new Error(tr(`이슈 번호가 아닙니다: ${raw}`, `Not an issue number: ${raw}`));
2207
+ return n;
2208
+ }
2209
+
2210
+ /**
2211
+ * ISSUE-PUBLIC — 이슈트래커 명령(창업자 지시 2026-11-02 "api cli mcp scaffold 에도 적용"). 예전엔 CLI 에 이슈 명령이 아예 없었다.
2212
+ * · 안에서: tracker(설정·외부 참여) · list · create · comment
2213
+ * · 밖에서(웹게시한 트래커): submit · reply — 키가 있으면 그 계정으로, --name 이면 키 없이 손님으로
2214
+ */
2215
+ async function cmdIssue(args) {
2216
+ const sub = args._[0];
2217
+ const client = makeClient();
2218
+ if (sub === 'tracker') {
2219
+ const target = args._[1];
2220
+ if (!target) throw new Error(usage('rootr issue tracker <path|id> [--participation read|login|anyone] [--json]'));
2221
+ const id = await resolveNodeId(client, target);
2222
+ let t;
2223
+ if (args.participation !== undefined) {
2224
+ if (!PARTICIPATION_MODES.includes(args.participation)) {
2225
+ throw new Error(tr(`--participation 은 다음 중 하나여야 합니다: ${PARTICIPATION_MODES.join(', ')}`, `--participation must be one of: ${PARTICIPATION_MODES.join(', ')}`));
2226
+ }
2227
+ t = await client.updateIssueTracker(id, { publicParticipation: args.participation });
2228
+ } else {
2229
+ t = await client.getIssueTracker(id);
2230
+ }
2231
+ if (args.json) return console.log(JSON.stringify(t, null, 2));
2232
+ const mode = (t.config && t.config.publicParticipation) || 'read';
2233
+ console.log(`${t.name} (${t.path})`);
2234
+ console.log(tr(` 열림 ${t.openCount} · 닫힘 ${t.closedCount}`, ` open ${t.openCount} · closed ${t.closedCount}`));
2235
+ console.log(tr(` 외부 참여: ${participationLabel(mode)}`, ` outside participation: ${participationLabel(mode)}`));
2236
+ console.log(tr(` 라벨: ${(t.labels || []).map((l) => l.name).join(', ') || '없음'}`, ` labels: ${(t.labels || []).map((l) => l.name).join(', ') || 'none'}`));
2237
+ if (mode !== 'read') {
2238
+ const st = await client.nodePublicStatus(id);
2239
+ if (!st.isPublic) {
2240
+ 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.'));
2241
+ } else {
2242
+ console.log(tr(` 공개 주소: /p/${st.slug}`, ` public address: /p/${st.slug}`));
2243
+ }
2244
+ }
2245
+ return;
2246
+ }
2247
+ if (sub === 'list') {
2248
+ const target = args._[1];
2249
+ if (!target) throw new Error(usage('rootr issue list <path|id> [--state OPEN|CLOSED|ALL] [--q <text>] [--json]'));
2250
+ const id = await resolveNodeId(client, target);
2251
+ const list = issuesOf(await client.listIssues(id, { state: args.state, q: typeof args.q === 'string' ? args.q : undefined }));
2252
+ if (args.json) return console.log(JSON.stringify(list, null, 2));
2253
+ if (!list.length) return console.log(tr('이슈가 없습니다.', 'No issues.'));
2254
+ for (const i of list) {
2255
+ const who = i.guestName ? tr(`${i.guestName} (외부)`, `${i.guestName} (guest)`) : i.author?.name ?? '';
2256
+ console.log(`#${i.number}\t${i.state}\t${i.title}${who ? `\t${who}` : ''}${i.commentCount ? `\t💬${i.commentCount}` : ''}`);
2257
+ }
2258
+ return;
2259
+ }
2260
+ if (sub === 'create') {
2261
+ const target = args._[1];
2262
+ if (!target || typeof args.title !== 'string') throw new Error(usage('rootr issue create <path|id> --title "<title>" [--body "<body>"]'));
2263
+ const id = await resolveNodeId(client, target);
2264
+ const out = await client.createIssue(id, { title: args.title, body: typeof args.body === 'string' ? args.body : undefined });
2265
+ console.log(tr(`올렸습니다: #${out.number} ${out.title}`, `Filed: #${out.number} ${out.title}`));
2266
+ return;
2267
+ }
2268
+ if (sub === 'comment') {
2269
+ const [, target, num, ...rest] = args._;
2270
+ const text = rest.join(' ').trim();
2271
+ if (!target || !num || !text) throw new Error(usage('rootr issue comment <path|id> <#number> "<comment>"'));
2272
+ const id = await resolveNodeId(client, target);
2273
+ const n = issueNumberOf(num);
2274
+ const hit = issuesOf(await client.listIssues(id, { state: 'ALL', q: `#${n}` })).find((i) => i.number === n);
2275
+ if (!hit) throw new Error(tr(`#${n} 이슈가 없습니다.`, `Issue #${n} not found.`));
2276
+ await client.commentIssue(id, hit.id, text);
2277
+ console.log(tr(`#${n} 에 댓글을 달았습니다.`, `Commented on #${n}.`));
2278
+ return;
2279
+ }
2280
+ if (sub === 'submit' || sub === 'reply') {
2281
+ const slug = publicSlugOf(args._[1]);
2282
+ if (!slug) throw new Error(usage(`rootr issue ${sub} <public URL> …`));
2283
+ const asGuest = typeof args.name === 'string';
2284
+ const opts = {
2285
+ blockId: typeof args.block === 'string' ? args.block : undefined,
2286
+ guestName: asGuest ? args.name : undefined,
2287
+ asGuest,
2288
+ pass: typeof args.password === 'string' ? await client.unlockPublicNode(slug, args.password) : undefined,
2289
+ };
2290
+ if (sub === 'submit') {
2291
+ if (typeof args.title !== 'string') throw new Error(usage('rootr issue submit <public URL> --title "<title>" [--body …] [--name <name>] [--block <id>]'));
2292
+ const out = await client.submitPublicIssue(slug, { ...opts, title: args.title, body: typeof args.body === 'string' ? args.body : undefined });
2293
+ console.log(out?.number ? tr(`올렸습니다: #${out.number}`, `Filed: #${out.number}`) : tr('올렸습니다.', 'Filed.'));
2294
+ return;
2295
+ }
2296
+ const [, , num, ...rest] = args._;
2297
+ const text = rest.join(' ').trim();
2298
+ if (!num || !text) throw new Error(usage('rootr issue reply <public URL> <#number> "<comment>" [--name <name>] [--block <id>]'));
2299
+ await client.commentPublicIssue(slug, issueNumberOf(num), { ...opts, body: text });
2300
+ console.log(tr('댓글을 달았습니다.', 'Comment posted.'));
2301
+ return;
2302
+ }
2303
+ throw new Error(usage('rootr issue tracker|list|create|comment|submit|reply …'));
2304
+ }
2305
+
2164
2306
  async function cmdPublish(args) {
2165
2307
  const target = args._[0];
2166
2308
  if (!target) {
@@ -2219,6 +2361,7 @@ async function cmdPublish(args) {
2219
2361
  printSeo(st.publicConfig?.seo);
2220
2362
  } else {
2221
2363
  if (st.hasPassword) console.log(tr(' 비밀번호 걸려 있음', ` password protected`));
2364
+ if (st.participation) console.log(tr(` 외부 참여: ${participationLabel(st.participation)}`, ` outside participation: ${participationLabel(st.participation)}`));
2222
2365
  console.log(tr(` 하위 문서도 공개: ${st.includeChildren ? '예' : '아니오'}`, ` children published too: ${st.includeChildren ? 'yes' : 'no'}`));
2223
2366
  console.log(tr(` 복제 허용: ${st.allowClone === false ? '아니오' : '예'}`, ` cloning allowed: ${st.allowClone === false ? 'no' : 'yes'}`));
2224
2367
  if (st.design && (st.design.layout || st.design.skin)) {
@@ -2274,12 +2417,22 @@ async function cmdPublish(args) {
2274
2417
  if (design.skin && !DB_SKINS.includes(design.skin)) {
2275
2418
  throw new Error(tr(`--skin 은 다음 중 하나여야 합니다: ${DB_SKINS.join(', ')}`, `--skin must be one of: ${DB_SKINS.join(', ')}`));
2276
2419
  }
2420
+ // ISSUE-PUBLIC — 이슈트래커의 외부 참여(트래커 설정에 저장 — 페이지에 끼운 블록도 같은 값을 따른다)
2421
+ if (args.participation !== undefined && !PARTICIPATION_MODES.includes(args.participation)) {
2422
+ throw new Error(tr(`--participation 은 다음 중 하나여야 합니다: ${PARTICIPATION_MODES.join(', ')}`, `--participation must be one of: ${PARTICIPATION_MODES.join(', ')}`));
2423
+ }
2277
2424
  const res = await client.publishNode(id, {
2278
2425
  ...(args.children ? { includeChildren: true } : {}),
2279
2426
  ...(typeof args.view === 'string' ? { view: args.view } : {}),
2280
2427
  ...(Object.keys(design).length ? { design } : {}),
2428
+ ...(typeof args.participation === 'string' ? { participation: args.participation } : {}),
2281
2429
  });
2282
2430
  console.log(tr(`공개했습니다: /p/${res.slug}`, `Published: /p/${res.slug}`));
2431
+ if (res.participation) console.log(tr(` 외부 참여: ${participationLabel(res.participation)}`, ` outside participation: ${participationLabel(res.participation)}`));
2432
+ if (typeof args.password === 'string' || args['no-password']) {
2433
+ await client.setNodePublic(id, { password: typeof args.password === 'string' ? args.password : null });
2434
+ console.log(typeof args.password === 'string' ? tr(' 비밀번호를 걸었습니다(쓰기도 같이 잠김).', ' Locked with a password (writing too).') : tr(' 비밀번호를 풀었습니다.', ' Password removed.'));
2435
+ }
2283
2436
  if (args.children) console.log(tr(' 이 문서 아래 전부(손자·증손자 포함)도 각자 주소로 공개됩니다.', ` Everything under this document (grandchildren included) gets its own public address.`));
2284
2437
  if (res.skippedPagesRequireTeam) {
2285
2438
  // PAGE-PUBLISH-TEAM — 무료 워크스페이스에서는 아래의 페이지(PAGE)는 따라 나가지 않는다.
@@ -2987,6 +3140,17 @@ async function cmdDbView(args) {
2987
3140
  if (!['compact', 'normal'].includes(density)) throw new Error(tr('--density 는 compact · normal 중 하나여야 합니다.', `--density must be compact or normal.`));
2988
3141
  patch.density = density;
2989
3142
  }
3143
+ // VIEW-GRID-LINES — 칸 선(rows 가로선만 · all 칸마다 · none)·진하기(light · strong).
3144
+ if (args.lines !== undefined) {
3145
+ const lines = String(args.lines);
3146
+ if (!['rows', 'all', 'none'].includes(lines)) throw new Error(tr('--lines 는 rows · all · none 중 하나여야 합니다.', `--lines must be rows, all or none.`));
3147
+ patch.gridLines = lines;
3148
+ }
3149
+ if (args['line-strength'] !== undefined) {
3150
+ const strength = String(args['line-strength']);
3151
+ if (!['light', 'strong'].includes(strength)) throw new Error(tr('--line-strength 는 light · strong 중 하나여야 합니다.', `--line-strength must be light or strong.`));
3152
+ patch.lineStrength = strength;
3153
+ }
2990
3154
  if (args.freeze !== undefined) {
2991
3155
  const n = Number(args.freeze);
2992
3156
  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)`));
@@ -3001,7 +3165,11 @@ async function cmdDbView(args) {
3001
3165
  throw new Error(tr(`--${flag} 는 on 또는 off`, `--${flag} must be on or off`));
3002
3166
  };
3003
3167
  if (args['row-hidden'] !== undefined) patch.rowPaneShowHidden = onOff('row-hidden', args['row-hidden']);
3004
- if (args['entry-row'] !== undefined) patch.entryRow = onOff('entry-row', args['entry-row']);
3168
+ if (args['personal-filters'] !== undefined) patch.filtersPersonalOnly = onOff('personal-filters', args['personal-filters']);
3169
+ if (args['entry-row'] !== undefined) {
3170
+ const v = String(args['entry-row']).toLowerCase();
3171
+ patch.entryRow = v === 'top' || v === 'bottom' ? v : onOff('entry-row', args['entry-row']);
3172
+ }
3005
3173
  if (typeof args.quick === 'string') {
3006
3174
  const quick = args.quick.split(',').map((c) => c.trim()).filter(Boolean);
3007
3175
  patch.quickFilters = quick.length ? quick : null;
@@ -3011,7 +3179,7 @@ async function cmdDbView(args) {
3011
3179
  patch.dayPager = !v || v === 'off' || v === 'false' ? null : { dateProp: v };
3012
3180
  }
3013
3181
  if (!Object.keys(patch).length) {
3014
- 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 또는 --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, or --day-pager <date field>|off`));
3182
+ 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`));
3015
3183
  }
3016
3184
 
3017
3185
  const view = await client.updateDatabaseView(id, viewRef, patch);
@@ -4148,6 +4316,9 @@ async function main() {
4148
4316
  return cmdForm(args);
4149
4317
  case 'publish':
4150
4318
  return cmdPublish(args);
4319
+ case 'issue':
4320
+ case 'issues':
4321
+ return cmdIssue(args);
4151
4322
  case 'booking':
4152
4323
  return cmdBooking(args);
4153
4324
  case 'gcal':
package/lib/client.js CHANGED
@@ -165,11 +165,12 @@ export class RootrClient {
165
165
  * @param {boolean} [opts.returnResponse] - if true, return the raw Response (for header access)
166
166
  */
167
167
  async request(path, opts = {}) {
168
- this._requireApiKey();
168
+ // ISSUE-PUBLIC — `noAuth` 는 공개 주소에 **손님으로** 부를 때(키를 보내면 그 키 주인의 계정으로 쓰인다).
169
+ if (!opts.noAuth) this._requireApiKey();
169
170
 
170
171
  const url = opts.url || `${this.baseUrl}${path}`;
171
172
  const headers = {
172
- 'x-api-key': this.apiKey,
173
+ ...(opts.noAuth ? {} : { 'x-api-key': this.apiKey }),
173
174
  ...this.defaultHeaders,
174
175
  ...(opts.headers || {}),
175
176
  };
@@ -936,10 +937,10 @@ export class RootrClient {
936
937
 
937
938
  // ---- Issues ---------------------------------------------------------------
938
939
 
939
- async createIssueTracker(workspace, name) {
940
+ async createIssueTracker(workspace, name, extra = {}) {
940
941
  return this.request(`/workspaces/${encodeURIComponent(workspace)}/issue-trackers`, {
941
942
  method: 'POST',
942
- body: { name },
943
+ body: { name, ...extra },
943
944
  });
944
945
  }
945
946
 
@@ -947,6 +948,60 @@ export class RootrClient {
947
948
  return this.request(`/issue-trackers/${encodeURIComponent(trackerId)}`);
948
949
  }
949
950
 
951
+ /**
952
+ * 트래커 설정을 고친다 — `{ publicParticipation }`(외부 참여: read·login·anyone)만 보내면 그 값만 바뀐다.
953
+ * `labels`·`types` 는 통째로 바꾼다. 외부 참여를 **바꾸는 것**은 MANAGE(웹게시와 같은 주인 몫)다.
954
+ */
955
+ async updateIssueTracker(trackerId, patch = {}) {
956
+ return this.request(`/issue-trackers/${encodeURIComponent(trackerId)}`, {
957
+ method: 'PATCH',
958
+ body: patch,
959
+ });
960
+ }
961
+
962
+ // ── ISSUE-PUBLIC — 남의(또는 내) **웹게시한** 이슈트래커에 밖에서 쓰기 ──────────────
963
+ // 공개 주소(rootr.io/p/{slug}) 위의 길이다. 키를 보내면 그 키 주인(계정)으로 쓰이고, `asGuest` 면 키 없이 손님 이름으로.
964
+ // 페이지에 끼운 트래커는 페이지 주소 + `blockId` 로 닿는다.
965
+
966
+ /** 잠긴 공개 주소의 비밀번호 → 통과증(`?pass=`). */
967
+ async unlockPublicNode(slug, password) {
968
+ const out = await this.request(`/public/nodes/${encodeURIComponent(slug)}/unlock`, {
969
+ method: 'POST',
970
+ body: { password },
971
+ noAuth: true,
972
+ });
973
+ return out?.token;
974
+ }
975
+
976
+ _publicIssuesBase(slug, blockId) {
977
+ const s = encodeURIComponent(slug);
978
+ return blockId ? `/public/nodes/${s}/issue-blocks/${encodeURIComponent(blockId)}/issues` : `/public/nodes/${s}/issues`;
979
+ }
980
+
981
+ async submitPublicIssue(slug, { blockId, title, body, guestName, pass, asGuest } = {}) {
982
+ const qs = pass ? `?pass=${encodeURIComponent(pass)}` : '';
983
+ return this.request(`${this._publicIssuesBase(slug, blockId)}${qs}`, {
984
+ method: 'POST',
985
+ body: { title, body, ...(guestName ? { guestName } : {}) },
986
+ noAuth: Boolean(asGuest) || !this.apiKey,
987
+ });
988
+ }
989
+
990
+ async commentPublicIssue(slug, number, { blockId, body, guestName, pass, asGuest } = {}) {
991
+ const qs = pass ? `?pass=${encodeURIComponent(pass)}` : '';
992
+ return this.request(`${this._publicIssuesBase(slug, blockId)}/${encodeURIComponent(String(number))}/comments${qs}`, {
993
+ method: 'POST',
994
+ body: { body, ...(guestName ? { guestName } : {}) },
995
+ noAuth: Boolean(asGuest) || !this.apiKey,
996
+ });
997
+ }
998
+
999
+ /** 공개 트래커(또는 페이지 블록)의 이슈 하나 — 댓글까지. */
1000
+ async readPublicIssue(slug, number, { blockId, pass } = {}) {
1001
+ const qs = pass ? `?pass=${encodeURIComponent(pass)}` : '';
1002
+ return this.request(`${this._publicIssuesBase(slug, blockId)}/${encodeURIComponent(String(number))}${qs}`, { noAuth: true });
1003
+ }
1004
+
950
1005
  async listIssues(trackerId, { state, label, type, assigneeId, q } = {}) {
951
1006
  const params = new URLSearchParams();
952
1007
  if (state) params.set('state', state);
@@ -1133,11 +1188,11 @@ export class RootrClient {
1133
1188
  */
1134
1189
  async createDatabaseView(
1135
1190
  databaseId,
1136
- { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns, rowPaneShowHidden, quickFilters, entryRow, dayPager } = {},
1191
+ { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, gridLines, lineStrength, frozenColumns, rowPaneShowHidden, quickFilters, entryRow, filtersPersonalOnly, dayPager } = {},
1137
1192
  ) {
1138
1193
  return this.request(`/databases/${encodeURIComponent(databaseId)}/views`, {
1139
1194
  method: 'POST',
1140
- body: { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns, rowPaneShowHidden, quickFilters, entryRow, dayPager },
1195
+ body: { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, gridLines, lineStrength, frozenColumns, rowPaneShowHidden, quickFilters, entryRow, filtersPersonalOnly, dayPager },
1141
1196
  });
1142
1197
  }
1143
1198
 
@@ -2126,10 +2181,10 @@ export class RootrClient {
2126
2181
  }
2127
2182
 
2128
2183
  /** 연구노트 프리셋: 기재자(나) → 점검자 두 단계. */
2129
- async applyResearchNotePreset(nodeId, { reviewerUserId, noteMeta } = {}) {
2184
+ async applyResearchNotePreset(nodeId, { reviewerUserId, noteMeta, authorUserId } = {}) {
2130
2185
  return this.request(`/signed-docs/${encodeURIComponent(nodeId)}/research-note-preset`, {
2131
2186
  method: 'POST',
2132
- body: { reviewerUserId, noteMeta },
2187
+ body: { reviewerUserId, noteMeta, authorUserId },
2133
2188
  });
2134
2189
  }
2135
2190
 
@@ -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)와
@@ -105,14 +109,24 @@ const VIEW_EXTRA_SHAPE = {
105
109
  'has value, relation → pick linked rows, date → that day. null or [] turns it off.',
106
110
  ),
107
111
  entryRow: z
108
- .boolean()
112
+ .union([z.boolean(), z.enum(['top', 'bottom'])])
109
113
  .nullable()
110
114
  .optional()
111
115
  .describe(
112
- 'table view: keep one always-empty entry row at the bottom (spreadsheet-like). Typing a value into any of its cells ' +
116
+ 'table view: keep one always-empty entry row (spreadsheet-like) — true/"bottom" at the bottom, "top" above the rows ' +
117
+ '(for tables read newest-first; rows made there stick to the top). Typing a value into any of its cells ' +
113
118
  'creates the row (pre-filled to match the view filters, like the "new row" button) and a fresh empty row appears. ' +
114
119
  'Focus/click alone creates nothing. false/null = off.',
115
120
  ),
121
+ filtersPersonalOnly: z
122
+ .boolean()
123
+ .nullable()
124
+ .optional()
125
+ .describe(
126
+ 'table view: filters set in this view always apply only to the person setting them (no "share with everyone" switch) — ' +
127
+ 'for views several people search at once. Conditions already shared stay. false/null = off. (Filters are just-me by default anyway; ' +
128
+ 'this removes the switch.)',
129
+ ),
116
130
  dayPager: z
117
131
  .union([z.object({ dateProp: z.string() }), z.string()])
118
132
  .nullable()
@@ -168,7 +182,8 @@ export function registerDatabaseTools(server) {
168
182
  title: 'Create a Rootr database (table / board / kanban)',
169
183
  description:
170
184
  'Create a DATABASE node — a typed table with columns, views and rows — in ONE call, optionally seeded with ' +
171
- '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 ' +
172
187
  'board, a task/requirement/risk tracker, anything with trackable rows. Do NOT fake a table with a markdown ' +
173
188
  'document, and do NOT create an empty database and then add columns one by one — `properties` order IS the ' +
174
189
  'column order, so one call keeps the columns in exactly the order you list them. ' +
@@ -376,6 +391,16 @@ export function registerDatabaseTools(server) {
376
391
  .nullable()
377
392
  .optional()
378
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.'),
379
404
  frozenColumns: z
380
405
  .number()
381
406
  .int()
@@ -390,7 +415,7 @@ export function registerDatabaseTools(server) {
390
415
  ...VIEW_EXTRA_SHAPE,
391
416
  },
392
417
  },
393
- async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns, rowPaneShowHidden, quickFilters, entryRow, dayPager }) => {
418
+ async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, gridLines, lineStrength, frozenColumns, rowPaneShowHidden, quickFilters, entryRow, filtersPersonalOnly, dayPager }) => {
394
419
  try {
395
420
  const client = makeClient();
396
421
  const id = await resolveNodeId(client, databaseId);
@@ -405,10 +430,13 @@ export function registerDatabaseTools(server) {
405
430
  colWidths,
406
431
  calendarScale,
407
432
  density,
433
+ gridLines,
434
+ lineStrength,
408
435
  frozenColumns,
409
436
  rowPaneShowHidden,
410
437
  quickFilters,
411
438
  entryRow,
439
+ filtersPersonalOnly,
412
440
  dayPager,
413
441
  });
414
442
  return jsonResult(view);
@@ -455,8 +483,7 @@ export function registerDatabaseTools(server) {
455
483
  'Pass an empty array to show everything again. Only the fields you pass change, and other views are untouched. ' +
456
484
  'For an at-a-glance table also pass density "compact" (lower rows, smaller text) and frozenColumns N (first N visible ' +
457
485
  'columns stay fixed while scrolling sideways). Data-entry helpers: rowPaneShowHidden (hidden columns still show when a row ' +
458
- 'is opened), quickFilters (columns pinned at the top of the filter panel, just type), entryRow (always-empty bottom row, ' +
459
- 'Excel-like) and dayPager {dateProp} (one day per page with ◀ ▶ / Today, new rows get that day).',
486
+ 'is opened), quickFilters (columns pinned at the top of the filter panel, just type), entryRow (always-empty row, Excel-like; "top" puts it above the rows), filtersPersonalOnly (filters always just-me) and dayPager {dateProp} (one day per page with ◀ ▶ / Today, new rows get that day).',
460
487
  annotations: { readOnlyHint: false, destructiveHint: false },
461
488
  inputSchema: {
462
489
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
@@ -515,6 +542,16 @@ export function registerDatabaseTools(server) {
515
542
  .nullable()
516
543
  .optional()
517
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.'),
518
555
  frozenColumns: z
519
556
  .number()
520
557
  .int()
@@ -544,10 +581,13 @@ export function registerDatabaseTools(server) {
544
581
  cardProps,
545
582
  rowColors,
546
583
  density,
584
+ gridLines,
585
+ lineStrength,
547
586
  frozenColumns,
548
587
  rowPaneShowHidden,
549
588
  quickFilters,
550
589
  entryRow,
590
+ filtersPersonalOnly,
551
591
  dayPager,
552
592
  }) => {
553
593
  try {
@@ -566,10 +606,13 @@ export function registerDatabaseTools(server) {
566
606
  cardProps,
567
607
  rowColors,
568
608
  density,
609
+ gridLines,
610
+ lineStrength,
569
611
  frozenColumns,
570
612
  rowPaneShowHidden,
571
613
  quickFilters,
572
614
  entryRow,
615
+ filtersPersonalOnly,
573
616
  dayPager,
574
617
  });
575
618
  return jsonResult({ view });
@@ -1376,7 +1419,9 @@ export function registerDatabaseTools(server) {
1376
1419
  'sources?: "all" (default — per column take the most recent earlier row that HAS a value) | "latest" (only the latest earlier row); ' +
1377
1420
  'strategy?: default per column, one of fill_empty (default) | prefer_new | prefer_old | latest | skip; columns?: { "<column>": ' +
1378
1421
  'fill_empty | prefer_new | prefer_old | latest | skip | max | min | sum (number) | earliest | latest_date (date) | union ' +
1379
- '(multi_select/relation/files/person) | { append: { sep?: " / ", datePrefix?: true } } (text) } — a strategy on the wrong column ' +
1422
+ '(multi_select/relation/files/person) | { append: { sep?: " / ", datePrefix?: true, unique?: true, emptyText?: "기존 메모 없음", ' +
1423
+ 'history?: { format: "{date}지원", dateFormat?: "YY-MM-DD", sep?: ", ", only?: true } } } (text — unique keeps a piece once, ' +
1424
+ 'emptyText stands in for an empty earlier note, history adds ONE line of earlier rows\' dates, only = just that line) } — a strategy on the wrong column ' +
1380
1425
  'type is a 400; exclude = skip, overwrite = prefer_old; representative?: "latest" | "oldest", representativeWhen?: [{ propId, op, ' +
1381
1426
  'values }] (first row matching wins). Every operation returns an opId (undo with rootr_dedupe_undo); preview with ' +
1382
1427
  'rootr_dedupe_preview. ' +
@@ -1975,7 +2020,10 @@ export function registerDatabaseTools(server) {
1975
2020
  'Remove ONE column from a Rootr (루터) DATABASE. The values stored under it are NOT erased from the rows, ' +
1976
2021
  'so re-adding a column is not enough to bring them back in the UI — confirm with the user before calling this. ' +
1977
2022
  'Exception: values of a vector column are erased (see below). ' +
1978
- '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. ' +
1979
2027
  'For a vector column the stored vectors are deleted with it (not kept for re-adding — re-add the column and ' +
1980
2028
  'store the vectors again with rootr_set_row_vectors).',
1981
2029
  annotations: { readOnlyHint: false, destructiveHint: true },
@@ -2053,11 +2101,17 @@ export function registerDatabaseTools(server) {
2053
2101
  '· {kind:"update_rows", target:"created"|"selected", values} — set values on the rows this button just ' +
2054
2102
  'made, or on the rows the person ticked in the table.\n' +
2055
2103
  '· {kind:"ai_fill", target, propId, prompt} — let AI write ONE cell. The prompt may quote the row with ' +
2056
- '{칸이름}, e.g. "{제목}과 {메모}를 한 문장으로 요약해". If the AI fails the button keeps going.\n' +
2104
+ '{칸이름}, e.g. "{제목}과 {메모}를 한 문장으로 요약해". If the AI fails the button keeps going. ' +
2105
+ 'Pass fields:[{propId, prompt?}] (≤20) instead of propId to fill SEVERAL columns with ONE AI call from the same prompt ' +
2106
+ '(e.g. split one pasted text into many columns in one step; missing values stay empty).\n' +
2057
2107
  '· {kind:"open_row"} — open the row this button made (UI only; over the API it just reports the id).\n' +
2058
2108
  'Values accept @today, @today+7 and @me — the server fills them in at press time, not now. A text value ' +
2059
2109
  'may also quote the pressed row with {칸이름}, e.g. "{이름} {통화 건수+1}차": computed columns (rollup, ' +
2060
- 'formula) are read too, {칸이름+N} / {칸이름-N} adds to a numeric value, and when several rows are ' +
2110
+ 'formula) are read too, {칸이름+N} / {칸이름-N} adds to a numeric value (on a DATE column: N days later/earlier, ' +
2111
+ '{투입예정일+3}); {칸:YY-MM-DD} formats a date; {칸+3|@today+3} or {메모|없음} gives the value used when the column is ' +
2112
+ 'empty; {@today} / {@today+7} / {@today:YY-MM-DD} work inside text too. APPEND A LINE: a value { append: "{@today:YY-MM-DD} ' +
2113
+ '{상태}", sep?: "\\n", unique?: true } on a text column adds that line to the END of the cell at write time (same line already ' +
2114
+ 'there → skipped) instead of rewriting the whole cell. When several rows are ' +
2061
2115
  'selected each new row is filled from its own source row. A relation column resolves to the linked row: ' +
2062
2116
  '{지원자} is its TITLE (not the id) and {지원자.통화 건수} reads one of its columns — EXCEPT when the value ' +
2063
2117
  'is exactly "{관계 칸}" and the column it goes into is a relation to the SAME table: then the row ids are ' +
@@ -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)
@@ -116,6 +116,9 @@ const FORM_CONFIG_DESC =
116
116
  '`mirror: { fieldMap, defaults: { <표의 칸 이름 또는 id>: value } }` — `defaults` stamps extra values onto the ' +
117
117
  'row a response creates, for columns the person did not answer; use it to start a 신청 at "상신 중" so a ' +
118
118
  'manager has something to move to 승인/거절. What the person actually answered always wins. ' +
119
+ // FORM-DEFAULTS-SUBMITTER(이슈 #227/#228) — 적어 두지 않으면 에이전트는 "누가 냈는지"를 칸에 남기는 길을 모른다.
120
+ 'A default of `"@me"` becomes the logged-in submitter\'s name (person column → [name], title/text → name; ' +
121
+ 'left empty for anonymous submissions) — use it to record who applied; `"@today"`/`"@today+7"` for dates. ' +
119
122
  '`publicSubmit` ("anon"|"login") — who may answer from outside the workspace. Publishing the form ' +
120
123
  '(`rootr_publish_node`, or the 웹에 공개 switch) turns this on by itself, so you almost never set it by hand; ' +
121
124
  'to stop taking answers use `closesAt`/`maxResponses`, or unpublish. ' +
@@ -2,6 +2,27 @@ 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
4
 
5
+ /**
6
+ * ISSUE-PUBLIC — 외부 참여(웹게시한 트래커·페이지에 끼운 트래커에 밖의 사람이 무엇을 할 수 있나). 서버 값과 같아야 한다
7
+ * (issues/issue-schema.ts PUBLIC_PARTICIPATION_MODES).
8
+ */
9
+ const PARTICIPATION = z
10
+ .enum(['read', 'login', 'anyone'])
11
+ .optional()
12
+ .describe(
13
+ 'Outside participation (외부 참여) on the tracker\'s PUBLIC page and on any published PAGE that shows it (`issues` block): ' +
14
+ '"read" 보기만 (default) · "login" 로그인한 사람만 작성 (any Rootr account may file issues and comment) · ' +
15
+ '"anyone" 누구나 작성 (no account — visitors type a name, shown as "이름 (외부)"). Takes effect once the tracker ' +
16
+ '(or the page holding it) is published — rootr_publish_node.',
17
+ );
18
+
19
+ /** 공개 주소 글자(slug · rootr.io/…/p/{slug} 주소)에서 slug 만. */
20
+ function slugOf(target) {
21
+ const t = String(target || '').trim();
22
+ const m = /\/p\/([^/?#\s]+)/.exec(t);
23
+ return m ? decodeURIComponent(m[1]) : t;
24
+ }
25
+
5
26
  /** Issue tracker tools: create tracker, list/create/get/update issues, comment. */
6
27
  export function registerIssueTools(server) {
7
28
  server.registerTool(
@@ -19,13 +40,14 @@ export function registerIssueTools(server) {
19
40
  .string()
20
41
  .optional()
21
42
  .describe('Workspace id; defaults to ROOTR_WORKSPACE/config if omitted'),
43
+ publicParticipation: PARTICIPATION,
22
44
  },
23
45
  },
24
- async ({ name, workspace }) => {
46
+ async ({ name, workspace, publicParticipation }) => {
25
47
  try {
26
48
  const ws = requireWorkspace(workspace);
27
49
  const client = makeClient(ws);
28
- const result = await client.createIssueTracker(ws, name);
50
+ const result = await client.createIssueTracker(ws, name, publicParticipation ? { publicParticipation } : {});
29
51
  return jsonResult(result);
30
52
  } catch (err) {
31
53
  return errorResult(err);
@@ -170,4 +192,124 @@ export function registerIssueTools(server) {
170
192
  }
171
193
  },
172
194
  );
195
+
196
+ server.registerTool(
197
+ 'rootr_update_issue_tracker',
198
+ {
199
+ title: 'Change an issue tracker\'s settings (outside participation, labels, types)',
200
+ description:
201
+ 'Change settings of a Rootr (루터) issue tracker. `publicParticipation` decides what people OUTSIDE the workspace may ' +
202
+ 'do on its public page (rootr.io/p/…) and on published PAGEs that show it: "read" 보기만 · "login" 로그인한 사람만 ' +
203
+ '작성 · "anyone" 누구나 작성 (just a name). Only the keys you send change; `labels`/`types` REPLACE the whole list ' +
204
+ '([{ id?, name, color? }] — read the current ones with rootr_list_issues / the tracker first). Changing ' +
205
+ 'publicParticipation is an owner-level action (MANAGE, same as publishing) — use an account key (PAT). ' +
206
+ 'To also publish in one step use rootr_publish_node with `participation`.',
207
+ annotations: { readOnlyHint: false, destructiveHint: false },
208
+ inputSchema: {
209
+ trackerId: z.string().describe(`Issue tracker node id${NODE_TARGET_HINT}`),
210
+ publicParticipation: PARTICIPATION,
211
+ labels: z
212
+ .array(z.object({ id: z.string().optional(), name: z.string(), color: z.string().optional() }))
213
+ .optional()
214
+ .describe('Full label list (replaces the current one)'),
215
+ types: z
216
+ .array(z.object({ id: z.string().optional(), name: z.string(), color: z.string().optional() }))
217
+ .optional()
218
+ .describe('Full issue type list (replaces the current one)'),
219
+ },
220
+ },
221
+ async ({ trackerId, ...patch }) => {
222
+ try {
223
+ const client = makeClient();
224
+ trackerId = await resolveNodeId(client, trackerId);
225
+ const body = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined));
226
+ return jsonResult(await client.updateIssueTracker(trackerId, body));
227
+ } catch (err) {
228
+ return errorResult(err);
229
+ }
230
+ },
231
+ );
232
+
233
+ const PUBLIC_TARGET = {
234
+ url: z
235
+ .string()
236
+ .describe(
237
+ 'The PUBLIC address of the tracker — "https://rootr.io/ko/p/<slug>" or just the slug. For a tracker shown on a ' +
238
+ 'published PAGE, the page\'s address plus `blockId`.',
239
+ ),
240
+ blockId: z.string().optional().describe('Only when the tracker sits on a published PAGE: that `issues` block\'s id'),
241
+ guestName: z
242
+ .string()
243
+ .max(40)
244
+ .optional()
245
+ .describe('Name to show when writing as a guest (누구나 작성 mode with asGuest). Ignored when writing as your account.'),
246
+ asGuest: z
247
+ .boolean()
248
+ .optional()
249
+ .describe(
250
+ 'Write WITHOUT your API key, as a guest with `guestName` (only works when participation is "anyone"). ' +
251
+ 'Default false: the key\'s owner account is the author (works for "login" and "anyone").',
252
+ ),
253
+ password: z.string().optional().describe('If the public page is password-locked'),
254
+ };
255
+
256
+ server.registerTool(
257
+ 'rootr_submit_public_issue',
258
+ {
259
+ title: 'File an issue on a PUBLISHED tracker (outside participation)',
260
+ description:
261
+ 'File an issue on someone\'s (or your own) PUBLISHED Rootr (루터) issue tracker through its public address — the ' +
262
+ 'way an outside person or an external agent reports a bug/request. Works only when the tracker\'s participation ' +
263
+ 'allows it: "login" (your account key counts as signed in) or "anyone". Errors: 403 PUBLIC_READ_ONLY (보기만), ' +
264
+ '401 LOGIN_REQUIRED, 400 GUEST_NAME_REQUIRED, 401 PUBLIC_DOC_PASSWORD (pass `password`). Text only — labels, ' +
265
+ 'assignee and state cannot be set from outside, and @mentions are stripped. Returns { ok, number }. ' +
266
+ 'For a tracker in YOUR workspace prefer rootr_create_issue (full fields).',
267
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
268
+ inputSchema: {
269
+ ...PUBLIC_TARGET,
270
+ title: z.string().max(200).describe('Issue title (≤200)'),
271
+ body: z.string().max(20000).optional().describe('Issue body, markdown (≤20,000)'),
272
+ },
273
+ },
274
+ async ({ url, blockId, guestName, asGuest, password, title, body }) => {
275
+ try {
276
+ const client = makeClient();
277
+ const slug = slugOf(url);
278
+ const pass = password ? await client.unlockPublicNode(slug, password) : undefined;
279
+ return jsonResult(await client.submitPublicIssue(slug, { blockId, title, body, guestName, pass, asGuest }));
280
+ } catch (err) {
281
+ return errorResult(err);
282
+ }
283
+ },
284
+ );
285
+
286
+ server.registerTool(
287
+ 'rootr_comment_public_issue',
288
+ {
289
+ title: 'Comment on an issue of a PUBLISHED tracker (outside participation)',
290
+ description:
291
+ 'Comment on issue #number of a PUBLISHED Rootr (루터) issue tracker through its public address (same rules as ' +
292
+ 'rootr_submit_public_issue). Pass `read: true` instead of `body` to just read that issue\'s public comments. ' +
293
+ 'Returns { ok, id } (or { number, comments } when reading).',
294
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
295
+ inputSchema: {
296
+ ...PUBLIC_TARGET,
297
+ number: z.number().int().positive().describe('Issue number (#N)'),
298
+ body: z.string().max(10000).optional().describe('Comment, markdown (≤10,000). Required unless read is true'),
299
+ read: z.boolean().optional().describe('Only read the public comments of this issue'),
300
+ },
301
+ },
302
+ async ({ url, blockId, guestName, asGuest, password, number, body, read }) => {
303
+ try {
304
+ const client = makeClient();
305
+ const slug = slugOf(url);
306
+ const pass = password ? await client.unlockPublicNode(slug, password) : undefined;
307
+ if (read) return jsonResult(await client.readPublicIssue(slug, number, { blockId, pass }));
308
+ if (!body) throw new Error('body is required (or pass read: true)');
309
+ return jsonResult(await client.commentPublicIssue(slug, number, { blockId, body, guestName, pass, asGuest }));
310
+ } catch (err) {
311
+ return errorResult(err);
312
+ }
313
+ },
314
+ );
173
315
  }
@@ -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.31.0",
3
+ "version": "0.33.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": {