@bifos/dooray-cli 0.10.0 → 0.11.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.md CHANGED
@@ -91,6 +91,22 @@ dooray post get <project> 42 --json # JSON 출력
91
91
  대상: `post get`/`edit`/`done`/`workflow`, `post comment list`/`add`/`edit`/`delete`, `post file list`/`upload`/`download`/`download-all`/`delete`.
92
92
  AI 에이전트는 사용자 메시지의 Dooray URL을 그대로 첫 인자로 전달하면 가장 빠르다 (ADR-020).
93
93
 
94
+ **projectId 직접 입력** (Issue #78, ADR-030):
95
+
96
+ `member=me` 응답에 없는 프로젝트 (다른 팀 / 권한만 있는 프로젝트) 도 projectId (15+자리 numeric) 를 직접 입력하면 자동으로 cache 우회.
97
+
98
+ ```bash
99
+ # 코드 매칭 (기존)
100
+ dooray post search <project> "keyword"
101
+
102
+ # projectId 직접 입력 — member 아닌 프로젝트도 자동화 가능
103
+ dooray post search 1234567890123456789 "keyword"
104
+ dooray post list 1234567890123456789
105
+ dooray member list 1234567890123456789
106
+ ```
107
+
108
+ 권한 검증은 후속 API 호출 시점 — 권한 없으면 4xx.
109
+
94
110
  ### 업무 생성
95
111
 
96
112
  ```bash
@@ -209,6 +225,22 @@ interactive ($EDITOR) 모드에서는 위 6개 옵션이 무시되고 stderr 경
209
225
  `post edit --dry-run --json` 사용 시 출력에 `users: { to, cc }` 가 포함되어 API 호출 없이 변경 결과 미리보기 가능.
210
226
  `post create --dry-run` 은 본문만 출력하며 `users` 는 포함하지 않음.
211
227
 
228
+ **그룹 cc / mention 사용 예 (Issue #76 fix)**:
229
+
230
+ ```bash
231
+ # code 부분일치
232
+ dooray post create <project> ... --cc-group "개발"
233
+
234
+ # code 정확 일치
235
+ dooray post create <project> ... --mention-group "all"
236
+
237
+ # 19자리 id 직접 입력 (response shape robustness 또는 code 누락 그룹 회피)
238
+ dooray post create <project> ... --cc-group "<19자리 group id>"
239
+
240
+ # 후보 탐색
241
+ dooray project groups <project>
242
+ ```
243
+
212
244
  ```bash
213
245
  dooray post edit <project> <post-number> --cc-group dev-team --dry-run --json | jq '.users.cc'
214
246
  # 출력 예: [{ "type": "group", "group": { "projectMemberGroupId": "...", "members": [] } }, ...]
@@ -367,6 +399,22 @@ dooray wiki page file list "https://<tenant>.dooray.com/wiki/<wikiId>/<pageId>"
367
399
  dooray wiki page file upload --id <pageId> --project <project> --file ./README.md
368
400
  ```
369
401
 
402
+ `--json` 옵션으로 자동화 파이프라인에서 동일 parse 코드를 사용할 수 있습니다 (ADR-031):
403
+
404
+ ```bash
405
+ # download — { outputPath, fileName, size }
406
+ dooray wiki page file download <project> <page-id> --file-id <id> -o ./ --json
407
+
408
+ # download-all — { count, succeeded, failed } (부분 실패 시 exit 1)
409
+ dooray wiki page file download-all <project> <page-id> -o ./ --json | jq '.failed'
410
+
411
+ # delete — { fileId, status: "deleted" }
412
+ dooray wiki page file delete <project> <page-id> --file-id <id> --json
413
+
414
+ # upload — res.result raw (--quiet 는 id 만)
415
+ dooray wiki page file upload <project> <page-id> --file ./report.pdf --json
416
+ ```
417
+
370
418
  **주의**:
371
419
  - `upload` 시 multipart 필드 순서 (`type` → `file`) 가 중요.
372
420
  클라이언트가 자동으로 강제 (ADR-029 참조)
@@ -458,6 +506,25 @@ dooray post file upload <project> <number> ./report.pdf
458
506
  dooray post file delete <project> <number> <file-id>
459
507
  ```
460
508
 
509
+ 자동화 스크립트에서 `--json` 옵션으로 구조화된 출력을 파이프로 가공할 수 있습니다 (ADR-031):
510
+
511
+ ```bash
512
+ # download — { outputPath, fileName, size }
513
+ dooray post file download <project> <number> --file-id <id> -o ./ --json
514
+ # 출력: {"outputPath": "./<fileName>", "fileName": "...", "size": 12345}
515
+
516
+ # download-all — { count, succeeded, failed } (부분 실패 시 exit 1)
517
+ dooray post file download-all <project> <number> -o ./ --json | jq '.failed'
518
+ # 출력: [] 또는 [{"fileId": "...", "error": "..."}]
519
+
520
+ # delete — { fileId, status: "deleted" }
521
+ dooray post file delete <project> <number> --file-id <id> --json
522
+ # 출력: {"fileId": "...", "status": "deleted"}
523
+
524
+ # upload — res.result raw (--quiet 는 id 만)
525
+ dooray post file upload <project> <number> ./report.pdf --json
526
+ ```
527
+
461
528
  URL/`--id`/`--url` 모드에서는 sub-id를 옵션으로 전달:
462
529
  ```bash
463
530
  dooray post file download --url <url> --file-id <fileId> -o ./downloads
package/dist/index.js CHANGED
@@ -1206,6 +1206,7 @@ var setupCommand = new import_commander4.Command("setup").description("\uB300\uD
1206
1206
  var import_commander5 = require("commander");
1207
1207
 
1208
1208
  // src/resolvers/project.ts
1209
+ var PROJECT_ID_RE = /^\d{15,}$/;
1209
1210
  async function fetchAllProjects(client, options) {
1210
1211
  const all = [];
1211
1212
  let page = 0;
@@ -1243,6 +1244,9 @@ async function ensurePrivateProjects(client) {
1243
1244
  return items;
1244
1245
  }
1245
1246
  async function resolveProject(client, input2) {
1247
+ if (PROJECT_ID_RE.test(input2)) {
1248
+ return input2;
1249
+ }
1246
1250
  const projects = await ensureProjects(client);
1247
1251
  const match = projects.find((p) => p.code === input2 || p.id === input2);
1248
1252
  if (match) return match.id;
@@ -1253,7 +1257,8 @@ async function resolveProject(client, input2) {
1253
1257
  }
1254
1258
  throw new DoorayCliError(
1255
1259
  `\uD504\uB85C\uC81D\uD2B8\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${input2}
1256
- \uAC1C\uC778 \uD504\uB85C\uC81D\uD2B8\uB77C\uBA74: dooray project list --type private \uB85C \uCE90\uC2DC\uB97C \uAC31\uC2E0\uD558\uC138\uC694`,
1260
+ \uAC1C\uC778 \uD504\uB85C\uC81D\uD2B8\uB77C\uBA74: dooray project list --type private \uB85C \uCE90\uC2DC\uB97C \uAC31\uC2E0\uD558\uC138\uC694
1261
+ member=me \uC751\uB2F5\uC5D0 \uC5C6\uB294 \uD504\uB85C\uC81D\uD2B8\uB294 projectId (15+\uC790\uB9AC numeric) \uC9C1\uC811 \uC785\uB825\uC73C\uB85C \uC6B0\uD68C \uAC00\uB2A5 (ADR-030)`,
1257
1262
  EXIT_PARAM_ERROR
1258
1263
  );
1259
1264
  }
@@ -1549,13 +1554,15 @@ var projectWorkflowsCommand = new import_commander7.Command("workflows").descrip
1549
1554
  var import_commander8 = require("commander");
1550
1555
 
1551
1556
  // src/resolvers/member-group.ts
1557
+ var GROUP_ID_RE = /^\d{15,}$/;
1552
1558
  async function fetchAllMemberGroups(client, projectId) {
1553
1559
  const all = [];
1554
1560
  let page = 0;
1555
1561
  const size = RESOLVER_FETCH_PAGE_SIZE;
1556
1562
  while (true) {
1557
1563
  const res = await client.getProjectMemberGroups(projectId, { page, size });
1558
- for (const g of res.result) {
1564
+ const groups = res.result.flat();
1565
+ for (const g of groups) {
1559
1566
  all.push({ id: g.id, code: g.code });
1560
1567
  }
1561
1568
  const total = res.totalCount ?? all.length;
@@ -1576,17 +1583,30 @@ function hasValidCode(g) {
1576
1583
  }
1577
1584
  async function resolveMemberGroup(client, projectId, input2) {
1578
1585
  const groups = await ensureMemberGroups(client, projectId);
1586
+ if (GROUP_ID_RE.test(input2)) {
1587
+ const found = groups.find((g) => g.id === input2);
1588
+ if (found) {
1589
+ return { id: found.id, code: found.code ?? "" };
1590
+ }
1591
+ throw new DoorayCliError(
1592
+ `\uADF8\uB8F9 id \uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: "${input2}"
1593
+ \uC804\uCCB4 \uBAA9\uB85D\uC740 \`dooray project groups <project>\` \uB85C \uD655\uC778\uD558\uC138\uC694.`,
1594
+ EXIT_PARAM_ERROR
1595
+ );
1596
+ }
1579
1597
  const valid = groups.filter(hasValidCode);
1580
1598
  const skipped = groups.length - valid.length;
1581
1599
  if (skipped > 0) {
1582
1600
  process.stderr.write(
1583
- `\u26A0 ${skipped}\uAC1C \uADF8\uB8F9\uC5D0 code \uAC00 \uC5C6\uC5B4 \uB9E4\uCE6D\uC5D0\uC11C \uC81C\uC678\uD588\uC2B5\uB2C8\uB2E4 (Dooray API \uC751\uB2F5 mismatch \u2014 ADR-026).
1601
+ // ADR 번호 정정: ADR-026 ADR-028
1602
+ `\u26A0 ${skipped}\uAC1C \uADF8\uB8F9\uC5D0 code \uAC00 \uC5C6\uC5B4 \uB9E4\uCE6D\uC5D0\uC11C \uC81C\uC678\uD588\uC2B5\uB2C8\uB2E4 (ADR-028).
1603
+ id \uC9C1\uC811 \uC785\uB825 (15+\uC790\uB9AC numeric) \uB610\uB294 UI \uC218\uB3D9 cc / \`--cc <member>\` \uC6B0\uD68C \uAC00\uB2A5.
1584
1604
  `
1585
1605
  );
1586
1606
  }
1587
1607
  const adapter = valid.map((g) => ({ name: g.code, id: g.id, code: g.code }));
1588
1608
  const match = matchByName(adapter, input2, "\uADF8\uB8F9", (g) => `${g.code} (${g.id})`, {
1589
- helpHint: "dooray project groups <project>"
1609
+ helpHint: "\uC804\uCCB4 \uBAA9\uB85D: `dooray project groups <project>` / id \uC9C1\uC811 \uC785\uB825\uB3C4 \uAC00\uB2A5 (15+\uC790\uB9AC numeric \u2014 code \uB204\uB77D \uADF8\uB8F9\uB3C4 \uB9E4\uCE6D, ADR-028)"
1590
1610
  });
1591
1611
  return { id: match.id, code: match.code };
1592
1612
  }
@@ -3752,7 +3772,49 @@ var fileListCommand = new import_commander29.Command("list").description("\uC5C5
3752
3772
  var import_commander30 = require("commander");
3753
3773
  var import_promises9 = require("fs/promises");
3754
3774
  var import_node_path6 = require("path");
3775
+
3776
+ // src/formatters/file-output.ts
3777
+ function emitDownloadResult(globalOpts, result) {
3778
+ if (globalOpts.json) {
3779
+ printJson(result);
3780
+ } else {
3781
+ process.stdout.write(`${result.outputPath}
3782
+ `);
3783
+ }
3784
+ }
3785
+ function emitDownloadAllResult(globalOpts, data) {
3786
+ if (globalOpts.json) {
3787
+ printJson(data);
3788
+ } else if (globalOpts.quiet) {
3789
+ for (const s of data.succeeded) {
3790
+ process.stdout.write(`${s.path}
3791
+ `);
3792
+ }
3793
+ if (data.failed.length > 0) {
3794
+ process.stderr.write(`${data.failed.length} failed
3795
+ `);
3796
+ }
3797
+ } else {
3798
+ process.stdout.write(`
3799
+ \uC644\uB8CC: ${data.succeeded.length}/${data.count}
3800
+ `);
3801
+ }
3802
+ }
3803
+ function emitDeleteResult(globalOpts, result) {
3804
+ if (globalOpts.json) {
3805
+ printJson({ fileId: result.fileId, status: "deleted" });
3806
+ } else if (globalOpts.quiet) {
3807
+ process.stdout.write(`${result.fileId}
3808
+ `);
3809
+ } else {
3810
+ process.stdout.write(`\uD30C\uC77C(${result.fileId})\uC774 \uC0AD\uC81C\uB418\uC5C8\uC2B5\uB2C8\uB2E4.
3811
+ `);
3812
+ }
3813
+ }
3814
+
3815
+ // src/commands/post/file/download.ts
3755
3816
  var fileDownloadCommand = new import_commander30.Command("download").description("\uCCA8\uBD80\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC").argument("[arg1]", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC, Dooray URL, \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C ID").argument("[arg2]", "\uC5C5\uBB34 \uBC88\uD638 \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C ID").argument("[arg3]", "\uD30C\uC77C ID (positional \uBAA8\uB4DC)").option("--id <postId>", "Dooray post ID (project/post-number \uB300\uC2E0)").option("--url <url>", "Dooray \uC5C5\uBB34 URL (project/post-number \uB300\uC2E0)").option("--file-id <fileId>", "\uD30C\uC77C ID (positional \uB300\uCCB4)").option("-o, --output <dir>", "\uC800\uC7A5 \uB514\uB809\uD1A0\uB9AC", ".").action(async (arg1, arg2, arg3, opts) => {
3817
+ const globalOpts = fileDownloadCommand.optsWithGlobals();
3756
3818
  const config = await getConfigOrThrow();
3757
3819
  const client = new DoorayApiClient(config.apiKey, config.baseUrl);
3758
3820
  let projectArg;
@@ -3799,11 +3861,11 @@ var fileDownloadCommand = new import_commander30.Command("download").description
3799
3861
  urlOpt: opts.url
3800
3862
  });
3801
3863
  const { buffer, fileName } = await client.downloadPostFile(projectId, postId, fileId);
3802
- const outputPath = (0, import_node_path6.join)(opts.output, fileName);
3864
+ const safeName = (0, import_node_path6.basename)(decodeURIComponent(fileName));
3865
+ const outputPath = (0, import_node_path6.join)(opts.output, safeName);
3803
3866
  await (0, import_promises9.writeFile)(outputPath, Buffer.from(buffer));
3804
3867
  stopSpinner(true, "\uB2E4\uC6B4\uB85C\uB4DC \uC644\uB8CC");
3805
- process.stdout.write(`${outputPath}
3806
- `);
3868
+ emitDownloadResult(globalOpts, { outputPath, fileName: safeName, size: buffer.byteLength });
3807
3869
  });
3808
3870
 
3809
3871
  // src/commands/post/file/download-all.ts
@@ -3811,9 +3873,10 @@ var import_commander31 = require("commander");
3811
3873
  var import_promises10 = require("fs/promises");
3812
3874
  var import_node_path7 = require("path");
3813
3875
  var fileDownloadAllCommand = new import_commander31.Command("download-all").description("\uC5C5\uBB34\uC758 \uBAA8\uB4E0 \uCCA8\uBD80\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC").argument("[project]", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC (\uB610\uB294 \uCCAB \uC778\uC790\uC5D0 Dooray URL)").argument("[post-number]", "\uC5C5\uBB34 \uBC88\uD638 (project\uC640 \uD568\uAED8 \uC0AC\uC6A9)").option("--id <postId>", "Dooray post ID (project/post-number \uB300\uC2E0)").option("--url <url>", "Dooray \uC5C5\uBB34 URL (project/post-number \uB300\uC2E0)").option("-o, --output <dir>", "\uC800\uC7A5 \uB514\uB809\uD1A0\uB9AC", ".").action(async (project, postNumberStr, opts) => {
3876
+ const globalOpts = fileDownloadAllCommand.optsWithGlobals();
3814
3877
  const config = await getConfigOrThrow();
3815
3878
  const client = new DoorayApiClient(config.apiKey, config.baseUrl);
3816
- const spinner = startSpinner("\uCCA8\uBD80\uD30C\uC77C \uBAA9\uB85D \uC870\uD68C \uC911...");
3879
+ startSpinner("\uCCA8\uBD80\uD30C\uC77C \uBAA9\uB85D \uC870\uD68C \uC911...");
3817
3880
  const { projectId, postId } = await resolvePostInput(client, {
3818
3881
  projectArg: project,
3819
3882
  postNumberArg: postNumberStr,
@@ -3823,23 +3886,38 @@ var fileDownloadAllCommand = new import_commander31.Command("download-all").desc
3823
3886
  const res = await client.getPostFiles(projectId, postId);
3824
3887
  if (res.result.length === 0) {
3825
3888
  stopSpinner(true, "\uCCA8\uBD80\uD30C\uC77C \uC5C6\uC74C");
3826
- process.stdout.write("\uCCA8\uBD80\uD30C\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.\n");
3889
+ if (globalOpts.json) {
3890
+ printJson({ count: 0, succeeded: [], failed: [] });
3891
+ } else if (!globalOpts.quiet) {
3892
+ process.stdout.write("\uCCA8\uBD80\uD30C\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.\n");
3893
+ }
3827
3894
  return;
3828
3895
  }
3896
+ stopSpinner(true, `${res.result.length}\uAC1C \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2DC\uC791`);
3829
3897
  await (0, import_promises10.mkdir)(opts.output, { recursive: true });
3830
- const downloaded = [];
3898
+ const succeeded = [];
3899
+ const failed = [];
3831
3900
  for (const file of res.result) {
3832
- spinner.text = `\uB2E4\uC6B4\uB85C\uB4DC \uC911: ${file.name} (${downloaded.length + 1}/${res.result.length})`;
3833
- const { buffer, fileName } = await client.downloadPostFile(projectId, postId, file.id);
3834
- const outputPath = (0, import_node_path7.join)(opts.output, fileName);
3835
- await (0, import_promises10.writeFile)(outputPath, Buffer.from(buffer));
3836
- downloaded.push(outputPath);
3837
- }
3838
- stopSpinner(true, `${downloaded.length}\uAC1C \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC644\uB8CC`);
3839
- for (const path3 of downloaded) {
3840
- process.stdout.write(`${path3}
3901
+ try {
3902
+ const { buffer, fileName } = await client.downloadPostFile(projectId, postId, file.id);
3903
+ const safeName = (0, import_node_path7.basename)(decodeURIComponent(fileName));
3904
+ const outputPath = (0, import_node_path7.join)(opts.output, safeName);
3905
+ await (0, import_promises10.writeFile)(outputPath, Buffer.from(buffer));
3906
+ succeeded.push({ path: outputPath, fileName: safeName });
3907
+ if (!globalOpts.json && !globalOpts.quiet) {
3908
+ process.stdout.write(`\u2713 ${safeName}
3909
+ `);
3910
+ }
3911
+ } catch (e) {
3912
+ failed.push({ fileId: file.id, error: e instanceof Error ? e.message : String(e) });
3913
+ if (!globalOpts.json) {
3914
+ process.stderr.write(`\u2717 ${file.name} (${file.id}): ${e instanceof Error ? e.message : String(e)}
3841
3915
  `);
3916
+ }
3917
+ }
3842
3918
  }
3919
+ emitDownloadAllResult(globalOpts, { count: res.result.length, succeeded, failed });
3920
+ if (failed.length > 0) process.exitCode = 1;
3843
3921
  });
3844
3922
 
3845
3923
  // src/commands/post/file/upload.ts
@@ -3908,6 +3986,7 @@ var fileUploadCommand = new import_commander32.Command("upload").description("\u
3908
3986
  // src/commands/post/file/delete.ts
3909
3987
  var import_commander33 = require("commander");
3910
3988
  var fileDeleteCommand = new import_commander33.Command("delete").description("\uCCA8\uBD80\uD30C\uC77C \uC0AD\uC81C").argument("[arg1]", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC, Dooray URL, \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C ID").argument("[arg2]", "\uC5C5\uBB34 \uBC88\uD638 \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C ID").argument("[arg3]", "\uD30C\uC77C ID (positional 3\uAC1C \uBAA8\uB4DC)").option("--id <postId>", "Dooray post ID (project/post-number \uB300\uC2E0)").option("--url <url>", "Dooray \uC5C5\uBB34 URL (project/post-number \uB300\uC2E0)").option("--file-id <fileId>", "\uD30C\uC77C ID (positional \uB300\uCCB4)").action(async (arg1, arg2, arg3, opts) => {
3989
+ const globalOpts = fileDeleteCommand.optsWithGlobals();
3911
3990
  const config = await getConfigOrThrow();
3912
3991
  const client = new DoorayApiClient(config.apiKey, config.baseUrl);
3913
3992
  let projectArg;
@@ -3955,8 +4034,7 @@ var fileDeleteCommand = new import_commander33.Command("delete").description("\u
3955
4034
  });
3956
4035
  await client.deletePostFile(projectId, postId, fileId);
3957
4036
  stopSpinner(true, "\uC0AD\uC81C \uC644\uB8CC");
3958
- process.stdout.write(`\uD30C\uC77C(${fileId})\uC774 \uC0AD\uC81C\uB418\uC5C8\uC2B5\uB2C8\uB2E4.
3959
- `);
4037
+ emitDeleteResult(globalOpts, { fileId });
3960
4038
  });
3961
4039
 
3962
4040
  // src/commands/wiki/list.ts
@@ -4264,6 +4342,7 @@ var wikiPageFileListCommand = new import_commander39.Command("list").description
4264
4342
  // src/commands/wiki/page-file/upload.ts
4265
4343
  var import_commander40 = require("commander");
4266
4344
  var wikiPageFileUploadCommand = new import_commander40.Command("upload").description("\uC704\uD0A4 \uD398\uC774\uC9C0 \uCCA8\uBD80\uD30C\uC77C \uC5C5\uB85C\uB4DC (multipart type \uC21C\uC11C \uAC15\uC81C, ADR-029)").argument("[arg1]", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC, Dooray Wiki URL, \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C \uACBD\uB85C").argument("[arg2]", "page-id \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C \uACBD\uB85C").argument("[arg3]", "\uD30C\uC77C \uACBD\uB85C (positional 3\uAC1C \uBAA8\uB4DC)").option("--id <pageId>", "\uC704\uD0A4 \uD398\uC774\uC9C0 ID").option("--url <url>", "Dooray Wiki URL").option("--project <code>", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC (--id \uBAA8\uB4DC\uC5D0\uC11C wikiId \uD574\uC11D\uC6A9)").option("--file <path>", "\uC5C5\uB85C\uB4DC\uD560 \uD30C\uC77C \uACBD\uB85C (positional \uB300\uCCB4)").option("--type <type>", "\uD30C\uC77C \uD0C0\uC785: general | inline_image (\uAE30\uBCF8 general)", "general").action(async (arg1, arg2, arg3, opts) => {
4345
+ const globalOpts = wikiPageFileUploadCommand.optsWithGlobals();
4267
4346
  const config = await getConfigOrThrow();
4268
4347
  const client = new DoorayApiClient(config.apiKey, config.baseUrl);
4269
4348
  const fileType = opts.type;
@@ -4320,18 +4399,25 @@ var wikiPageFileUploadCommand = new import_commander40.Command("upload").descrip
4320
4399
  try {
4321
4400
  const res = await client.uploadWikiPageFile(wikiId, pageId, filePath, fileType);
4322
4401
  stopSpinner(true, "\uC5C5\uB85C\uB4DC \uC644\uB8CC");
4323
- process.stdout.write(`attachFileId: ${res.result.attachFileId}
4402
+ if (globalOpts.json) {
4403
+ printJson(res.result);
4404
+ } else if (globalOpts.quiet) {
4405
+ process.stdout.write(`${res.result.id}
4324
4406
  `);
4325
- process.stdout.write(`name: ${res.result.name}
4407
+ } else {
4408
+ process.stdout.write(`attachFileId: ${res.result.attachFileId}
4326
4409
  `);
4327
- process.stdout.write(`size: ${res.result.size}
4410
+ process.stdout.write(`name: ${res.result.name}
4328
4411
  `);
4329
- process.stdout.write(`type: ${res.result.type}
4412
+ process.stdout.write(`size: ${res.result.size}
4330
4413
  `);
4331
- if (fileType === "inline_image") {
4332
- process.stdout.write("\n\uBCF8\uBB38 \uC0BD\uC785\uC6A9 markdown snippet (\uC9C1\uC811 wiki page edit \uC73C\uB85C \uBCF8\uBB38\uC5D0 \uBC15\uC73C\uC138\uC694):\n");
4333
- process.stdout.write(` ![${res.result.name}](/wikis/${wikiId}/files/${res.result.attachFileId})
4414
+ process.stdout.write(`type: ${res.result.type}
4334
4415
  `);
4416
+ if (fileType === "inline_image") {
4417
+ process.stdout.write("\n\uBCF8\uBB38 \uC0BD\uC785\uC6A9 markdown snippet (\uC9C1\uC811 wiki page edit \uC73C\uB85C \uBCF8\uBB38\uC5D0 \uBC15\uC73C\uC138\uC694):\n");
4418
+ process.stdout.write(` ![${res.result.name}](/wikis/${wikiId}/files/${res.result.attachFileId})
4419
+ `);
4420
+ }
4335
4421
  }
4336
4422
  } catch (e) {
4337
4423
  stopSpinner(false);
@@ -4344,6 +4430,7 @@ var import_commander41 = require("commander");
4344
4430
  var import_promises11 = require("fs/promises");
4345
4431
  var import_node_path9 = require("path");
4346
4432
  var wikiPageFileDownloadCommand = new import_commander41.Command("download").description("\uC704\uD0A4 \uD398\uC774\uC9C0 \uCCA8\uBD80\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC").argument("[arg1]", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC, Dooray Wiki URL, \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C ID").argument("[arg2]", "page-id \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C ID").argument("[arg3]", "\uD30C\uC77C ID (positional 3\uAC1C \uBAA8\uB4DC)").option("--id <pageId>", "\uC704\uD0A4 \uD398\uC774\uC9C0 ID").option("--url <url>", "Dooray Wiki URL").option("--project <code>", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC (--id \uBAA8\uB4DC\uC5D0\uC11C wikiId \uD574\uC11D\uC6A9)").option("--file-id <fileId>", "\uD30C\uC77C ID (positional \uB300\uCCB4)").option("-o, --output <dir>", "\uC800\uC7A5 \uB514\uB809\uD1A0\uB9AC", ".").action(async (arg1, arg2, arg3, opts) => {
4433
+ const globalOpts = wikiPageFileDownloadCommand.optsWithGlobals();
4347
4434
  const config = await getConfigOrThrow();
4348
4435
  const client = new DoorayApiClient(config.apiKey, config.baseUrl);
4349
4436
  let projectArg;
@@ -4392,11 +4479,11 @@ var wikiPageFileDownloadCommand = new import_commander41.Command("download").des
4392
4479
  startSpinner("\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC911...");
4393
4480
  try {
4394
4481
  const { buffer, fileName } = await client.downloadWikiPageFile(wikiId, pageId, fileId);
4395
- const outputPath = (0, import_node_path9.join)(opts.output, (0, import_node_path9.basename)(fileName));
4482
+ const safeName = (0, import_node_path9.basename)(decodeURIComponent(fileName));
4483
+ const outputPath = (0, import_node_path9.join)(opts.output, safeName);
4396
4484
  await (0, import_promises11.writeFile)(outputPath, Buffer.from(buffer));
4397
4485
  stopSpinner(true, `\uB2E4\uC6B4\uB85C\uB4DC \uC644\uB8CC: ${outputPath}`);
4398
- process.stdout.write(`${outputPath}
4399
- `);
4486
+ emitDownloadResult(globalOpts, { outputPath, fileName: safeName, size: buffer.byteLength });
4400
4487
  } catch (e) {
4401
4488
  stopSpinner(false);
4402
4489
  throw e;
@@ -4408,6 +4495,7 @@ var import_commander42 = require("commander");
4408
4495
  var import_promises12 = require("fs/promises");
4409
4496
  var import_node_path10 = require("path");
4410
4497
  var wikiPageFileDownloadAllCommand = new import_commander42.Command("download-all").description("\uC704\uD0A4 \uD398\uC774\uC9C0\uC758 \uBAA8\uB4E0 \uCCA8\uBD80\uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC (general + inline image)").argument("[project]", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC (\uB610\uB294 \uCCAB \uC778\uC790\uC5D0 Dooray Wiki URL)").argument("[page-id]", "\uC704\uD0A4 \uD398\uC774\uC9C0 ID (project\uC640 \uD568\uAED8 \uC0AC\uC6A9)").option("--id <pageId>", "\uC704\uD0A4 \uD398\uC774\uC9C0 ID (--project \uB3D9\uBC18 \uD544\uC694)").option("--url <url>", "Dooray Wiki URL").option("--project <code>", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC (--id \uBAA8\uB4DC\uC5D0\uC11C wikiId \uD574\uC11D\uC6A9)").option("-o, --output <dir>", "\uC800\uC7A5 \uB514\uB809\uD1A0\uB9AC", ".").action(async (project, pageIdArg, opts) => {
4498
+ const globalOpts = wikiPageFileDownloadAllCommand.optsWithGlobals();
4411
4499
  const config = await getConfigOrThrow();
4412
4500
  const client = new DoorayApiClient(config.apiKey, config.baseUrl);
4413
4501
  const { wikiId, pageId } = await resolveWikiPageInput(client, {
@@ -4428,40 +4516,44 @@ var wikiPageFileDownloadAllCommand = new import_commander42.Command("download-al
4428
4516
  }
4429
4517
  if (allFiles.length === 0) {
4430
4518
  stopSpinner(true, "\uCCA8\uBD80\uD30C\uC77C \uC5C6\uC74C");
4431
- process.stdout.write("\uCCA8\uBD80\uD30C\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.\n");
4519
+ if (globalOpts.json) {
4520
+ printJson({ count: 0, succeeded: [], failed: [] });
4521
+ } else if (!globalOpts.quiet) {
4522
+ process.stdout.write("\uCCA8\uBD80\uD30C\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.\n");
4523
+ }
4432
4524
  return;
4433
4525
  }
4434
4526
  stopSpinner(true, `${allFiles.length}\uAC1C \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2DC\uC791`);
4435
4527
  await (0, import_promises12.mkdir)(opts.output, { recursive: true });
4436
- let successCount = 0;
4437
- const failures = [];
4528
+ const succeeded = [];
4529
+ const failed = [];
4438
4530
  for (const f of allFiles) {
4439
4531
  try {
4440
4532
  const { buffer, fileName } = await client.downloadWikiPageFile(wikiId, pageId, f.id);
4441
- await (0, import_promises12.writeFile)((0, import_node_path10.join)(opts.output, fileName), Buffer.from(buffer));
4442
- process.stdout.write(`\u2713 ${fileName}
4533
+ const safeName = (0, import_node_path10.basename)(decodeURIComponent(fileName));
4534
+ const outputPath = (0, import_node_path10.join)(opts.output, safeName);
4535
+ await (0, import_promises12.writeFile)(outputPath, Buffer.from(buffer));
4536
+ succeeded.push({ path: outputPath, fileName: safeName });
4537
+ if (!globalOpts.json && !globalOpts.quiet) {
4538
+ process.stdout.write(`\u2713 ${safeName}
4443
4539
  `);
4444
- successCount++;
4540
+ }
4445
4541
  } catch (e) {
4446
- failures.push({ fileId: f.id, error: e instanceof Error ? e.message : String(e) });
4447
- process.stderr.write(`\u2717 ${f.name} (${f.id}): ${e instanceof Error ? e.message : String(e)}
4542
+ failed.push({ fileId: f.id, error: e instanceof Error ? e.message : String(e) });
4543
+ if (!globalOpts.json) {
4544
+ process.stderr.write(`\u2717 ${f.name} (${f.id}): ${e instanceof Error ? e.message : String(e)}
4448
4545
  `);
4546
+ }
4449
4547
  }
4450
4548
  }
4451
- process.stdout.write(`
4452
- \uC644\uB8CC: ${successCount}/${allFiles.length}
4453
- `);
4454
- if (failures.length > 0) {
4455
- throw new DoorayCliError(
4456
- `${failures.length}\uAC1C \uD30C\uC77C \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328 (\uC131\uACF5: ${successCount}/${allFiles.length})`,
4457
- EXIT_API_ERROR
4458
- );
4459
- }
4549
+ emitDownloadAllResult(globalOpts, { count: allFiles.length, succeeded, failed });
4550
+ if (failed.length > 0) process.exitCode = 1;
4460
4551
  });
4461
4552
 
4462
4553
  // src/commands/wiki/page-file/delete.ts
4463
4554
  var import_commander43 = require("commander");
4464
4555
  var wikiPageFileDeleteCommand = new import_commander43.Command("delete").description("\uC704\uD0A4 \uD398\uC774\uC9C0 \uCCA8\uBD80\uD30C\uC77C \uC0AD\uC81C").argument("[arg1]", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC, Dooray Wiki URL, \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C ID").argument("[arg2]", "page-id \uB610\uB294 (`--id`/`--url` \uBAA8\uB4DC\uC77C \uB54C) \uD30C\uC77C ID").argument("[arg3]", "\uD30C\uC77C ID (positional 3\uAC1C \uBAA8\uB4DC)").option("--id <pageId>", "\uC704\uD0A4 \uD398\uC774\uC9C0 ID").option("--url <url>", "Dooray Wiki URL").option("--project <code>", "\uD504\uB85C\uC81D\uD2B8 \uCF54\uB4DC (--id \uBAA8\uB4DC\uC5D0\uC11C wikiId \uD574\uC11D\uC6A9)").option("--file-id <fileId>", "\uD30C\uC77C ID (positional \uB300\uCCB4)").action(async (arg1, arg2, arg3, opts) => {
4556
+ const globalOpts = wikiPageFileDeleteCommand.optsWithGlobals();
4465
4557
  const config = await getConfigOrThrow();
4466
4558
  const client = new DoorayApiClient(config.apiKey, config.baseUrl);
4467
4559
  let projectArg;
@@ -4511,8 +4603,7 @@ var wikiPageFileDeleteCommand = new import_commander43.Command("delete").descrip
4511
4603
  try {
4512
4604
  await client.deleteWikiPageFile(wikiId, pageId, fileId);
4513
4605
  stopSpinner(true, "\uC0AD\uC81C \uC644\uB8CC");
4514
- process.stdout.write(`\uD30C\uC77C(${fileId})\uC774 \uC0AD\uC81C\uB418\uC5C8\uC2B5\uB2C8\uB2E4.
4515
- `);
4606
+ emitDeleteResult(globalOpts, { fileId });
4516
4607
  } catch (e) {
4517
4608
  stopSpinner(false);
4518
4609
  throw e;
@@ -5560,7 +5651,7 @@ function sanitizeArgv(argv) {
5560
5651
 
5561
5652
  // src/index.ts
5562
5653
  var program = new import_commander61.Command();
5563
- program.name("dooray").description("Dooray REST API CLI").version("0.10.0").option("--json", "JSON \uD615\uC2DD\uC73C\uB85C \uCD9C\uB825").option("--quiet", "ID\uB9CC \uCD9C\uB825").option("--no-color", "\uC0C9\uC0C1 \uBE44\uD65C\uC131\uD654");
5654
+ program.name("dooray").description("Dooray REST API CLI").version("0.11.0").option("--json", "JSON \uD615\uC2DD\uC73C\uB85C \uCD9C\uB825").option("--quiet", "ID\uB9CC \uCD9C\uB825").option("--no-color", "\uC0C9\uC0C1 \uBE44\uD65C\uC131\uD654");
5564
5655
  program.hook("preAction", () => {
5565
5656
  const opts = program.opts();
5566
5657
  if (opts.color === false || process.env.NO_COLOR) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bifos/dooray-cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "CLI tool for Dooray project management — AI agent & terminal friendly",
5
5
  "keywords": [
6
6
  "dooray",
@@ -62,7 +62,7 @@ dooray doctor # 설정 검증
62
62
  | 멤버 상세 (organizationMemberId) | `dooray member get <organizationMemberId>` (cache 우회, ADR-021) |
63
63
  | organization 전체 멤버 검색 | `dooray member search <keyword>` (이름 기본), `--email`(이메일 exact), `--user-code`(사번 like), `--user-code-exact`(사번 exact), `--page`/`--size` |
64
64
  | 업무 목록 조회 | `dooray post list <project>` |
65
- | 업무 검색 | `dooray post search <project> "<keyword>"` |
65
+ | 업무 검색 | `dooray post search <project|projectId> "<keyword>"` — projectId (15+자리 numeric) 직접 입력 시 cache 우회 (ADR-030) |
66
66
  | 업무 상세 보기 | `dooray post get <project> <number>` |
67
67
  | 업무 생성 | `dooray post create <project> --title "..." [--body "..." \| --body-file <path>]` (`--tag`/`--parent`/`--workflow`/`--milestone` 지원) |
68
68
  | 템플릿 기반 업무 생성 | `dooray post create <project> --template <name\|id>` — body/users/tags 자동 채움 (사용자 옵션 우선 override, ADR-027) |
@@ -104,6 +104,7 @@ dooray doctor # 설정 검증
104
104
  | 전체 첨부파일 다운로드 | `dooray post file download-all <project> <number>` |
105
105
  | 첨부파일 업로드 | `dooray post file upload <project> <number> <file-path>` |
106
106
  | 첨부파일 삭제 | `dooray post file delete <project> <number> <file-id>` |
107
+ | file 명령군 자동화 파싱 | `dooray post file <verb> ... --json` — `download` = `{outputPath,fileName,size}`, `download-all` = `{count,succeeded,failed}` (부분 실패 시 exit 1), `delete` = `{fileId,status}`, `upload` = `res.result` raw (ADR-031) |
107
108
  | 댓글 첨부 목록 | `dooray post comment file list <project> <number> <comment-id>` |
108
109
  | 댓글 파일 업로드 | `dooray post comment file upload <project> <number> <comment-id> <path>` |
109
110
  | 댓글 파일 다운로드 | `dooray post comment file download <project> <number> <comment-id> <file-id>` |
@@ -111,6 +112,7 @@ dooray doctor # 설정 검증
111
112
  | 참조자(cc) 멤버/그룹 추가 | `dooray post edit <project> <number> --cc-group <code>` — 기존 참조자 유지 + 그룹 추가 (dedupe, ADR-025) |
112
113
  | 참조자 전체 교체 | `dooray post edit <project> <number> --cc-clear --cc <name>` — 기존 참조자 비우고 신규 멤버만 |
113
114
  | 신규 업무 + 그룹 cc | `dooray post create <project> --title "..." --cc-group <code>` — 생성 시 그룹 참조자 포함 |
115
+ | `--cc-group <code\|id>` / `--mention-group <code\|id>` | 그룹 매칭 — 15+자리 numeric → id 직접 / 그 외 → code matchByName (부분일치, ADR-028) |
114
116
  | 상위 업무 설정/변경 | `dooray post edit <project> <number> --title "<원제목>" --parent <ref>` (`<ref>`: `<project>/<number>` 또는 raw postId. `--title` 필수, unset 미지원) |
115
117
  | `dooray post edit --id <postId> --tag <name>` | 태그 추가 (반복, dedupe) |
116
118
  | `dooray post edit --id <postId> --tag-clear --tag <name>` | 태그 전체 교체 |
@@ -206,6 +208,16 @@ dooray wiki pages <project> --json
206
208
  dooray wiki page get <project> <pageId> --json
207
209
  ```
208
210
 
211
+ ### 첨부파일 일괄 다운로드 후 실패 분리 (ADR-031)
212
+
213
+ ```bash
214
+ # --json 으로 구조화 출력 → jq 로 성공/실패 분리
215
+ RESULT=$(dooray post file download-all <project> <number> -o ./ --json)
216
+ echo "$RESULT" | jq -r '.failed[] | "\(.fileId): \(.error)"' >&2
217
+ echo "$RESULT" | jq -r '.succeeded[].path'
218
+ # exit code 1 이 설정되어 있으면 실패 있는 상태
219
+ ```
220
+
209
221
  ### 위키 페이지 첨부파일 — 스킬 파일 팀 공유 (Issue #70)
210
222
 
211
223
  **스킬 파일 팀 공유**: 팀 위키에 스킬 파일 (예: `SKILL.md`) 을 `wiki page file upload` 로 첨부 → 팀원이 `wiki page file download-all` 로 일괄 받아 `~/.claude/skills/` 에 그대로 설치.
@@ -451,6 +463,35 @@ dooray post comment latest <project> <number>
451
463
 
452
464
  ---
453
465
 
466
+ ## 그룹 멘션 / cc 시 AI agent 동선 (Issue #76, ADR-028)
467
+
468
+ 자연어 그룹명을 사용자가 지칭했을 때 AI agent 의 의사결정 순서:
469
+
470
+ 1. **사용자가 명확한 code 를 줬으면 바로 시도**
471
+ ```bash
472
+ dooray post create <project> --mention-group "<code>"
473
+ ```
474
+ 부분일치 가능 (예: "AI-Data" → "AI-Data파트" 매칭).
475
+
476
+ 2. **부분일치 모호 / 매칭 실패 시 후보 탐색**
477
+ ```bash
478
+ dooray project groups <project>
479
+ ```
480
+ ID + Code 표 출력.
481
+ AI agent 가 자연어 의도와 가장 가까운 code 선택 후 재시도.
482
+
483
+ 3. **모든 컬럼이 빈값일 때 (response shape 이상) 회피**
484
+ - ADR-028 fix 이후 거의 발생 안 함 (`fetchAllMemberGroups` 가 nested array 정규화)
485
+ - 만약 발생 시: 사용자에게 그룹 id (UI 의 그룹 URL 에서 19자리 numeric) 확인 요청
486
+ - `--cc-group <id>` / `--mention-group <id>` 직접 입력
487
+ - 또는 그룹 멤버를 개별 `--cc <member>` / `--mention <member>` 로 지정
488
+
489
+ 4. **모호한 자연어 매핑은 사용자에게 확인**
490
+ - 후보가 여러 개일 때 임의 선택 금지 — 사용자에게 선택지 제시
491
+ - 예: "AI-Data파트 / AI-Data실험팀 — 어느 그룹인가요?"
492
+
493
+ 순서 고정 — 멤버 먼저, 그룹 다음 (기존 정책 유지).
494
+
454
495
  ## 멘션·링크 자동 삽입 (first-class)
455
496
 
456
497
  `post create`, `post edit`, `post comment add`, `post comment edit` 모두 지원:
@@ -557,6 +598,24 @@ POST_ID=$(dooray post create <project> \
557
598
  템플릿 본문의 `${year}` / `${month}` 등 매크로는 Dooray 가 자동 치환 (`interpolation=true` 기본).
558
599
  사용자 정의 변수는 미지원 — 필요 시 client 측 string replace 로 처리.
559
600
 
601
+ ## projectId 직접 입력 시나리오 (Issue #78, ADR-030)
602
+
603
+ AI agent 가 `member=me` 응답에 없는 프로젝트의 업무를 다뤄야 할 때:
604
+
605
+ 1. **사용자가 projectId (15+자리 numeric) 를 줬으면 그대로 명령에 사용**:
606
+ ```bash
607
+ dooray post search 1234567890123456789 "keyword"
608
+ ```
609
+
610
+ 2. **사용자가 코드만 줬고 cache 매칭 실패 (member 아닌 프로젝트)**:
611
+ - 에러 메시지의 ADR-030 안내 확인
612
+ - 사용자에게 "프로젝트 ID 가 필요합니다 — Dooray UI 의 프로젝트 URL 에서 확인 가능" 요청
613
+ - 또는 `dooray project list --type private` 로 private 캐시 갱신 시도
614
+
615
+ 3. **권한 없는 projectId 입력 시**: resolver 통과 후 후속 API 4xx 발생 — 에러 메시지에서 권한 부재 확인 후 사용자에게 보고
616
+
617
+ 권한 검증이 resolver 단보다 한 단계 지연되는 trade-off — AI 친화적 자동화 우선 (ADR-030).
618
+
560
619
  ## 캐시
561
620
 
562
621
  프로젝트, 멤버, 워크플로우, 위키 정보는 `~/.dooray/cache/`에 캐시된다.