diff --git a/README.md b/README.md index 6652f38..1d3a514 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ CLI & Agent Skill for managing Slack Enterprise Grid / Business+ workspaces via ## Features -- **79 admin commands** covering 10 API groups: teams, users, conversations, apps, invite-requests, workflows, functions, scim-users, scim-groups, and token management +- **107 admin commands** covering 15 API groups: teams, users, conversations, apps, invite-requests, workflows, functions, scim-users, scim-groups, auth-policy, barriers, emoji, roles, usergroups, and token management - **Agent Skill** — ships with a Claude Code / Codex skill so AI agents can drive Slack admin tasks using the CLI as a tool - **Bulk operations** — archive, delete, or move hundreds of channels at once with `conversations bulk-*` - **Output formats** — table (human), JSON (programmatic), TSV (pipe-friendly) @@ -162,6 +162,7 @@ sladm teams list --plain # TSV (for scripting) | `teams settings set-icon` | Set team icon | | `teams settings set-description` | Set description | | `teams settings set-discoverability` | Set discoverability | +| `teams settings set-default-channels` | Set default channels | ### Users @@ -174,7 +175,15 @@ sladm teams list --plain # TSV (for scripting) | `users set-admin` | Promote to admin | | `users set-owner` | Promote to owner | | `users set-regular` | Demote to regular user | +| `users set-expiration` | Set guest account expiration | +| `users unsupported-versions export` | Export users on unsupported Slack versions | | `users session reset` | Reset session | +| `users session list` | List active sessions | +| `users session invalidate` | Invalidate a session | +| `users session reset-bulk` | Bulk reset sessions | +| `users session get-settings` | Get session settings | +| `users session set-settings` | Set session settings | +| `users session clear-settings` | Clear session settings | ### Conversations @@ -270,6 +279,50 @@ sladm teams list --plain # TSV (for scripting) | `functions permissions lookup` | Lookup permissions | | `functions permissions set` | Set permissions | +### Auth Policy + +| Command | Description | +|---------|-------------| +| `auth-policy assign-entities` | Assign entities to an auth policy | +| `auth-policy get-entities` | List entities assigned to a policy | +| `auth-policy remove-entities` | Remove entities from a policy | + +### Barriers + +| Command | Description | +|---------|-------------| +| `barriers create` | Create an information barrier | +| `barriers delete` | Delete a barrier | +| `barriers list` | List barriers | +| `barriers update` | Update a barrier | + +### Emoji + +| Command | Description | +|---------|-------------| +| `emoji add` | Add a custom emoji | +| `emoji add-alias` | Add an emoji alias | +| `emoji list` | List custom emoji | +| `emoji remove` | Remove an emoji | +| `emoji rename` | Rename an emoji | + +### Roles + +| Command | Description | +|---------|-------------| +| `roles add-assignments` | Add role assignments | +| `roles list-assignments` | List role assignments | +| `roles remove-assignments` | Remove role assignments | + +### Usergroups + +| Command | Description | +|---------|-------------| +| `usergroups add-channels` | Add default channels to a usergroup | +| `usergroups add-teams` | Add teams to a usergroup | +| `usergroups list-channels` | List default channels of a usergroup | +| `usergroups remove-channels` | Remove default channels from a usergroup | + ## Required Scopes | Scope | Purpose | diff --git a/README_ja.md b/README_ja.md index 572d130..b31a31f 100644 --- a/README_ja.md +++ b/README_ja.md @@ -6,7 +6,7 @@ Slack Enterprise Grid / Business+ ワークスペースの `admin.*` API を操 ## Features -- **79の管理コマンド** — teams, users, conversations, apps, invite-requests, workflows, functions, scim-users, scim-groups, token の10グループをカバー +- **107の管理コマンド** — teams, users, conversations, apps, invite-requests, workflows, functions, scim-users, scim-groups, auth-policy, barriers, emoji, roles, usergroups, token の15グループをカバー - **Agent Skill** — Claude Code / Codex のスキルとして動作し、AI エージェントが CLI 経由で Slack 管理操作を実行可能 - **一括操作** — `conversations bulk-*` で数百チャンネルのアーカイブ・削除・移動を一発実行 - **出力形式** — テーブル(人間向け)、JSON(プログラム連携)、TSV(パイプ向け) @@ -162,6 +162,7 @@ sladm teams list --plain # TSV 形式(スクリプト連携向け) | `teams settings set-icon` | アイコン変更 | | `teams settings set-description` | 説明文変更 | | `teams settings set-discoverability` | 公開設定変更 | +| `teams settings set-default-channels` | デフォルトチャンネル設定 | ### Users @@ -174,7 +175,15 @@ sladm teams list --plain # TSV 形式(スクリプト連携向け) | `users set-admin` | 管理者に昇格 | | `users set-owner` | オーナーに昇格 | | `users set-regular` | 一般ユーザーに降格 | +| `users set-expiration` | ゲストアカウント有効期限設定 | +| `users unsupported-versions export` | 非対応Slackバージョンのユーザー一覧エクスポート | | `users session reset` | セッションリセット | +| `users session list` | アクティブセッション一覧 | +| `users session invalidate` | セッション無効化 | +| `users session reset-bulk` | セッション一括リセット | +| `users session get-settings` | セッション設定取得 | +| `users session set-settings` | セッション設定変更 | +| `users session clear-settings` | セッション設定クリア | ### Conversations @@ -270,6 +279,50 @@ sladm teams list --plain # TSV 形式(スクリプト連携向け) | `functions permissions lookup` | 権限確認 | | `functions permissions set` | 権限設定 | +### Auth Policy + +| コマンド | 説明 | +|---------|------| +| `auth-policy assign-entities` | ポリシーにエンティティを割り当て | +| `auth-policy get-entities` | ポリシーに割り当てられたエンティティ一覧 | +| `auth-policy remove-entities` | ポリシーからエンティティを削除 | + +### Barriers + +| コマンド | 説明 | +|---------|------| +| `barriers create` | 情報バリア作成 | +| `barriers delete` | バリア削除 | +| `barriers list` | バリア一覧 | +| `barriers update` | バリア更新 | + +### Emoji + +| コマンド | 説明 | +|---------|------| +| `emoji add` | カスタム絵文字追加 | +| `emoji add-alias` | 絵文字エイリアス追加 | +| `emoji list` | カスタム絵文字一覧 | +| `emoji remove` | 絵文字削除 | +| `emoji rename` | 絵文字リネーム | + +### Roles + +| コマンド | 説明 | +|---------|------| +| `roles add-assignments` | ロール割り当て追加 | +| `roles list-assignments` | ロール割り当て一覧 | +| `roles remove-assignments` | ロール割り当て削除 | + +### Usergroups + +| コマンド | 説明 | +|---------|------| +| `usergroups add-channels` | ユーザーグループにデフォルトチャンネル追加 | +| `usergroups add-teams` | ユーザーグループにチーム追加 | +| `usergroups list-channels` | ユーザーグループのデフォルトチャンネル一覧 | +| `usergroups remove-channels` | ユーザーグループからデフォルトチャンネル削除 | + ## Required Scopes | スコープ | 用途 | diff --git a/docs/superpowers/plans/2026-04-17-admin-api-coverage.md b/docs/superpowers/plans/2026-04-17-admin-api-coverage.md new file mode 100644 index 0000000..56e3066 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-admin-api-coverage.md @@ -0,0 +1,1957 @@ +# Admin API Coverage Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** SDK 対応済みかつ未実装の admin.* メソッド 28 個を `sladm` に追加し、README/SKILL ドキュメントと PR を整える。 + +**Architecture:** 既存の `src/commands//[/].ts` 構造に従い、各コマンドは `execute*(client, opts)` を export。CLI ルーティングは `src/index.ts` の `@optique/core` パーサー定義と末尾の switch 文に追記。テストは `tests/commands/<同パス>` にユニットテスト併設。 + +**Tech Stack:** Bun, TypeScript, `@slack/web-api`, `@optique/core`, `bun:test` + +**仕様書:** `docs/superpowers/specs/2026-04-17-admin-api-coverage-design.md` + +--- + +## グローバル規約 + +- `Options` インターフェースは camelCase。 +- API 呼び出しは型付きメソッド(例: `client.admin.barriers.list(...)`)を優先。SDK 型ユニオンで TS エラーになる場合のみ `client.apiCall("admin.x.y", params)` にフォールバック。 +- `as` キャスト禁止(`as any` はテストの mock client のみ可、既存パターン踏襲)。 +- 任意パラメータは undefined 時に渡さない(`Record` を構築 → 条件追加)。 +- list 系は `Promise` を返し、index.ts 側で `formatOutput()` 整形。 +- 各タスク完了時に `bun run lint` と `bun test <該当ファイル>` を実行し、グリーンになってからコミット。 + +--- + +## Task 1: auth-policy グループ + +**Files:** +- Create: `src/commands/auth-policy/assign-entities.ts` +- Create: `src/commands/auth-policy/get-entities.ts` +- Create: `src/commands/auth-policy/remove-entities.ts` +- Create: `tests/commands/auth-policy/assign-entities.test.ts` +- Create: `tests/commands/auth-policy/get-entities.test.ts` +- Create: `tests/commands/auth-policy/remove-entities.test.ts` +- Modify: `src/index.ts`(import 追加 + パーサー定義 + switch ケース 3 件) + +### Step 1.1: テストを 3 本書く + +- [ ] `tests/commands/auth-policy/assign-entities.test.ts` + +```typescript +import { describe, expect, test, mock } from "bun:test"; +import { executeAuthPolicyAssignEntities } from "../../../src/commands/auth-policy/assign-entities"; + +describe("auth-policy assign-entities", () => { + test("calls admin.auth.policy.assignEntities with snake_case params", async () => { + const mockApiCall = mock(() => Promise.resolve({ ok: true })); + const client = { apiCall: mockApiCall } as any; + + await executeAuthPolicyAssignEntities(client, { + entityIds: ["T001", "T002"], + entityType: "USER", + policyName: "email_password", + }); + + expect(mockApiCall).toHaveBeenCalledWith("admin.auth.policy.assignEntities", { + entity_ids: ["T001", "T002"], + entity_type: "USER", + policy_name: "email_password", + }); + }); +}); +``` + +- [ ] `tests/commands/auth-policy/get-entities.test.ts` + +```typescript +import { describe, expect, test, mock } from "bun:test"; +import { executeAuthPolicyGetEntities } from "../../../src/commands/auth-policy/get-entities"; + +describe("auth-policy get-entities", () => { + test("returns entities array", async () => { + const mockApiCall = mock(() => + Promise.resolve({ ok: true, entities: [{ entity_id: "T001" }] }), + ); + const client = { apiCall: mockApiCall } as any; + + const result = await executeAuthPolicyGetEntities(client, { + policyName: "email_password", + entityType: "USER", + cursor: "abc", + limit: 10, + }); + + expect(mockApiCall).toHaveBeenCalledWith("admin.auth.policy.getEntities", { + policy_name: "email_password", + entity_type: "USER", + cursor: "abc", + limit: 10, + }); + expect(result).toEqual([{ entity_id: "T001" }]); + }); + + test("omits optional params when not provided", async () => { + const mockApiCall = mock(() => Promise.resolve({ ok: true, entities: [] })); + const client = { apiCall: mockApiCall } as any; + await executeAuthPolicyGetEntities(client, { policyName: "email_password" }); + expect(mockApiCall).toHaveBeenCalledWith("admin.auth.policy.getEntities", { + policy_name: "email_password", + }); + }); +}); +``` + +- [ ] `tests/commands/auth-policy/remove-entities.test.ts` + +```typescript +import { describe, expect, test, mock } from "bun:test"; +import { executeAuthPolicyRemoveEntities } from "../../../src/commands/auth-policy/remove-entities"; + +describe("auth-policy remove-entities", () => { + test("calls admin.auth.policy.removeEntities", async () => { + const mockApiCall = mock(() => Promise.resolve({ ok: true })); + const client = { apiCall: mockApiCall } as any; + await executeAuthPolicyRemoveEntities(client, { + entityIds: ["T001"], + entityType: "USER", + policyName: "email_password", + }); + expect(mockApiCall).toHaveBeenCalledWith("admin.auth.policy.removeEntities", { + entity_ids: ["T001"], + entity_type: "USER", + policy_name: "email_password", + }); + }); +}); +``` + +- [ ] **Step 1.2: テストが失敗することを確認** + +Run: `bun test tests/commands/auth-policy/` +Expected: モジュール未解決でエラー + +### Step 1.3: 実装 + +- [ ] `src/commands/auth-policy/assign-entities.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface AuthPolicyAssignEntitiesOptions { + entityIds: string[]; + entityType: "USER"; + policyName: "email_password"; +} + +export async function executeAuthPolicyAssignEntities( + client: WebClient, + opts: AuthPolicyAssignEntitiesOptions, +): Promise { + await client.apiCall("admin.auth.policy.assignEntities", { + entity_ids: opts.entityIds, + entity_type: opts.entityType, + policy_name: opts.policyName, + }); +} +``` + +- [ ] `src/commands/auth-policy/get-entities.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface AuthPolicyGetEntitiesOptions { + policyName: "email_password"; + entityType?: "USER"; + cursor?: string; + limit?: number; +} + +export async function executeAuthPolicyGetEntities( + client: WebClient, + opts: AuthPolicyGetEntitiesOptions, +) { + const params: Record = { policy_name: opts.policyName }; + if (opts.entityType !== undefined) params.entity_type = opts.entityType; + if (opts.cursor !== undefined) params.cursor = opts.cursor; + if (opts.limit !== undefined) params.limit = opts.limit; + const response = await client.apiCall("admin.auth.policy.getEntities", params); + const entities = (response as { entities?: unknown[] }).entities; + return entities ?? []; +} +``` + +- [ ] `src/commands/auth-policy/remove-entities.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface AuthPolicyRemoveEntitiesOptions { + entityIds: string[]; + entityType: "USER"; + policyName: "email_password"; +} + +export async function executeAuthPolicyRemoveEntities( + client: WebClient, + opts: AuthPolicyRemoveEntitiesOptions, +): Promise { + await client.apiCall("admin.auth.policy.removeEntities", { + entity_ids: opts.entityIds, + entity_type: opts.entityType, + policy_name: opts.policyName, + }); +} +``` + +### Step 1.4: index.ts に組み込み + +- [ ] import 追加(既存 import ブロックの末尾、SCIM の前) + +```typescript +import { executeAuthPolicyAssignEntities } from "./commands/auth-policy/assign-entities"; +import { executeAuthPolicyGetEntities } from "./commands/auth-policy/get-entities"; +import { executeAuthPolicyRemoveEntities } from "./commands/auth-policy/remove-entities"; +``` + +- [ ] パーサー定義を `// SCIM Users commands` セクションの直前に追加 + +```typescript +// --------------------------------------------------------------------------- +// Auth Policy commands +// --------------------------------------------------------------------------- + +const authPolicyCommands = command( + "auth-policy", + or( + command("assign-entities", object({ + cmd: constant("auth-policy-assign-entities" as const), + entityIds: option("--entity-ids", string({ metavar: "ENTITY_IDS" })), + entityType: option("--entity-type", string({ metavar: "ENTITY_TYPE" })), + policyName: option("--policy-name", string({ metavar: "POLICY_NAME" })), + })), + command("get-entities", object({ + cmd: constant("auth-policy-get-entities" as const), + policyName: option("--policy-name", string({ metavar: "POLICY_NAME" })), + entityType: optional(option("--entity-type", string({ metavar: "ENTITY_TYPE" }))), + cursor: optional(option("--cursor", string({ metavar: "CURSOR" }))), + limit: optional(option("--limit", integer({ metavar: "LIMIT" }))), + })), + command("remove-entities", object({ + cmd: constant("auth-policy-remove-entities" as const), + entityIds: option("--entity-ids", string({ metavar: "ENTITY_IDS" })), + entityType: option("--entity-type", string({ metavar: "ENTITY_TYPE" })), + policyName: option("--policy-name", string({ metavar: "POLICY_NAME" })), + })), + ), +); +``` + +- [ ] `rootParser` に `authPolicyCommands` を追加(既存の `or(...)` ブロックに) + +```typescript +const rootParser = or( + or(tokenCommands, teamsCommands, usersCommands), + or(conversationsCommands, appsCommands), + or(inviteRequestsCommands, workflowsCommands, functionsCommands), + or(scimUsersCommands, scimGroupsCommands), + or(authPolicyCommands /* 後続タスクで他グループも追加 */), +); +``` + +- [ ] switch 文末尾に 3 ケース追加(SCIM ケースの後) + +```typescript +case "auth-policy-assign-entities": { + const client = await createSlackClient(store, profileFlag); + const entityIds = config.entityIds.split(","); + if (config.entityType !== "USER") throw new Error('--entity-type must be "USER"'); + if (config.policyName !== "email_password") throw new Error('--policy-name must be "email_password"'); + await executeAuthPolicyAssignEntities(client, { + entityIds, + entityType: "USER", + policyName: "email_password", + }); + console.log(`Assigned ${entityIds.length} entities to policy '${config.policyName}'.`); + break; +} +case "auth-policy-get-entities": { + const client = await createSlackClient(store, profileFlag); + if (config.entityType !== undefined && config.entityType !== "USER") { + throw new Error('--entity-type must be "USER"'); + } + if (config.policyName !== "email_password") throw new Error('--policy-name must be "email_password"'); + const entities = await executeAuthPolicyGetEntities(client, { + policyName: "email_password", + entityType: config.entityType === "USER" ? "USER" : undefined, + cursor: config.cursor, + limit: config.limit, + }); + const rows = (entities as Array<{ entity_id?: string; entity_type?: string }>).map((e) => ({ + entity_id: e.entity_id ?? "", + entity_type: e.entity_type ?? "", + })); + console.log(formatOutput(rows, ["entity_id", "entity_type"], outputFormat)); + break; +} +case "auth-policy-remove-entities": { + const client = await createSlackClient(store, profileFlag); + const entityIds = config.entityIds.split(","); + if (config.entityType !== "USER") throw new Error('--entity-type must be "USER"'); + if (config.policyName !== "email_password") throw new Error('--policy-name must be "email_password"'); + await executeAuthPolicyRemoveEntities(client, { + entityIds, + entityType: "USER", + policyName: "email_password", + }); + console.log(`Removed ${entityIds.length} entities from policy '${config.policyName}'.`); + break; +} +``` + +### Step 1.5: 検証 + コミット + +- [ ] `bun run lint` グリーン +- [ ] `bun test tests/commands/auth-policy/` 全 PASS +- [ ] コミット + +```bash +git add src/commands/auth-policy tests/commands/auth-policy src/index.ts +git commit -m "feat: add auth-policy admin commands" +``` + +--- + +## Task 2: barriers グループ + +**Files:** +- Create: `src/commands/barriers/{create,delete,list,update}.ts` +- Create: `tests/commands/barriers/{create,delete,list,update}.test.ts` +- Modify: `src/index.ts` + +### Step 2.1: 実装ファイル + +- [ ] `src/commands/barriers/create.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface BarriersCreateOptions { + primaryUsergroupId: string; + barrieredFromUsergroupIds: string[]; +} + +export async function executeBarriersCreate( + client: WebClient, + opts: BarriersCreateOptions, +) { + return await client.admin.barriers.create({ + primary_usergroup_id: opts.primaryUsergroupId, + barriered_from_usergroup_ids: opts.barrieredFromUsergroupIds, + restricted_subjects: ["im", "mpim", "call"], + }); +} +``` + +- [ ] `src/commands/barriers/delete.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface BarriersDeleteOptions { barrierId: string } + +export async function executeBarriersDelete( + client: WebClient, + opts: BarriersDeleteOptions, +): Promise { + await client.admin.barriers.delete({ barrier_id: opts.barrierId }); +} +``` + +- [ ] `src/commands/barriers/list.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface BarriersListOptions { + cursor?: string; + limit?: number; +} + +export async function executeBarriersList( + client: WebClient, + opts: BarriersListOptions, +) { + const response = await client.admin.barriers.list({ + cursor: opts.cursor, + limit: opts.limit, + }); + return response.barriers ?? []; +} +``` + +- [ ] `src/commands/barriers/update.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface BarriersUpdateOptions { + barrierId: string; + primaryUsergroupId: string; + barrieredFromUsergroupIds: string[]; +} + +export async function executeBarriersUpdate( + client: WebClient, + opts: BarriersUpdateOptions, +) { + return await client.admin.barriers.update({ + barrier_id: opts.barrierId, + primary_usergroup_id: opts.primaryUsergroupId, + barriered_from_usergroup_ids: opts.barrieredFromUsergroupIds, + restricted_subjects: ["im", "mpim", "call"], + }); +} +``` + +### Step 2.2: テストファイル + +- [ ] 4 ファイル。`create` をテンプレに残り 3 つも同パターン。 + +```typescript +// tests/commands/barriers/create.test.ts +import { describe, expect, test, mock } from "bun:test"; +import { executeBarriersCreate } from "../../../src/commands/barriers/create"; + +describe("barriers create", () => { + test("creates a barrier with restricted_subjects defaulted", async () => { + const mockCreate = mock(() => Promise.resolve({ ok: true, barrier: { id: "B001" } })); + const client = { admin: { barriers: { create: mockCreate } } } as any; + await executeBarriersCreate(client, { + primaryUsergroupId: "S001", + barrieredFromUsergroupIds: ["S002", "S003"], + }); + expect(mockCreate).toHaveBeenCalledWith({ + primary_usergroup_id: "S001", + barriered_from_usergroup_ids: ["S002", "S003"], + restricted_subjects: ["im", "mpim", "call"], + }); + }); +}); +``` + +```typescript +// tests/commands/barriers/delete.test.ts +import { describe, expect, test, mock } from "bun:test"; +import { executeBarriersDelete } from "../../../src/commands/barriers/delete"; + +describe("barriers delete", () => { + test("deletes by id", async () => { + const mockDelete = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { barriers: { delete: mockDelete } } } as any; + await executeBarriersDelete(client, { barrierId: "B001" }); + expect(mockDelete).toHaveBeenCalledWith({ barrier_id: "B001" }); + }); +}); +``` + +```typescript +// tests/commands/barriers/list.test.ts +import { describe, expect, test, mock } from "bun:test"; +import { executeBarriersList } from "../../../src/commands/barriers/list"; + +describe("barriers list", () => { + test("returns barriers array", async () => { + const mockList = mock(() => + Promise.resolve({ ok: true, barriers: [{ id: "B001" }] }), + ); + const client = { admin: { barriers: { list: mockList } } } as any; + const result = await executeBarriersList(client, { cursor: "c", limit: 5 }); + expect(mockList).toHaveBeenCalledWith({ cursor: "c", limit: 5 }); + expect(result).toEqual([{ id: "B001" }]); + }); +}); +``` + +```typescript +// tests/commands/barriers/update.test.ts +import { describe, expect, test, mock } from "bun:test"; +import { executeBarriersUpdate } from "../../../src/commands/barriers/update"; + +describe("barriers update", () => { + test("updates a barrier", async () => { + const mockUpdate = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { barriers: { update: mockUpdate } } } as any; + await executeBarriersUpdate(client, { + barrierId: "B001", + primaryUsergroupId: "S001", + barrieredFromUsergroupIds: ["S002"], + }); + expect(mockUpdate).toHaveBeenCalledWith({ + barrier_id: "B001", + primary_usergroup_id: "S001", + barriered_from_usergroup_ids: ["S002"], + restricted_subjects: ["im", "mpim", "call"], + }); + }); +}); +``` + +### Step 2.3: index.ts 統合 + +- [ ] import を 4 件追加: + +```typescript +import { executeBarriersCreate } from "./commands/barriers/create"; +import { executeBarriersDelete } from "./commands/barriers/delete"; +import { executeBarriersList } from "./commands/barriers/list"; +import { executeBarriersUpdate } from "./commands/barriers/update"; +``` + +- [ ] パーサーを `// Auth Policy commands` セクションの後に追加: + +```typescript +// --------------------------------------------------------------------------- +// Barriers commands +// --------------------------------------------------------------------------- + +const barriersCommands = command( + "barriers", + or( + command("create", object({ + cmd: constant("barriers-create" as const), + primaryUsergroupId: option("--primary-usergroup-id", string({ metavar: "USERGROUP_ID" })), + barrieredFromUsergroupIds: option("--barriered-from-usergroup-ids", string({ metavar: "USERGROUP_IDS" })), + })), + command("delete", object({ + cmd: constant("barriers-delete" as const), + barrierId: option("--barrier-id", string({ metavar: "BARRIER_ID" })), + })), + command("list", object({ + cmd: constant("barriers-list" as const), + cursor: optional(option("--cursor", string({ metavar: "CURSOR" }))), + limit: optional(option("--limit", integer({ metavar: "LIMIT" }))), + })), + command("update", object({ + cmd: constant("barriers-update" as const), + barrierId: option("--barrier-id", string({ metavar: "BARRIER_ID" })), + primaryUsergroupId: option("--primary-usergroup-id", string({ metavar: "USERGROUP_ID" })), + barrieredFromUsergroupIds: option("--barriered-from-usergroup-ids", string({ metavar: "USERGROUP_IDS" })), + })), + ), +); +``` + +- [ ] `rootParser` の追加グループに `barriersCommands` を加える。 +- [ ] switch ケース 4 件追加: + +```typescript +case "barriers-create": { + const client = await createSlackClient(store, profileFlag); + const result = await executeBarriersCreate(client, { + primaryUsergroupId: config.primaryUsergroupId, + barrieredFromUsergroupIds: config.barrieredFromUsergroupIds.split(","), + }); + console.log(JSON.stringify(result, null, 2)); + break; +} +case "barriers-delete": { + const client = await createSlackClient(store, profileFlag); + await executeBarriersDelete(client, { barrierId: config.barrierId }); + console.log(`Barrier '${config.barrierId}' deleted.`); + break; +} +case "barriers-list": { + const client = await createSlackClient(store, profileFlag); + const barriers = await executeBarriersList(client, { cursor: config.cursor, limit: config.limit }); + const rows = (barriers as Array<{ id?: string; primary_usergroup?: { id?: string } }>).map((b) => ({ + id: b.id ?? "", + primary_usergroup_id: b.primary_usergroup?.id ?? "", + })); + console.log(formatOutput(rows, ["id", "primary_usergroup_id"], outputFormat)); + break; +} +case "barriers-update": { + const client = await createSlackClient(store, profileFlag); + const result = await executeBarriersUpdate(client, { + barrierId: config.barrierId, + primaryUsergroupId: config.primaryUsergroupId, + barrieredFromUsergroupIds: config.barrieredFromUsergroupIds.split(","), + }); + console.log(JSON.stringify(result, null, 2)); + break; +} +``` + +### Step 2.4: 検証 + コミット + +- [ ] `bun run lint` グリーン +- [ ] `bun test tests/commands/barriers/` PASS +- [ ] `git add src/commands/barriers tests/commands/barriers src/index.ts && git commit -m "feat: add barriers admin commands"` + +--- + +## Task 3: emoji グループ + +**Files:** +- Create: `src/commands/emoji/{add,add-alias,list,remove,rename}.ts` +- Create: `tests/commands/emoji/{add,add-alias,list,remove,rename}.test.ts` +- Modify: `src/index.ts` + +### Step 3.1: 実装 + +- [ ] `src/commands/emoji/add.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface EmojiAddOptions { name: string; url: string } + +export async function executeEmojiAdd( + client: WebClient, + opts: EmojiAddOptions, +): Promise { + await client.admin.emoji.add({ name: opts.name, url: opts.url }); +} +``` + +- [ ] `src/commands/emoji/add-alias.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface EmojiAddAliasOptions { name: string; aliasFor: string } + +export async function executeEmojiAddAlias( + client: WebClient, + opts: EmojiAddAliasOptions, +): Promise { + await client.admin.emoji.addAlias({ name: opts.name, alias_for: opts.aliasFor }); +} +``` + +- [ ] `src/commands/emoji/list.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface EmojiListOptions { cursor?: string; limit?: number } + +export async function executeEmojiList( + client: WebClient, + opts: EmojiListOptions, +) { + const response = await client.admin.emoji.list({ + cursor: opts.cursor, + limit: opts.limit, + }); + const emoji = (response as { emoji?: Record }).emoji ?? {}; + return Object.entries(emoji).map(([name, url]) => ({ name, url })); +} +``` + +- [ ] `src/commands/emoji/remove.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface EmojiRemoveOptions { name: string } + +export async function executeEmojiRemove( + client: WebClient, + opts: EmojiRemoveOptions, +): Promise { + await client.admin.emoji.remove({ name: opts.name }); +} +``` + +- [ ] `src/commands/emoji/rename.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface EmojiRenameOptions { name: string; newName: string } + +export async function executeEmojiRename( + client: WebClient, + opts: EmojiRenameOptions, +): Promise { + await client.admin.emoji.rename({ name: opts.name, new_name: opts.newName }); +} +``` + +### Step 3.2: テスト(5 ファイル、同パターン) + +- [ ] 各テストは Task 2 と同じく `mock()` で `client = { admin: { emoji: { : mockFn } } } as any` を作り、引数を `toHaveBeenCalledWith` で検証。代表例: + +```typescript +// tests/commands/emoji/list.test.ts +import { describe, expect, test, mock } from "bun:test"; +import { executeEmojiList } from "../../../src/commands/emoji/list"; + +describe("emoji list", () => { + test("converts emoji map into rows", async () => { + const mockList = mock(() => + Promise.resolve({ ok: true, emoji: { tada: "https://x", smile: "https://y" } }), + ); + const client = { admin: { emoji: { list: mockList } } } as any; + const result = await executeEmojiList(client, {}); + expect(mockList).toHaveBeenCalledWith({ cursor: undefined, limit: undefined }); + expect(result).toEqual([ + { name: "tada", url: "https://x" }, + { name: "smile", url: "https://y" }, + ]); + }); +}); +``` + +他 4 ファイル(`add`, `add-alias`, `remove`, `rename`)は引数検証のみ。例: + +```typescript +// tests/commands/emoji/add-alias.test.ts +import { describe, expect, test, mock } from "bun:test"; +import { executeEmojiAddAlias } from "../../../src/commands/emoji/add-alias"; + +describe("emoji add-alias", () => { + test("calls admin.emoji.addAlias", async () => { + const mockAddAlias = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { emoji: { addAlias: mockAddAlias } } } as any; + await executeEmojiAddAlias(client, { name: "party_tada", aliasFor: "tada" }); + expect(mockAddAlias).toHaveBeenCalledWith({ name: "party_tada", alias_for: "tada" }); + }); +}); +``` + +### Step 3.3: index.ts + +- [ ] import 5 件、パーサー定義、switch ケース 5 件追加: + +```typescript +const emojiCommands = command( + "emoji", + or( + command("add", object({ + cmd: constant("emoji-add" as const), + name: option("--name", string({ metavar: "NAME" })), + url: option("--url", string({ metavar: "URL" })), + })), + command("add-alias", object({ + cmd: constant("emoji-add-alias" as const), + name: option("--name", string({ metavar: "NAME" })), + aliasFor: option("--alias-for", string({ metavar: "ALIAS_FOR" })), + })), + command("list", object({ + cmd: constant("emoji-list" as const), + cursor: optional(option("--cursor", string({ metavar: "CURSOR" }))), + limit: optional(option("--limit", integer({ metavar: "LIMIT" }))), + })), + command("remove", object({ + cmd: constant("emoji-remove" as const), + name: option("--name", string({ metavar: "NAME" })), + })), + command("rename", object({ + cmd: constant("emoji-rename" as const), + name: option("--name", string({ metavar: "NAME" })), + newName: option("--new-name", string({ metavar: "NEW_NAME" })), + })), + ), +); +``` + +switch ケース: + +```typescript +case "emoji-add": { + const client = await createSlackClient(store, profileFlag); + await executeEmojiAdd(client, { name: config.name, url: config.url }); + console.log(`Emoji '${config.name}' added.`); + break; +} +case "emoji-add-alias": { + const client = await createSlackClient(store, profileFlag); + await executeEmojiAddAlias(client, { name: config.name, aliasFor: config.aliasFor }); + console.log(`Alias '${config.name}' for '${config.aliasFor}' added.`); + break; +} +case "emoji-list": { + const client = await createSlackClient(store, profileFlag); + const rows = await executeEmojiList(client, { cursor: config.cursor, limit: config.limit }); + console.log(formatOutput(rows, ["name", "url"], outputFormat)); + break; +} +case "emoji-remove": { + const client = await createSlackClient(store, profileFlag); + await executeEmojiRemove(client, { name: config.name }); + console.log(`Emoji '${config.name}' removed.`); + break; +} +case "emoji-rename": { + const client = await createSlackClient(store, profileFlag); + await executeEmojiRename(client, { name: config.name, newName: config.newName }); + console.log(`Emoji renamed '${config.name}' -> '${config.newName}'.`); + break; +} +``` + +- [ ] `rootParser` に `emojiCommands` を加える。 + +### Step 3.4: 検証 + コミット + +- [ ] `bun run lint` && `bun test tests/commands/emoji/` +- [ ] `git add src/commands/emoji tests/commands/emoji src/index.ts && git commit -m "feat: add emoji admin commands"` + +--- + +## Task 4: roles グループ + +**Files:** +- Create: `src/commands/roles/{add-assignments,list-assignments,remove-assignments}.ts` +- Create: `tests/commands/roles/{add-assignments,list-assignments,remove-assignments}.test.ts` +- Modify: `src/index.ts` + +### Step 4.1: 実装 + +- [ ] `src/commands/roles/add-assignments.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface RolesAddAssignmentsOptions { + roleId: string; + entityIds: [string, ...string[]]; + userIds: [string, ...string[]]; +} + +export async function executeRolesAddAssignments( + client: WebClient, + opts: RolesAddAssignmentsOptions, +): Promise { + await client.admin.roles.addAssignments({ + role_id: opts.roleId, + entity_ids: opts.entityIds, + user_ids: opts.userIds, + }); +} +``` + +- [ ] `src/commands/roles/list-assignments.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface RolesListAssignmentsOptions { + entityIds?: string[]; + roleIds?: string[]; + cursor?: string; + limit?: number; + sortDir?: "asc" | "desc"; +} + +export async function executeRolesListAssignments( + client: WebClient, + opts: RolesListAssignmentsOptions, +) { + const params: Record = {}; + if (opts.entityIds !== undefined) params.entity_ids = opts.entityIds; + if (opts.roleIds !== undefined) params.role_ids = opts.roleIds; + if (opts.cursor !== undefined) params.cursor = opts.cursor; + if (opts.limit !== undefined) params.limit = opts.limit; + if (opts.sortDir !== undefined) params.sort_dir = opts.sortDir; + const response = await client.apiCall("admin.roles.listAssignments", params); + return (response as { role_assignments?: unknown[] }).role_assignments ?? []; +} +``` + +- [ ] `src/commands/roles/remove-assignments.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface RolesRemoveAssignmentsOptions { + roleId: string; + entityIds: [string, ...string[]]; + userIds: [string, ...string[]]; +} + +export async function executeRolesRemoveAssignments( + client: WebClient, + opts: RolesRemoveAssignmentsOptions, +): Promise { + await client.admin.roles.removeAssignments({ + role_id: opts.roleId, + entity_ids: opts.entityIds, + user_ids: opts.userIds, + }); +} +``` + +### Step 4.2: テスト 3 ファイル + +```typescript +// tests/commands/roles/add-assignments.test.ts +import { describe, expect, test, mock } from "bun:test"; +import { executeRolesAddAssignments } from "../../../src/commands/roles/add-assignments"; + +describe("roles add-assignments", () => { + test("calls admin.roles.addAssignments", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { roles: { addAssignments: mockFn } } } as any; + await executeRolesAddAssignments(client, { + roleId: "Rl0123", + entityIds: ["T001"], + userIds: ["U001", "U002"], + }); + expect(mockFn).toHaveBeenCalledWith({ + role_id: "Rl0123", + entity_ids: ["T001"], + user_ids: ["U001", "U002"], + }); + }); +}); +``` + +```typescript +// tests/commands/roles/list-assignments.test.ts +import { describe, expect, test, mock } from "bun:test"; +import { executeRolesListAssignments } from "../../../src/commands/roles/list-assignments"; + +describe("roles list-assignments", () => { + test("returns role_assignments", async () => { + const mockApiCall = mock(() => + Promise.resolve({ ok: true, role_assignments: [{ role_id: "Rl0", user_id: "U1" }] }), + ); + const client = { apiCall: mockApiCall } as any; + const result = await executeRolesListAssignments(client, { + roleIds: ["Rl0"], + cursor: "c", + limit: 5, + sortDir: "asc", + }); + expect(mockApiCall).toHaveBeenCalledWith("admin.roles.listAssignments", { + role_ids: ["Rl0"], + cursor: "c", + limit: 5, + sort_dir: "asc", + }); + expect(result).toEqual([{ role_id: "Rl0", user_id: "U1" }]); + }); +}); +``` + +```typescript +// tests/commands/roles/remove-assignments.test.ts +import { describe, expect, test, mock } from "bun:test"; +import { executeRolesRemoveAssignments } from "../../../src/commands/roles/remove-assignments"; + +describe("roles remove-assignments", () => { + test("calls admin.roles.removeAssignments", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { roles: { removeAssignments: mockFn } } } as any; + await executeRolesRemoveAssignments(client, { + roleId: "Rl0", + entityIds: ["T001"], + userIds: ["U001"], + }); + expect(mockFn).toHaveBeenCalledWith({ + role_id: "Rl0", + entity_ids: ["T001"], + user_ids: ["U001"], + }); + }); +}); +``` + +### Step 4.3: index.ts + +- [ ] パーサー: + +```typescript +const rolesCommands = command( + "roles", + or( + command("add-assignments", object({ + cmd: constant("roles-add-assignments" as const), + roleId: option("--role-id", string({ metavar: "ROLE_ID" })), + entityIds: option("--entity-ids", string({ metavar: "ENTITY_IDS" })), + userIds: option("--user-ids", string({ metavar: "USER_IDS" })), + })), + command("list-assignments", object({ + cmd: constant("roles-list-assignments" as const), + entityIds: optional(option("--entity-ids", string({ metavar: "ENTITY_IDS" }))), + roleIds: optional(option("--role-ids", string({ metavar: "ROLE_IDS" }))), + cursor: optional(option("--cursor", string({ metavar: "CURSOR" }))), + limit: optional(option("--limit", integer({ metavar: "LIMIT" }))), + sortDir: optional(option("--sort-dir", string({ metavar: "DIR" }))), + })), + command("remove-assignments", object({ + cmd: constant("roles-remove-assignments" as const), + roleId: option("--role-id", string({ metavar: "ROLE_ID" })), + entityIds: option("--entity-ids", string({ metavar: "ENTITY_IDS" })), + userIds: option("--user-ids", string({ metavar: "USER_IDS" })), + })), + ), +); +``` + +- [ ] switch ケース: + +```typescript +case "roles-add-assignments": { + const client = await createSlackClient(store, profileFlag); + const entityParts = config.entityIds.split(","); + const userParts = config.userIds.split(","); + const eFirst = entityParts[0]; const uFirst = userParts[0]; + if (eFirst === undefined) throw new Error("--entity-ids must not be empty"); + if (uFirst === undefined) throw new Error("--user-ids must not be empty"); + await executeRolesAddAssignments(client, { + roleId: config.roleId, + entityIds: [eFirst, ...entityParts.slice(1)], + userIds: [uFirst, ...userParts.slice(1)], + }); + console.log(`Role '${config.roleId}' assigned.`); + break; +} +case "roles-list-assignments": { + const client = await createSlackClient(store, profileFlag); + const sortDir = + config.sortDir === "asc" || config.sortDir === "desc" ? config.sortDir : undefined; + if (config.sortDir !== undefined && sortDir === undefined) { + throw new Error('--sort-dir must be "asc" or "desc"'); + } + const assignments = await executeRolesListAssignments(client, { + entityIds: config.entityIds?.split(","), + roleIds: config.roleIds?.split(","), + cursor: config.cursor, + limit: config.limit, + sortDir, + }); + const rows = (assignments as Array<{ role_id?: string; entity_id?: string; user_id?: string }>).map((a) => ({ + role_id: a.role_id ?? "", + entity_id: a.entity_id ?? "", + user_id: a.user_id ?? "", + })); + console.log(formatOutput(rows, ["role_id", "entity_id", "user_id"], outputFormat)); + break; +} +case "roles-remove-assignments": { + const client = await createSlackClient(store, profileFlag); + const entityParts = config.entityIds.split(","); + const userParts = config.userIds.split(","); + const eFirst = entityParts[0]; const uFirst = userParts[0]; + if (eFirst === undefined) throw new Error("--entity-ids must not be empty"); + if (uFirst === undefined) throw new Error("--user-ids must not be empty"); + await executeRolesRemoveAssignments(client, { + roleId: config.roleId, + entityIds: [eFirst, ...entityParts.slice(1)], + userIds: [uFirst, ...userParts.slice(1)], + }); + console.log(`Role '${config.roleId}' removed.`); + break; +} +``` + +- [ ] `rootParser` に追加。 + +### Step 4.4: 検証 + コミット + +- [ ] `bun run lint` && `bun test tests/commands/roles/` +- [ ] `git commit -m "feat: add roles admin commands"` + +--- + +## Task 5: usergroups グループ + +**Files:** +- Create: `src/commands/usergroups/{add-channels,add-teams,list-channels,remove-channels}.ts` +- Create: `tests/commands/usergroups/{add-channels,add-teams,list-channels,remove-channels}.test.ts` +- Modify: `src/index.ts` + +### Step 5.1: 実装 + +- [ ] `src/commands/usergroups/add-channels.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface UsergroupsAddChannelsOptions { + usergroupId: string; + channelIds: string[]; + teamId?: string; +} + +export async function executeUsergroupsAddChannels( + client: WebClient, + opts: UsergroupsAddChannelsOptions, +): Promise { + await client.admin.usergroups.addChannels({ + usergroup_id: opts.usergroupId, + channel_ids: opts.channelIds, + team_id: opts.teamId, + }); +} +``` + +- [ ] `src/commands/usergroups/add-teams.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface UsergroupsAddTeamsOptions { + usergroupId: string; + teamIds: string[]; + autoProvision?: boolean; +} + +export async function executeUsergroupsAddTeams( + client: WebClient, + opts: UsergroupsAddTeamsOptions, +): Promise { + await client.admin.usergroups.addTeams({ + usergroup_id: opts.usergroupId, + team_ids: opts.teamIds, + auto_provision: opts.autoProvision, + }); +} +``` + +- [ ] `src/commands/usergroups/list-channels.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface UsergroupsListChannelsOptions { + usergroupId: string; + teamId?: string; + includeNumMembers?: boolean; +} + +export async function executeUsergroupsListChannels( + client: WebClient, + opts: UsergroupsListChannelsOptions, +) { + const response = await client.admin.usergroups.listChannels({ + usergroup_id: opts.usergroupId, + team_id: opts.teamId, + include_num_members: opts.includeNumMembers, + }); + return response.channels ?? []; +} +``` + +- [ ] `src/commands/usergroups/remove-channels.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface UsergroupsRemoveChannelsOptions { + usergroupId: string; + channelIds: string[]; +} + +export async function executeUsergroupsRemoveChannels( + client: WebClient, + opts: UsergroupsRemoveChannelsOptions, +): Promise { + await client.admin.usergroups.removeChannels({ + usergroup_id: opts.usergroupId, + channel_ids: opts.channelIds, + }); +} +``` + +### Step 5.2: テスト 4 ファイル(同パターン、`mock` + `toHaveBeenCalledWith`) + +代表例: +```typescript +// tests/commands/usergroups/list-channels.test.ts +import { describe, expect, test, mock } from "bun:test"; +import { executeUsergroupsListChannels } from "../../../src/commands/usergroups/list-channels"; + +describe("usergroups list-channels", () => { + test("returns channels array", async () => { + const mockFn = mock(() => + Promise.resolve({ ok: true, channels: [{ id: "C001" }] }), + ); + const client = { admin: { usergroups: { listChannels: mockFn } } } as any; + const result = await executeUsergroupsListChannels(client, { + usergroupId: "S001", + teamId: "T001", + includeNumMembers: true, + }); + expect(mockFn).toHaveBeenCalledWith({ + usergroup_id: "S001", + team_id: "T001", + include_num_members: true, + }); + expect(result).toEqual([{ id: "C001" }]); + }); +}); +``` + +他 3 ファイルも同形式(add-channels / add-teams / remove-channels)。 + +### Step 5.3: index.ts + +- [ ] パーサー: + +```typescript +const usergroupsCommands = command( + "usergroups", + or( + command("add-channels", object({ + cmd: constant("usergroups-add-channels" as const), + usergroupId: option("--usergroup-id", string({ metavar: "USERGROUP_ID" })), + channelIds: option("--channel-ids", string({ metavar: "CHANNEL_IDS" })), + teamId: optional(option("--team-id", string({ metavar: "TEAM_ID" }))), + })), + command("add-teams", object({ + cmd: constant("usergroups-add-teams" as const), + usergroupId: option("--usergroup-id", string({ metavar: "USERGROUP_ID" })), + teamIds: option("--team-ids", string({ metavar: "TEAM_IDS" })), + autoProvision: optional(option("--auto-provision", boolValueParser)), + })), + command("list-channels", object({ + cmd: constant("usergroups-list-channels" as const), + usergroupId: option("--usergroup-id", string({ metavar: "USERGROUP_ID" })), + teamId: optional(option("--team-id", string({ metavar: "TEAM_ID" }))), + includeNumMembers: optional(option("--include-num-members", boolValueParser)), + })), + command("remove-channels", object({ + cmd: constant("usergroups-remove-channels" as const), + usergroupId: option("--usergroup-id", string({ metavar: "USERGROUP_ID" })), + channelIds: option("--channel-ids", string({ metavar: "CHANNEL_IDS" })), + })), + ), +); +``` + +- [ ] switch ケース: + +```typescript +case "usergroups-add-channels": { + const client = await createSlackClient(store, profileFlag); + await executeUsergroupsAddChannels(client, { + usergroupId: config.usergroupId, + channelIds: config.channelIds.split(","), + teamId: config.teamId, + }); + console.log(`Channels added to usergroup '${config.usergroupId}'.`); + break; +} +case "usergroups-add-teams": { + const client = await createSlackClient(store, profileFlag); + await executeUsergroupsAddTeams(client, { + usergroupId: config.usergroupId, + teamIds: config.teamIds.split(","), + autoProvision: config.autoProvision, + }); + console.log(`Teams added to usergroup '${config.usergroupId}'.`); + break; +} +case "usergroups-list-channels": { + const client = await createSlackClient(store, profileFlag); + const channels = await executeUsergroupsListChannels(client, { + usergroupId: config.usergroupId, + teamId: config.teamId, + includeNumMembers: config.includeNumMembers, + }); + const rows = (channels as Array<{ id?: string; name?: string; num_members?: number }>).map((c) => ({ + id: c.id ?? "", + name: c.name ?? "", + num_members: c.num_members ?? "", + })); + console.log(formatOutput(rows, ["id", "name", "num_members"], outputFormat)); + break; +} +case "usergroups-remove-channels": { + const client = await createSlackClient(store, profileFlag); + await executeUsergroupsRemoveChannels(client, { + usergroupId: config.usergroupId, + channelIds: config.channelIds.split(","), + }); + console.log(`Channels removed from usergroup '${config.usergroupId}'.`); + break; +} +``` + +- [ ] `rootParser` 追加。 + +### Step 5.4: 検証 + コミット + +- [ ] `bun run lint` && `bun test tests/commands/usergroups/` +- [ ] `git commit -m "feat: add usergroups admin commands"` + +--- + +## Task 6: teams settings set-default-channels + +**Files:** +- Create: `src/commands/teams/settings/set-default-channels.ts` +- Create: `tests/commands/teams/settings/set-default-channels.test.ts` +- Modify: `src/index.ts`(teamsSettingsCommands に追加 + switch) + +### Step 6.1: 実装 + +- [ ] `src/commands/teams/settings/set-default-channels.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface SetDefaultChannelsOptions { + teamId: string; + channelIds: string[]; +} + +export async function executeSetDefaultChannels( + client: WebClient, + opts: SetDefaultChannelsOptions, +): Promise { + await client.admin.teams.settings.setDefaultChannels({ + team_id: opts.teamId, + channel_ids: opts.channelIds, + }); +} +``` + +### Step 6.2: テスト + +- [ ] `tests/commands/teams/settings/set-default-channels.test.ts` + +```typescript +import { describe, expect, test, mock } from "bun:test"; +import { executeSetDefaultChannels } from "../../../../src/commands/teams/settings/set-default-channels"; + +describe("teams settings set-default-channels", () => { + test("calls admin.teams.settings.setDefaultChannels", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { teams: { settings: { setDefaultChannels: mockFn } } } } as any; + await executeSetDefaultChannels(client, { + teamId: "T001", + channelIds: ["C001", "C002"], + }); + expect(mockFn).toHaveBeenCalledWith({ + team_id: "T001", + channel_ids: ["C001", "C002"], + }); + }); +}); +``` + +### Step 6.3: index.ts + +- [ ] import 追加: + +```typescript +import { executeSetDefaultChannels } from "./commands/teams/settings/set-default-channels"; +``` + +- [ ] `teamsSettingsCommands` の `or(...)` 内に追加: + +```typescript +command("set-default-channels", object({ + cmd: constant("teams-settings-set-default-channels" as const), + teamId: option("--team-id", string({ metavar: "TEAM_ID" })), + channelIds: option("--channel-ids", string({ metavar: "CHANNEL_IDS" })), +})), +``` + +- [ ] switch ケース: + +```typescript +case "teams-settings-set-default-channels": { + const client = await createSlackClient(store, profileFlag); + await executeSetDefaultChannels(client, { + teamId: config.teamId, + channelIds: config.channelIds.split(","), + }); + console.log("Team default channels updated."); + break; +} +``` + +### Step 6.4: 検証 + コミット + +- [ ] `bun run lint` && `bun test tests/commands/teams/settings/set-default-channels.test.ts` +- [ ] `git commit -m "feat: add teams settings set-default-channels"` + +--- + +## Task 7: users set-expiration + users unsupported-versions export + +**Files:** +- Create: `src/commands/users/set-expiration.ts` +- Create: `src/commands/users/unsupported-versions/export.ts` +- Create: `tests/commands/users/set-expiration.test.ts` +- Create: `tests/commands/users/unsupported-versions/export.test.ts` +- Modify: `src/index.ts` + +### Step 7.1: 実装 + +- [ ] `src/commands/users/set-expiration.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface UsersSetExpirationOptions { + userId: string; + expirationTs: number; + teamId?: string; +} + +export async function executeUsersSetExpiration( + client: WebClient, + opts: UsersSetExpirationOptions, +): Promise { + await client.admin.users.setExpiration({ + user_id: opts.userId, + expiration_ts: opts.expirationTs, + team_id: opts.teamId, + }); +} +``` + +- [ ] `src/commands/users/unsupported-versions/export.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface UsersUnsupportedVersionsExportOptions { + dateEndOfSupport?: number; + dateSessionsStarted?: number; +} + +export async function executeUsersUnsupportedVersionsExport( + client: WebClient, + opts: UsersUnsupportedVersionsExportOptions, +): Promise { + await client.admin.users.unsupportedVersions.export({ + date_end_of_support: opts.dateEndOfSupport, + date_sessions_started: opts.dateSessionsStarted, + }); +} +``` + +### Step 7.2: テスト + +- [ ] `tests/commands/users/set-expiration.test.ts` + +```typescript +import { describe, expect, test, mock } from "bun:test"; +import { executeUsersSetExpiration } from "../../../src/commands/users/set-expiration"; + +describe("users set-expiration", () => { + test("calls admin.users.setExpiration", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { users: { setExpiration: mockFn } } } as any; + await executeUsersSetExpiration(client, { + userId: "U001", + expirationTs: 1700000000, + teamId: "T001", + }); + expect(mockFn).toHaveBeenCalledWith({ + user_id: "U001", + expiration_ts: 1700000000, + team_id: "T001", + }); + }); +}); +``` + +- [ ] `tests/commands/users/unsupported-versions/export.test.ts` + +```typescript +import { describe, expect, test, mock } from "bun:test"; +import { executeUsersUnsupportedVersionsExport } from "../../../../src/commands/users/unsupported-versions/export"; + +describe("users unsupported-versions export", () => { + test("calls admin.users.unsupportedVersions.export", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { + admin: { users: { unsupportedVersions: { export: mockFn } } }, + } as any; + await executeUsersUnsupportedVersionsExport(client, { + dateEndOfSupport: 1700000000, + dateSessionsStarted: 1690000000, + }); + expect(mockFn).toHaveBeenCalledWith({ + date_end_of_support: 1700000000, + date_sessions_started: 1690000000, + }); + }); +}); +``` + +### Step 7.3: index.ts + +- [ ] import 追加: + +```typescript +import { executeUsersSetExpiration } from "./commands/users/set-expiration"; +import { executeUsersUnsupportedVersionsExport } from "./commands/users/unsupported-versions/export"; +``` + +- [ ] `usersCommands` の `or(...)` 内に追加: + +```typescript +command("set-expiration", object({ + cmd: constant("users-set-expiration" as const), + userId: option("--user-id", string({ metavar: "USER_ID" })), + expirationTs: option("--expiration-ts", integer({ metavar: "TIMESTAMP" })), + teamId: optional(option("--team-id", string({ metavar: "TEAM_ID" }))), +})), +command("unsupported-versions", command("export", object({ + cmd: constant("users-unsupported-versions-export" as const), + dateEndOfSupport: optional(option("--date-end-of-support", integer({ metavar: "TIMESTAMP" }))), + dateSessionsStarted: optional(option("--date-sessions-started", integer({ metavar: "TIMESTAMP" }))), +}))), +``` + +- [ ] switch ケース: + +```typescript +case "users-set-expiration": { + const client = await createSlackClient(store, profileFlag); + await executeUsersSetExpiration(client, { + userId: config.userId, + expirationTs: config.expirationTs, + teamId: config.teamId, + }); + console.log(`User '${config.userId}' expiration set.`); + break; +} +case "users-unsupported-versions-export": { + const client = await createSlackClient(store, profileFlag); + await executeUsersUnsupportedVersionsExport(client, { + dateEndOfSupport: config.dateEndOfSupport, + dateSessionsStarted: config.dateSessionsStarted, + }); + console.log("Unsupported versions export requested."); + break; +} +``` + +### Step 7.4: 検証 + コミット + +- [ ] `bun run lint` && `bun test tests/commands/users/set-expiration.test.ts tests/commands/users/unsupported-versions/` +- [ ] `git commit -m "feat: add users set-expiration and unsupported-versions export"` + +--- + +## Task 8: users session.* (6 commands) + +**Files:** +- Create: `src/commands/users/session/{clear-settings,get-settings,invalidate,list,reset-bulk,set-settings}.ts` +- Create: `tests/commands/users/session/{clear-settings,get-settings,invalidate,list,reset-bulk,set-settings}.test.ts` +- Modify: `src/index.ts`(既存 `users session` サブコマンド `reset` の隣に追加) + +### Step 8.1: 実装 + +- [ ] `src/commands/users/session/clear-settings.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface SessionClearSettingsOptions { userIds: [string, ...string[]] } + +export async function executeUsersSessionClearSettings( + client: WebClient, + opts: SessionClearSettingsOptions, +): Promise { + await client.admin.users.session.clearSettings({ user_ids: opts.userIds }); +} +``` + +- [ ] `src/commands/users/session/get-settings.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface SessionGetSettingsOptions { userIds: [string, ...string[]] } + +export async function executeUsersSessionGetSettings( + client: WebClient, + opts: SessionGetSettingsOptions, +) { + const response = await client.admin.users.session.getSettings({ user_ids: opts.userIds }); + return response.session_settings ?? []; +} +``` + +- [ ] `src/commands/users/session/invalidate.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface SessionInvalidateOptions { + teamId: string; + sessionId: string; +} + +export async function executeUsersSessionInvalidate( + client: WebClient, + opts: SessionInvalidateOptions, +): Promise { + await client.admin.users.session.invalidate({ + team_id: opts.teamId, + session_id: opts.sessionId, + }); +} +``` + +- [ ] `src/commands/users/session/list.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface SessionListOptions { + teamId?: string; + userId?: string; + cursor?: string; + limit?: number; +} + +export async function executeUsersSessionList( + client: WebClient, + opts: SessionListOptions, +) { + const params: Record = {}; + if (opts.teamId !== undefined && opts.userId !== undefined) { + params.team_id = opts.teamId; + params.user_id = opts.userId; + } else if (opts.teamId !== undefined || opts.userId !== undefined) { + throw new Error("--team-id and --user-id must be provided together (or neither)"); + } + if (opts.cursor !== undefined) params.cursor = opts.cursor; + if (opts.limit !== undefined) params.limit = opts.limit; + const response = await client.apiCall("admin.users.session.list", params); + return (response as { active_sessions?: unknown[] }).active_sessions ?? []; +} +``` + +- [ ] `src/commands/users/session/reset-bulk.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface SessionResetBulkOptions { + userIds: [string, ...string[]]; + mobileOnly?: boolean; + webOnly?: boolean; +} + +export async function executeUsersSessionResetBulk( + client: WebClient, + opts: SessionResetBulkOptions, +): Promise { + await client.admin.users.session.resetBulk({ + user_ids: opts.userIds, + mobile_only: opts.mobileOnly, + web_only: opts.webOnly, + }); +} +``` + +- [ ] `src/commands/users/session/set-settings.ts` + +```typescript +import type { WebClient } from "@slack/web-api"; + +interface SessionSetSettingsOptions { + userIds: [string, ...string[]]; + desktopAppBrowserQuit?: boolean; + duration?: number; +} + +export async function executeUsersSessionSetSettings( + client: WebClient, + opts: SessionSetSettingsOptions, +): Promise { + await client.admin.users.session.setSettings({ + user_ids: opts.userIds, + desktop_app_browser_quit: opts.desktopAppBrowserQuit, + duration: opts.duration, + }); +} +``` + +### Step 8.2: テスト 6 ファイル + +代表例: + +```typescript +// tests/commands/users/session/list.test.ts +import { describe, expect, test, mock } from "bun:test"; +import { executeUsersSessionList } from "../../../../src/commands/users/session/list"; + +describe("users session list", () => { + test("returns active_sessions array with team+user", async () => { + const mockApiCall = mock(() => + Promise.resolve({ ok: true, active_sessions: [{ session_id: "S1" }] }), + ); + const client = { apiCall: mockApiCall } as any; + const result = await executeUsersSessionList(client, { + teamId: "T001", + userId: "U001", + cursor: "c", + limit: 5, + }); + expect(mockApiCall).toHaveBeenCalledWith("admin.users.session.list", { + team_id: "T001", + user_id: "U001", + cursor: "c", + limit: 5, + }); + expect(result).toEqual([{ session_id: "S1" }]); + }); + + test("works with neither team nor user", async () => { + const mockApiCall = mock(() => Promise.resolve({ ok: true, active_sessions: [] })); + const client = { apiCall: mockApiCall } as any; + await executeUsersSessionList(client, {}); + expect(mockApiCall).toHaveBeenCalledWith("admin.users.session.list", {}); + }); + + test("rejects partial team/user", async () => { + const client = { apiCall: mock(() => Promise.resolve({ ok: true })) } as any; + await expect(executeUsersSessionList(client, { teamId: "T001" })).rejects.toThrow(); + }); +}); +``` + +他 5 ファイルは引数検証のみ(同パターン)。例: + +```typescript +// tests/commands/users/session/clear-settings.test.ts +import { describe, expect, test, mock } from "bun:test"; +import { executeUsersSessionClearSettings } from "../../../../src/commands/users/session/clear-settings"; + +describe("users session clear-settings", () => { + test("calls admin.users.session.clearSettings", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { users: { session: { clearSettings: mockFn } } } } as any; + await executeUsersSessionClearSettings(client, { userIds: ["U001", "U002"] }); + expect(mockFn).toHaveBeenCalledWith({ user_ids: ["U001", "U002"] }); + }); +}); +``` + +### Step 8.3: index.ts + +- [ ] import 追加(6 件): + +```typescript +import { executeUsersSessionClearSettings } from "./commands/users/session/clear-settings"; +import { executeUsersSessionGetSettings } from "./commands/users/session/get-settings"; +import { executeUsersSessionInvalidate } from "./commands/users/session/invalidate"; +import { executeUsersSessionList } from "./commands/users/session/list"; +import { executeUsersSessionResetBulk } from "./commands/users/session/reset-bulk"; +import { executeUsersSessionSetSettings } from "./commands/users/session/set-settings"; +``` + +- [ ] 既存の `usersCommands` 内、`command("session", command("reset", ...))` を `or` 化して 7 サブコマンドにする。**注意**:現状 `command("session", command("reset", ...))` の形なので、これを `command("session", or(command("reset", ...), ...))` へ変更する。 + +```typescript +command("session", or( + command("reset", object({ + cmd: constant("users-session-reset" as const), + userId: option("--user-id", string({ metavar: "USER_ID" })), + mobileOnly: optional(option("--mobile-only", boolValueParser)), + webOnly: optional(option("--web-only", boolValueParser)), + })), + command("clear-settings", object({ + cmd: constant("users-session-clear-settings" as const), + userIds: option("--user-ids", string({ metavar: "USER_IDS" })), + })), + command("get-settings", object({ + cmd: constant("users-session-get-settings" as const), + userIds: option("--user-ids", string({ metavar: "USER_IDS" })), + })), + command("invalidate", object({ + cmd: constant("users-session-invalidate" as const), + teamId: option("--team-id", string({ metavar: "TEAM_ID" })), + sessionId: option("--session-id", string({ metavar: "SESSION_ID" })), + })), + command("list", object({ + cmd: constant("users-session-list" as const), + teamId: optional(option("--team-id", string({ metavar: "TEAM_ID" }))), + userId: optional(option("--user-id", string({ metavar: "USER_ID" }))), + cursor: optional(option("--cursor", string({ metavar: "CURSOR" }))), + limit: optional(option("--limit", integer({ metavar: "LIMIT" }))), + })), + command("reset-bulk", object({ + cmd: constant("users-session-reset-bulk" as const), + userIds: option("--user-ids", string({ metavar: "USER_IDS" })), + mobileOnly: optional(option("--mobile-only", boolValueParser)), + webOnly: optional(option("--web-only", boolValueParser)), + })), + command("set-settings", object({ + cmd: constant("users-session-set-settings" as const), + userIds: option("--user-ids", string({ metavar: "USER_IDS" })), + desktopAppBrowserQuit: optional(option("--desktop-app-browser-quit", boolValueParser)), + duration: optional(option("--duration", integer({ metavar: "SECONDS" }))), + })), +)), +``` + +- [ ] switch ケース 6 件: + +```typescript +case "users-session-clear-settings": { + const client = await createSlackClient(store, profileFlag); + const parts = config.userIds.split(","); + const first = parts[0]; if (first === undefined) throw new Error("--user-ids must not be empty"); + await executeUsersSessionClearSettings(client, { userIds: [first, ...parts.slice(1)] }); + console.log("Session settings cleared."); + break; +} +case "users-session-get-settings": { + const client = await createSlackClient(store, profileFlag); + const parts = config.userIds.split(","); + const first = parts[0]; if (first === undefined) throw new Error("--user-ids must not be empty"); + const settings = await executeUsersSessionGetSettings(client, { userIds: [first, ...parts.slice(1)] }); + console.log(JSON.stringify(settings, null, 2)); + break; +} +case "users-session-invalidate": { + const client = await createSlackClient(store, profileFlag); + await executeUsersSessionInvalidate(client, { + teamId: config.teamId, + sessionId: config.sessionId, + }); + console.log(`Session '${config.sessionId}' invalidated.`); + break; +} +case "users-session-list": { + const client = await createSlackClient(store, profileFlag); + const sessions = await executeUsersSessionList(client, { + teamId: config.teamId, + userId: config.userId, + cursor: config.cursor, + limit: config.limit, + }); + const rows = (sessions as Array<{ session_id?: string; user_id?: string; team_id?: string }>).map((s) => ({ + session_id: s.session_id ?? "", + user_id: s.user_id ?? "", + team_id: s.team_id ?? "", + })); + console.log(formatOutput(rows, ["session_id", "user_id", "team_id"], outputFormat)); + break; +} +case "users-session-reset-bulk": { + const client = await createSlackClient(store, profileFlag); + const parts = config.userIds.split(","); + const first = parts[0]; if (first === undefined) throw new Error("--user-ids must not be empty"); + await executeUsersSessionResetBulk(client, { + userIds: [first, ...parts.slice(1)], + mobileOnly: config.mobileOnly, + webOnly: config.webOnly, + }); + console.log("Bulk session reset requested."); + break; +} +case "users-session-set-settings": { + const client = await createSlackClient(store, profileFlag); + const parts = config.userIds.split(","); + const first = parts[0]; if (first === undefined) throw new Error("--user-ids must not be empty"); + await executeUsersSessionSetSettings(client, { + userIds: [first, ...parts.slice(1)], + desktopAppBrowserQuit: config.desktopAppBrowserQuit, + duration: config.duration, + }); + console.log("Session settings updated."); + break; +} +``` + +### Step 8.4: 検証 + コミット + +- [ ] `bun run lint` && `bun test tests/commands/users/session/` +- [ ] `git commit -m "feat: add users session admin commands"` + +--- + +## Task 9: README 更新 + +**Files:** +- Modify: `README.md` + +### Step 9.1: 既存のコマンド一覧表を確認し、追加 + +- [ ] `README.md` の Commands セクションを `Read` で確認。 +- [ ] 各既存グループ表に新コマンド行を追加: + - `auth-policy assign-entities | get-entities | remove-entities` + - `barriers create | delete | list | update` + - `emoji add | add-alias | list | remove | rename` + - `roles add-assignments | list-assignments | remove-assignments` + - `usergroups add-channels | add-teams | list-channels | remove-channels` + - `teams settings set-default-channels` + - `users set-expiration` + - `users unsupported-versions export` + - `users session clear-settings | get-settings | invalidate | list | reset-bulk | set-settings` +- [ ] コマンド総数の記述("79 commands" 等)を **107 commands**(79 + 28)に更新。 +- [ ] `git commit -m "docs: update README with new admin commands"` + +--- + +## Task 10: SKILL.md 更新 + +**Files:** +- Modify: `skills/slack-admin-cli-skill/SKILL.md` + +### Step 10.1: 新グループ・新サブコマンドを追記 + +- [ ] `Read` で既存スタイルを確認。 +- [ ] 新コマンド一覧を該当セクション(コマンドリファレンス、使用例)に追加。 +- [ ] `git commit -m "docs: update skill with new admin commands"` + +--- + +## Task 11: 統合検証 + +- [ ] `bun run lint` 全体グリーン +- [ ] `bun test` 全体グリーン +- [ ] `bun run dev -- --help` で全 28 サブコマンドがヘルプに表示されることを目視確認 +- [ ] `bun run dev -- auth-policy --help` 等、新グループの help 出力を 1 つずつ確認 + +--- + +## Task 12: PR 作成 + +- [ ] `git push -u origin feat/admin-api-coverage` +- [ ] `gh pr create` で PR 作成: + +```bash +gh pr create --title "feat: cover remaining SDK-supported admin.* methods" --body "$(cat <<'EOF' +## Summary +- SDK 対応済みかつ未実装の admin.* メソッド 28 個を追加 +- 新グループ: auth-policy / barriers / emoji / roles / usergroups +- 既存グループ拡張: teams settings set-default-channels, users set-expiration / unsupported-versions export / session.* +- README と Skill ドキュメントを更新 + +仕様書: `docs/superpowers/specs/2026-04-17-admin-api-coverage-design.md` +実装計画: `docs/superpowers/plans/2026-04-17-admin-api-coverage.md` + +## Test plan +- [x] `bun run lint` グリーン +- [x] `bun test` 全 PASS +- [x] `bun run dev -- --help` で全新コマンド表示確認 +- [ ] 実 Slack ワークスペースでスモークテスト(reviewer 任意) + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] PR URL を出力。 + +--- + +## スコープ外(実装しない) + +SDK 非対応のため対応しない: + +- `admin.analytics.*`、`admin.audit.anomaly.allow.*` +- `admin.conversations.{bulkSetExcludeFromSlackAi, createForObjects, linkObjects, unlinkObjects}` +- `admin.users.getExpiration` +- `admin.workflows.triggers.types.permissions.*` diff --git a/docs/superpowers/specs/2026-04-17-admin-api-coverage-design.md b/docs/superpowers/specs/2026-04-17-admin-api-coverage-design.md new file mode 100644 index 0000000..70a619c --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-admin-api-coverage-design.md @@ -0,0 +1,146 @@ +# Admin API カバレッジ拡張 設計 + +## 背景 + +現状の `sladm` は Slack Admin API の主要グループを実装済みだが、SDK (`@slack/web-api`) が対応している admin.* メソッドのうち以下が未実装: + +- グループ丸ごと未対応: `auth-policy`, `barriers`, `emoji`, `roles`, `usergroups` +- 既存グループ内で未対応: `teams settings setDefaultChannels`、`users` 系の `setExpiration` / `unsupportedVersions.export` / `session.*` (6 メソッド) + +SDK が対応していないメソッド(`admin.analytics.*`、`admin.audit.anomaly.allow.*`、`admin.conversations.{bulkSetExcludeFromSlackAi, createForObjects, linkObjects, unlinkObjects}`、`admin.users.getExpiration`、`admin.workflows.triggers.types.permissions.*`)は本対応のスコープ外とする。 + +## 目的 + +SDK 対応済みの未実装 admin.* メソッド 28 個を一括で追加し、`sladm` の admin API カバレッジを完成させる。 + +## スコープ(追加コマンド一覧) + +### 新規グループ + +- `sladm auth-policy assign-entities` → `admin.auth.policy.assignEntities` +- `sladm auth-policy get-entities` → `admin.auth.policy.getEntities` +- `sladm auth-policy remove-entities` → `admin.auth.policy.removeEntities` +- `sladm barriers create` → `admin.barriers.create` +- `sladm barriers delete` → `admin.barriers.delete` +- `sladm barriers list` → `admin.barriers.list` +- `sladm barriers update` → `admin.barriers.update` +- `sladm emoji add` → `admin.emoji.add` +- `sladm emoji add-alias` → `admin.emoji.addAlias` +- `sladm emoji list` → `admin.emoji.list` +- `sladm emoji remove` → `admin.emoji.remove` +- `sladm emoji rename` → `admin.emoji.rename` +- `sladm roles add-assignments` → `admin.roles.addAssignments` +- `sladm roles list-assignments` → `admin.roles.listAssignments` +- `sladm roles remove-assignments` → `admin.roles.removeAssignments` +- `sladm usergroups add-channels` → `admin.usergroups.addChannels` +- `sladm usergroups add-teams` → `admin.usergroups.addTeams` +- `sladm usergroups list-channels` → `admin.usergroups.listChannels` +- `sladm usergroups remove-channels` → `admin.usergroups.removeChannels` + +### 既存グループへの追加 + +- `sladm teams settings set-default-channels` → `admin.teams.settings.setDefaultChannels` +- `sladm users set-expiration` → `admin.users.setExpiration` +- `sladm users unsupported-versions export` → `admin.users.unsupportedVersions.export` +- `sladm users session clear-settings` → `admin.users.session.clearSettings` +- `sladm users session get-settings` → `admin.users.session.getSettings` +- `sladm users session invalidate` → `admin.users.session.invalidate` +- `sladm users session list` → `admin.users.session.list` +- `sladm users session reset-bulk` → `admin.users.session.resetBulk` +- `sladm users session set-settings` → `admin.users.session.setSettings` + +合計 **28 コマンド**。 + +## 実装方針 + +### ディレクトリ構造 + +既存の `src/commands//[/].ts` パターンに従う: + +``` +src/commands/ +├── auth-policy/ +│ ├── assign-entities.ts +│ ├── get-entities.ts +│ └── remove-entities.ts +├── barriers/ +│ ├── create.ts +│ ├── delete.ts +│ ├── list.ts +│ └── update.ts +├── emoji/ +│ ├── add.ts +│ ├── add-alias.ts +│ ├── list.ts +│ ├── remove.ts +│ └── rename.ts +├── roles/ +│ ├── add-assignments.ts +│ ├── list-assignments.ts +│ └── remove-assignments.ts +├── usergroups/ +│ ├── add-channels.ts +│ ├── add-teams.ts +│ ├── list-channels.ts +│ └── remove-channels.ts +├── teams/settings/set-default-channels.ts +└── users/ + ├── set-expiration.ts + ├── unsupported-versions/export.ts + └── session/ + ├── clear-settings.ts + ├── get-settings.ts + ├── invalidate.ts + ├── list.ts + ├── reset-bulk.ts + └── set-settings.ts +``` + +### コーディング規約 + +- `Options` インターフェースは camelCase で定義 +- `execute(client, opts)` シグネチャ +- camelCase オプション → snake_case で `client.apiCall(method, params)` に渡す(型回避は `as` ではなく `apiCall()` 経由) +- データ返却メソッド(list 系)は `Promise` を返し、`output.ts` のフォーマッタで整形 +- `as` キャスト禁止(既存ルール踏襲) +- 各コマンドに対応するテストを `tests/commands/<同パス>` に追加 + +### CLI ルーティング + +`src/index.ts` の `@optique/core` パーサー定義および switch 文に新コマンドを追加。既存の `teams settings`, `users` 配下のサブグループパターンを踏襲。 + +### 出力フォーマット + +- list 系(`barriers list`, `emoji list`, `roles list-assignments`, `usergroups list-channels`, `users session list`, `auth-policy get-entities` など)はテーブル/JSON/plain 切り替えに対応 +- 副作用系(create/delete/set/remove/invalidate 等)は成功時に簡潔なメッセージまたは更新後オブジェクトを返す + +## ドキュメント更新 + +- `README.md`: コマンド一覧表に 28 コマンドを追加。コマンド総数(現在 79)を更新。 +- `skills/slack-admin-cli-skill/SKILL.md`: 新グループ・新コマンドの説明と典型的な使用例を追記。 + +## テスト方針 + +各コマンドについて、既存の `apiCall` モックパターンでユニットテストを追加: + +```typescript +const mockApiCall = mock(() => Promise.resolve({ ok: true, /* ... */ })); +const client = { apiCall: mockApiCall } as any; +``` + +- 引数が正しく snake_case で渡されること +- 必須/任意パラメータの分岐 +- list 系は配列/オブジェクト返却の検証 + +## デリバリ + +1. 実装+テスト +2. `bun run lint` と `bun test` がグリーン +3. README / SKILL 更新 +4. 1 本の PR を作成(タイトル例: `feat: cover remaining SDK-supported admin.* methods`) + +## スコープ外 + +- SDK 非対応メソッドの対応(`admin.analytics.*` 等) +- 出力フォーマッタや既存コマンドのリファクタ +- 新規プロファイル/認証フローの変更 diff --git a/skills/slack-admin-cli-skill/SKILL.md b/skills/slack-admin-cli-skill/SKILL.md index 198dd24..1ec031d 100644 --- a/skills/slack-admin-cli-skill/SKILL.md +++ b/skills/slack-admin-cli-skill/SKILL.md @@ -2,8 +2,8 @@ name: slack-admin-cli-skill description: >- Slack Admin CLI (`sladm`) を使って Slack Admin API / SCIM API を操作する。 - チーム管理・ユーザー管理・チャンネル管理・アプリ管理・ワークフロー管理・招待リクエスト管理・関数管理・SCIMユーザー/グループ管理など、 - Slack ワークスペースの管理操作を行いたいときに使う。 + チーム管理・ユーザー管理・チャンネル管理・アプリ管理・ワークフロー管理・招待リクエスト管理・関数管理・SCIMユーザー/グループ管理・ + 認証ポリシー・情報バリア・絵文字・ロール・ユーザーグループ管理など、Slack ワークスペースの管理操作を行いたいときに使う。 --- # Slack Admin CLI (sladm) @@ -75,6 +75,35 @@ sladm --profile staging users list | `functions` | カスタム関数の一覧・権限設定 | [functions](recipes/functions.md) | | `scim-users` | SCIM ユーザー管理(作成・更新・無効化) | [scim-users](recipes/scim-users.md) | | `scim-groups` | SCIM グループ管理(作成・更新・削除) | [scim-groups](recipes/scim-groups.md) | +| `auth-policy` | 認証ポリシーへのエンティティ割り当て | — | +| `barriers` | 情報バリア(Information Barriers)の作成・更新・削除 | — | +| `emoji` | カスタム絵文字の追加・エイリアス・リネーム・削除 | — | +| `roles` | システムロール割り当ての追加・一覧・削除 | — | +| `usergroups` | ユーザーグループのチーム・デフォルトチャンネル管理 | — | + +## 新規グループの使用例 + +レシピ未整備のグループについては `--help` で詳細を確認すること。代表的な例: + +```bash +# カスタム絵文字一覧 +sladm emoji list --json + +# カスタム絵文字追加(URL 指定) +sladm emoji add --name party-parrot --url https://example.com/parrot.gif + +# 情報バリア一覧 +sladm barriers list + +# ロール割り当て一覧(例: チャンネル管理者 ロール) +sladm roles list-assignments --role-id Rl0A + +# ユーザーグループにデフォルトチャンネルを追加 +sladm usergroups add-channels --usergroup-id S123 --team-id T123 --channel-ids C1,C2 + +# 認証ポリシーにエンティティを割り当て +sladm auth-policy assign-entities --policy-name email_password --entity-type user --entity-ids U1,U2 +``` ## トラブルシューティング diff --git a/skills/slack-admin-cli-skill/recipes/teams.md b/skills/slack-admin-cli-skill/recipes/teams.md index 40537c2..bfd2ce3 100644 --- a/skills/slack-admin-cli-skill/recipes/teams.md +++ b/skills/slack-admin-cli-skill/recipes/teams.md @@ -15,6 +15,7 @@ | `teams settings set-icon` | アイコン設定 | `admin.teams:write` | | `teams settings set-description` | 説明文変更 | `admin.teams:write` | | `teams settings set-discoverability` | 公開設定変更 | `admin.teams:write` | +| `teams settings set-default-channels` | デフォルトチャンネル設定 | `admin.teams:write` | ## 使用例 diff --git a/skills/slack-admin-cli-skill/recipes/users.md b/skills/slack-admin-cli-skill/recipes/users.md index 2661d39..7535bd3 100644 --- a/skills/slack-admin-cli-skill/recipes/users.md +++ b/skills/slack-admin-cli-skill/recipes/users.md @@ -13,7 +13,15 @@ | `users set-admin` | 管理者に昇格 | `admin.users:write` | | `users set-owner` | オーナーに昇格 | `admin.users:write` | | `users set-regular` | 一般ユーザーに降格 | `admin.users:write` | +| `users set-expiration` | ゲストアカウント有効期限設定 | `admin.users:write` | +| `users unsupported-versions export` | 非対応 Slack バージョンのユーザー一覧エクスポート | `admin.users:read` | | `users session reset` | セッションリセット | `admin.users:write` | +| `users session list` | アクティブセッション一覧 | `admin.users:read` | +| `users session invalidate` | セッション無効化 | `admin.users:write` | +| `users session reset-bulk` | セッション一括リセット | `admin.users:write` | +| `users session get-settings` | セッション設定取得 | `admin.users:read` | +| `users session set-settings` | セッション設定変更 | `admin.users:write` | +| `users session clear-settings` | セッション設定クリア | `admin.users:write` | ## 使用例 diff --git a/src/commands/auth-policy/assign-entities.ts b/src/commands/auth-policy/assign-entities.ts new file mode 100644 index 0000000..fd06622 --- /dev/null +++ b/src/commands/auth-policy/assign-entities.ts @@ -0,0 +1,18 @@ +import type { WebClient } from "@slack/web-api"; + +interface AuthPolicyAssignEntitiesOptions { + entityIds: [string, ...string[]]; + entityType: "USER"; + policyName: "email_password"; +} + +export async function executeAuthPolicyAssignEntities( + client: WebClient, + opts: AuthPolicyAssignEntitiesOptions, +): Promise { + await client.apiCall("admin.auth.policy.assignEntities", { + entity_ids: opts.entityIds, + entity_type: opts.entityType, + policy_name: opts.policyName, + }); +} diff --git a/src/commands/auth-policy/get-entities.ts b/src/commands/auth-policy/get-entities.ts new file mode 100644 index 0000000..79f18e7 --- /dev/null +++ b/src/commands/auth-policy/get-entities.ts @@ -0,0 +1,28 @@ +import type { WebClient } from "@slack/web-api"; + +interface AuthPolicyGetEntitiesOptions { + policyName: "email_password"; + entityType?: "USER"; + cursor?: string; + limit?: number; +} + +export interface AuthPolicyEntity { + entity_id?: string; + entity_type?: string; +} + +export async function executeAuthPolicyGetEntities( + client: WebClient, + opts: AuthPolicyGetEntitiesOptions, +): Promise { + const params: Record = { policy_name: opts.policyName }; + if (opts.entityType !== undefined) params.entity_type = opts.entityType; + if (opts.cursor !== undefined) params.cursor = opts.cursor; + if (opts.limit !== undefined) params.limit = opts.limit; + const response: unknown = await client.apiCall("admin.auth.policy.getEntities", params); + if (typeof response !== "object" || response === null || !("entities" in response)) return []; + const entities = response.entities; + if (!Array.isArray(entities)) return []; + return entities.filter((e): e is AuthPolicyEntity => typeof e === "object" && e !== null); +} diff --git a/src/commands/auth-policy/remove-entities.ts b/src/commands/auth-policy/remove-entities.ts new file mode 100644 index 0000000..b03ddb4 --- /dev/null +++ b/src/commands/auth-policy/remove-entities.ts @@ -0,0 +1,18 @@ +import type { WebClient } from "@slack/web-api"; + +interface AuthPolicyRemoveEntitiesOptions { + entityIds: [string, ...string[]]; + entityType: "USER"; + policyName: "email_password"; +} + +export async function executeAuthPolicyRemoveEntities( + client: WebClient, + opts: AuthPolicyRemoveEntitiesOptions, +): Promise { + await client.apiCall("admin.auth.policy.removeEntities", { + entity_ids: opts.entityIds, + entity_type: opts.entityType, + policy_name: opts.policyName, + }); +} diff --git a/src/commands/barriers/create.ts b/src/commands/barriers/create.ts new file mode 100644 index 0000000..15caeba --- /dev/null +++ b/src/commands/barriers/create.ts @@ -0,0 +1,17 @@ +import type { WebClient } from "@slack/web-api"; + +interface BarriersCreateOptions { + primaryUsergroupId: string; + barrieredFromUsergroupIds: string[]; +} + +export async function executeBarriersCreate( + client: WebClient, + opts: BarriersCreateOptions, +) { + return await client.admin.barriers.create({ + primary_usergroup_id: opts.primaryUsergroupId, + barriered_from_usergroup_ids: opts.barrieredFromUsergroupIds, + restricted_subjects: ["im", "mpim", "call"], + }); +} diff --git a/src/commands/barriers/delete.ts b/src/commands/barriers/delete.ts new file mode 100644 index 0000000..2a51fb7 --- /dev/null +++ b/src/commands/barriers/delete.ts @@ -0,0 +1,12 @@ +import type { WebClient } from "@slack/web-api"; + +interface BarriersDeleteOptions { + barrierId: string; +} + +export async function executeBarriersDelete( + client: WebClient, + opts: BarriersDeleteOptions, +): Promise { + await client.admin.barriers.delete({ barrier_id: opts.barrierId }); +} diff --git a/src/commands/barriers/list.ts b/src/commands/barriers/list.ts new file mode 100644 index 0000000..ed25113 --- /dev/null +++ b/src/commands/barriers/list.ts @@ -0,0 +1,17 @@ +import type { WebClient } from "@slack/web-api"; + +interface BarriersListOptions { + cursor?: string; + limit?: number; +} + +export async function executeBarriersList( + client: WebClient, + opts: BarriersListOptions, +) { + const response = await client.admin.barriers.list({ + cursor: opts.cursor, + limit: opts.limit, + }); + return response.barriers ?? []; +} diff --git a/src/commands/barriers/update.ts b/src/commands/barriers/update.ts new file mode 100644 index 0000000..407fed1 --- /dev/null +++ b/src/commands/barriers/update.ts @@ -0,0 +1,19 @@ +import type { WebClient } from "@slack/web-api"; + +interface BarriersUpdateOptions { + barrierId: string; + primaryUsergroupId: string; + barrieredFromUsergroupIds: string[]; +} + +export async function executeBarriersUpdate( + client: WebClient, + opts: BarriersUpdateOptions, +) { + return await client.admin.barriers.update({ + barrier_id: opts.barrierId, + primary_usergroup_id: opts.primaryUsergroupId, + barriered_from_usergroup_ids: opts.barrieredFromUsergroupIds, + restricted_subjects: ["im", "mpim", "call"], + }); +} diff --git a/src/commands/emoji/add-alias.ts b/src/commands/emoji/add-alias.ts new file mode 100644 index 0000000..07bf6dc --- /dev/null +++ b/src/commands/emoji/add-alias.ts @@ -0,0 +1,13 @@ +import type { WebClient } from "@slack/web-api"; + +interface EmojiAddAliasOptions { + name: string; + aliasFor: string; +} + +export async function executeEmojiAddAlias( + client: WebClient, + opts: EmojiAddAliasOptions, +): Promise { + await client.admin.emoji.addAlias({ name: opts.name, alias_for: opts.aliasFor }); +} diff --git a/src/commands/emoji/add.ts b/src/commands/emoji/add.ts new file mode 100644 index 0000000..416139b --- /dev/null +++ b/src/commands/emoji/add.ts @@ -0,0 +1,13 @@ +import type { WebClient } from "@slack/web-api"; + +interface EmojiAddOptions { + name: string; + url: string; +} + +export async function executeEmojiAdd( + client: WebClient, + opts: EmojiAddOptions, +): Promise { + await client.admin.emoji.add({ name: opts.name, url: opts.url }); +} diff --git a/src/commands/emoji/list.ts b/src/commands/emoji/list.ts new file mode 100644 index 0000000..149b359 --- /dev/null +++ b/src/commands/emoji/list.ts @@ -0,0 +1,21 @@ +import type { WebClient } from "@slack/web-api"; + +interface EmojiListOptions { + cursor?: string; + limit?: number; +} + +export async function executeEmojiList( + client: WebClient, + opts: EmojiListOptions, +): Promise<{ name: string; url: string }[]> { + const response = await client.admin.emoji.list({ + cursor: opts.cursor, + limit: opts.limit, + }); + const emoji = response.emoji ?? {}; + return Object.entries(emoji).map(([name, value]) => ({ + name, + url: value.url ?? "", + })); +} diff --git a/src/commands/emoji/remove.ts b/src/commands/emoji/remove.ts new file mode 100644 index 0000000..c33a04b --- /dev/null +++ b/src/commands/emoji/remove.ts @@ -0,0 +1,12 @@ +import type { WebClient } from "@slack/web-api"; + +interface EmojiRemoveOptions { + name: string; +} + +export async function executeEmojiRemove( + client: WebClient, + opts: EmojiRemoveOptions, +): Promise { + await client.admin.emoji.remove({ name: opts.name }); +} diff --git a/src/commands/emoji/rename.ts b/src/commands/emoji/rename.ts new file mode 100644 index 0000000..f379368 --- /dev/null +++ b/src/commands/emoji/rename.ts @@ -0,0 +1,13 @@ +import type { WebClient } from "@slack/web-api"; + +interface EmojiRenameOptions { + name: string; + newName: string; +} + +export async function executeEmojiRename( + client: WebClient, + opts: EmojiRenameOptions, +): Promise { + await client.admin.emoji.rename({ name: opts.name, new_name: opts.newName }); +} diff --git a/src/commands/roles/add-assignments.ts b/src/commands/roles/add-assignments.ts new file mode 100644 index 0000000..177456e --- /dev/null +++ b/src/commands/roles/add-assignments.ts @@ -0,0 +1,18 @@ +import type { WebClient } from "@slack/web-api"; + +interface RolesAddAssignmentsOptions { + roleId: string; + entityIds: [string, ...string[]]; + userIds: [string, ...string[]]; +} + +export async function executeRolesAddAssignments( + client: WebClient, + opts: RolesAddAssignmentsOptions, +): Promise { + await client.admin.roles.addAssignments({ + role_id: opts.roleId, + entity_ids: opts.entityIds, + user_ids: opts.userIds, + }); +} diff --git a/src/commands/roles/list-assignments.ts b/src/commands/roles/list-assignments.ts new file mode 100644 index 0000000..512c2a6 --- /dev/null +++ b/src/commands/roles/list-assignments.ts @@ -0,0 +1,30 @@ +import type { WebClient } from "@slack/web-api"; + +interface RolesListAssignmentsOptions { + entityIds?: string[]; + roleIds?: string[]; + cursor?: string; + limit?: number; + sortDir?: "asc" | "desc"; +} + +export interface RoleAssignment { + role_id?: string; + entity_id?: string; + user_id?: string; +} + +export async function executeRolesListAssignments( + client: WebClient, + opts: RolesListAssignmentsOptions, +): Promise { + const params: Record = {}; + if (opts.entityIds !== undefined) params.entity_ids = opts.entityIds; + if (opts.roleIds !== undefined) params.role_ids = opts.roleIds; + if (opts.cursor !== undefined) params.cursor = opts.cursor; + if (opts.limit !== undefined) params.limit = opts.limit; + if (opts.sortDir !== undefined) params.sort_dir = opts.sortDir; + const response = await client.apiCall("admin.roles.listAssignments", params); + const assignments = (response as { role_assignments?: RoleAssignment[] }).role_assignments; + return assignments ?? []; +} diff --git a/src/commands/roles/remove-assignments.ts b/src/commands/roles/remove-assignments.ts new file mode 100644 index 0000000..b58ec07 --- /dev/null +++ b/src/commands/roles/remove-assignments.ts @@ -0,0 +1,18 @@ +import type { WebClient } from "@slack/web-api"; + +interface RolesRemoveAssignmentsOptions { + roleId: string; + entityIds: [string, ...string[]]; + userIds: [string, ...string[]]; +} + +export async function executeRolesRemoveAssignments( + client: WebClient, + opts: RolesRemoveAssignmentsOptions, +): Promise { + await client.admin.roles.removeAssignments({ + role_id: opts.roleId, + entity_ids: opts.entityIds, + user_ids: opts.userIds, + }); +} diff --git a/src/commands/teams/settings/set-default-channels.ts b/src/commands/teams/settings/set-default-channels.ts new file mode 100644 index 0000000..f4cb522 --- /dev/null +++ b/src/commands/teams/settings/set-default-channels.ts @@ -0,0 +1,16 @@ +import type { WebClient } from "@slack/web-api"; + +interface SetDefaultChannelsOptions { + teamId: string; + channelIds: [string, ...string[]]; +} + +export async function executeSetDefaultChannels( + client: WebClient, + opts: SetDefaultChannelsOptions, +): Promise { + await client.admin.teams.settings.setDefaultChannels({ + team_id: opts.teamId, + channel_ids: opts.channelIds, + }); +} diff --git a/src/commands/usergroups/add-channels.ts b/src/commands/usergroups/add-channels.ts new file mode 100644 index 0000000..97b8ae1 --- /dev/null +++ b/src/commands/usergroups/add-channels.ts @@ -0,0 +1,18 @@ +import type { WebClient } from "@slack/web-api"; + +interface UsergroupsAddChannelsOptions { + usergroupId: string; + channelIds: string[]; + teamId?: string; +} + +export async function executeUsergroupsAddChannels( + client: WebClient, + opts: UsergroupsAddChannelsOptions, +): Promise { + await client.admin.usergroups.addChannels({ + usergroup_id: opts.usergroupId, + channel_ids: opts.channelIds, + team_id: opts.teamId, + }); +} diff --git a/src/commands/usergroups/add-teams.ts b/src/commands/usergroups/add-teams.ts new file mode 100644 index 0000000..bc8d07b --- /dev/null +++ b/src/commands/usergroups/add-teams.ts @@ -0,0 +1,18 @@ +import type { WebClient } from "@slack/web-api"; + +interface UsergroupsAddTeamsOptions { + usergroupId: string; + teamIds: string[]; + autoProvision?: boolean; +} + +export async function executeUsergroupsAddTeams( + client: WebClient, + opts: UsergroupsAddTeamsOptions, +): Promise { + await client.admin.usergroups.addTeams({ + usergroup_id: opts.usergroupId, + team_ids: opts.teamIds, + auto_provision: opts.autoProvision, + }); +} diff --git a/src/commands/usergroups/list-channels.ts b/src/commands/usergroups/list-channels.ts new file mode 100644 index 0000000..6528153 --- /dev/null +++ b/src/commands/usergroups/list-channels.ts @@ -0,0 +1,29 @@ +import type { WebClient } from "@slack/web-api"; + +interface UsergroupsListChannelsOptions { + usergroupId: string; + teamId?: string; + includeNumMembers?: boolean; +} + +export interface UsergroupChannel { + id?: string; + name?: string; + num_members?: number; +} + +export async function executeUsergroupsListChannels( + client: WebClient, + opts: UsergroupsListChannelsOptions, +): Promise { + const response = await client.admin.usergroups.listChannels({ + usergroup_id: opts.usergroupId, + team_id: opts.teamId, + include_num_members: opts.includeNumMembers, + }); + const channels = response.channels ?? []; + return channels.map((c) => { + const numMembers = (c as { num_members?: number }).num_members; + return { id: c.id, name: c.name, num_members: numMembers }; + }); +} diff --git a/src/commands/usergroups/remove-channels.ts b/src/commands/usergroups/remove-channels.ts new file mode 100644 index 0000000..4669fd0 --- /dev/null +++ b/src/commands/usergroups/remove-channels.ts @@ -0,0 +1,16 @@ +import type { WebClient } from "@slack/web-api"; + +interface UsergroupsRemoveChannelsOptions { + usergroupId: string; + channelIds: string[]; +} + +export async function executeUsergroupsRemoveChannels( + client: WebClient, + opts: UsergroupsRemoveChannelsOptions, +): Promise { + await client.admin.usergroups.removeChannels({ + usergroup_id: opts.usergroupId, + channel_ids: opts.channelIds, + }); +} diff --git a/src/commands/users/session/clear-settings.ts b/src/commands/users/session/clear-settings.ts new file mode 100644 index 0000000..7e0bea5 --- /dev/null +++ b/src/commands/users/session/clear-settings.ts @@ -0,0 +1,12 @@ +import type { WebClient } from "@slack/web-api"; + +interface SessionClearSettingsOptions { + userIds: [string, ...string[]]; +} + +export async function executeUsersSessionClearSettings( + client: WebClient, + opts: SessionClearSettingsOptions, +): Promise { + await client.admin.users.session.clearSettings({ user_ids: opts.userIds }); +} diff --git a/src/commands/users/session/get-settings.ts b/src/commands/users/session/get-settings.ts new file mode 100644 index 0000000..9a138d7 --- /dev/null +++ b/src/commands/users/session/get-settings.ts @@ -0,0 +1,14 @@ +import type { WebClient } from "@slack/web-api"; +import type { SessionSetting } from "@slack/web-api/dist/types/response/AdminUsersSessionGetSettingsResponse"; + +interface SessionGetSettingsOptions { + userIds: [string, ...string[]]; +} + +export async function executeUsersSessionGetSettings( + client: WebClient, + opts: SessionGetSettingsOptions, +): Promise { + const response = await client.admin.users.session.getSettings({ user_ids: opts.userIds }); + return response.session_settings ?? []; +} diff --git a/src/commands/users/session/invalidate.ts b/src/commands/users/session/invalidate.ts new file mode 100644 index 0000000..6b3f22e --- /dev/null +++ b/src/commands/users/session/invalidate.ts @@ -0,0 +1,16 @@ +import type { WebClient } from "@slack/web-api"; + +interface SessionInvalidateOptions { + teamId: string; + sessionId: string; +} + +export async function executeUsersSessionInvalidate( + client: WebClient, + opts: SessionInvalidateOptions, +): Promise { + await client.admin.users.session.invalidate({ + team_id: opts.teamId, + session_id: opts.sessionId, + }); +} diff --git a/src/commands/users/session/list.ts b/src/commands/users/session/list.ts new file mode 100644 index 0000000..5f97bbb --- /dev/null +++ b/src/commands/users/session/list.ts @@ -0,0 +1,34 @@ +import type { WebClient } from "@slack/web-api"; + +interface SessionListOptions { + teamId?: string; + userId?: string; + cursor?: string; + limit?: number; +} + +export interface ActiveSession { + session_id?: string; + user_id?: string; + team_id?: string; +} + +export async function executeUsersSessionList( + client: WebClient, + opts: SessionListOptions, +): Promise { + const params: Record = {}; + if (opts.teamId !== undefined && opts.userId !== undefined) { + params.team_id = opts.teamId; + params.user_id = opts.userId; + } else if (opts.teamId !== undefined || opts.userId !== undefined) { + throw new Error("--team-id and --user-id must be provided together (or neither)"); + } + if (opts.cursor !== undefined) params.cursor = opts.cursor; + if (opts.limit !== undefined) params.limit = opts.limit; + const response: unknown = await client.apiCall("admin.users.session.list", params); + if (typeof response !== "object" || response === null || !("active_sessions" in response)) return []; + const sessions = response.active_sessions; + if (!Array.isArray(sessions)) return []; + return sessions.filter((s): s is ActiveSession => typeof s === "object" && s !== null); +} diff --git a/src/commands/users/session/reset-bulk.ts b/src/commands/users/session/reset-bulk.ts new file mode 100644 index 0000000..c062746 --- /dev/null +++ b/src/commands/users/session/reset-bulk.ts @@ -0,0 +1,18 @@ +import type { WebClient } from "@slack/web-api"; + +interface SessionResetBulkOptions { + userIds: [string, ...string[]]; + mobileOnly?: boolean; + webOnly?: boolean; +} + +export async function executeUsersSessionResetBulk( + client: WebClient, + opts: SessionResetBulkOptions, +): Promise { + await client.admin.users.session.resetBulk({ + user_ids: opts.userIds, + ...(opts.mobileOnly !== undefined ? { mobile_only: opts.mobileOnly } : {}), + ...(opts.webOnly !== undefined ? { web_only: opts.webOnly } : {}), + }); +} diff --git a/src/commands/users/session/set-settings.ts b/src/commands/users/session/set-settings.ts new file mode 100644 index 0000000..e6543bf --- /dev/null +++ b/src/commands/users/session/set-settings.ts @@ -0,0 +1,20 @@ +import type { WebClient } from "@slack/web-api"; + +interface SessionSetSettingsOptions { + userIds: [string, ...string[]]; + desktopAppBrowserQuit?: boolean; + duration?: number; +} + +export async function executeUsersSessionSetSettings( + client: WebClient, + opts: SessionSetSettingsOptions, +): Promise { + await client.admin.users.session.setSettings({ + user_ids: opts.userIds, + ...(opts.desktopAppBrowserQuit !== undefined + ? { desktop_app_browser_quit: opts.desktopAppBrowserQuit } + : {}), + ...(opts.duration !== undefined ? { duration: opts.duration } : {}), + }); +} diff --git a/src/commands/users/set-expiration.ts b/src/commands/users/set-expiration.ts new file mode 100644 index 0000000..7b16000 --- /dev/null +++ b/src/commands/users/set-expiration.ts @@ -0,0 +1,18 @@ +import type { WebClient } from "@slack/web-api"; + +interface UsersSetExpirationOptions { + userId: string; + expirationTs: number; + teamId?: string; +} + +export async function executeUsersSetExpiration( + client: WebClient, + opts: UsersSetExpirationOptions, +): Promise { + await client.admin.users.setExpiration({ + user_id: opts.userId, + expiration_ts: opts.expirationTs, + team_id: opts.teamId, + }); +} diff --git a/src/commands/users/unsupported-versions/export.ts b/src/commands/users/unsupported-versions/export.ts new file mode 100644 index 0000000..129fb86 --- /dev/null +++ b/src/commands/users/unsupported-versions/export.ts @@ -0,0 +1,16 @@ +import type { WebClient } from "@slack/web-api"; + +interface UsersUnsupportedVersionsExportOptions { + dateEndOfSupport?: number; + dateSessionsStarted?: number; +} + +export async function executeUsersUnsupportedVersionsExport( + client: WebClient, + opts: UsersUnsupportedVersionsExportOptions, +): Promise { + await client.admin.users.unsupportedVersions.export({ + date_end_of_support: opts.dateEndOfSupport, + date_sessions_started: opts.dateSessionsStarted, + }); +} diff --git a/src/index.ts b/src/index.ts index 07d1bff..24d1b55 100755 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { executeSetName } from "./commands/teams/settings/set-name"; import { executeSetIcon } from "./commands/teams/settings/set-icon"; import { executeSetDescription } from "./commands/teams/settings/set-description"; import { executeSetDiscoverability } from "./commands/teams/settings/set-discoverability"; +import { executeSetDefaultChannels } from "./commands/teams/settings/set-default-channels"; import { executeUsersList } from "./commands/users/list"; import { executeUsersInvite } from "./commands/users/invite"; import { executeUsersAssign } from "./commands/users/assign"; @@ -27,6 +28,14 @@ import { executeUsersSetAdmin } from "./commands/users/set-admin"; import { executeUsersSetOwner } from "./commands/users/set-owner"; import { executeUsersSetRegular } from "./commands/users/set-regular"; import { executeSessionReset } from "./commands/users/session-reset"; +import { executeUsersSessionClearSettings } from "./commands/users/session/clear-settings"; +import { executeUsersSessionGetSettings } from "./commands/users/session/get-settings"; +import { executeUsersSessionInvalidate } from "./commands/users/session/invalidate"; +import { executeUsersSessionList } from "./commands/users/session/list"; +import { executeUsersSessionResetBulk } from "./commands/users/session/reset-bulk"; +import { executeUsersSessionSetSettings } from "./commands/users/session/set-settings"; +import { executeUsersSetExpiration } from "./commands/users/set-expiration"; +import { executeUsersUnsupportedVersionsExport } from "./commands/users/unsupported-versions/export"; import { executeConversationsArchive } from "./commands/conversations/archive"; import { executeConversationsUnarchive } from "./commands/conversations/unarchive"; @@ -82,6 +91,30 @@ import { executeFunctionsList } from "./commands/functions/list"; import { executeFunctionsPermissionsLookup } from "./commands/functions/permissions/lookup"; import { executeFunctionsPermissionsSet } from "./commands/functions/permissions/set"; +import { executeAuthPolicyAssignEntities } from "./commands/auth-policy/assign-entities"; +import { executeAuthPolicyGetEntities } from "./commands/auth-policy/get-entities"; +import { executeAuthPolicyRemoveEntities } from "./commands/auth-policy/remove-entities"; + +import { executeBarriersCreate } from "./commands/barriers/create"; +import { executeBarriersDelete } from "./commands/barriers/delete"; +import { executeBarriersList } from "./commands/barriers/list"; +import { executeBarriersUpdate } from "./commands/barriers/update"; + +import { executeEmojiAdd } from "./commands/emoji/add"; +import { executeEmojiAddAlias } from "./commands/emoji/add-alias"; +import { executeEmojiList } from "./commands/emoji/list"; +import { executeEmojiRemove } from "./commands/emoji/remove"; +import { executeEmojiRename } from "./commands/emoji/rename"; + +import { executeRolesAddAssignments } from "./commands/roles/add-assignments"; +import { executeRolesListAssignments } from "./commands/roles/list-assignments"; +import { executeRolesRemoveAssignments } from "./commands/roles/remove-assignments"; + +import { executeUsergroupsAddChannels } from "./commands/usergroups/add-channels"; +import { executeUsergroupsAddTeams } from "./commands/usergroups/add-teams"; +import { executeUsergroupsListChannels } from "./commands/usergroups/list-channels"; +import { executeUsergroupsRemoveChannels } from "./commands/usergroups/remove-channels"; + import { executeScimUsersList } from "./commands/scim-users/list"; import { executeScimUsersGet } from "./commands/scim-users/get"; import { executeScimUsersCreate } from "./commands/scim-users/create"; @@ -240,6 +273,11 @@ const teamsSettingsCommands = command( teamId: option("--team-id", string({ metavar: "TEAM_ID" })), discoverability: option("--discoverability", discoverabilityValueParser), })), + command("set-default-channels", object({ + cmd: constant("teams-settings-set-default-channels" as const), + teamId: option("--team-id", string({ metavar: "TEAM_ID" })), + channelIds: option("--channel-ids", string({ metavar: "CHANNEL_IDS" })), + })), ), ); @@ -322,11 +360,56 @@ const usersCommands = command( teamId: option("--team-id", string({ metavar: "TEAM_ID" })), userId: option("--user-id", string({ metavar: "USER_ID" })), })), - command("session", command("reset", object({ - cmd: constant("users-session-reset" as const), + command("session", or( + command("reset", object({ + cmd: constant("users-session-reset" as const), + userId: option("--user-id", string({ metavar: "USER_ID" })), + mobileOnly: optional(option("--mobile-only", boolValueParser)), + webOnly: optional(option("--web-only", boolValueParser)), + })), + command("clear-settings", object({ + cmd: constant("users-session-clear-settings" as const), + userIds: option("--user-ids", string({ metavar: "USER_IDS" })), + })), + command("get-settings", object({ + cmd: constant("users-session-get-settings" as const), + userIds: option("--user-ids", string({ metavar: "USER_IDS" })), + })), + command("invalidate", object({ + cmd: constant("users-session-invalidate" as const), + teamId: option("--team-id", string({ metavar: "TEAM_ID" })), + sessionId: option("--session-id", string({ metavar: "SESSION_ID" })), + })), + command("list", object({ + cmd: constant("users-session-list" as const), + teamId: optional(option("--team-id", string({ metavar: "TEAM_ID" }))), + userId: optional(option("--user-id", string({ metavar: "USER_ID" }))), + cursor: optional(option("--cursor", string({ metavar: "CURSOR" }))), + limit: optional(option("--limit", integer({ metavar: "LIMIT" }))), + })), + command("reset-bulk", object({ + cmd: constant("users-session-reset-bulk" as const), + userIds: option("--user-ids", string({ metavar: "USER_IDS" })), + mobileOnly: optional(option("--mobile-only", boolValueParser)), + webOnly: optional(option("--web-only", boolValueParser)), + })), + command("set-settings", object({ + cmd: constant("users-session-set-settings" as const), + userIds: option("--user-ids", string({ metavar: "USER_IDS" })), + desktopAppBrowserQuit: optional(option("--desktop-app-browser-quit", boolValueParser)), + duration: optional(option("--duration", integer({ metavar: "SECONDS" }))), + })), + )), + command("set-expiration", object({ + cmd: constant("users-set-expiration" as const), userId: option("--user-id", string({ metavar: "USER_ID" })), - mobileOnly: optional(option("--mobile-only", boolValueParser)), - webOnly: optional(option("--web-only", boolValueParser)), + expirationTs: option("--expiration-ts", integer({ metavar: "TIMESTAMP" })), + teamId: optional(option("--team-id", string({ metavar: "TEAM_ID" }))), + })), + command("unsupported-versions", command("export", object({ + cmd: constant("users-unsupported-versions-export" as const), + dateEndOfSupport: optional(option("--date-end-of-support", integer({ metavar: "TIMESTAMP" }))), + dateSessionsStarted: optional(option("--date-sessions-started", integer({ metavar: "TIMESTAMP" }))), }))), ), ); @@ -757,6 +840,162 @@ const functionsCommands = command( ), ); +// --------------------------------------------------------------------------- +// Auth Policy commands +// --------------------------------------------------------------------------- + +const authPolicyCommands = command( + "auth-policy", + or( + command("assign-entities", object({ + cmd: constant("auth-policy-assign-entities" as const), + entityIds: option("--entity-ids", string({ metavar: "ENTITY_IDS" })), + entityType: option("--entity-type", string({ metavar: "ENTITY_TYPE" })), + policyName: option("--policy-name", string({ metavar: "POLICY_NAME" })), + })), + command("get-entities", object({ + cmd: constant("auth-policy-get-entities" as const), + policyName: option("--policy-name", string({ metavar: "POLICY_NAME" })), + entityType: optional(option("--entity-type", string({ metavar: "ENTITY_TYPE" }))), + cursor: optional(option("--cursor", string({ metavar: "CURSOR" }))), + limit: optional(option("--limit", integer({ metavar: "LIMIT" }))), + })), + command("remove-entities", object({ + cmd: constant("auth-policy-remove-entities" as const), + entityIds: option("--entity-ids", string({ metavar: "ENTITY_IDS" })), + entityType: option("--entity-type", string({ metavar: "ENTITY_TYPE" })), + policyName: option("--policy-name", string({ metavar: "POLICY_NAME" })), + })), + ), +); + +// --------------------------------------------------------------------------- +// Barriers commands +// --------------------------------------------------------------------------- + +const barriersCommands = command( + "barriers", + or( + command("create", object({ + cmd: constant("barriers-create" as const), + primaryUsergroupId: option("--primary-usergroup-id", string({ metavar: "USERGROUP_ID" })), + barrieredFromUsergroupIds: option("--barriered-from-usergroup-ids", string({ metavar: "USERGROUP_IDS" })), + })), + command("delete", object({ + cmd: constant("barriers-delete" as const), + barrierId: option("--barrier-id", string({ metavar: "BARRIER_ID" })), + })), + command("list", object({ + cmd: constant("barriers-list" as const), + cursor: optional(option("--cursor", string({ metavar: "CURSOR" }))), + limit: optional(option("--limit", integer({ metavar: "LIMIT" }))), + })), + command("update", object({ + cmd: constant("barriers-update" as const), + barrierId: option("--barrier-id", string({ metavar: "BARRIER_ID" })), + primaryUsergroupId: option("--primary-usergroup-id", string({ metavar: "USERGROUP_ID" })), + barrieredFromUsergroupIds: option("--barriered-from-usergroup-ids", string({ metavar: "USERGROUP_IDS" })), + })), + ), +); + +// --------------------------------------------------------------------------- +// Emoji commands +// --------------------------------------------------------------------------- + +const emojiCommands = command( + "emoji", + or( + command("add", object({ + cmd: constant("emoji-add" as const), + name: option("--name", string({ metavar: "NAME" })), + url: option("--url", string({ metavar: "URL" })), + })), + command("add-alias", object({ + cmd: constant("emoji-add-alias" as const), + name: option("--name", string({ metavar: "NAME" })), + aliasFor: option("--alias-for", string({ metavar: "ALIAS_FOR" })), + })), + command("list", object({ + cmd: constant("emoji-list" as const), + cursor: optional(option("--cursor", string({ metavar: "CURSOR" }))), + limit: optional(option("--limit", integer({ metavar: "LIMIT" }))), + })), + command("remove", object({ + cmd: constant("emoji-remove" as const), + name: option("--name", string({ metavar: "NAME" })), + })), + command("rename", object({ + cmd: constant("emoji-rename" as const), + name: option("--name", string({ metavar: "NAME" })), + newName: option("--new-name", string({ metavar: "NEW_NAME" })), + })), + ), +); + +// --------------------------------------------------------------------------- +// Roles commands +// --------------------------------------------------------------------------- + +const rolesCommands = command( + "roles", + or( + command("add-assignments", object({ + cmd: constant("roles-add-assignments" as const), + roleId: option("--role-id", string({ metavar: "ROLE_ID" })), + entityIds: option("--entity-ids", string({ metavar: "ENTITY_IDS" })), + userIds: option("--user-ids", string({ metavar: "USER_IDS" })), + })), + command("list-assignments", object({ + cmd: constant("roles-list-assignments" as const), + entityIds: optional(option("--entity-ids", string({ metavar: "ENTITY_IDS" }))), + roleIds: optional(option("--role-ids", string({ metavar: "ROLE_IDS" }))), + cursor: optional(option("--cursor", string({ metavar: "CURSOR" }))), + limit: optional(option("--limit", integer({ metavar: "LIMIT" }))), + sortDir: optional(option("--sort-dir", string({ metavar: "DIR" }))), + })), + command("remove-assignments", object({ + cmd: constant("roles-remove-assignments" as const), + roleId: option("--role-id", string({ metavar: "ROLE_ID" })), + entityIds: option("--entity-ids", string({ metavar: "ENTITY_IDS" })), + userIds: option("--user-ids", string({ metavar: "USER_IDS" })), + })), + ), +); + +// --------------------------------------------------------------------------- +// Usergroups commands +// --------------------------------------------------------------------------- + +const usergroupsCommands = command( + "usergroups", + or( + command("add-channels", object({ + cmd: constant("usergroups-add-channels" as const), + usergroupId: option("--usergroup-id", string({ metavar: "USERGROUP_ID" })), + channelIds: option("--channel-ids", string({ metavar: "CHANNEL_IDS" })), + teamId: optional(option("--team-id", string({ metavar: "TEAM_ID" }))), + })), + command("add-teams", object({ + cmd: constant("usergroups-add-teams" as const), + usergroupId: option("--usergroup-id", string({ metavar: "USERGROUP_ID" })), + teamIds: option("--team-ids", string({ metavar: "TEAM_IDS" })), + autoProvision: optional(option("--auto-provision", boolValueParser)), + })), + command("list-channels", object({ + cmd: constant("usergroups-list-channels" as const), + usergroupId: option("--usergroup-id", string({ metavar: "USERGROUP_ID" })), + teamId: optional(option("--team-id", string({ metavar: "TEAM_ID" }))), + includeNumMembers: optional(option("--include-num-members", boolValueParser)), + })), + command("remove-channels", object({ + cmd: constant("usergroups-remove-channels" as const), + usergroupId: option("--usergroup-id", string({ metavar: "USERGROUP_ID" })), + channelIds: option("--channel-ids", string({ metavar: "CHANNEL_IDS" })), + })), + ), +); + // --------------------------------------------------------------------------- // SCIM Users commands // --------------------------------------------------------------------------- @@ -844,7 +1083,7 @@ const rootParser = or( or(tokenCommands, teamsCommands, usersCommands), or(conversationsCommands, appsCommands), or(inviteRequestsCommands, workflowsCommands, functionsCommands), - or(scimUsersCommands, scimGroupsCommands), + or(scimUsersCommands, scimGroupsCommands, authPolicyCommands, barriersCommands, emojiCommands, rolesCommands, usergroupsCommands), ); // --------------------------------------------------------------------------- @@ -952,6 +1191,21 @@ switch (config.cmd) { console.log("Team discoverability updated."); break; } + case "teams-settings-set-default-channels": { + const client = await createSlackClient(store, profileFlag); + const defaultChannelParts = config.channelIds.split(","); + const defaultChannelFirst = defaultChannelParts[0]; + if (defaultChannelFirst === undefined || defaultChannelFirst === "") { + throw new Error("--channel-ids must not be empty"); + } + const defaultChannelIds: [string, ...string[]] = [defaultChannelFirst, ...defaultChannelParts.slice(1)]; + await executeSetDefaultChannels(client, { + teamId: config.teamId, + channelIds: defaultChannelIds, + }); + console.log("Team default channels updated."); + break; + } case "users-list": { const client = await createSlackClient(store, profileFlag); const users = await executeUsersList(client, { @@ -1035,6 +1289,94 @@ switch (config.cmd) { console.log(`Session reset for user '${config.userId}'.`); break; } + case "users-session-clear-settings": { + const client = await createSlackClient(store, profileFlag); + const parts = config.userIds.split(","); + const first = parts[0]; + if (first === undefined || first === "") throw new Error("--user-ids must not be empty"); + await executeUsersSessionClearSettings(client, { userIds: [first, ...parts.slice(1)] }); + console.log("Session settings cleared."); + break; + } + case "users-session-get-settings": { + const client = await createSlackClient(store, profileFlag); + const parts = config.userIds.split(","); + const first = parts[0]; + if (first === undefined || first === "") throw new Error("--user-ids must not be empty"); + const settings = await executeUsersSessionGetSettings(client, { userIds: [first, ...parts.slice(1)] }); + console.log(JSON.stringify(settings, null, 2)); + break; + } + case "users-session-invalidate": { + const client = await createSlackClient(store, profileFlag); + await executeUsersSessionInvalidate(client, { + teamId: config.teamId, + sessionId: config.sessionId, + }); + console.log(`Session '${config.sessionId}' invalidated.`); + break; + } + case "users-session-list": { + const client = await createSlackClient(store, profileFlag); + const sessions = await executeUsersSessionList(client, { + teamId: config.teamId, + userId: config.userId, + cursor: config.cursor, + limit: config.limit, + }); + const rows = sessions.map((s) => ({ + session_id: s.session_id ?? "", + user_id: s.user_id ?? "", + team_id: s.team_id ?? "", + })); + console.log(formatOutput(rows, ["session_id", "user_id", "team_id"], outputFormat)); + break; + } + case "users-session-reset-bulk": { + const client = await createSlackClient(store, profileFlag); + const parts = config.userIds.split(","); + const first = parts[0]; + if (first === undefined || first === "") throw new Error("--user-ids must not be empty"); + await executeUsersSessionResetBulk(client, { + userIds: [first, ...parts.slice(1)], + mobileOnly: config.mobileOnly, + webOnly: config.webOnly, + }); + console.log("Bulk session reset requested."); + break; + } + case "users-session-set-settings": { + const client = await createSlackClient(store, profileFlag); + const parts = config.userIds.split(","); + const first = parts[0]; + if (first === undefined || first === "") throw new Error("--user-ids must not be empty"); + await executeUsersSessionSetSettings(client, { + userIds: [first, ...parts.slice(1)], + desktopAppBrowserQuit: config.desktopAppBrowserQuit, + duration: config.duration, + }); + console.log("Session settings updated."); + break; + } + case "users-set-expiration": { + const client = await createSlackClient(store, profileFlag); + await executeUsersSetExpiration(client, { + userId: config.userId, + expirationTs: config.expirationTs, + teamId: config.teamId, + }); + console.log(`User '${config.userId}' expiration set.`); + break; + } + case "users-unsupported-versions-export": { + const client = await createSlackClient(store, profileFlag); + await executeUsersUnsupportedVersionsExport(client, { + dateEndOfSupport: config.dateEndOfSupport, + dateSessionsStarted: config.dateSessionsStarted, + }); + console.log("Unsupported versions export requested."); + break; + } case "conversations-archive": { const client = await createSlackClient(store, profileFlag); await executeConversationsArchive(client, { channelId: config.channelId }); @@ -1704,6 +2046,240 @@ switch (config.cmd) { console.log(`Group '${config.id}' deleted.`); break; } + case "auth-policy-assign-entities": { + const client = await createSlackClient(store, profileFlag); + const parts = config.entityIds.split(","); + const first = parts[0]; + if (first === undefined || first === "") throw new Error("--entity-ids must not be empty"); + const entityIds: [string, ...string[]] = [first, ...parts.slice(1)]; + if (config.entityType !== "USER") throw new Error('--entity-type must be "USER"'); + if (config.policyName !== "email_password") throw new Error('--policy-name must be "email_password"'); + await executeAuthPolicyAssignEntities(client, { + entityIds, + entityType: "USER", + policyName: "email_password", + }); + console.log(`Assigned ${entityIds.length} entities to policy '${config.policyName}'.`); + break; + } + case "auth-policy-get-entities": { + const client = await createSlackClient(store, profileFlag); + if (config.entityType !== undefined && config.entityType !== "USER") { + throw new Error('--entity-type must be "USER"'); + } + if (config.policyName !== "email_password") throw new Error('--policy-name must be "email_password"'); + const entities = await executeAuthPolicyGetEntities(client, { + policyName: "email_password", + entityType: config.entityType === "USER" ? "USER" : undefined, + cursor: config.cursor, + limit: config.limit, + }); + const rows = entities.map((e) => ({ + entity_id: e.entity_id ?? "", + entity_type: e.entity_type ?? "", + })); + console.log(formatOutput(rows, ["entity_id", "entity_type"], outputFormat)); + break; + } + case "auth-policy-remove-entities": { + const client = await createSlackClient(store, profileFlag); + const parts = config.entityIds.split(","); + const first = parts[0]; + if (first === undefined || first === "") throw new Error("--entity-ids must not be empty"); + const entityIds: [string, ...string[]] = [first, ...parts.slice(1)]; + if (config.entityType !== "USER") throw new Error('--entity-type must be "USER"'); + if (config.policyName !== "email_password") throw new Error('--policy-name must be "email_password"'); + await executeAuthPolicyRemoveEntities(client, { + entityIds, + entityType: "USER", + policyName: "email_password", + }); + console.log(`Removed ${entityIds.length} entities from policy '${config.policyName}'.`); + break; + } + case "barriers-create": { + const client = await createSlackClient(store, profileFlag); + const parts = config.barrieredFromUsergroupIds.split(","); + const first = parts[0]; + if (first === undefined || first === "") throw new Error("--barriered-from-usergroup-ids must not be empty"); + const barrieredFromUsergroupIds = [first, ...parts.slice(1)]; + const result = await executeBarriersCreate(client, { + primaryUsergroupId: config.primaryUsergroupId, + barrieredFromUsergroupIds, + }); + console.log(JSON.stringify(result, null, 2)); + break; + } + case "barriers-delete": { + const client = await createSlackClient(store, profileFlag); + await executeBarriersDelete(client, { barrierId: config.barrierId }); + console.log(`Barrier '${config.barrierId}' deleted.`); + break; + } + case "barriers-list": { + const client = await createSlackClient(store, profileFlag); + const barriers = await executeBarriersList(client, { cursor: config.cursor, limit: config.limit }); + const rows = barriers.map((b) => ({ + id: b.id ?? "", + primary_usergroup_id: b.primary_usergroup?.id ?? "", + })); + console.log(formatOutput(rows, ["id", "primary_usergroup_id"], outputFormat)); + break; + } + case "barriers-update": { + const client = await createSlackClient(store, profileFlag); + const parts = config.barrieredFromUsergroupIds.split(","); + const first = parts[0]; + if (first === undefined || first === "") throw new Error("--barriered-from-usergroup-ids must not be empty"); + const barrieredFromUsergroupIds = [first, ...parts.slice(1)]; + const result = await executeBarriersUpdate(client, { + barrierId: config.barrierId, + primaryUsergroupId: config.primaryUsergroupId, + barrieredFromUsergroupIds, + }); + console.log(JSON.stringify(result, null, 2)); + break; + } + case "emoji-add": { + const client = await createSlackClient(store, profileFlag); + await executeEmojiAdd(client, { name: config.name, url: config.url }); + console.log(`Emoji '${config.name}' added.`); + break; + } + case "emoji-add-alias": { + const client = await createSlackClient(store, profileFlag); + await executeEmojiAddAlias(client, { name: config.name, aliasFor: config.aliasFor }); + console.log(`Alias '${config.name}' for '${config.aliasFor}' added.`); + break; + } + case "emoji-list": { + const client = await createSlackClient(store, profileFlag); + const rows = await executeEmojiList(client, { cursor: config.cursor, limit: config.limit }); + console.log(formatOutput(rows, ["name", "url"], outputFormat)); + break; + } + case "emoji-remove": { + const client = await createSlackClient(store, profileFlag); + await executeEmojiRemove(client, { name: config.name }); + console.log(`Emoji '${config.name}' removed.`); + break; + } + case "emoji-rename": { + const client = await createSlackClient(store, profileFlag); + await executeEmojiRename(client, { name: config.name, newName: config.newName }); + console.log(`Emoji renamed '${config.name}' -> '${config.newName}'.`); + break; + } + case "roles-add-assignments": { + const client = await createSlackClient(store, profileFlag); + const entityParts = config.entityIds.split(","); + const userParts = config.userIds.split(","); + const eFirst = entityParts[0]; + const uFirst = userParts[0]; + if (eFirst === undefined || eFirst === "") throw new Error("--entity-ids must not be empty"); + if (uFirst === undefined || uFirst === "") throw new Error("--user-ids must not be empty"); + await executeRolesAddAssignments(client, { + roleId: config.roleId, + entityIds: [eFirst, ...entityParts.slice(1)], + userIds: [uFirst, ...userParts.slice(1)], + }); + console.log(`Role '${config.roleId}' assigned.`); + break; + } + case "roles-list-assignments": { + const client = await createSlackClient(store, profileFlag); + const sortDir = + config.sortDir === "asc" || config.sortDir === "desc" ? config.sortDir : undefined; + if (config.sortDir !== undefined && sortDir === undefined) { + throw new Error('--sort-dir must be "asc" or "desc"'); + } + const assignments = await executeRolesListAssignments(client, { + entityIds: config.entityIds?.split(","), + roleIds: config.roleIds?.split(","), + cursor: config.cursor, + limit: config.limit, + sortDir, + }); + const rows = assignments.map((a) => ({ + role_id: a.role_id ?? "", + entity_id: a.entity_id ?? "", + user_id: a.user_id ?? "", + })); + console.log(formatOutput(rows, ["role_id", "entity_id", "user_id"], outputFormat)); + break; + } + case "roles-remove-assignments": { + const client = await createSlackClient(store, profileFlag); + const entityParts = config.entityIds.split(","); + const userParts = config.userIds.split(","); + const eFirst = entityParts[0]; + const uFirst = userParts[0]; + if (eFirst === undefined || eFirst === "") throw new Error("--entity-ids must not be empty"); + if (uFirst === undefined || uFirst === "") throw new Error("--user-ids must not be empty"); + await executeRolesRemoveAssignments(client, { + roleId: config.roleId, + entityIds: [eFirst, ...entityParts.slice(1)], + userIds: [uFirst, ...userParts.slice(1)], + }); + console.log(`Role '${config.roleId}' removed.`); + break; + } + case "usergroups-add-channels": { + const client = await createSlackClient(store, profileFlag); + const channelIds = config.channelIds.split(","); + if (channelIds.length === 0 || channelIds[0] === "") { + throw new Error("--channel-ids must not be empty"); + } + await executeUsergroupsAddChannels(client, { + usergroupId: config.usergroupId, + channelIds, + teamId: config.teamId, + }); + console.log(`Channels added to usergroup '${config.usergroupId}'.`); + break; + } + case "usergroups-add-teams": { + const client = await createSlackClient(store, profileFlag); + const teamIds = config.teamIds.split(","); + if (teamIds.length === 0 || teamIds[0] === "") { + throw new Error("--team-ids must not be empty"); + } + await executeUsergroupsAddTeams(client, { + usergroupId: config.usergroupId, + teamIds, + autoProvision: config.autoProvision, + }); + console.log(`Teams added to usergroup '${config.usergroupId}'.`); + break; + } + case "usergroups-list-channels": { + const client = await createSlackClient(store, profileFlag); + const channels = await executeUsergroupsListChannels(client, { + usergroupId: config.usergroupId, + teamId: config.teamId, + includeNumMembers: config.includeNumMembers, + }); + const rows = channels.map((c) => ({ + id: c.id ?? "", + name: c.name ?? "", + num_members: c.num_members ?? "", + })); + console.log(formatOutput(rows, ["id", "name", "num_members"], outputFormat)); + break; + } + case "usergroups-remove-channels": { + const client = await createSlackClient(store, profileFlag); + const channelIds = config.channelIds.split(","); + if (channelIds.length === 0 || channelIds[0] === "") { + throw new Error("--channel-ids must not be empty"); + } + await executeUsergroupsRemoveChannels(client, { + usergroupId: config.usergroupId, + channelIds, + }); + console.log(`Channels removed from usergroup '${config.usergroupId}'.`); + break; + } default: { const _exhaustive: never = config; throw new Error(`Unknown command`); diff --git a/tests/commands/auth-policy/assign-entities.test.ts b/tests/commands/auth-policy/assign-entities.test.ts new file mode 100644 index 0000000..2fa0b9b --- /dev/null +++ b/tests/commands/auth-policy/assign-entities.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeAuthPolicyAssignEntities } from "../../../src/commands/auth-policy/assign-entities"; + +describe("auth-policy assign-entities", () => { + test("calls admin.auth.policy.assignEntities with snake_case params", async () => { + const mockApiCall = mock(() => Promise.resolve({ ok: true })); + const client = { apiCall: mockApiCall } as any; + + await executeAuthPolicyAssignEntities(client, { + entityIds: ["T001", "T002"], + entityType: "USER", + policyName: "email_password", + }); + + expect(mockApiCall).toHaveBeenCalledWith("admin.auth.policy.assignEntities", { + entity_ids: ["T001", "T002"], + entity_type: "USER", + policy_name: "email_password", + }); + }); +}); diff --git a/tests/commands/auth-policy/get-entities.test.ts b/tests/commands/auth-policy/get-entities.test.ts new file mode 100644 index 0000000..12b2fed --- /dev/null +++ b/tests/commands/auth-policy/get-entities.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeAuthPolicyGetEntities } from "../../../src/commands/auth-policy/get-entities"; + +describe("auth-policy get-entities", () => { + test("returns entities array", async () => { + const mockApiCall = mock(() => + Promise.resolve({ ok: true, entities: [{ entity_id: "T001" }] }), + ); + const client = { apiCall: mockApiCall } as any; + + const result = await executeAuthPolicyGetEntities(client, { + policyName: "email_password", + entityType: "USER", + cursor: "abc", + limit: 10, + }); + + expect(mockApiCall).toHaveBeenCalledWith("admin.auth.policy.getEntities", { + policy_name: "email_password", + entity_type: "USER", + cursor: "abc", + limit: 10, + }); + expect(result).toEqual([{ entity_id: "T001" }]); + }); + + test("omits optional params when not provided", async () => { + const mockApiCall = mock(() => Promise.resolve({ ok: true, entities: [] })); + const client = { apiCall: mockApiCall } as any; + await executeAuthPolicyGetEntities(client, { policyName: "email_password" }); + expect(mockApiCall).toHaveBeenCalledWith("admin.auth.policy.getEntities", { + policy_name: "email_password", + }); + }); +}); diff --git a/tests/commands/auth-policy/remove-entities.test.ts b/tests/commands/auth-policy/remove-entities.test.ts new file mode 100644 index 0000000..3ac7d40 --- /dev/null +++ b/tests/commands/auth-policy/remove-entities.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeAuthPolicyRemoveEntities } from "../../../src/commands/auth-policy/remove-entities"; + +describe("auth-policy remove-entities", () => { + test("calls admin.auth.policy.removeEntities", async () => { + const mockApiCall = mock(() => Promise.resolve({ ok: true })); + const client = { apiCall: mockApiCall } as any; + await executeAuthPolicyRemoveEntities(client, { + entityIds: ["T001"], + entityType: "USER", + policyName: "email_password", + }); + expect(mockApiCall).toHaveBeenCalledWith("admin.auth.policy.removeEntities", { + entity_ids: ["T001"], + entity_type: "USER", + policy_name: "email_password", + }); + }); +}); diff --git a/tests/commands/barriers/create.test.ts b/tests/commands/barriers/create.test.ts new file mode 100644 index 0000000..01ec0b0 --- /dev/null +++ b/tests/commands/barriers/create.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeBarriersCreate } from "../../../src/commands/barriers/create"; + +describe("barriers create", () => { + test("creates a barrier with restricted_subjects defaulted", async () => { + const mockCreate = mock(() => Promise.resolve({ ok: true, barrier: { id: "B001" } })); + const client = { admin: { barriers: { create: mockCreate } } } as any; + await executeBarriersCreate(client, { + primaryUsergroupId: "S001", + barrieredFromUsergroupIds: ["S002", "S003"], + }); + expect(mockCreate).toHaveBeenCalledWith({ + primary_usergroup_id: "S001", + barriered_from_usergroup_ids: ["S002", "S003"], + restricted_subjects: ["im", "mpim", "call"], + }); + }); +}); diff --git a/tests/commands/barriers/delete.test.ts b/tests/commands/barriers/delete.test.ts new file mode 100644 index 0000000..5e54d5a --- /dev/null +++ b/tests/commands/barriers/delete.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeBarriersDelete } from "../../../src/commands/barriers/delete"; + +describe("barriers delete", () => { + test("deletes by id", async () => { + const mockDelete = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { barriers: { delete: mockDelete } } } as any; + await executeBarriersDelete(client, { barrierId: "B001" }); + expect(mockDelete).toHaveBeenCalledWith({ barrier_id: "B001" }); + }); +}); diff --git a/tests/commands/barriers/list.test.ts b/tests/commands/barriers/list.test.ts new file mode 100644 index 0000000..aac414a --- /dev/null +++ b/tests/commands/barriers/list.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeBarriersList } from "../../../src/commands/barriers/list"; + +describe("barriers list", () => { + test("returns barriers array", async () => { + const mockList = mock(() => + Promise.resolve({ ok: true, barriers: [{ id: "B001" }] }), + ); + const client = { admin: { barriers: { list: mockList } } } as any; + const result = await executeBarriersList(client, { cursor: "c", limit: 5 }); + expect(mockList).toHaveBeenCalledWith({ cursor: "c", limit: 5 }); + expect(result).toEqual([{ id: "B001" }]); + }); +}); diff --git a/tests/commands/barriers/update.test.ts b/tests/commands/barriers/update.test.ts new file mode 100644 index 0000000..e134d58 --- /dev/null +++ b/tests/commands/barriers/update.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeBarriersUpdate } from "../../../src/commands/barriers/update"; + +describe("barriers update", () => { + test("updates a barrier", async () => { + const mockUpdate = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { barriers: { update: mockUpdate } } } as any; + await executeBarriersUpdate(client, { + barrierId: "B001", + primaryUsergroupId: "S001", + barrieredFromUsergroupIds: ["S002"], + }); + expect(mockUpdate).toHaveBeenCalledWith({ + barrier_id: "B001", + primary_usergroup_id: "S001", + barriered_from_usergroup_ids: ["S002"], + restricted_subjects: ["im", "mpim", "call"], + }); + }); +}); diff --git a/tests/commands/emoji/add-alias.test.ts b/tests/commands/emoji/add-alias.test.ts new file mode 100644 index 0000000..1515b37 --- /dev/null +++ b/tests/commands/emoji/add-alias.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeEmojiAddAlias } from "../../../src/commands/emoji/add-alias"; + +describe("emoji add-alias", () => { + test("calls admin.emoji.addAlias", async () => { + const mockAddAlias = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { emoji: { addAlias: mockAddAlias } } } as any; + await executeEmojiAddAlias(client, { name: "party_tada", aliasFor: "tada" }); + expect(mockAddAlias).toHaveBeenCalledWith({ name: "party_tada", alias_for: "tada" }); + }); +}); diff --git a/tests/commands/emoji/add.test.ts b/tests/commands/emoji/add.test.ts new file mode 100644 index 0000000..de0f8d6 --- /dev/null +++ b/tests/commands/emoji/add.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeEmojiAdd } from "../../../src/commands/emoji/add"; + +describe("emoji add", () => { + test("calls admin.emoji.add", async () => { + const mockAdd = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { emoji: { add: mockAdd } } } as any; + await executeEmojiAdd(client, { name: "party", url: "https://x" }); + expect(mockAdd).toHaveBeenCalledWith({ name: "party", url: "https://x" }); + }); +}); diff --git a/tests/commands/emoji/list.test.ts b/tests/commands/emoji/list.test.ts new file mode 100644 index 0000000..618964e --- /dev/null +++ b/tests/commands/emoji/list.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeEmojiList } from "../../../src/commands/emoji/list"; + +describe("emoji list", () => { + test("converts emoji map into rows", async () => { + const mockList = mock(() => + Promise.resolve({ + ok: true, + emoji: { + tada: { url: "https://x" }, + smile: { url: "https://y" }, + }, + }), + ); + const client = { admin: { emoji: { list: mockList } } } as any; + const result = await executeEmojiList(client, {}); + expect(mockList).toHaveBeenCalledWith({ cursor: undefined, limit: undefined }); + expect(result).toEqual([ + { name: "tada", url: "https://x" }, + { name: "smile", url: "https://y" }, + ]); + }); + + test("passes cursor and limit", async () => { + const mockList = mock(() => Promise.resolve({ ok: true, emoji: {} })); + const client = { admin: { emoji: { list: mockList } } } as any; + await executeEmojiList(client, { cursor: "c", limit: 10 }); + expect(mockList).toHaveBeenCalledWith({ cursor: "c", limit: 10 }); + }); +}); diff --git a/tests/commands/emoji/remove.test.ts b/tests/commands/emoji/remove.test.ts new file mode 100644 index 0000000..f381129 --- /dev/null +++ b/tests/commands/emoji/remove.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeEmojiRemove } from "../../../src/commands/emoji/remove"; + +describe("emoji remove", () => { + test("calls admin.emoji.remove", async () => { + const mockRemove = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { emoji: { remove: mockRemove } } } as any; + await executeEmojiRemove(client, { name: "party" }); + expect(mockRemove).toHaveBeenCalledWith({ name: "party" }); + }); +}); diff --git a/tests/commands/emoji/rename.test.ts b/tests/commands/emoji/rename.test.ts new file mode 100644 index 0000000..b11dfbb --- /dev/null +++ b/tests/commands/emoji/rename.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeEmojiRename } from "../../../src/commands/emoji/rename"; + +describe("emoji rename", () => { + test("calls admin.emoji.rename", async () => { + const mockRename = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { emoji: { rename: mockRename } } } as any; + await executeEmojiRename(client, { name: "party", newName: "party2" }); + expect(mockRename).toHaveBeenCalledWith({ name: "party", new_name: "party2" }); + }); +}); diff --git a/tests/commands/roles/add-assignments.test.ts b/tests/commands/roles/add-assignments.test.ts new file mode 100644 index 0000000..8313923 --- /dev/null +++ b/tests/commands/roles/add-assignments.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeRolesAddAssignments } from "../../../src/commands/roles/add-assignments"; + +describe("roles add-assignments", () => { + test("calls admin.roles.addAssignments", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { roles: { addAssignments: mockFn } } } as any; + await executeRolesAddAssignments(client, { + roleId: "Rl0123", + entityIds: ["T001"], + userIds: ["U001", "U002"], + }); + expect(mockFn).toHaveBeenCalledWith({ + role_id: "Rl0123", + entity_ids: ["T001"], + user_ids: ["U001", "U002"], + }); + }); +}); diff --git a/tests/commands/roles/list-assignments.test.ts b/tests/commands/roles/list-assignments.test.ts new file mode 100644 index 0000000..6713df9 --- /dev/null +++ b/tests/commands/roles/list-assignments.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeRolesListAssignments } from "../../../src/commands/roles/list-assignments"; + +describe("roles list-assignments", () => { + test("returns role_assignments", async () => { + const mockApiCall = mock(() => + Promise.resolve({ ok: true, role_assignments: [{ role_id: "Rl0", user_id: "U1" }] }), + ); + const client = { apiCall: mockApiCall } as any; + const result = await executeRolesListAssignments(client, { + roleIds: ["Rl0"], + cursor: "c", + limit: 5, + sortDir: "asc", + }); + expect(mockApiCall).toHaveBeenCalledWith("admin.roles.listAssignments", { + role_ids: ["Rl0"], + cursor: "c", + limit: 5, + sort_dir: "asc", + }); + expect(result).toEqual([{ role_id: "Rl0", user_id: "U1" }]); + }); + + test("returns empty array when role_assignments missing", async () => { + const mockApiCall = mock(() => Promise.resolve({ ok: true })); + const client = { apiCall: mockApiCall } as any; + const result = await executeRolesListAssignments(client, {}); + expect(mockApiCall).toHaveBeenCalledWith("admin.roles.listAssignments", {}); + expect(result).toEqual([]); + }); +}); diff --git a/tests/commands/roles/remove-assignments.test.ts b/tests/commands/roles/remove-assignments.test.ts new file mode 100644 index 0000000..71e0d07 --- /dev/null +++ b/tests/commands/roles/remove-assignments.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeRolesRemoveAssignments } from "../../../src/commands/roles/remove-assignments"; + +describe("roles remove-assignments", () => { + test("calls admin.roles.removeAssignments", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { roles: { removeAssignments: mockFn } } } as any; + await executeRolesRemoveAssignments(client, { + roleId: "Rl0", + entityIds: ["T001"], + userIds: ["U001"], + }); + expect(mockFn).toHaveBeenCalledWith({ + role_id: "Rl0", + entity_ids: ["T001"], + user_ids: ["U001"], + }); + }); +}); diff --git a/tests/commands/teams/settings/set-default-channels.test.ts b/tests/commands/teams/settings/set-default-channels.test.ts new file mode 100644 index 0000000..d23ca57 --- /dev/null +++ b/tests/commands/teams/settings/set-default-channels.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeSetDefaultChannels } from "../../../../src/commands/teams/settings/set-default-channels"; + +describe("teams settings set-default-channels", () => { + test("calls admin.teams.settings.setDefaultChannels", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { teams: { settings: { setDefaultChannels: mockFn } } } } as any; + await executeSetDefaultChannels(client, { + teamId: "T001", + channelIds: ["C001", "C002"], + }); + expect(mockFn).toHaveBeenCalledWith({ + team_id: "T001", + channel_ids: ["C001", "C002"], + }); + }); +}); diff --git a/tests/commands/usergroups/add-channels.test.ts b/tests/commands/usergroups/add-channels.test.ts new file mode 100644 index 0000000..f13ba5e --- /dev/null +++ b/tests/commands/usergroups/add-channels.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeUsergroupsAddChannels } from "../../../src/commands/usergroups/add-channels"; + +describe("usergroups add-channels", () => { + test("calls admin.usergroups.addChannels with snake_case params", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { usergroups: { addChannels: mockFn } } } as any; + await executeUsergroupsAddChannels(client, { + usergroupId: "S001", + channelIds: ["C001", "C002"], + teamId: "T001", + }); + expect(mockFn).toHaveBeenCalledWith({ + usergroup_id: "S001", + channel_ids: ["C001", "C002"], + team_id: "T001", + }); + }); + + test("omits team_id when undefined", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { usergroups: { addChannels: mockFn } } } as any; + await executeUsergroupsAddChannels(client, { + usergroupId: "S001", + channelIds: ["C001"], + }); + expect(mockFn).toHaveBeenCalledWith({ + usergroup_id: "S001", + channel_ids: ["C001"], + team_id: undefined, + }); + }); +}); diff --git a/tests/commands/usergroups/add-teams.test.ts b/tests/commands/usergroups/add-teams.test.ts new file mode 100644 index 0000000..94368bd --- /dev/null +++ b/tests/commands/usergroups/add-teams.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeUsergroupsAddTeams } from "../../../src/commands/usergroups/add-teams"; + +describe("usergroups add-teams", () => { + test("calls admin.usergroups.addTeams with snake_case params", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { usergroups: { addTeams: mockFn } } } as any; + await executeUsergroupsAddTeams(client, { + usergroupId: "S001", + teamIds: ["T001", "T002"], + autoProvision: true, + }); + expect(mockFn).toHaveBeenCalledWith({ + usergroup_id: "S001", + team_ids: ["T001", "T002"], + auto_provision: true, + }); + }); + + test("omits auto_provision when undefined", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { usergroups: { addTeams: mockFn } } } as any; + await executeUsergroupsAddTeams(client, { + usergroupId: "S001", + teamIds: ["T001"], + }); + expect(mockFn).toHaveBeenCalledWith({ + usergroup_id: "S001", + team_ids: ["T001"], + auto_provision: undefined, + }); + }); +}); diff --git a/tests/commands/usergroups/list-channels.test.ts b/tests/commands/usergroups/list-channels.test.ts new file mode 100644 index 0000000..12ac09a --- /dev/null +++ b/tests/commands/usergroups/list-channels.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeUsergroupsListChannels } from "../../../src/commands/usergroups/list-channels"; + +describe("usergroups list-channels", () => { + test("returns channels array", async () => { + const mockFn = mock(() => + Promise.resolve({ ok: true, channels: [{ id: "C001", name: "general", num_members: 5 }] }), + ); + const client = { admin: { usergroups: { listChannels: mockFn } } } as any; + const result = await executeUsergroupsListChannels(client, { + usergroupId: "S001", + teamId: "T001", + includeNumMembers: true, + }); + expect(mockFn).toHaveBeenCalledWith({ + usergroup_id: "S001", + team_id: "T001", + include_num_members: true, + }); + expect(result).toEqual([{ id: "C001", name: "general", num_members: 5 }]); + }); + + test("returns empty array when channels missing", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { usergroups: { listChannels: mockFn } } } as any; + const result = await executeUsergroupsListChannels(client, { usergroupId: "S001" }); + expect(result).toEqual([]); + }); +}); diff --git a/tests/commands/usergroups/remove-channels.test.ts b/tests/commands/usergroups/remove-channels.test.ts new file mode 100644 index 0000000..7584182 --- /dev/null +++ b/tests/commands/usergroups/remove-channels.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeUsergroupsRemoveChannels } from "../../../src/commands/usergroups/remove-channels"; + +describe("usergroups remove-channels", () => { + test("calls admin.usergroups.removeChannels with snake_case params", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { usergroups: { removeChannels: mockFn } } } as any; + await executeUsergroupsRemoveChannels(client, { + usergroupId: "S001", + channelIds: ["C001", "C002"], + }); + expect(mockFn).toHaveBeenCalledWith({ + usergroup_id: "S001", + channel_ids: ["C001", "C002"], + }); + }); +}); diff --git a/tests/commands/users/session/clear-settings.test.ts b/tests/commands/users/session/clear-settings.test.ts new file mode 100644 index 0000000..088737b --- /dev/null +++ b/tests/commands/users/session/clear-settings.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeUsersSessionClearSettings } from "../../../../src/commands/users/session/clear-settings"; + +describe("users session clear-settings", () => { + test("calls admin.users.session.clearSettings with user_ids", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { users: { session: { clearSettings: mockFn } } } } as any; + await executeUsersSessionClearSettings(client, { userIds: ["U001", "U002"] }); + expect(mockFn).toHaveBeenCalledWith({ user_ids: ["U001", "U002"] }); + }); +}); diff --git a/tests/commands/users/session/get-settings.test.ts b/tests/commands/users/session/get-settings.test.ts new file mode 100644 index 0000000..826a35d --- /dev/null +++ b/tests/commands/users/session/get-settings.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeUsersSessionGetSettings } from "../../../../src/commands/users/session/get-settings"; + +describe("users session get-settings", () => { + test("returns session_settings array", async () => { + const mockFn = mock(() => + Promise.resolve({ + ok: true, + session_settings: [{ user_id: "U001", duration: 3600 }], + }), + ); + const client = { admin: { users: { session: { getSettings: mockFn } } } } as any; + const result = await executeUsersSessionGetSettings(client, { userIds: ["U001"] }); + expect(mockFn).toHaveBeenCalledWith({ user_ids: ["U001"] }); + expect(result).toEqual([{ user_id: "U001", duration: 3600 }]); + }); + + test("returns [] when session_settings is missing", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { users: { session: { getSettings: mockFn } } } } as any; + const result = await executeUsersSessionGetSettings(client, { userIds: ["U001"] }); + expect(result).toEqual([]); + }); +}); diff --git a/tests/commands/users/session/invalidate.test.ts b/tests/commands/users/session/invalidate.test.ts new file mode 100644 index 0000000..0479ed3 --- /dev/null +++ b/tests/commands/users/session/invalidate.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeUsersSessionInvalidate } from "../../../../src/commands/users/session/invalidate"; + +describe("users session invalidate", () => { + test("calls admin.users.session.invalidate with team_id and session_id", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { users: { session: { invalidate: mockFn } } } } as any; + await executeUsersSessionInvalidate(client, { teamId: "T001", sessionId: "S001" }); + expect(mockFn).toHaveBeenCalledWith({ team_id: "T001", session_id: "S001" }); + }); +}); diff --git a/tests/commands/users/session/list.test.ts b/tests/commands/users/session/list.test.ts new file mode 100644 index 0000000..9b6335d --- /dev/null +++ b/tests/commands/users/session/list.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeUsersSessionList } from "../../../../src/commands/users/session/list"; + +describe("users session list", () => { + test("returns active_sessions array with team+user", async () => { + const mockApiCall = mock(() => + Promise.resolve({ ok: true, active_sessions: [{ session_id: "S1" }] }), + ); + const client = { apiCall: mockApiCall } as any; + const result = await executeUsersSessionList(client, { + teamId: "T001", + userId: "U001", + cursor: "c", + limit: 5, + }); + expect(mockApiCall).toHaveBeenCalledWith("admin.users.session.list", { + team_id: "T001", + user_id: "U001", + cursor: "c", + limit: 5, + }); + expect(result).toEqual([{ session_id: "S1" }]); + }); + + test("works with neither team nor user", async () => { + const mockApiCall = mock(() => Promise.resolve({ ok: true, active_sessions: [] })); + const client = { apiCall: mockApiCall } as any; + await executeUsersSessionList(client, {}); + expect(mockApiCall).toHaveBeenCalledWith("admin.users.session.list", {}); + }); + + test("rejects when only team_id is provided", async () => { + const client = { apiCall: mock(() => Promise.resolve({ ok: true })) } as any; + await expect(executeUsersSessionList(client, { teamId: "T001" })).rejects.toThrow(); + }); + + test("rejects when only user_id is provided", async () => { + const client = { apiCall: mock(() => Promise.resolve({ ok: true })) } as any; + await expect(executeUsersSessionList(client, { userId: "U001" })).rejects.toThrow(); + }); + + test("returns [] when active_sessions is missing", async () => { + const mockApiCall = mock(() => Promise.resolve({ ok: true })); + const client = { apiCall: mockApiCall } as any; + const result = await executeUsersSessionList(client, {}); + expect(result).toEqual([]); + }); +}); diff --git a/tests/commands/users/session/reset-bulk.test.ts b/tests/commands/users/session/reset-bulk.test.ts new file mode 100644 index 0000000..2fcf437 --- /dev/null +++ b/tests/commands/users/session/reset-bulk.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeUsersSessionResetBulk } from "../../../../src/commands/users/session/reset-bulk"; + +describe("users session reset-bulk", () => { + test("calls admin.users.session.resetBulk with user_ids", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { users: { session: { resetBulk: mockFn } } } } as any; + await executeUsersSessionResetBulk(client, { userIds: ["U001", "U002"] }); + expect(mockFn).toHaveBeenCalledWith({ user_ids: ["U001", "U002"] }); + }); + + test("passes mobile_only and web_only flags when provided", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { users: { session: { resetBulk: mockFn } } } } as any; + await executeUsersSessionResetBulk(client, { + userIds: ["U001"], + mobileOnly: true, + webOnly: false, + }); + expect(mockFn).toHaveBeenCalledWith({ + user_ids: ["U001"], + mobile_only: true, + web_only: false, + }); + }); +}); diff --git a/tests/commands/users/session/set-settings.test.ts b/tests/commands/users/session/set-settings.test.ts new file mode 100644 index 0000000..8743c90 --- /dev/null +++ b/tests/commands/users/session/set-settings.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeUsersSessionSetSettings } from "../../../../src/commands/users/session/set-settings"; + +describe("users session set-settings", () => { + test("calls admin.users.session.setSettings with user_ids", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { users: { session: { setSettings: mockFn } } } } as any; + await executeUsersSessionSetSettings(client, { userIds: ["U001"] }); + expect(mockFn).toHaveBeenCalledWith({ user_ids: ["U001"] }); + }); + + test("passes desktop_app_browser_quit and duration when provided", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { users: { session: { setSettings: mockFn } } } } as any; + await executeUsersSessionSetSettings(client, { + userIds: ["U001"], + desktopAppBrowserQuit: true, + duration: 7200, + }); + expect(mockFn).toHaveBeenCalledWith({ + user_ids: ["U001"], + desktop_app_browser_quit: true, + duration: 7200, + }); + }); +}); diff --git a/tests/commands/users/set-expiration.test.ts b/tests/commands/users/set-expiration.test.ts new file mode 100644 index 0000000..36766f4 --- /dev/null +++ b/tests/commands/users/set-expiration.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeUsersSetExpiration } from "../../../src/commands/users/set-expiration"; + +describe("users set-expiration", () => { + test("calls admin.users.setExpiration", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { admin: { users: { setExpiration: mockFn } } } as any; + await executeUsersSetExpiration(client, { + userId: "U001", + expirationTs: 1700000000, + teamId: "T001", + }); + expect(mockFn).toHaveBeenCalledWith({ + user_id: "U001", + expiration_ts: 1700000000, + team_id: "T001", + }); + }); +}); diff --git a/tests/commands/users/unsupported-versions/export.test.ts b/tests/commands/users/unsupported-versions/export.test.ts new file mode 100644 index 0000000..cea82d6 --- /dev/null +++ b/tests/commands/users/unsupported-versions/export.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test, mock } from "bun:test"; +import { executeUsersUnsupportedVersionsExport } from "../../../../src/commands/users/unsupported-versions/export"; + +describe("users unsupported-versions export", () => { + test("calls admin.users.unsupportedVersions.export", async () => { + const mockFn = mock(() => Promise.resolve({ ok: true })); + const client = { + admin: { users: { unsupportedVersions: { export: mockFn } } }, + } as any; + await executeUsersUnsupportedVersionsExport(client, { + dateEndOfSupport: 1700000000, + dateSessionsStarted: 1690000000, + }); + expect(mockFn).toHaveBeenCalledWith({ + date_end_of_support: 1700000000, + date_sessions_started: 1690000000, + }); + }); +});