diff --git a/package-lock.json b/package-lock.json index 99464a2..2a25d43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "@doist/cli-core": "0.20.0", - "@doist/twist-sdk": "2.7.0", + "@doist/twist-sdk": "2.8.1", "@pnpm/tabtab": "0.5.4", "chalk": "5.6.2", "commander": "14.0.3", @@ -182,9 +182,9 @@ } }, "node_modules/@doist/twist-sdk": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@doist/twist-sdk/-/twist-sdk-2.7.0.tgz", - "integrity": "sha512-hcwmmkTDrxwS4/RLTFBlt5O/pLKs+QeL6a7izU8NcPdCqw/GlzRaLbO/Gpl5w+gsEfaUdBQ91ogyKaszxpGggw==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/@doist/twist-sdk/-/twist-sdk-2.8.1.tgz", + "integrity": "sha512-FfBEI/9EyxPQGHU3SZ7aXW/l5x6Ric+7lcLye5VuNFzS24ws4iDqmKddWks+LF8bQitTjIC03Kbd8aX1AM2ZOQ==", "license": "MIT", "dependencies": { "camelcase": "8.0.0", @@ -201,7 +201,6 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -213,7 +212,6 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -224,7 +222,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1036,7 +1033,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1930,7 +1926,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1947,7 +1942,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1964,7 +1958,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1981,7 +1974,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1998,7 +1990,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2015,7 +2006,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2032,7 +2022,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2049,7 +2038,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2066,7 +2054,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2083,7 +2070,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2100,7 +2086,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2117,7 +2102,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2134,7 +2118,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2153,7 +2136,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2170,7 +2152,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2951,7 +2932,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2993,7 +2973,7 @@ "version": "25.8.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -4565,7 +4545,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5480,7 +5459,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5501,7 +5479,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5522,7 +5499,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5543,7 +5519,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5564,7 +5539,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5585,7 +5559,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5606,7 +5579,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5627,7 +5599,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5648,7 +5619,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5669,7 +5639,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5690,7 +5659,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10100,7 +10068,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD", "optional": true }, @@ -10182,7 +10149,7 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { diff --git a/package.json b/package.json index 66cd6dc..e5c8a90 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ ], "dependencies": { "@doist/cli-core": "0.20.0", - "@doist/twist-sdk": "2.7.0", + "@doist/twist-sdk": "2.8.1", "@pnpm/tabtab": "0.5.4", "chalk": "5.6.2", "commander": "14.0.3", diff --git a/skills/twist-cli/SKILL.md b/skills/twist-cli/SKILL.md index 1572e7c..ea9ed1a 100644 --- a/skills/twist-cli/SKILL.md +++ b/skills/twist-cli/SKILL.md @@ -204,8 +204,9 @@ tw search "query" --all # Fetch all result pages tw user # Show current user info tw user --json # JSON output tw user --json --full # Include all fields in JSON output -tw users # List workspace users +tw users # List active workspace users tw users --search # Filter by name/email +tw users --include-removed # Include users removed from the workspace tw channels # List active joined workspace channels (alias of: tw channel list) tw channels --state all # Include archived joined channels too tw channels --scope discoverable # Active public channels you can see but have not joined diff --git a/src/commands/user.test.ts b/src/commands/user.test.ts index be0b2c2..6ceb5ed 100644 --- a/src/commands/user.test.ts +++ b/src/commands/user.test.ts @@ -107,3 +107,72 @@ describe('user --json', () => { consoleSpy.mockRestore() }) }) + +describe('tw users --include-removed', () => { + const active = { + id: 1, + name: 'Active', + email: 'a@x', + userType: 'USER', + bot: false, + removed: false, + } + const removed = { + id: 2, + name: 'Ghost', + email: 'ghost@x', + userType: 'GUEST', + bot: false, + removed: true, + } + + beforeEach(() => { + vi.clearAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + }) + + it('passes includeRemoved: undefined by default so the SDK applies its default filter', async () => { + apiMocks.getWorkspaceUsers.mockResolvedValueOnce([active]) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'users']) + + expect(apiMocks.getWorkspaceUsers).toHaveBeenCalledWith(1, { includeRemoved: undefined }) + expect(consoleSpy.mock.calls.flat().join('\n')).not.toMatch(/\[removed\]/) + + consoleSpy.mockRestore() + }) + + it('passes includeRemoved: true and annotates removed users in text output', async () => { + apiMocks.getWorkspaceUsers.mockResolvedValueOnce([active, removed]) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'users', '--include-removed']) + + expect(apiMocks.getWorkspaceUsers).toHaveBeenCalledWith(1, { includeRemoved: true }) + const lines = consoleSpy.mock.calls.flat().join('\n') + expect(lines).toMatch(/id:2.*Ghost.*\[removed\]/) + expect(lines).not.toMatch(/id:1.*Active.*\[removed\]/) + + consoleSpy.mockRestore() + }) + + it('surfaces removed in curated --json output without --full', async () => { + apiMocks.getWorkspaceUsers.mockResolvedValueOnce([active, removed]) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'users', '--include-removed', '--json']) + + const parsed = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(parsed).toHaveLength(2) + expect(parsed[0]).toMatchObject({ id: 1, removed: false }) + expect(parsed[1]).toMatchObject({ id: 2, removed: true }) + // Curated, not --full: shortName must not leak in. + expect(parsed[0]).not.toHaveProperty('shortName') + + consoleSpy.mockRestore() + }) +}) diff --git a/src/commands/user.ts b/src/commands/user.ts index 79df38f..34c5d95 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -6,7 +6,7 @@ import type { ViewOptions } from '../lib/options.js' import { colors, formatJson, formatNdjson, printEmpty } from '../lib/output.js' import { resolveWorkspaceRef } from '../lib/refs.js' -type UsersOptions = ViewOptions & { workspace?: string; search?: string } +type UsersOptions = ViewOptions & { workspace?: string; search?: string; includeRemoved?: boolean } async function showCurrentUser(options: ViewOptions): Promise { const user = await getSessionUser() @@ -44,7 +44,7 @@ async function listUsers(workspaceRef: string | undefined, options: UsersOptions workspaceId = await getCurrentWorkspaceId() } - let users = await getWorkspaceUsers(workspaceId) + let users = await getWorkspaceUsers(workspaceId, { includeRemoved: options.includeRemoved }) if (options.search) { const search = options.search.toLowerCase() @@ -74,7 +74,8 @@ async function listUsers(workspaceRef: string | undefined, options: UsersOptions const email = u.email ? colors.timestamp(`<${u.email}>`) : '' const type = colors.channel(`[${u.userType}]`) const bot = u.bot ? chalk.yellow(' [bot]') : '' - console.log(`${id} ${name} ${email} ${type}${bot}`) + const removed = u.removed ? chalk.red(' [removed]') : '' + console.log(`${id} ${name} ${email} ${type}${bot}${removed}`) } } @@ -98,6 +99,7 @@ Examples: .description('List users in a workspace') .option('--workspace ', 'Workspace ID or name') .option('--search ', 'Filter by name/email') + .option('--include-removed', 'Include users who have been removed from the workspace') .option('--json', 'Output as JSON') .option('--ndjson', 'Output as newline-delimited JSON') .option('--full', 'Include all fields in JSON output') @@ -106,7 +108,8 @@ Examples: ` Examples: tw users - tw users --search "Jane" --json`, + tw users --search "Jane" --json + tw users --include-removed`, ) .action(listUsers) } diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts new file mode 100644 index 0000000..01ec5ec --- /dev/null +++ b/src/lib/api.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock the SDK so we can observe how `getWorkspaceUsers` invokes +// `client.workspaceUsers.getWorkspaceUsers`. The real filtering of +// removed users lives in the SDK (≥2.8.1), so the contract this test +// guards is "we pass `includeRemoved` through unchanged." +const getWorkspaceUsersMock = vi.hoisted(() => vi.fn().mockResolvedValue([])) + +vi.mock('@doist/twist-sdk', () => ({ + TwistApi: class { + workspaceUsers = { getWorkspaceUsers: getWorkspaceUsersMock } + }, +})) + +vi.mock('./auth.js', () => ({ + getApiToken: vi.fn().mockResolvedValue('test-token'), +})) + +vi.mock('./permissions.js', () => ({ + ensureWriteAllowed: vi.fn(), + isMutatingMethod: vi.fn().mockReturnValue(false), +})) + +vi.mock('./spinner.js', () => ({ + withSpinner: (_label: unknown, fn: () => Promise) => fn(), +})) + +vi.mock('./progress.js', () => ({ + getProgressTracker: () => ({ isEnabled: () => false, emitApiCall: vi.fn() }), +})) + +import { getWorkspaceUsers } from './api.js' + +describe('getWorkspaceUsers', () => { + beforeEach(() => { + getWorkspaceUsersMock.mockClear() + }) + + it('passes includeRemoved: undefined by default so the SDK applies its default filter', async () => { + await getWorkspaceUsers(1585) + expect(getWorkspaceUsersMock).toHaveBeenCalledWith({ + workspaceId: 1585, + includeRemoved: undefined, + }) + }) + + it('forwards includeRemoved: true to the SDK', async () => { + await getWorkspaceUsers(1585, { includeRemoved: true }) + expect(getWorkspaceUsersMock).toHaveBeenCalledWith({ + workspaceId: 1585, + includeRemoved: true, + }) + }) +}) diff --git a/src/lib/api.ts b/src/lib/api.ts index 590c6bb..8c4de1b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -297,9 +297,15 @@ export async function getSessionUser(): Promise { return sessionUserCache } -export async function getWorkspaceUsers(workspaceId: number): Promise { +export async function getWorkspaceUsers( + workspaceId: number, + options: { includeRemoved?: boolean } = {}, +): Promise { const client = await getTwistClient() - return client.workspaceUsers.getWorkspaceUsers({ workspaceId }) + return client.workspaceUsers.getWorkspaceUsers({ + workspaceId, + includeRemoved: options.includeRemoved, + }) } export async function getWorkspaceGroups(workspaceId: number): Promise { diff --git a/src/lib/output.ts b/src/lib/output.ts index 284acbe..d125c86 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -58,7 +58,15 @@ const MESSAGE_ESSENTIAL_FIELDS = [ const WORKSPACE_ESSENTIAL_FIELDS = ['id', 'name', 'creator', 'plan'] as const -const USER_ESSENTIAL_FIELDS = ['id', 'name', 'email', 'timezone', 'userType', 'awayMode'] as const +const USER_ESSENTIAL_FIELDS = [ + 'id', + 'name', + 'email', + 'timezone', + 'userType', + 'awayMode', + 'removed', +] as const const CHANNEL_ESSENTIAL_FIELDS = ['id', 'name', 'workspaceId'] as const diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 83be5a5..4903675 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -208,8 +208,9 @@ tw search "query" --all # Fetch all result pages tw user # Show current user info tw user --json # JSON output tw user --json --full # Include all fields in JSON output -tw users # List workspace users +tw users # List active workspace users tw users --search # Filter by name/email +tw users --include-removed # Include users removed from the workspace tw channels # List active joined workspace channels (alias of: tw channel list) tw channels --state all # Include archived joined channels too tw channels --scope discoverable # Active public channels you can see but have not joined