From 4bbe4961fd74e6a3fc9a8ff23fb98425b6aadcb4 Mon Sep 17 00:00:00 2001 From: lmjabreu Date: Fri, 22 May 2026 10:27:29 +0100 Subject: [PATCH 1/9] feat(channel): add resolveChannelMemberRefs helper Parse mixed user / group: lists for channel membership commands. Groups expand to their current userIds at call time (one-shot, not a persistent link); the caveat is surfaced later in command help. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/refs.ts | 73 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/lib/refs.ts b/src/lib/refs.ts index 0786d5b..5ed7d02 100644 --- a/src/lib/refs.ts +++ b/src/lib/refs.ts @@ -398,6 +398,79 @@ export async function resolveGroupRef(ref: string, workspaceId: number): Promise ]) } +export interface ChannelMemberRefs { + userIds: number[] + expandedFrom: { groupId: number; groupName: string; userIds: number[] }[] +} + +const GROUP_REF_PREFIX = 'group:' + +/** + * Resolve a mixed list of user and `group:` references for channel membership. + * + * Groups are expanded to their current `userIds` at call time. The group itself + * is not persistently linked to the channel — callers should surface that + * caveat in user-facing help text. + * + * Returns deduped userIds in input order, with a parallel `expandedFrom` list + * recording which groups contributed (and which users each group brought in, + * pre-dedup) for reporting purposes. + */ +export async function resolveChannelMemberRefs( + refs: string[], + workspaceId: number, +): Promise { + if (refs.length === 0) { + throw new CliError('MISSING_USERS', 'Provide at least one user or group: reference.') + } + + const userRefs: string[] = [] + const groupRefs: string[] = [] + for (const ref of refs) { + const trimmed = normalizeRef(ref) + if (trimmed.toLowerCase().startsWith(GROUP_REF_PREFIX)) { + const inner = trimmed.slice(GROUP_REF_PREFIX.length).trim() + if (!inner) { + throw new CliError( + 'INVALID_REF', + `Empty group reference: "${ref}". Use group:.`, + ) + } + groupRefs.push(inner) + } else { + userRefs.push(trimmed) + } + } + + const expandedFrom: ChannelMemberRefs['expandedFrom'] = [] + const seen = new Set() + const userIds: number[] = [] + + const pushId = (id: number) => { + if (!seen.has(id)) { + seen.add(id) + userIds.push(id) + } + } + + if (userRefs.length > 0) { + const resolved = await resolveUserRefs(userRefs.join(','), workspaceId) + for (const id of resolved) pushId(id) + } + + for (const groupRef of groupRefs) { + const group = await resolveGroupRef(groupRef, workspaceId) + expandedFrom.push({ + groupId: group.id, + groupName: group.name, + userIds: [...group.userIds], + }) + for (const id of group.userIds) pushId(id) + } + + return { userIds, expandedFrom } +} + export async function resolveUserRefs(refs: string, workspaceId: number): Promise { const { getWorkspaceUsers } = await import('./api.js') const users = await getWorkspaceUsers(workspaceId) From 8fb856ecb213d36240705ee8b9495a69da25612c Mon Sep 17 00:00:00 2001 From: lmjabreu Date: Fri, 22 May 2026 10:32:14 +0100 Subject: [PATCH 2/9] feat(channel): add members/add/remove/sync subcommands - src/lib/api.ts: wrap channels.addUsers / removeUsers with spinner messages and helpers (addUsersToChannel / removeUsersFromChannel). - src/commands/channel/members.ts: list members + groups-fully-in-channel hint, add/remove with group: expansion, sync with dry-run-by-default, --apply, and --include-self self-removal guard. - src/commands/channel/index.ts: register the four subcommands with examples and the one-shot group-expansion caveat in --help. - src/commands/channel/members.test.ts: 15 tests covering the cases in CHANNEL-MEMBERSHIP-PLAN.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/channel/index.ts | 89 ++++++ src/commands/channel/members.test.ts | 397 +++++++++++++++++++++++++++ src/commands/channel/members.ts | 333 ++++++++++++++++++++++ src/lib/api.ts | 12 + 4 files changed, 831 insertions(+) create mode 100644 src/commands/channel/members.test.ts create mode 100644 src/commands/channel/members.ts diff --git a/src/commands/channel/index.ts b/src/commands/channel/index.ts index fa15046..bc67421 100644 --- a/src/commands/channel/index.ts +++ b/src/commands/channel/index.ts @@ -1,6 +1,12 @@ import { Command, Option } from 'commander' import { withCaseInsensitiveChoices } from '../../lib/completion.js' import { listChannels } from './list.js' +import { + addChannelMembers, + listChannelMembers, + removeChannelMembers, + syncChannelMembers, +} from './members.js' import { showChannelThreads } from './threads.js' export function registerChannelCommand(program: Command): void { @@ -86,4 +92,87 @@ Notes: and --unread are applied client-side; --archive-filter is applied server-side.`, ) .action(showChannelThreads) + + channel + .command('members ') + .description('List channel members and groups whose members are all in the channel') + .option('--json', 'Output as JSON') + .option('--ndjson', 'Output as newline-delimited JSON') + .option('--full', 'Include all fields in JSON output') + .addHelpText( + 'after', + ` +Examples: + tw channel members 12345 + tw channel members "general" --json + +Notes: + "Groups fully in channel" lists groups whose entire current membership is + already in the channel — a hint, not a persistent link.`, + ) + .action(listChannelMembers) + + channel + .command('add [refs...]') + .description('Add users and/or groups to a channel') + .option('--dry-run', 'Show what would change without changing') + .option('--json', 'Output result as JSON') + .option('--full', 'Include the full updated channel in JSON output') + .addHelpText( + 'after', + ` +Examples: + tw channel add 12345 alice@doist.com bob@doist.com + tw channel add "general" group:Frontend + tw channel add 12345 alice group:Design id:789 --json + +Notes: + Refs accept user identifiers (id:N, email, name) or "group:" to expand + a group to its current members. Group expansion is one-shot — users added + later to the group will not auto-join the channel.`, + ) + .action(addChannelMembers) + + channel + .command('remove [refs...]') + .description('Remove users and/or groups from a channel') + .option('--dry-run', 'Show what would change without changing') + .option('--json', 'Output result as JSON') + .option('--full', 'Include the full updated channel in JSON output') + .addHelpText( + 'after', + ` +Examples: + tw channel remove 12345 alice@doist.com + tw channel remove "general" group:Frontend + +Notes: + Refs accept user identifiers (id:N, email, name) or "group:" to expand + a group to its current members.`, + ) + .action(removeChannelMembers) + + channel + .command('sync [refs...]') + .description('Replace channel membership with the resolved set of refs') + .option('--apply', 'Actually mutate (otherwise dry-run)') + .option('--include-self', 'Allow sync to remove the acting user') + .option('--dry-run', 'Force dry-run (default behaviour)') + .option('--json', 'Output result as JSON') + .option('--full', 'Include the full updated channel in JSON output') + .addHelpText( + 'after', + ` +Examples: + tw channel sync 12345 group:Frontend group:Design + tw channel sync "general" alice bob carol --apply + tw channel sync 12345 group:Squad --apply --include-self + +Notes: + Dry-run by default. Pass --apply to mutate. + Refuses to remove the acting user unless --include-self is also passed. + Group expansion is one-shot — users added later to a referenced group will + not auto-join the channel.`, + ) + .action(syncChannelMembers) } diff --git a/src/commands/channel/members.test.ts b/src/commands/channel/members.test.ts new file mode 100644 index 0000000..2b4171c --- /dev/null +++ b/src/commands/channel/members.test.ts @@ -0,0 +1,397 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockBatch = vi.fn() +const mockGetUserById = vi.fn() +const mockGetChannel = vi.fn() + +const apiMocks = vi.hoisted(() => ({ + getCurrentWorkspaceId: vi.fn().mockResolvedValue(1), + getTwistClient: vi.fn(), + getSessionUser: vi.fn(), + getWorkspaceGroups: vi.fn(), + addUsersToChannel: vi.fn(), + removeUsersFromChannel: vi.fn(), +})) + +vi.mock('../../lib/api.js', () => apiMocks) + +const refsMocks = vi.hoisted(() => ({ + resolveChannelRef: vi.fn(), + resolveChannelMemberRefs: vi.fn(), +})) + +vi.mock('../../lib/refs.js', () => refsMocks) + +vi.mock('chalk') + +import { registerChannelCommand } from './index.js' + +function createProgram() { + const program = new Command() + program.exitOverride() + registerChannelCommand(program) + return program +} + +const sampleChannel = { + id: 500, + name: 'general', + workspaceId: 1, + userIds: [1, 2, 3], + creator: 1, + public: true, + archived: false, + created: new Date(), + version: 1, + url: 'https://twist.com/a/1/ch/500', +} + +const frontendGroup = { id: 100, name: 'Frontend', workspaceId: 1, userIds: [1, 2], version: 1 } +const backendGroup = { id: 200, name: 'Backend', workspaceId: 1, userIds: [4, 5], version: 1 } +// Group whose membership is a subset of channel's userIds [1,2,3] +const allInChannel = { id: 300, name: 'Core', workspaceId: 1, userIds: [1, 2], version: 1 } + +beforeEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + apiMocks.getTwistClient.mockResolvedValue({ + workspaceUsers: { getUserById: mockGetUserById }, + channels: { getChannel: mockGetChannel }, + batch: mockBatch, + }) + apiMocks.getSessionUser.mockResolvedValue({ id: 1, name: 'Me', email: 'me@d.com' }) + apiMocks.getWorkspaceGroups.mockResolvedValue([frontendGroup, backendGroup, allInChannel]) + refsMocks.resolveChannelRef.mockResolvedValue(sampleChannel) +}) + +describe('tw channel members (list)', () => { + beforeEach(() => { + mockBatch.mockResolvedValue([ + { code: 200, data: { id: 1, name: 'Alice', email: 'a@d.com' } }, + { code: 200, data: { id: 2, name: 'Bob', email: 'b@d.com' } }, + { code: 200, data: { id: 3, name: 'Carol', email: 'c@d.com' } }, + ]) + }) + + it('lists users and groups fully in channel', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'members', 'general']) + + const text = consoleSpy.mock.calls.map((c) => c[0]).join('\n') + expect(text).toContain('Alice') + expect(text).toContain('Bob') + expect(text).toContain('Carol') + // "Core" group's userIds [1,2] are all in channel userIds [1,2,3] + expect(text).toContain('Core') + expect(text).toContain('Frontend') + // Backend's [4,5] are not in channel → should not appear in groups section + expect(text).not.toMatch(/Backend\s+\(/) + }) + + it('outputs JSON with default shape', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'members', 'general', '--json']) + + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(output.id).toBe(500) + expect(output.members).toHaveLength(3) + expect(output.members[0]).toMatchObject({ id: 1, name: 'Alice' }) + expect(output.groupsFullyInChannel.map((g: { id: number }) => g.id).sort()).toEqual([ + 100, 300, + ]) + }) +}) + +describe('tw channel add', () => { + it('adds users (no group expansion)', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [4, 5], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'add', + 'general', + 'dave@d.com', + 'eve@d.com', + ]) + + expect(refsMocks.resolveChannelMemberRefs).toHaveBeenCalledWith( + ['dave@d.com', 'eve@d.com'], + 1, + ) + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4, 5]) + }) + + it('expands group: and dedupes', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [4, 5, 6], + expandedFrom: [{ groupId: 200, groupName: 'Backend', userIds: [4, 5] }], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'add', + 'general', + 'group:Backend', + 'frank', + ]) + + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4, 5, 6]) + const text = consoleSpy.mock.calls.map((c) => c[0]).join('\n') + expect(text).toContain('Backend') + }) + + it('skips users already in channel', async () => { + // channel has [1,2,3]; requesting [2,4] → only 4 should be added, 2 skipped + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 4], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'add', 'general', 'id:2', 'id:4']) + + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4]) + }) + + it('makes no API call when all users already members', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [1, 2], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'add', 'general', 'id:1', 'id:2']) + + expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled() + }) + + it('--dry-run does not mutate', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [4, 5], + expandedFrom: [], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'add', + 'general', + 'id:4', + 'id:5', + '--dry-run', + ]) + + expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled() + expect(consoleSpy.mock.calls.some((c) => String(c[0]).includes('[dry-run]'))).toBe(true) + }) + + it('--json shape includes added/alreadyMembers', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 4], + expandedFrom: [], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'add', + 'general', + 'id:2', + 'id:4', + '--json', + ]) + + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(output).toMatchObject({ id: 500, added: [4], alreadyMembers: [2] }) + }) +}) + +describe('tw channel remove', () => { + it('only removes users that are members', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 3, 99], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'remove', + 'general', + 'id:2', + 'id:3', + 'id:99', + ]) + + expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith(500, [2, 3]) + }) + + it('makes no API call when none of the users are members', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [99, 100], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'remove', + 'general', + 'id:99', + 'id:100', + ]) + + expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() + }) +}) + +describe('tw channel sync', () => { + it('dry-run by default — does not mutate', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [1, 4, 5], + expandedFrom: [], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'sync', 'general', 'id:1', 'id:4', 'id:5']) + + expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled() + expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() + const text = consoleSpy.mock.calls.map((c) => c[0]).join('\n') + expect(text).toContain('[dry-run]') + }) + + it('--apply computes add/remove diff', async () => { + // channel currently [1,2,3]; desired [1,4,5] → add [4,5], remove [2,3] + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [1, 4, 5], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'sync', + 'general', + 'id:1', + 'id:4', + 'id:5', + '--apply', + ]) + + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4, 5]) + expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith(500, [2, 3]) + }) + + it('refuses to remove acting user without --include-self', async () => { + // session user id:1 is in current channel; desired set omits id:1 → would remove self + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 3, 4], + expandedFrom: [], + }) + const program = createProgram() + + await expect( + program.parseAsync([ + 'node', + 'tw', + 'channel', + 'sync', + 'general', + 'id:2', + 'id:3', + 'id:4', + '--apply', + ]), + ).rejects.toMatchObject({ code: 'INVALID_VALUE' }) + }) + + it('--include-self allows removing acting user', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 3, 4], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'sync', + 'general', + 'id:2', + 'id:3', + 'id:4', + '--apply', + '--include-self', + ]) + + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4]) + expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith(500, [1]) + }) + + it('--json output shape', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [1, 4], + expandedFrom: [{ groupId: 200, groupName: 'Backend', userIds: [4] }], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'sync', + 'general', + 'id:1', + 'group:Backend', + '--apply', + '--json', + ]) + + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(output).toMatchObject({ + id: 500, + added: [4], + removed: [2, 3], + expandedFrom: [{ groupId: 200, groupName: 'Backend', userIds: [4] }], + }) + }) +}) diff --git a/src/commands/channel/members.ts b/src/commands/channel/members.ts new file mode 100644 index 0000000..8844aae --- /dev/null +++ b/src/commands/channel/members.ts @@ -0,0 +1,333 @@ +import type { Channel, Group, User, WorkspaceUser } from '@doist/twist-sdk' +import { + addUsersToChannel, + getCurrentWorkspaceId, + getSessionUser, + getTwistClient, + getWorkspaceGroups, + removeUsersFromChannel, +} from '../../lib/api.js' +import { CliError } from '../../lib/errors.js' +import type { MutationOptions, ViewOptions } from '../../lib/options.js' +import { colors, formatJson, formatNdjson, pluralize, printDryRun } from '../../lib/output.js' +import { resolveChannelMemberRefs, resolveChannelRef } from '../../lib/refs.js' + +type ChannelMutationOptions = MutationOptions + +export type SyncOptions = MutationOptions & { + apply?: boolean + includeSelf?: boolean +} + +function dedupe(ids: number[]): number[] { + return [...new Set(ids)] +} + +function channelUserIds(channel: Channel): number[] { + return channel.userIds ?? [] +} + +async function fetchUsersByIds( + workspaceId: number, + userIds: number[], +): Promise> { + if (userIds.length === 0) return new Map() + const client = await getTwistClient() + const calls = userIds.map((userId) => + client.workspaceUsers.getUserById({ workspaceId, userId }, { batch: true }), + ) + const responses = await client.batch(...calls) + const map = new Map() + userIds.forEach((id, i) => { + const user = responses[i]?.data + if (user) map.set(id, user) + }) + return map +} + +function describeExpansion( + expandedFrom: { groupId: number; groupName: string; userIds: number[] }[], +): string | undefined { + if (expandedFrom.length === 0) return undefined + return expandedFrom + .map((g) => `${g.groupName} (id:${g.groupId}, ${g.userIds.length} users)`) + .join('\n') +} + +async function mutateChannelMembership( + channelRef: string, + refs: string[], + action: 'add' | 'remove', + options: ChannelMutationOptions, +): Promise { + const workspaceId = await getCurrentWorkspaceId() + const channel = await resolveChannelRef(channelRef, workspaceId) + const { userIds: requestedIds, expandedFrom } = await resolveChannelMemberRefs( + refs, + workspaceId, + ) + const requested = dedupe(requestedIds) + + const current = new Set(channelUserIds(channel)) + const actionable = + action === 'add' + ? requested.filter((id) => !current.has(id)) + : requested.filter((id) => current.has(id)) + const skipped = + action === 'add' + ? requested.filter((id) => current.has(id)) + : requested.filter((id) => !current.has(id)) + + const actionLabel = action === 'add' ? 'add users to' : 'remove users from' + const skippedLabel = action === 'add' ? 'Already members' : 'Not members' + + if (options.dryRun) { + printDryRun(`${actionLabel} channel`, { + Channel: `${channel.name} (id:${channel.id})`, + 'Expanded from groups': describeExpansion(expandedFrom), + [`Users to ${action}`]: actionable.length > 0 ? actionable.join(', ') : '(none)', + [skippedLabel]: skipped.length > 0 ? skipped.join(', ') : undefined, + }) + return + } + + if (actionable.length > 0) { + if (action === 'add') { + await addUsersToChannel(channel.id, actionable) + } else { + await removeUsersFromChannel(channel.id, actionable) + } + } + + const newMemberCount = + action === 'add' + ? channelUserIds(channel).length + actionable.length + : channelUserIds(channel).length - actionable.length + + if (options.json) { + if (options.full) { + const client = await getTwistClient() + const updated = await client.channels.getChannel(channel.id) + console.log(formatJson(updated, 'channel', true)) + } else { + const result: Record = { + id: channel.id, + memberCount: newMemberCount, + } + if (expandedFrom.length > 0) result.expandedFrom = expandedFrom + if (action === 'add') { + result.added = actionable + result.alreadyMembers = skipped + } else { + result.removed = actionable + result.notMembers = skipped + } + console.log(formatJson(result)) + } + return + } + + const pastVerb = action === 'add' ? 'Added' : 'Removed' + const preposition = action === 'add' ? 'to' : 'from' + const noneMsg = + action === 'add' + ? `No new members added to "${channel.name}" (already in channel).` + : `No members removed from "${channel.name}" (none of the users were in channel).` + + if (expandedFrom.length > 0) { + for (const g of expandedFrom) { + console.log( + colors.timestamp( + `Expanded group "${g.groupName}" → ${g.userIds.length} ${pluralize(g.userIds.length, 'user')}`, + ), + ) + } + } + + if (actionable.length === 0) { + console.log(noneMsg) + } else { + console.log( + `${pastVerb} ${actionable.length} ${pluralize(actionable.length, 'user')} ${preposition} "${channel.name}" (now ${newMemberCount} ${pluralize(newMemberCount, 'member')}).`, + ) + } + if (skipped.length > 0) { + console.log(`${skippedLabel}: ${skipped.join(', ')}`) + } +} + +export async function addChannelMembers( + channelRef: string, + refs: string[], + options: ChannelMutationOptions, +): Promise { + return mutateChannelMembership(channelRef, refs, 'add', options) +} + +export async function removeChannelMembers( + channelRef: string, + refs: string[], + options: ChannelMutationOptions, +): Promise { + return mutateChannelMembership(channelRef, refs, 'remove', options) +} + +function groupsFullyInChannel(groups: Group[], channelUserIdSet: Set): Group[] { + return groups.filter( + (g) => g.userIds.length > 0 && g.userIds.every((id) => channelUserIdSet.has(id)), + ) +} + +export async function listChannelMembers( + channelRef: string, + options: ViewOptions & { full?: boolean }, +): Promise { + const workspaceId = await getCurrentWorkspaceId() + const channel = await resolveChannelRef(channelRef, workspaceId) + const userIds = channelUserIds(channel) + + const [userMap, groups] = await Promise.all([ + fetchUsersByIds(workspaceId, userIds), + getWorkspaceGroups(workspaceId), + ]) + + const userIdSet = new Set(userIds) + const fullyInChannel = groupsFullyInChannel(groups, userIdSet) + + const members = userIds.map((id) => { + const user = userMap.get(id) + return { id, name: user?.name ?? null, email: user?.email ?? null } + }) + + if (options.json) { + const payload = { + id: channel.id, + name: channel.name, + workspaceId: channel.workspaceId, + members, + groupsFullyInChannel: fullyInChannel.map((g) => ({ + id: g.id, + name: g.name, + userIds: g.userIds, + })), + } + if (options.full) { + console.log(formatJson({ ...channel, members, groupsFullyInChannel: fullyInChannel })) + } else { + console.log(formatJson(payload)) + } + return + } + + if (options.ndjson) { + console.log(formatNdjson([{ ...channel, members, groupsFullyInChannel: fullyInChannel }])) + return + } + + console.log(colors.channel(channel.name)) + console.log(colors.timestamp(`id:${channel.id}`)) + console.log('') + console.log(`${members.length} ${pluralize(members.length, 'member')}`) + for (const m of members) { + const name = m.name ?? `user:${m.id}` + const email = m.email ? colors.timestamp(`<${m.email}>`) : '' + const id = colors.timestamp(`id:${m.id}`) + console.log(` ${id} ${colors.author(name)} ${email}`.trimEnd()) + } + + if (fullyInChannel.length > 0) { + console.log('') + console.log(`Groups fully in channel (${fullyInChannel.length}):`) + for (const g of fullyInChannel) { + console.log( + ` ${colors.timestamp(`id:${g.id}`)} ${g.name} ${colors.timestamp(`(${g.userIds.length} ${pluralize(g.userIds.length, 'member')})`)}`, + ) + } + } +} + +export async function syncChannelMembers( + channelRef: string, + refs: string[], + options: SyncOptions, +): Promise { + const workspaceId = await getCurrentWorkspaceId() + const [channel, sessionUser] = await Promise.all([ + resolveChannelRef(channelRef, workspaceId), + getSessionUser() as Promise, + ]) + const { userIds: targetIds, expandedFrom } = await resolveChannelMemberRefs(refs, workspaceId) + const desired = new Set(targetIds) + const current = new Set(channelUserIds(channel)) + + const toAdd = [...desired].filter((id) => !current.has(id)) + const toRemoveAll = [...current].filter((id) => !desired.has(id)) + + const selfId = sessionUser.id + const wouldRemoveSelf = toRemoveAll.includes(selfId) + if (wouldRemoveSelf && !options.includeSelf) { + throw new CliError( + 'INVALID_VALUE', + `Sync would remove you (id:${selfId}) from "${channel.name}".`, + ['Pass --include-self to allow removing yourself, or include yourself in the ref list.'], + ) + } + const toRemove = wouldRemoveSelf && !options.includeSelf + ? toRemoveAll.filter((id) => id !== selfId) + : toRemoveAll + + const isDryRun = options.dryRun || !options.apply + + if (isDryRun) { + printDryRun(`sync channel membership`, { + Channel: `${channel.name} (id:${channel.id})`, + 'Expanded from groups': describeExpansion(expandedFrom), + 'To add': toAdd.length > 0 ? toAdd.join(', ') : '(none)', + 'To remove': toRemove.length > 0 ? toRemove.join(', ') : '(none)', + Note: options.apply ? undefined : 'sync is dry-run by default; pass --apply to mutate.', + }) + return + } + + if (toAdd.length > 0) { + await addUsersToChannel(channel.id, toAdd) + } + if (toRemove.length > 0) { + await removeUsersFromChannel(channel.id, toRemove) + } + + const newMemberCount = current.size + toAdd.length - toRemove.length + + if (options.json) { + const result: Record = { + id: channel.id, + memberCount: newMemberCount, + added: toAdd, + removed: toRemove, + } + if (expandedFrom.length > 0) result.expandedFrom = expandedFrom + if (options.full) { + const client = await getTwistClient() + const updated = await client.channels.getChannel(channel.id) + console.log(formatJson({ ...updated, ...result }, 'channel', true)) + } else { + console.log(formatJson(result)) + } + return + } + + if (expandedFrom.length > 0) { + for (const g of expandedFrom) { + console.log( + colors.timestamp( + `Expanded group "${g.groupName}" → ${g.userIds.length} ${pluralize(g.userIds.length, 'user')}`, + ), + ) + } + } + console.log( + `Synced "${channel.name}": +${toAdd.length} / -${toRemove.length} (now ${newMemberCount} ${pluralize(newMemberCount, 'member')}).`, + ) + if (toAdd.length > 0) console.log(` Added: ${toAdd.join(', ')}`) + if (toRemove.length > 0) console.log(` Removed: ${toRemove.join(', ')}`) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 590c6bb..3bb8a05 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -52,6 +52,8 @@ const API_SPINNER_MESSAGES: Record { + const client = await getTwistClient() + await client.channels.addUsers({ id, userIds }) +} + +export async function removeUsersFromChannel(id: number, userIds: number[]): Promise { + const client = await getTwistClient() + await client.channels.removeUsers({ id, userIds }) +} + export function clearUserCache(): void { sessionUserCache = null } From 946e0a511dcf18424ba3839e5c7f906a07a2a027 Mon Sep 17 00:00:00 2001 From: lmjabreu Date: Fri, 22 May 2026 10:34:26 +0100 Subject: [PATCH 3/9] docs(skill): document channel members/add/remove/sync subcommands Add Quick Reference rows and a Notes paragraph explaining the group: ref expansion (one-shot, not a persistent link), sync's dry-run default, and --include-self. Regenerated skills/twist-cli/SKILL.md. Also pulls a fresh npm install (resolves prior @doist/cli-core/auth type-check errors on main). Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 37 ++-------------------------- skills/twist-cli/SKILL.md | 10 ++++++++ src/commands/channel/members.test.ts | 21 ++++++++-------- src/commands/channel/members.ts | 11 ++++++--- src/lib/skills/content.ts | 10 ++++++++ 5 files changed, 40 insertions(+), 49 deletions(-) diff --git a/package-lock.json b/package-lock.json index 99464a2..ace472f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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/skills/twist-cli/SKILL.md b/skills/twist-cli/SKILL.md index 1572e7c..2436d07 100644 --- a/skills/twist-cli/SKILL.md +++ b/skills/twist-cli/SKILL.md @@ -217,6 +217,14 @@ tw channel threads --since 2026-01-01 # Filter by last-updated date (ISO) tw channel threads --limit 20 # Max threads per page (default: 50) tw channel threads --limit 20 --cursor # Paginate tw channel threads --json # { results, nextCursor } with isUnread + url +tw channel members # List members + groups whose membership ⊆ channel +tw channel members --json # JSON with id, name, members, groupsFullyInChannel +tw channel add # Add users and/or group: (group expansion is one-shot) +tw channel add alice group:Frontend # Mix of user refs and group: prefix +tw channel add id:123 --dry-run # Preview the diff +tw channel remove # Remove users and/or group: +tw channel sync # Replace membership; dry-run by default +tw channel sync group:Squad --apply # Mutate. Refuses to remove self unless --include-self tw groups # List workspace groups tw groups --search "frontend" # Filter groups by name (case-insensitive) tw groups --json # JSON output @@ -242,6 +250,8 @@ If a channel is not found in `tw channels`, widen with broader listings such as `tw channel threads` returns every thread in the channel; pagination filters (`--limit`, `--cursor`, `--since`, `--until`, `--unread`) are applied client-side after fetch. `--archive-filter` is applied server-side. Results are sorted newest-first by last activity. In `--json` / `--ndjson`, the response includes a `nextCursor` string (opaque) you can pass via `--cursor` to fetch the next page; NDJSON emits the cursor as a final `{ "_meta": true, "nextCursor": "..." }` line. +`tw channel add`, `remove`, and `sync` accept user refs (id:N, email, name) and `group:` mixed in any order. Groups are expanded to their current users at call time — the group is **not** persistently linked to the channel, so users added later to the group will not auto-join. `sync` is dry-run by default; pass `--apply` to mutate and `--include-self` to allow the diff to remove the acting user. `tw channel members` shows the user list plus a "groups fully in channel" hint — groups whose entire membership is currently present in the channel. + ## Away Status ```bash diff --git a/src/commands/channel/members.test.ts b/src/commands/channel/members.test.ts index 2b4171c..75a639a 100644 --- a/src/commands/channel/members.test.ts +++ b/src/commands/channel/members.test.ts @@ -262,15 +262,7 @@ describe('tw channel remove', () => { const program = createProgram() vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync([ - 'node', - 'tw', - 'channel', - 'remove', - 'general', - 'id:99', - 'id:100', - ]) + await program.parseAsync(['node', 'tw', 'channel', 'remove', 'general', 'id:99', 'id:100']) expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() }) @@ -285,7 +277,16 @@ describe('tw channel sync', () => { const program = createProgram() const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'channel', 'sync', 'general', 'id:1', 'id:4', 'id:5']) + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'sync', + 'general', + 'id:1', + 'id:4', + 'id:5', + ]) expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled() expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() diff --git a/src/commands/channel/members.ts b/src/commands/channel/members.ts index 8844aae..ae061d5 100644 --- a/src/commands/channel/members.ts +++ b/src/commands/channel/members.ts @@ -269,12 +269,15 @@ export async function syncChannelMembers( throw new CliError( 'INVALID_VALUE', `Sync would remove you (id:${selfId}) from "${channel.name}".`, - ['Pass --include-self to allow removing yourself, or include yourself in the ref list.'], + [ + 'Pass --include-self to allow removing yourself, or include yourself in the ref list.', + ], ) } - const toRemove = wouldRemoveSelf && !options.includeSelf - ? toRemoveAll.filter((id) => id !== selfId) - : toRemoveAll + const toRemove = + wouldRemoveSelf && !options.includeSelf + ? toRemoveAll.filter((id) => id !== selfId) + : toRemoveAll const isDryRun = options.dryRun || !options.apply diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 83be5a5..7d27aaa 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -221,6 +221,14 @@ tw channel threads --since 2026-01-01 # Filter by last-updated date (ISO) tw channel threads --limit 20 # Max threads per page (default: 50) tw channel threads --limit 20 --cursor # Paginate tw channel threads --json # { results, nextCursor } with isUnread + url +tw channel members # List members + groups whose membership ⊆ channel +tw channel members --json # JSON with id, name, members, groupsFullyInChannel +tw channel add # Add users and/or group: (group expansion is one-shot) +tw channel add alice group:Frontend # Mix of user refs and group: prefix +tw channel add id:123 --dry-run # Preview the diff +tw channel remove # Remove users and/or group: +tw channel sync # Replace membership; dry-run by default +tw channel sync group:Squad --apply # Mutate. Refuses to remove self unless --include-self tw groups # List workspace groups tw groups --search "frontend" # Filter groups by name (case-insensitive) tw groups --json # JSON output @@ -246,6 +254,8 @@ If a channel is not found in \`tw channels\`, widen with broader listings such a \`tw channel threads\` returns every thread in the channel; pagination filters (\`--limit\`, \`--cursor\`, \`--since\`, \`--until\`, \`--unread\`) are applied client-side after fetch. \`--archive-filter\` is applied server-side. Results are sorted newest-first by last activity. In \`--json\` / \`--ndjson\`, the response includes a \`nextCursor\` string (opaque) you can pass via \`--cursor\` to fetch the next page; NDJSON emits the cursor as a final \`{ "_meta": true, "nextCursor": "..." }\` line. +\`tw channel add\`, \`remove\`, and \`sync\` accept user refs (id:N, email, name) and \`group:\` mixed in any order. Groups are expanded to their current users at call time — the group is **not** persistently linked to the channel, so users added later to the group will not auto-join. \`sync\` is dry-run by default; pass \`--apply\` to mutate and \`--include-self\` to allow the diff to remove the acting user. \`tw channel members\` shows the user list plus a "groups fully in channel" hint — groups whose entire membership is currently present in the channel. + ## Away Status \`\`\`bash From 02c9f049a299ff492709d0ff5e2f52e968195643 Mon Sep 17 00:00:00 2001 From: lmjabreu Date: Fri, 22 May 2026 10:54:27 +0100 Subject: [PATCH 4/9] fix(auth): add channels:write and channels:remove to READ_WRITE_SCOPES Without these, tw channel add/remove/sync (and any future channel mutations like create/delete/update) fail with INSUFFICIENT_SCOPE. Discovered while testing the new channel-membership commands locally. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/auth-provider.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/auth-provider.ts b/src/lib/auth-provider.ts index a4ac917..0502c45 100644 --- a/src/lib/auth-provider.ts +++ b/src/lib/auth-provider.ts @@ -29,6 +29,8 @@ export const READ_WRITE_SCOPES = [ 'user:write', 'workspaces:read', 'channels:read', + 'channels:write', + 'channels:remove', 'threads:read', 'threads:write', 'comments:read', From af2568a2eae7dfcd1c04c6073f66849fc4b90501 Mon Sep 17 00:00:00 2001 From: lmjabreu Date: Fri, 22 May 2026 11:35:49 +0100 Subject: [PATCH 5/9] review: address doistbot feedback on PR #244 - refs.ts: preserve input order across mixed user/group: refs; resolve user refs and all group refs concurrently in one Promise.all. - members.ts: use getOptionalBatchData (don't bypass batch error handling); drop redundant dedupe (resolveChannelMemberRefs already dedupes via a Set); parallelize independent calls in mutate, list, and sync; run add+remove concurrently in sync; ndjson now honours --full like json does (slim default, raw on --full); extract a small logExpansion helper used by both mutate and sync. - members.test.ts: tighten mockGetUserById to return tagged requests so we can assert batch() composition; add tests for --full, --ndjson, remove --dry-run, remove --json, remove --full. - refs.test.ts: add helper-level coverage for resolveChannelMemberRefs (mixed order, dedupe across users + group expansion, empty group: rejection, case-insensitive prefix). Deferred (out of scope, will reply on PR): extracting a shared membership-mutation flow between groups/members.ts and channel/members.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/scheduled_tasks.lock | 1 + src/commands/channel/members.test.ts | 141 +++++++++++++++++++++++++++ src/commands/channel/members.ts | 105 +++++++++----------- src/lib/refs.test.ts | 72 ++++++++++++++ src/lib/refs.ts | 54 +++++++--- 5 files changed, 299 insertions(+), 74 deletions(-) create mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..7473bf0 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"55653e17-eb0c-4f18-bda8-fe162f6e381d","pid":90746,"procStart":"Fri May 22 08:17:49 2026","acquiredAt":1779444607428} \ No newline at end of file diff --git a/src/commands/channel/members.test.ts b/src/commands/channel/members.test.ts index 75a639a..21f4742 100644 --- a/src/commands/channel/members.test.ts +++ b/src/commands/channel/members.test.ts @@ -10,6 +10,7 @@ const apiMocks = vi.hoisted(() => ({ getTwistClient: vi.fn(), getSessionUser: vi.fn(), getWorkspaceGroups: vi.fn(), + getOptionalBatchData: vi.fn(), addUsersToChannel: vi.fn(), removeUsersFromChannel: vi.fn(), })) @@ -56,11 +57,21 @@ beforeEach(() => { vi.clearAllMocks() vi.restoreAllMocks() apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + // Return a tagged sentinel per user id so we can assert that batch() received the + // exact requests built by the SDK call. + mockGetUserById.mockImplementation((args: { userId: number }) => ({ + __req: 'getUserById', + userId: args.userId, + })) apiMocks.getTwistClient.mockResolvedValue({ workspaceUsers: { getUserById: mockGetUserById }, channels: { getChannel: mockGetChannel }, batch: mockBatch, }) + // The default real-world behaviour: pass through response.data, ignoring the label. + apiMocks.getOptionalBatchData.mockImplementation((response: { code: number; data: unknown }) => + response && response.code < 400 ? (response.data ?? null) : null, + ) apiMocks.getSessionUser.mockResolvedValue({ id: 1, name: 'Me', email: 'me@d.com' }) apiMocks.getWorkspaceGroups.mockResolvedValue([frontendGroup, backendGroup, allInChannel]) refsMocks.resolveChannelRef.mockResolvedValue(sampleChannel) @@ -106,6 +117,62 @@ describe('tw channel members (list)', () => { 100, 300, ]) }) + + it('batches one getUserById request per channel member', async () => { + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'members', 'general', '--json']) + + // Verify the batch wiring: getUserById is built for each userId, then those + // requests are passed verbatim into batch(). + expect(mockGetUserById).toHaveBeenCalledTimes(3) + expect(mockGetUserById).toHaveBeenNthCalledWith( + 1, + { workspaceId: 1, userId: 1 }, + { batch: true }, + ) + expect(mockBatch).toHaveBeenCalledWith( + { __req: 'getUserById', userId: 1 }, + { __req: 'getUserById', userId: 2 }, + { __req: 'getUserById', userId: 3 }, + ) + }) + + it('ndjson default shape matches json default shape', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'members', 'general', '--ndjson']) + + const line = consoleSpy.mock.calls[0][0].split('\n').filter(Boolean)[0] + const output = JSON.parse(line) + expect(output.id).toBe(500) + expect(output.members).toHaveLength(3) + // Slim shape — no raw SDK fields like `creator`, `version` unless --full was passed. + expect(output).not.toHaveProperty('creator') + expect(output).not.toHaveProperty('version') + }) + + it('--full ndjson includes raw channel fields', async () => { + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'members', + 'general', + '--ndjson', + '--full', + ]) + + const line = consoleSpy.mock.calls[0][0].split('\n').filter(Boolean)[0] + const output = JSON.parse(line) + expect(output).toHaveProperty('creator') + expect(output).toHaveProperty('version') + }) }) describe('tw channel add', () => { @@ -266,6 +333,80 @@ describe('tw channel remove', () => { expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() }) + + it('--dry-run does not mutate', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 3], + expandedFrom: [], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'remove', + 'general', + 'id:2', + 'id:3', + '--dry-run', + ]) + + expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() + expect(consoleSpy.mock.calls.some((c) => String(c[0]).includes('[dry-run]'))).toBe(true) + }) + + it('--json shape includes removed/notMembers', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 99], + expandedFrom: [], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'remove', + 'general', + 'id:2', + 'id:99', + '--json', + ]) + + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(output).toMatchObject({ id: 500, removed: [2], notMembers: [99] }) + }) + + it('--full fetches and prints the updated channel', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2], + expandedFrom: [], + }) + mockGetChannel.mockResolvedValue({ ...sampleChannel, userIds: [1, 3] }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'remove', + 'general', + 'id:2', + '--json', + '--full', + ]) + + expect(mockGetChannel).toHaveBeenCalledWith(500) + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + // --full returns the full SDK channel object + expect(output.id).toBe(500) + expect(output).toHaveProperty('creator') + expect(output).toHaveProperty('version') + }) }) describe('tw channel sync', () => { diff --git a/src/commands/channel/members.ts b/src/commands/channel/members.ts index ae061d5..a441d1c 100644 --- a/src/commands/channel/members.ts +++ b/src/commands/channel/members.ts @@ -2,6 +2,7 @@ import type { Channel, Group, User, WorkspaceUser } from '@doist/twist-sdk' import { addUsersToChannel, getCurrentWorkspaceId, + getOptionalBatchData, getSessionUser, getTwistClient, getWorkspaceGroups, @@ -19,10 +20,6 @@ export type SyncOptions = MutationOptions & { includeSelf?: boolean } -function dedupe(ids: number[]): number[] { - return [...new Set(ids)] -} - function channelUserIds(channel: Channel): number[] { return channel.userIds ?? [] } @@ -39,12 +36,24 @@ async function fetchUsersByIds( const responses = await client.batch(...calls) const map = new Map() userIds.forEach((id, i) => { - const user = responses[i]?.data + const user = getOptionalBatchData(responses[i], `user ${id}`) if (user) map.set(id, user) }) return map } +function logExpansion( + expandedFrom: { groupId: number; groupName: string; userIds: number[] }[], +): void { + for (const g of expandedFrom) { + console.log( + colors.timestamp( + `Expanded group "${g.groupName}" → ${g.userIds.length} ${pluralize(g.userIds.length, 'user')}`, + ), + ) + } +} + function describeExpansion( expandedFrom: { groupId: number; groupName: string; userIds: number[] }[], ): string | undefined { @@ -61,12 +70,10 @@ async function mutateChannelMembership( options: ChannelMutationOptions, ): Promise { const workspaceId = await getCurrentWorkspaceId() - const channel = await resolveChannelRef(channelRef, workspaceId) - const { userIds: requestedIds, expandedFrom } = await resolveChannelMemberRefs( - refs, - workspaceId, - ) - const requested = dedupe(requestedIds) + const [channel, { userIds: requested, expandedFrom }] = await Promise.all([ + resolveChannelRef(channelRef, workspaceId), + resolveChannelMemberRefs(refs, workspaceId), + ]) const current = new Set(channelUserIds(channel)) const actionable = @@ -134,15 +141,7 @@ async function mutateChannelMembership( ? `No new members added to "${channel.name}" (already in channel).` : `No members removed from "${channel.name}" (none of the users were in channel).` - if (expandedFrom.length > 0) { - for (const g of expandedFrom) { - console.log( - colors.timestamp( - `Expanded group "${g.groupName}" → ${g.userIds.length} ${pluralize(g.userIds.length, 'user')}`, - ), - ) - } - } + logExpansion(expandedFrom) if (actionable.length === 0) { console.log(noneMsg) @@ -183,13 +182,12 @@ export async function listChannelMembers( options: ViewOptions & { full?: boolean }, ): Promise { const workspaceId = await getCurrentWorkspaceId() - const channel = await resolveChannelRef(channelRef, workspaceId) - const userIds = channelUserIds(channel) - - const [userMap, groups] = await Promise.all([ - fetchUsersByIds(workspaceId, userIds), + const [channel, groups] = await Promise.all([ + resolveChannelRef(channelRef, workspaceId), getWorkspaceGroups(workspaceId), ]) + const userIds = channelUserIds(channel) + const userMap = await fetchUsersByIds(workspaceId, userIds) const userIdSet = new Set(userIds) const fullyInChannel = groupsFullyInChannel(groups, userIdSet) @@ -199,28 +197,26 @@ export async function listChannelMembers( return { id, name: user?.name ?? null, email: user?.email ?? null } }) + const slimPayload = { + id: channel.id, + name: channel.name, + workspaceId: channel.workspaceId, + members, + groupsFullyInChannel: fullyInChannel.map((g) => ({ + id: g.id, + name: g.name, + userIds: g.userIds, + })), + } + const fullPayload = { ...channel, members, groupsFullyInChannel: fullyInChannel } + if (options.json) { - const payload = { - id: channel.id, - name: channel.name, - workspaceId: channel.workspaceId, - members, - groupsFullyInChannel: fullyInChannel.map((g) => ({ - id: g.id, - name: g.name, - userIds: g.userIds, - })), - } - if (options.full) { - console.log(formatJson({ ...channel, members, groupsFullyInChannel: fullyInChannel })) - } else { - console.log(formatJson(payload)) - } + console.log(formatJson(options.full ? fullPayload : slimPayload)) return } if (options.ndjson) { - console.log(formatNdjson([{ ...channel, members, groupsFullyInChannel: fullyInChannel }])) + console.log(formatNdjson([options.full ? fullPayload : slimPayload])) return } @@ -252,11 +248,12 @@ export async function syncChannelMembers( options: SyncOptions, ): Promise { const workspaceId = await getCurrentWorkspaceId() - const [channel, sessionUser] = await Promise.all([ + const [channel, sessionUser, memberRefs] = await Promise.all([ resolveChannelRef(channelRef, workspaceId), getSessionUser() as Promise, + resolveChannelMemberRefs(refs, workspaceId), ]) - const { userIds: targetIds, expandedFrom } = await resolveChannelMemberRefs(refs, workspaceId) + const { userIds: targetIds, expandedFrom } = memberRefs const desired = new Set(targetIds) const current = new Set(channelUserIds(channel)) @@ -292,12 +289,10 @@ export async function syncChannelMembers( return } - if (toAdd.length > 0) { - await addUsersToChannel(channel.id, toAdd) - } - if (toRemove.length > 0) { - await removeUsersFromChannel(channel.id, toRemove) - } + await Promise.all([ + toAdd.length > 0 ? addUsersToChannel(channel.id, toAdd) : Promise.resolve(), + toRemove.length > 0 ? removeUsersFromChannel(channel.id, toRemove) : Promise.resolve(), + ]) const newMemberCount = current.size + toAdd.length - toRemove.length @@ -319,15 +314,7 @@ export async function syncChannelMembers( return } - if (expandedFrom.length > 0) { - for (const g of expandedFrom) { - console.log( - colors.timestamp( - `Expanded group "${g.groupName}" → ${g.userIds.length} ${pluralize(g.userIds.length, 'user')}`, - ), - ) - } - } + logExpansion(expandedFrom) console.log( `Synced "${channel.name}": +${toAdd.length} / -${toRemove.length} (now ${newMemberCount} ${pluralize(newMemberCount, 'member')}).`, ) diff --git a/src/lib/refs.test.ts b/src/lib/refs.test.ts index 6023dca..7c19798 100644 --- a/src/lib/refs.test.ts +++ b/src/lib/refs.test.ts @@ -25,6 +25,7 @@ import { parseTwistUrl, partitionNotifyIds, resolveChannelId, + resolveChannelMemberRefs, resolveChannelRef, resolveCommentId, resolveConversationId, @@ -511,3 +512,74 @@ describe('resolveUserRefs', () => { }) }) }) + +describe('resolveChannelMemberRefs', () => { + const sampleUsers = [ + { id: 10, name: 'Alice', email: 'a@d.com' }, + { id: 20, name: 'Bob', email: 'b@d.com' }, + { id: 30, name: 'Carol', email: 'c@d.com' }, + ] + const designGroup = { id: 100, name: 'Design', workspaceId: 1, userIds: [10, 20], version: 1 } + const engGroup = { id: 200, name: 'Eng', workspaceId: 1, userIds: [20, 30], version: 1 } + + beforeEach(() => { + apiMocks.getWorkspaceUsers.mockResolvedValue(sampleUsers) + apiMocks.getGroup.mockImplementation(async (id: number) => { + if (id === 100) return designGroup + if (id === 200) return engGroup + throw new Error(`unexpected group id ${id}`) + }) + }) + + it('rejects empty input', async () => { + await expect(resolveChannelMemberRefs([], 1)).rejects.toMatchObject({ + code: 'MISSING_USERS', + }) + }) + + it('rejects empty group ref', async () => { + await expect(resolveChannelMemberRefs(['group:'], 1)).rejects.toMatchObject({ + code: 'INVALID_REF', + }) + }) + + it('resolves user-only refs in input order', async () => { + const { userIds, expandedFrom } = await resolveChannelMemberRefs(['id:30', 'id:10'], 1) + expect(userIds).toEqual([30, 10]) + expect(expandedFrom).toEqual([]) + }) + + it('expands group: refs', async () => { + const { userIds, expandedFrom } = await resolveChannelMemberRefs(['group:100'], 1) + expect(userIds).toEqual([10, 20]) + expect(expandedFrom).toEqual([{ groupId: 100, groupName: 'Design', userIds: [10, 20] }]) + }) + + it('preserves input order across mixed user and group: refs', async () => { + // 'group:100' (id:200 in mock) expands to [10, 20]; then 'id:30' adds 30 last. + const { userIds } = await resolveChannelMemberRefs(['group:100', 'id:30'], 1) + expect(userIds).toEqual([10, 20, 30]) + + // Reversed input → user id:30 first, then the group's [10, 20]. + const reversed = await resolveChannelMemberRefs(['id:30', 'group:100'], 1) + expect(reversed.userIds).toEqual([30, 10, 20]) + }) + + it('dedupes across users and group expansion (input-order kept)', async () => { + // group:100 → [10, 20]; group:200 → [20, 30]. With 'id:10' first, that wins + // the order slot; 20 appears once from group:100; 30 only from group:200. + const { userIds, expandedFrom } = await resolveChannelMemberRefs( + ['id:10', 'group:100', 'group:200'], + 1, + ) + expect(userIds).toEqual([10, 20, 30]) + expect(expandedFrom.map((g) => g.groupId)).toEqual([100, 200]) + // expandedFrom records pre-dedup ids per group + expect(expandedFrom[1].userIds).toEqual([20, 30]) + }) + + it('is case-insensitive on the group: prefix', async () => { + const { expandedFrom } = await resolveChannelMemberRefs(['GROUP:100'], 1) + expect(expandedFrom[0]).toMatchObject({ groupId: 100 }) + }) +}) diff --git a/src/lib/refs.ts b/src/lib/refs.ts index 5ed7d02..2ff24e5 100644 --- a/src/lib/refs.ts +++ b/src/lib/refs.ts @@ -424,9 +424,10 @@ export async function resolveChannelMemberRefs( throw new CliError('MISSING_USERS', 'Provide at least one user or group: reference.') } - const userRefs: string[] = [] - const groupRefs: string[] = [] - for (const ref of refs) { + type Slot = + | { kind: 'user'; ref: string; index: number } + | { kind: 'group'; ref: string; index: number } + const slots: Slot[] = refs.map((ref, index) => { const trimmed = normalizeRef(ref) if (trimmed.toLowerCase().startsWith(GROUP_REF_PREFIX)) { const inner = trimmed.slice(GROUP_REF_PREFIX.length).trim() @@ -436,16 +437,38 @@ export async function resolveChannelMemberRefs( `Empty group reference: "${ref}". Use group:.`, ) } - groupRefs.push(inner) - } else { - userRefs.push(trimmed) + return { kind: 'group', ref: inner, index } } - } + return { kind: 'user', ref: trimmed, index } + }) + + const userSlots = slots.filter((s): s is Extract => s.kind === 'user') + const groupSlots = slots.filter( + (s): s is Extract => s.kind === 'group', + ) + + // Resolve users (one batched API call) and all groups concurrently. + const [userIdsResolved, groupsResolved] = await Promise.all([ + userSlots.length > 0 + ? resolveUserRefs(userSlots.map((s) => s.ref).join(','), workspaceId) + : Promise.resolve([] as number[]), + Promise.all(groupSlots.map((s) => resolveGroupRef(s.ref, workspaceId))), + ]) + const userIdByIndex = new Map() + userSlots.forEach((s, i) => { + const id = userIdsResolved[i] + if (typeof id === 'number') userIdByIndex.set(s.index, id) + }) + const groupByIndex = new Map() + groupSlots.forEach((s, i) => { + groupByIndex.set(s.index, groupsResolved[i]) + }) + + // Walk the original input order to assemble dedup'd userIds and expandedFrom. const expandedFrom: ChannelMemberRefs['expandedFrom'] = [] const seen = new Set() const userIds: number[] = [] - const pushId = (id: number) => { if (!seen.has(id)) { seen.add(id) @@ -453,13 +476,14 @@ export async function resolveChannelMemberRefs( } } - if (userRefs.length > 0) { - const resolved = await resolveUserRefs(userRefs.join(','), workspaceId) - for (const id of resolved) pushId(id) - } - - for (const groupRef of groupRefs) { - const group = await resolveGroupRef(groupRef, workspaceId) + for (let i = 0; i < refs.length; i++) { + if (userIdByIndex.has(i)) { + const id = userIdByIndex.get(i) + if (typeof id === 'number') pushId(id) + continue + } + const group = groupByIndex.get(i) + if (!group) continue expandedFrom.push({ groupId: group.id, groupName: group.name, From 3f3652f551704fbb91bd4b5eaa1aa405af8e9722 Mon Sep 17 00:00:00 2001 From: lmjabreu Date: Fri, 22 May 2026 11:36:05 +0100 Subject: [PATCH 6/9] chore: untrack .claude/scheduled_tasks.lock (local state) Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/scheduled_tasks.lock | 1 - .gitignore | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 7473bf0..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"55653e17-eb0c-4f18-bda8-fe162f6e381d","pid":90746,"procStart":"Fri May 22 08:17:49 2026","acquiredAt":1779444607428} \ No newline at end of file diff --git a/.gitignore b/.gitignore index fc5abe2..ab26531 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ *.tsbuildinfo .DS_Store .claude/settings.local.json +.claude/scheduled_tasks.lock From 0c40d2084adffdc185f1dd693a9e2c83b5ea0d8f Mon Sep 17 00:00:00 2001 From: lmjabreu Date: Fri, 22 May 2026 14:05:18 +0100 Subject: [PATCH 7/9] review: split channel membership commands into separate files Per @scottlovegrove's review feedback. Each subcommand now lives in its own file, mirroring the existing channel/ folder pattern (list.ts, threads.ts, helpers.ts): - src/commands/channel/membership-helpers.ts: shared bits used by add, remove, sync, and the members list view (channelUserIds, fetchUsersByIds, logExpansion, describeExpansion, groupsFullyInChannel, mutateChannelMembership). - src/commands/channel/members.ts: now only the list handler. - src/commands/channel/add.ts, remove.ts: thin wrappers around mutateChannelMembership. - src/commands/channel/sync.ts: the sync handler (kept here because of the self-removal guard and dry-run-by-default behaviour). - src/commands/channel/index.ts: imports updated. Tests split to match: - members.test.ts: only the list tests. - add.test.ts, remove.test.ts, sync.test.ts: one file per command, each with its own minimal mock setup. 677 tests still passing (43 test files, +3 from the split). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/channel/add.test.ts | 180 ++++++++++ src/commands/channel/add.ts | 9 + src/commands/channel/index.ts | 10 +- src/commands/channel/members.test.ts | 365 --------------------- src/commands/channel/members.ts | 263 +-------------- src/commands/channel/membership-helpers.ts | 157 +++++++++ src/commands/channel/remove.test.ts | 168 ++++++++++ src/commands/channel/remove.ts | 9 + src/commands/channel/sync.test.ts | 182 ++++++++++ src/commands/channel/sync.ts | 98 ++++++ 10 files changed, 812 insertions(+), 629 deletions(-) create mode 100644 src/commands/channel/add.test.ts create mode 100644 src/commands/channel/add.ts create mode 100644 src/commands/channel/membership-helpers.ts create mode 100644 src/commands/channel/remove.test.ts create mode 100644 src/commands/channel/remove.ts create mode 100644 src/commands/channel/sync.test.ts create mode 100644 src/commands/channel/sync.ts diff --git a/src/commands/channel/add.test.ts b/src/commands/channel/add.test.ts new file mode 100644 index 0000000..46596c2 --- /dev/null +++ b/src/commands/channel/add.test.ts @@ -0,0 +1,180 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockGetChannel = vi.fn() + +const apiMocks = vi.hoisted(() => ({ + getCurrentWorkspaceId: vi.fn().mockResolvedValue(1), + getTwistClient: vi.fn(), + getSessionUser: vi.fn(), + getWorkspaceGroups: vi.fn(), + getOptionalBatchData: vi.fn(), + addUsersToChannel: vi.fn(), + removeUsersFromChannel: vi.fn(), +})) + +vi.mock('../../lib/api.js', () => apiMocks) + +const refsMocks = vi.hoisted(() => ({ + resolveChannelRef: vi.fn(), + resolveChannelMemberRefs: vi.fn(), +})) + +vi.mock('../../lib/refs.js', () => refsMocks) + +vi.mock('chalk') + +import { registerChannelCommand } from './index.js' + +function createProgram() { + const program = new Command() + program.exitOverride() + registerChannelCommand(program) + return program +} + +const sampleChannel = { + id: 500, + name: 'general', + workspaceId: 1, + userIds: [1, 2, 3], + creator: 1, + public: true, + archived: false, + created: new Date(), + version: 1, + url: 'https://twist.com/a/1/ch/500', +} + +beforeEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + apiMocks.getTwistClient.mockResolvedValue({ + channels: { getChannel: mockGetChannel }, + }) + refsMocks.resolveChannelRef.mockResolvedValue(sampleChannel) +}) + +describe('tw channel add', () => { + it('adds users (no group expansion)', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [4, 5], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'add', + 'general', + 'dave@d.com', + 'eve@d.com', + ]) + + expect(refsMocks.resolveChannelMemberRefs).toHaveBeenCalledWith( + ['dave@d.com', 'eve@d.com'], + 1, + ) + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4, 5]) + }) + + it('expands group: and dedupes', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [4, 5, 6], + expandedFrom: [{ groupId: 200, groupName: 'Backend', userIds: [4, 5] }], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'add', + 'general', + 'group:Backend', + 'frank', + ]) + + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4, 5, 6]) + const text = consoleSpy.mock.calls.map((c) => c[0]).join('\n') + expect(text).toContain('Backend') + }) + + it('skips users already in channel', async () => { + // channel has [1,2,3]; requesting [2,4] → only 4 should be added, 2 skipped + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 4], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'add', 'general', 'id:2', 'id:4']) + + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4]) + }) + + it('makes no API call when all users already members', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [1, 2], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'add', 'general', 'id:1', 'id:2']) + + expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled() + }) + + it('--dry-run does not mutate', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [4, 5], + expandedFrom: [], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'add', + 'general', + 'id:4', + 'id:5', + '--dry-run', + ]) + + expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled() + expect(consoleSpy.mock.calls.some((c) => String(c[0]).includes('[dry-run]'))).toBe(true) + }) + + it('--json shape includes added/alreadyMembers', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 4], + expandedFrom: [], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'add', + 'general', + 'id:2', + 'id:4', + '--json', + ]) + + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(output).toMatchObject({ id: 500, added: [4], alreadyMembers: [2] }) + }) +}) diff --git a/src/commands/channel/add.ts b/src/commands/channel/add.ts new file mode 100644 index 0000000..ce5a44d --- /dev/null +++ b/src/commands/channel/add.ts @@ -0,0 +1,9 @@ +import { type ChannelMutationOptions, mutateChannelMembership } from './membership-helpers.js' + +export async function addChannelMembers( + channelRef: string, + refs: string[], + options: ChannelMutationOptions, +): Promise { + return mutateChannelMembership(channelRef, refs, 'add', options) +} diff --git a/src/commands/channel/index.ts b/src/commands/channel/index.ts index bc67421..8dca717 100644 --- a/src/commands/channel/index.ts +++ b/src/commands/channel/index.ts @@ -1,12 +1,10 @@ import { Command, Option } from 'commander' import { withCaseInsensitiveChoices } from '../../lib/completion.js' +import { addChannelMembers } from './add.js' import { listChannels } from './list.js' -import { - addChannelMembers, - listChannelMembers, - removeChannelMembers, - syncChannelMembers, -} from './members.js' +import { listChannelMembers } from './members.js' +import { removeChannelMembers } from './remove.js' +import { syncChannelMembers } from './sync.js' import { showChannelThreads } from './threads.js' export function registerChannelCommand(program: Command): void { diff --git a/src/commands/channel/members.test.ts b/src/commands/channel/members.test.ts index 21f4742..d9dbae0 100644 --- a/src/commands/channel/members.test.ts +++ b/src/commands/channel/members.test.ts @@ -68,11 +68,9 @@ beforeEach(() => { channels: { getChannel: mockGetChannel }, batch: mockBatch, }) - // The default real-world behaviour: pass through response.data, ignoring the label. apiMocks.getOptionalBatchData.mockImplementation((response: { code: number; data: unknown }) => response && response.code < 400 ? (response.data ?? null) : null, ) - apiMocks.getSessionUser.mockResolvedValue({ id: 1, name: 'Me', email: 'me@d.com' }) apiMocks.getWorkspaceGroups.mockResolvedValue([frontendGroup, backendGroup, allInChannel]) refsMocks.resolveChannelRef.mockResolvedValue(sampleChannel) }) @@ -174,366 +172,3 @@ describe('tw channel members (list)', () => { expect(output).toHaveProperty('version') }) }) - -describe('tw channel add', () => { - it('adds users (no group expansion)', async () => { - refsMocks.resolveChannelMemberRefs.mockResolvedValue({ - userIds: [4, 5], - expandedFrom: [], - }) - const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await program.parseAsync([ - 'node', - 'tw', - 'channel', - 'add', - 'general', - 'dave@d.com', - 'eve@d.com', - ]) - - expect(refsMocks.resolveChannelMemberRefs).toHaveBeenCalledWith( - ['dave@d.com', 'eve@d.com'], - 1, - ) - expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4, 5]) - }) - - it('expands group: and dedupes', async () => { - refsMocks.resolveChannelMemberRefs.mockResolvedValue({ - userIds: [4, 5, 6], - expandedFrom: [{ groupId: 200, groupName: 'Backend', userIds: [4, 5] }], - }) - const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await program.parseAsync([ - 'node', - 'tw', - 'channel', - 'add', - 'general', - 'group:Backend', - 'frank', - ]) - - expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4, 5, 6]) - const text = consoleSpy.mock.calls.map((c) => c[0]).join('\n') - expect(text).toContain('Backend') - }) - - it('skips users already in channel', async () => { - // channel has [1,2,3]; requesting [2,4] → only 4 should be added, 2 skipped - refsMocks.resolveChannelMemberRefs.mockResolvedValue({ - userIds: [2, 4], - expandedFrom: [], - }) - const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await program.parseAsync(['node', 'tw', 'channel', 'add', 'general', 'id:2', 'id:4']) - - expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4]) - }) - - it('makes no API call when all users already members', async () => { - refsMocks.resolveChannelMemberRefs.mockResolvedValue({ - userIds: [1, 2], - expandedFrom: [], - }) - const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await program.parseAsync(['node', 'tw', 'channel', 'add', 'general', 'id:1', 'id:2']) - - expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled() - }) - - it('--dry-run does not mutate', async () => { - refsMocks.resolveChannelMemberRefs.mockResolvedValue({ - userIds: [4, 5], - expandedFrom: [], - }) - const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await program.parseAsync([ - 'node', - 'tw', - 'channel', - 'add', - 'general', - 'id:4', - 'id:5', - '--dry-run', - ]) - - expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled() - expect(consoleSpy.mock.calls.some((c) => String(c[0]).includes('[dry-run]'))).toBe(true) - }) - - it('--json shape includes added/alreadyMembers', async () => { - refsMocks.resolveChannelMemberRefs.mockResolvedValue({ - userIds: [2, 4], - expandedFrom: [], - }) - const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await program.parseAsync([ - 'node', - 'tw', - 'channel', - 'add', - 'general', - 'id:2', - 'id:4', - '--json', - ]) - - const output = JSON.parse(consoleSpy.mock.calls[0][0]) - expect(output).toMatchObject({ id: 500, added: [4], alreadyMembers: [2] }) - }) -}) - -describe('tw channel remove', () => { - it('only removes users that are members', async () => { - refsMocks.resolveChannelMemberRefs.mockResolvedValue({ - userIds: [2, 3, 99], - expandedFrom: [], - }) - const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await program.parseAsync([ - 'node', - 'tw', - 'channel', - 'remove', - 'general', - 'id:2', - 'id:3', - 'id:99', - ]) - - expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith(500, [2, 3]) - }) - - it('makes no API call when none of the users are members', async () => { - refsMocks.resolveChannelMemberRefs.mockResolvedValue({ - userIds: [99, 100], - expandedFrom: [], - }) - const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await program.parseAsync(['node', 'tw', 'channel', 'remove', 'general', 'id:99', 'id:100']) - - expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() - }) - - it('--dry-run does not mutate', async () => { - refsMocks.resolveChannelMemberRefs.mockResolvedValue({ - userIds: [2, 3], - expandedFrom: [], - }) - const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await program.parseAsync([ - 'node', - 'tw', - 'channel', - 'remove', - 'general', - 'id:2', - 'id:3', - '--dry-run', - ]) - - expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() - expect(consoleSpy.mock.calls.some((c) => String(c[0]).includes('[dry-run]'))).toBe(true) - }) - - it('--json shape includes removed/notMembers', async () => { - refsMocks.resolveChannelMemberRefs.mockResolvedValue({ - userIds: [2, 99], - expandedFrom: [], - }) - const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await program.parseAsync([ - 'node', - 'tw', - 'channel', - 'remove', - 'general', - 'id:2', - 'id:99', - '--json', - ]) - - const output = JSON.parse(consoleSpy.mock.calls[0][0]) - expect(output).toMatchObject({ id: 500, removed: [2], notMembers: [99] }) - }) - - it('--full fetches and prints the updated channel', async () => { - refsMocks.resolveChannelMemberRefs.mockResolvedValue({ - userIds: [2], - expandedFrom: [], - }) - mockGetChannel.mockResolvedValue({ ...sampleChannel, userIds: [1, 3] }) - const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await program.parseAsync([ - 'node', - 'tw', - 'channel', - 'remove', - 'general', - 'id:2', - '--json', - '--full', - ]) - - expect(mockGetChannel).toHaveBeenCalledWith(500) - const output = JSON.parse(consoleSpy.mock.calls[0][0]) - // --full returns the full SDK channel object - expect(output.id).toBe(500) - expect(output).toHaveProperty('creator') - expect(output).toHaveProperty('version') - }) -}) - -describe('tw channel sync', () => { - it('dry-run by default — does not mutate', async () => { - refsMocks.resolveChannelMemberRefs.mockResolvedValue({ - userIds: [1, 4, 5], - expandedFrom: [], - }) - const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await program.parseAsync([ - 'node', - 'tw', - 'channel', - 'sync', - 'general', - 'id:1', - 'id:4', - 'id:5', - ]) - - expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled() - expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() - const text = consoleSpy.mock.calls.map((c) => c[0]).join('\n') - expect(text).toContain('[dry-run]') - }) - - it('--apply computes add/remove diff', async () => { - // channel currently [1,2,3]; desired [1,4,5] → add [4,5], remove [2,3] - refsMocks.resolveChannelMemberRefs.mockResolvedValue({ - userIds: [1, 4, 5], - expandedFrom: [], - }) - const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await program.parseAsync([ - 'node', - 'tw', - 'channel', - 'sync', - 'general', - 'id:1', - 'id:4', - 'id:5', - '--apply', - ]) - - expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4, 5]) - expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith(500, [2, 3]) - }) - - it('refuses to remove acting user without --include-self', async () => { - // session user id:1 is in current channel; desired set omits id:1 → would remove self - refsMocks.resolveChannelMemberRefs.mockResolvedValue({ - userIds: [2, 3, 4], - expandedFrom: [], - }) - const program = createProgram() - - await expect( - program.parseAsync([ - 'node', - 'tw', - 'channel', - 'sync', - 'general', - 'id:2', - 'id:3', - 'id:4', - '--apply', - ]), - ).rejects.toMatchObject({ code: 'INVALID_VALUE' }) - }) - - it('--include-self allows removing acting user', async () => { - refsMocks.resolveChannelMemberRefs.mockResolvedValue({ - userIds: [2, 3, 4], - expandedFrom: [], - }) - const program = createProgram() - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await program.parseAsync([ - 'node', - 'tw', - 'channel', - 'sync', - 'general', - 'id:2', - 'id:3', - 'id:4', - '--apply', - '--include-self', - ]) - - expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4]) - expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith(500, [1]) - }) - - it('--json output shape', async () => { - refsMocks.resolveChannelMemberRefs.mockResolvedValue({ - userIds: [1, 4], - expandedFrom: [{ groupId: 200, groupName: 'Backend', userIds: [4] }], - }) - const program = createProgram() - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await program.parseAsync([ - 'node', - 'tw', - 'channel', - 'sync', - 'general', - 'id:1', - 'group:Backend', - '--apply', - '--json', - ]) - - const output = JSON.parse(consoleSpy.mock.calls[0][0]) - expect(output).toMatchObject({ - id: 500, - added: [4], - removed: [2, 3], - expandedFrom: [{ groupId: 200, groupName: 'Backend', userIds: [4] }], - }) - }) -}) diff --git a/src/commands/channel/members.ts b/src/commands/channel/members.ts index a441d1c..1226911 100644 --- a/src/commands/channel/members.ts +++ b/src/commands/channel/members.ts @@ -1,181 +1,8 @@ -import type { Channel, Group, User, WorkspaceUser } from '@doist/twist-sdk' -import { - addUsersToChannel, - getCurrentWorkspaceId, - getOptionalBatchData, - getSessionUser, - getTwistClient, - getWorkspaceGroups, - removeUsersFromChannel, -} from '../../lib/api.js' -import { CliError } from '../../lib/errors.js' -import type { MutationOptions, ViewOptions } from '../../lib/options.js' -import { colors, formatJson, formatNdjson, pluralize, printDryRun } from '../../lib/output.js' -import { resolveChannelMemberRefs, resolveChannelRef } from '../../lib/refs.js' - -type ChannelMutationOptions = MutationOptions - -export type SyncOptions = MutationOptions & { - apply?: boolean - includeSelf?: boolean -} - -function channelUserIds(channel: Channel): number[] { - return channel.userIds ?? [] -} - -async function fetchUsersByIds( - workspaceId: number, - userIds: number[], -): Promise> { - if (userIds.length === 0) return new Map() - const client = await getTwistClient() - const calls = userIds.map((userId) => - client.workspaceUsers.getUserById({ workspaceId, userId }, { batch: true }), - ) - const responses = await client.batch(...calls) - const map = new Map() - userIds.forEach((id, i) => { - const user = getOptionalBatchData(responses[i], `user ${id}`) - if (user) map.set(id, user) - }) - return map -} - -function logExpansion( - expandedFrom: { groupId: number; groupName: string; userIds: number[] }[], -): void { - for (const g of expandedFrom) { - console.log( - colors.timestamp( - `Expanded group "${g.groupName}" → ${g.userIds.length} ${pluralize(g.userIds.length, 'user')}`, - ), - ) - } -} - -function describeExpansion( - expandedFrom: { groupId: number; groupName: string; userIds: number[] }[], -): string | undefined { - if (expandedFrom.length === 0) return undefined - return expandedFrom - .map((g) => `${g.groupName} (id:${g.groupId}, ${g.userIds.length} users)`) - .join('\n') -} - -async function mutateChannelMembership( - channelRef: string, - refs: string[], - action: 'add' | 'remove', - options: ChannelMutationOptions, -): Promise { - const workspaceId = await getCurrentWorkspaceId() - const [channel, { userIds: requested, expandedFrom }] = await Promise.all([ - resolveChannelRef(channelRef, workspaceId), - resolveChannelMemberRefs(refs, workspaceId), - ]) - - const current = new Set(channelUserIds(channel)) - const actionable = - action === 'add' - ? requested.filter((id) => !current.has(id)) - : requested.filter((id) => current.has(id)) - const skipped = - action === 'add' - ? requested.filter((id) => current.has(id)) - : requested.filter((id) => !current.has(id)) - - const actionLabel = action === 'add' ? 'add users to' : 'remove users from' - const skippedLabel = action === 'add' ? 'Already members' : 'Not members' - - if (options.dryRun) { - printDryRun(`${actionLabel} channel`, { - Channel: `${channel.name} (id:${channel.id})`, - 'Expanded from groups': describeExpansion(expandedFrom), - [`Users to ${action}`]: actionable.length > 0 ? actionable.join(', ') : '(none)', - [skippedLabel]: skipped.length > 0 ? skipped.join(', ') : undefined, - }) - return - } - - if (actionable.length > 0) { - if (action === 'add') { - await addUsersToChannel(channel.id, actionable) - } else { - await removeUsersFromChannel(channel.id, actionable) - } - } - - const newMemberCount = - action === 'add' - ? channelUserIds(channel).length + actionable.length - : channelUserIds(channel).length - actionable.length - - if (options.json) { - if (options.full) { - const client = await getTwistClient() - const updated = await client.channels.getChannel(channel.id) - console.log(formatJson(updated, 'channel', true)) - } else { - const result: Record = { - id: channel.id, - memberCount: newMemberCount, - } - if (expandedFrom.length > 0) result.expandedFrom = expandedFrom - if (action === 'add') { - result.added = actionable - result.alreadyMembers = skipped - } else { - result.removed = actionable - result.notMembers = skipped - } - console.log(formatJson(result)) - } - return - } - - const pastVerb = action === 'add' ? 'Added' : 'Removed' - const preposition = action === 'add' ? 'to' : 'from' - const noneMsg = - action === 'add' - ? `No new members added to "${channel.name}" (already in channel).` - : `No members removed from "${channel.name}" (none of the users were in channel).` - - logExpansion(expandedFrom) - - if (actionable.length === 0) { - console.log(noneMsg) - } else { - console.log( - `${pastVerb} ${actionable.length} ${pluralize(actionable.length, 'user')} ${preposition} "${channel.name}" (now ${newMemberCount} ${pluralize(newMemberCount, 'member')}).`, - ) - } - if (skipped.length > 0) { - console.log(`${skippedLabel}: ${skipped.join(', ')}`) - } -} - -export async function addChannelMembers( - channelRef: string, - refs: string[], - options: ChannelMutationOptions, -): Promise { - return mutateChannelMembership(channelRef, refs, 'add', options) -} - -export async function removeChannelMembers( - channelRef: string, - refs: string[], - options: ChannelMutationOptions, -): Promise { - return mutateChannelMembership(channelRef, refs, 'remove', options) -} - -function groupsFullyInChannel(groups: Group[], channelUserIdSet: Set): Group[] { - return groups.filter( - (g) => g.userIds.length > 0 && g.userIds.every((id) => channelUserIdSet.has(id)), - ) -} +import { getCurrentWorkspaceId, getWorkspaceGroups } from '../../lib/api.js' +import type { ViewOptions } from '../../lib/options.js' +import { colors, formatJson, formatNdjson, pluralize } from '../../lib/output.js' +import { resolveChannelRef } from '../../lib/refs.js' +import { channelUserIds, fetchUsersByIds, groupsFullyInChannel } from './membership-helpers.js' export async function listChannelMembers( channelRef: string, @@ -241,83 +68,3 @@ export async function listChannelMembers( } } } - -export async function syncChannelMembers( - channelRef: string, - refs: string[], - options: SyncOptions, -): Promise { - const workspaceId = await getCurrentWorkspaceId() - const [channel, sessionUser, memberRefs] = await Promise.all([ - resolveChannelRef(channelRef, workspaceId), - getSessionUser() as Promise, - resolveChannelMemberRefs(refs, workspaceId), - ]) - const { userIds: targetIds, expandedFrom } = memberRefs - const desired = new Set(targetIds) - const current = new Set(channelUserIds(channel)) - - const toAdd = [...desired].filter((id) => !current.has(id)) - const toRemoveAll = [...current].filter((id) => !desired.has(id)) - - const selfId = sessionUser.id - const wouldRemoveSelf = toRemoveAll.includes(selfId) - if (wouldRemoveSelf && !options.includeSelf) { - throw new CliError( - 'INVALID_VALUE', - `Sync would remove you (id:${selfId}) from "${channel.name}".`, - [ - 'Pass --include-self to allow removing yourself, or include yourself in the ref list.', - ], - ) - } - const toRemove = - wouldRemoveSelf && !options.includeSelf - ? toRemoveAll.filter((id) => id !== selfId) - : toRemoveAll - - const isDryRun = options.dryRun || !options.apply - - if (isDryRun) { - printDryRun(`sync channel membership`, { - Channel: `${channel.name} (id:${channel.id})`, - 'Expanded from groups': describeExpansion(expandedFrom), - 'To add': toAdd.length > 0 ? toAdd.join(', ') : '(none)', - 'To remove': toRemove.length > 0 ? toRemove.join(', ') : '(none)', - Note: options.apply ? undefined : 'sync is dry-run by default; pass --apply to mutate.', - }) - return - } - - await Promise.all([ - toAdd.length > 0 ? addUsersToChannel(channel.id, toAdd) : Promise.resolve(), - toRemove.length > 0 ? removeUsersFromChannel(channel.id, toRemove) : Promise.resolve(), - ]) - - const newMemberCount = current.size + toAdd.length - toRemove.length - - if (options.json) { - const result: Record = { - id: channel.id, - memberCount: newMemberCount, - added: toAdd, - removed: toRemove, - } - if (expandedFrom.length > 0) result.expandedFrom = expandedFrom - if (options.full) { - const client = await getTwistClient() - const updated = await client.channels.getChannel(channel.id) - console.log(formatJson({ ...updated, ...result }, 'channel', true)) - } else { - console.log(formatJson(result)) - } - return - } - - logExpansion(expandedFrom) - console.log( - `Synced "${channel.name}": +${toAdd.length} / -${toRemove.length} (now ${newMemberCount} ${pluralize(newMemberCount, 'member')}).`, - ) - if (toAdd.length > 0) console.log(` Added: ${toAdd.join(', ')}`) - if (toRemove.length > 0) console.log(` Removed: ${toRemove.join(', ')}`) -} diff --git a/src/commands/channel/membership-helpers.ts b/src/commands/channel/membership-helpers.ts new file mode 100644 index 0000000..6746bb8 --- /dev/null +++ b/src/commands/channel/membership-helpers.ts @@ -0,0 +1,157 @@ +import type { Channel, Group, WorkspaceUser } from '@doist/twist-sdk' +import { + addUsersToChannel, + getCurrentWorkspaceId, + getOptionalBatchData, + getTwistClient, + removeUsersFromChannel, +} from '../../lib/api.js' +import type { MutationOptions } from '../../lib/options.js' +import { colors, formatJson, pluralize, printDryRun } from '../../lib/output.js' +import { resolveChannelMemberRefs, resolveChannelRef } from '../../lib/refs.js' + +export type ChannelMutationOptions = MutationOptions + +export type ExpandedGroup = { groupId: number; groupName: string; userIds: number[] } + +export function channelUserIds(channel: Channel): number[] { + return channel.userIds ?? [] +} + +export async function fetchUsersByIds( + workspaceId: number, + userIds: number[], +): Promise> { + if (userIds.length === 0) return new Map() + const client = await getTwistClient() + const calls = userIds.map((userId) => + client.workspaceUsers.getUserById({ workspaceId, userId }, { batch: true }), + ) + const responses = await client.batch(...calls) + const map = new Map() + userIds.forEach((id, i) => { + const user = getOptionalBatchData(responses[i], `user ${id}`) + if (user) map.set(id, user) + }) + return map +} + +export function logExpansion(expandedFrom: ExpandedGroup[]): void { + for (const g of expandedFrom) { + console.log( + colors.timestamp( + `Expanded group "${g.groupName}" → ${g.userIds.length} ${pluralize(g.userIds.length, 'user')}`, + ), + ) + } +} + +export function describeExpansion(expandedFrom: ExpandedGroup[]): string | undefined { + if (expandedFrom.length === 0) return undefined + return expandedFrom + .map((g) => `${g.groupName} (id:${g.groupId}, ${g.userIds.length} users)`) + .join('\n') +} + +export function groupsFullyInChannel(groups: Group[], channelUserIdSet: Set): Group[] { + return groups.filter( + (g) => g.userIds.length > 0 && g.userIds.every((id) => channelUserIdSet.has(id)), + ) +} + +/** + * Shared add/remove mutation flow. Resolves the channel and the requested refs + * concurrently, diffs against current membership, then either previews + * (`--dry-run`) or applies the mutation and prints / emits JSON. + */ +export async function mutateChannelMembership( + channelRef: string, + refs: string[], + action: 'add' | 'remove', + options: ChannelMutationOptions, +): Promise { + const workspaceId = await getCurrentWorkspaceId() + const [channel, { userIds: requested, expandedFrom }] = await Promise.all([ + resolveChannelRef(channelRef, workspaceId), + resolveChannelMemberRefs(refs, workspaceId), + ]) + + const current = new Set(channelUserIds(channel)) + const actionable = + action === 'add' + ? requested.filter((id) => !current.has(id)) + : requested.filter((id) => current.has(id)) + const skipped = + action === 'add' + ? requested.filter((id) => current.has(id)) + : requested.filter((id) => !current.has(id)) + + const actionLabel = action === 'add' ? 'add users to' : 'remove users from' + const skippedLabel = action === 'add' ? 'Already members' : 'Not members' + + if (options.dryRun) { + printDryRun(`${actionLabel} channel`, { + Channel: `${channel.name} (id:${channel.id})`, + 'Expanded from groups': describeExpansion(expandedFrom), + [`Users to ${action}`]: actionable.length > 0 ? actionable.join(', ') : '(none)', + [skippedLabel]: skipped.length > 0 ? skipped.join(', ') : undefined, + }) + return + } + + if (actionable.length > 0) { + if (action === 'add') { + await addUsersToChannel(channel.id, actionable) + } else { + await removeUsersFromChannel(channel.id, actionable) + } + } + + const newMemberCount = + action === 'add' + ? channelUserIds(channel).length + actionable.length + : channelUserIds(channel).length - actionable.length + + if (options.json) { + if (options.full) { + const client = await getTwistClient() + const updated = await client.channels.getChannel(channel.id) + console.log(formatJson(updated, 'channel', true)) + } else { + const result: Record = { + id: channel.id, + memberCount: newMemberCount, + } + if (expandedFrom.length > 0) result.expandedFrom = expandedFrom + if (action === 'add') { + result.added = actionable + result.alreadyMembers = skipped + } else { + result.removed = actionable + result.notMembers = skipped + } + console.log(formatJson(result)) + } + return + } + + const pastVerb = action === 'add' ? 'Added' : 'Removed' + const preposition = action === 'add' ? 'to' : 'from' + const noneMsg = + action === 'add' + ? `No new members added to "${channel.name}" (already in channel).` + : `No members removed from "${channel.name}" (none of the users were in channel).` + + logExpansion(expandedFrom) + + if (actionable.length === 0) { + console.log(noneMsg) + } else { + console.log( + `${pastVerb} ${actionable.length} ${pluralize(actionable.length, 'user')} ${preposition} "${channel.name}" (now ${newMemberCount} ${pluralize(newMemberCount, 'member')}).`, + ) + } + if (skipped.length > 0) { + console.log(`${skippedLabel}: ${skipped.join(', ')}`) + } +} diff --git a/src/commands/channel/remove.test.ts b/src/commands/channel/remove.test.ts new file mode 100644 index 0000000..09824bf --- /dev/null +++ b/src/commands/channel/remove.test.ts @@ -0,0 +1,168 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockGetChannel = vi.fn() + +const apiMocks = vi.hoisted(() => ({ + getCurrentWorkspaceId: vi.fn().mockResolvedValue(1), + getTwistClient: vi.fn(), + getSessionUser: vi.fn(), + getWorkspaceGroups: vi.fn(), + getOptionalBatchData: vi.fn(), + addUsersToChannel: vi.fn(), + removeUsersFromChannel: vi.fn(), +})) + +vi.mock('../../lib/api.js', () => apiMocks) + +const refsMocks = vi.hoisted(() => ({ + resolveChannelRef: vi.fn(), + resolveChannelMemberRefs: vi.fn(), +})) + +vi.mock('../../lib/refs.js', () => refsMocks) + +vi.mock('chalk') + +import { registerChannelCommand } from './index.js' + +function createProgram() { + const program = new Command() + program.exitOverride() + registerChannelCommand(program) + return program +} + +const sampleChannel = { + id: 500, + name: 'general', + workspaceId: 1, + userIds: [1, 2, 3], + creator: 1, + public: true, + archived: false, + created: new Date(), + version: 1, + url: 'https://twist.com/a/1/ch/500', +} + +beforeEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + apiMocks.getTwistClient.mockResolvedValue({ + channels: { getChannel: mockGetChannel }, + }) + refsMocks.resolveChannelRef.mockResolvedValue(sampleChannel) +}) + +describe('tw channel remove', () => { + it('only removes users that are members', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 3, 99], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'remove', + 'general', + 'id:2', + 'id:3', + 'id:99', + ]) + + expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith(500, [2, 3]) + }) + + it('makes no API call when none of the users are members', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [99, 100], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync(['node', 'tw', 'channel', 'remove', 'general', 'id:99', 'id:100']) + + expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() + }) + + it('--dry-run does not mutate', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 3], + expandedFrom: [], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'remove', + 'general', + 'id:2', + 'id:3', + '--dry-run', + ]) + + expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() + expect(consoleSpy.mock.calls.some((c) => String(c[0]).includes('[dry-run]'))).toBe(true) + }) + + it('--json shape includes removed/notMembers', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 99], + expandedFrom: [], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'remove', + 'general', + 'id:2', + 'id:99', + '--json', + ]) + + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(output).toMatchObject({ id: 500, removed: [2], notMembers: [99] }) + }) + + it('--full fetches and prints the updated channel', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2], + expandedFrom: [], + }) + mockGetChannel.mockResolvedValue({ ...sampleChannel, userIds: [1, 3] }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'remove', + 'general', + 'id:2', + '--json', + '--full', + ]) + + expect(mockGetChannel).toHaveBeenCalledWith(500) + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + // --full returns the full SDK channel object + expect(output.id).toBe(500) + expect(output).toHaveProperty('creator') + expect(output).toHaveProperty('version') + }) +}) diff --git a/src/commands/channel/remove.ts b/src/commands/channel/remove.ts new file mode 100644 index 0000000..ed46029 --- /dev/null +++ b/src/commands/channel/remove.ts @@ -0,0 +1,9 @@ +import { type ChannelMutationOptions, mutateChannelMembership } from './membership-helpers.js' + +export async function removeChannelMembers( + channelRef: string, + refs: string[], + options: ChannelMutationOptions, +): Promise { + return mutateChannelMembership(channelRef, refs, 'remove', options) +} diff --git a/src/commands/channel/sync.test.ts b/src/commands/channel/sync.test.ts new file mode 100644 index 0000000..268976b --- /dev/null +++ b/src/commands/channel/sync.test.ts @@ -0,0 +1,182 @@ +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const apiMocks = vi.hoisted(() => ({ + getCurrentWorkspaceId: vi.fn().mockResolvedValue(1), + getTwistClient: vi.fn(), + getSessionUser: vi.fn(), + getWorkspaceGroups: vi.fn(), + getOptionalBatchData: vi.fn(), + addUsersToChannel: vi.fn(), + removeUsersFromChannel: vi.fn(), +})) + +vi.mock('../../lib/api.js', () => apiMocks) + +const refsMocks = vi.hoisted(() => ({ + resolveChannelRef: vi.fn(), + resolveChannelMemberRefs: vi.fn(), +})) + +vi.mock('../../lib/refs.js', () => refsMocks) + +vi.mock('chalk') + +import { registerChannelCommand } from './index.js' + +function createProgram() { + const program = new Command() + program.exitOverride() + registerChannelCommand(program) + return program +} + +const sampleChannel = { + id: 500, + name: 'general', + workspaceId: 1, + userIds: [1, 2, 3], + creator: 1, + public: true, + archived: false, + created: new Date(), + version: 1, + url: 'https://twist.com/a/1/ch/500', +} + +beforeEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() + apiMocks.getCurrentWorkspaceId.mockResolvedValue(1) + apiMocks.getSessionUser.mockResolvedValue({ id: 1, name: 'Me', email: 'me@d.com' }) + refsMocks.resolveChannelRef.mockResolvedValue(sampleChannel) +}) + +describe('tw channel sync', () => { + it('dry-run by default — does not mutate', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [1, 4, 5], + expandedFrom: [], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'sync', + 'general', + 'id:1', + 'id:4', + 'id:5', + ]) + + expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled() + expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() + const text = consoleSpy.mock.calls.map((c) => c[0]).join('\n') + expect(text).toContain('[dry-run]') + }) + + it('--apply computes add/remove diff', async () => { + // channel currently [1,2,3]; desired [1,4,5] → add [4,5], remove [2,3] + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [1, 4, 5], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'sync', + 'general', + 'id:1', + 'id:4', + 'id:5', + '--apply', + ]) + + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4, 5]) + expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith(500, [2, 3]) + }) + + it('refuses to remove acting user without --include-self', async () => { + // session user id:1 is in current channel; desired set omits id:1 → would remove self + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 3, 4], + expandedFrom: [], + }) + const program = createProgram() + + await expect( + program.parseAsync([ + 'node', + 'tw', + 'channel', + 'sync', + 'general', + 'id:2', + 'id:3', + 'id:4', + '--apply', + ]), + ).rejects.toMatchObject({ code: 'INVALID_VALUE' }) + }) + + it('--include-self allows removing acting user', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [2, 3, 4], + expandedFrom: [], + }) + const program = createProgram() + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'sync', + 'general', + 'id:2', + 'id:3', + 'id:4', + '--apply', + '--include-self', + ]) + + expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4]) + expect(apiMocks.removeUsersFromChannel).toHaveBeenCalledWith(500, [1]) + }) + + it('--json output shape', async () => { + refsMocks.resolveChannelMemberRefs.mockResolvedValue({ + userIds: [1, 4], + expandedFrom: [{ groupId: 200, groupName: 'Backend', userIds: [4] }], + }) + const program = createProgram() + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'sync', + 'general', + 'id:1', + 'group:Backend', + '--apply', + '--json', + ]) + + const output = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(output).toMatchObject({ + id: 500, + added: [4], + removed: [2, 3], + expandedFrom: [{ groupId: 200, groupName: 'Backend', userIds: [4] }], + }) + }) +}) diff --git a/src/commands/channel/sync.ts b/src/commands/channel/sync.ts new file mode 100644 index 0000000..d352369 --- /dev/null +++ b/src/commands/channel/sync.ts @@ -0,0 +1,98 @@ +import type { User } from '@doist/twist-sdk' +import { + addUsersToChannel, + getCurrentWorkspaceId, + getSessionUser, + getTwistClient, + removeUsersFromChannel, +} from '../../lib/api.js' +import { CliError } from '../../lib/errors.js' +import type { MutationOptions } from '../../lib/options.js' +import { formatJson, pluralize, printDryRun } from '../../lib/output.js' +import { resolveChannelMemberRefs, resolveChannelRef } from '../../lib/refs.js' +import { channelUserIds, describeExpansion, logExpansion } from './membership-helpers.js' + +export type SyncOptions = MutationOptions & { + apply?: boolean + includeSelf?: boolean +} + +export async function syncChannelMembers( + channelRef: string, + refs: string[], + options: SyncOptions, +): Promise { + const workspaceId = await getCurrentWorkspaceId() + const [channel, sessionUser, memberRefs] = await Promise.all([ + resolveChannelRef(channelRef, workspaceId), + getSessionUser() as Promise, + resolveChannelMemberRefs(refs, workspaceId), + ]) + const { userIds: targetIds, expandedFrom } = memberRefs + const desired = new Set(targetIds) + const current = new Set(channelUserIds(channel)) + + const toAdd = [...desired].filter((id) => !current.has(id)) + const toRemoveAll = [...current].filter((id) => !desired.has(id)) + + const selfId = sessionUser.id + const wouldRemoveSelf = toRemoveAll.includes(selfId) + if (wouldRemoveSelf && !options.includeSelf) { + throw new CliError( + 'INVALID_VALUE', + `Sync would remove you (id:${selfId}) from "${channel.name}".`, + [ + 'Pass --include-self to allow removing yourself, or include yourself in the ref list.', + ], + ) + } + const toRemove = + wouldRemoveSelf && !options.includeSelf + ? toRemoveAll.filter((id) => id !== selfId) + : toRemoveAll + + const isDryRun = options.dryRun || !options.apply + + if (isDryRun) { + printDryRun(`sync channel membership`, { + Channel: `${channel.name} (id:${channel.id})`, + 'Expanded from groups': describeExpansion(expandedFrom), + 'To add': toAdd.length > 0 ? toAdd.join(', ') : '(none)', + 'To remove': toRemove.length > 0 ? toRemove.join(', ') : '(none)', + Note: options.apply ? undefined : 'sync is dry-run by default; pass --apply to mutate.', + }) + return + } + + await Promise.all([ + toAdd.length > 0 ? addUsersToChannel(channel.id, toAdd) : Promise.resolve(), + toRemove.length > 0 ? removeUsersFromChannel(channel.id, toRemove) : Promise.resolve(), + ]) + + const newMemberCount = current.size + toAdd.length - toRemove.length + + if (options.json) { + const result: Record = { + id: channel.id, + memberCount: newMemberCount, + added: toAdd, + removed: toRemove, + } + if (expandedFrom.length > 0) result.expandedFrom = expandedFrom + if (options.full) { + const client = await getTwistClient() + const updated = await client.channels.getChannel(channel.id) + console.log(formatJson({ ...updated, ...result }, 'channel', true)) + } else { + console.log(formatJson(result)) + } + return + } + + logExpansion(expandedFrom) + console.log( + `Synced "${channel.name}": +${toAdd.length} / -${toRemove.length} (now ${newMemberCount} ${pluralize(newMemberCount, 'member')}).`, + ) + if (toAdd.length > 0) console.log(` Added: ${toAdd.join(', ')}`) + if (toRemove.length > 0) console.log(` Removed: ${toRemove.join(', ')}`) +} From fc5dda2de4b19206afe0d61f169085160eed6068 Mon Sep 17 00:00:00 2001 From: lmjabreu Date: Fri, 22 May 2026 19:32:58 +0100 Subject: [PATCH 8/9] review: address Scott's second pass on PR #244 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the membership commands as nested sub-commands of `members` so the context is explicit, and rename `sync` to `set`: tw channel members # list (default) tw channel members add ... tw channel members remove ... tw channel members set ... # was: sync Reasoning: `tw channel add` / `remove` / `sync` sounded like channel-level operations (creating a channel, deleting it, syncing something unclear). Nesting under `members` makes intent unambiguous, and `set` reads as the replacement semantic better than `sync`. `members` is now a pure command group with `list` as its `{ isDefault: true }` subcommand, so `tw channel members ` continues to list (default action) without options bleeding between the parent and the child mutate subcommands. Also: - src/lib/refs.ts: switch `interface ChannelMemberRefs` to `type` per repo preference. - src/lib/skills/content.ts: fix `` → `` for consistency with the line above it; update the Quick Reference and notes to reflect the new command structure. Regenerated SKILL.md. - package-lock.json: reset to main; the previous diff was an unintentional version bump from `npm i` and shouldn't have been committed. - src/commands/channel/sync.ts → set.ts (with handler and option type renamed accordingly); sync.test.ts → set.test.ts; tests in add/remove/set updated to invoke the new nested paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 540 +++++++++--------- skills/twist-cli/SKILL.md | 18 +- src/commands/channel/add.test.ts | 28 +- src/commands/channel/index.ts | 43 +- src/commands/channel/remove.test.ts | 17 +- .../channel/{sync.test.ts => set.test.ts} | 17 +- src/commands/channel/{sync.ts => set.ts} | 14 +- src/lib/refs.ts | 2 +- src/lib/skills/content.ts | 18 +- 9 files changed, 376 insertions(+), 321 deletions(-) rename src/commands/channel/{sync.test.ts => set.test.ts} (95%) rename src/commands/channel/{sync.ts => set.ts} (86%) diff --git a/package-lock.json b/package-lock.json index ace472f..5dd9172 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,22 @@ { "name": "@doist/twist-cli", - "version": "2.42.0", + "version": "2.36.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@doist/twist-cli", - "version": "2.42.0", + "version": "2.36.3", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@doist/cli-core": "0.20.0", - "@doist/twist-sdk": "2.7.0", + "@doist/cli-core": "0.10.0", + "@doist/twist-sdk": "2.5.1", "@pnpm/tabtab": "0.5.4", "chalk": "5.6.2", "commander": "14.0.3", "marked": "18.0.3", "marked-terminal-renderer": "2.2.0", - "oauth4webapi": "3.8.6", "open": "11.0.0" }, "bin": { @@ -27,14 +26,14 @@ "@semantic-release/changelog": "6.0.3", "@semantic-release/exec": "7.1.0", "@semantic-release/git": "10.0.1", - "@types/node": "25.8.0", + "@types/node": "25.6.2", "conventional-changelog-conventionalcommits": "9.3.1", "lefthook": "2.1.6", - "oxfmt": "0.50.0", - "oxlint": "1.65.0", + "oxfmt": "0.48.0", + "oxlint": "1.63.0", "semantic-release": "25.0.3", "typescript": "6.0.3", - "vitest": "4.1.6" + "vitest": "4.1.5" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -138,25 +137,21 @@ } }, "node_modules/@doist/cli-core": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@doist/cli-core/-/cli-core-0.20.0.tgz", - "integrity": "sha512-18Mh68yoVND+kKKtFHggEqR+eLqA9gl5jSUOFE0uqjbHBzOAb6VBzwvYVocceZaVRxF3j/Pu+Rbkpwta5Lkvww==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@doist/cli-core/-/cli-core-0.10.0.tgz", + "integrity": "sha512-ya/+G0fJ8s+KJxIKsftKUAFGSHKFPGSPfNYJY961xoIuz1F+fm575vesPzWxaCRbmD6Z3vaiXUAxxG7SyfmgLQ==", "license": "MIT", "dependencies": { "chalk": "5.6.2", - "yocto-spinner": "1.2.0" + "yocto-spinner": "1.1.0" }, "engines": { "node": ">=20.18.1" }, - "optionalDependencies": { - "@napi-rs/keyring": "1.3.0" - }, "peerDependencies": { "commander": ">=14", "marked": ">=18", "marked-terminal-renderer": ">=2", - "oauth4webapi": ">=3", "open": ">=10", "vitest": ">=4.1" }, @@ -170,9 +165,6 @@ "marked-terminal-renderer": { "optional": true }, - "oauth4webapi": { - "optional": true - }, "open": { "optional": true }, @@ -181,10 +173,25 @@ } } }, + "node_modules/@doist/cli-core/node_modules/yocto-spinner": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.1.0.tgz", + "integrity": "sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==", + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "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.5.1", + "resolved": "https://registry.npmjs.org/@doist/twist-sdk/-/twist-sdk-2.5.1.tgz", + "integrity": "sha512-e1DDJAPdIcqhpeZz1MB797S7suZW8rUurKxcpb4/oIYVaFzSllZw8EoFtyOlz6KcxWgcrtNECzIOmCwXhMfWXg==", "license": "MIT", "dependencies": { "camelcase": "8.0.0", @@ -201,6 +208,7 @@ "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": { @@ -212,6 +220,7 @@ "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": { @@ -222,6 +231,7 @@ "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": { @@ -1033,6 +1043,7 @@ "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": { @@ -1214,9 +1225,9 @@ } }, "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.50.0.tgz", - "integrity": "sha512-ICXQVKrDvsWUtfx6EiVJxfWrajKTwTfRV8vz2XiMkxZeuCKJLgD4YAj6dE3BWvpqDlkVkie4VSTAtMUWO9LDXg==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.48.0.tgz", + "integrity": "sha512-uwqk+/KhQvBIpULD8SMM/zAafMRC/+DV/xsEQjkkIsJ/kLmEI/2bxonVowcYTiXqqZ/a0FEW8DPkZY3VvwELDA==", "cpu": [ "arm" ], @@ -1231,9 +1242,9 @@ } }, "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.50.0.tgz", - "integrity": "sha512-quwjLQFkuW6OwLHeDeIXsTzOmipQFQbqsYN9HLk2B5I01IlAQZHP1UiLIg0O7pP+dUgPD2AD7SCYA3gs6NH5/g==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.48.0.tgz", + "integrity": "sha512-VUCiKuXK5+McVssgHEJdrcGK7hRJzrRb36zm9/jwzMholyYt4BgXhw5Nm1V1DX6Ce717Zi/1jk432b/tgmQgtQ==", "cpu": [ "arm64" ], @@ -1248,9 +1259,9 @@ } }, "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.50.0.tgz", - "integrity": "sha512-ikU5umElcMi78/TNI334wtjr5WZ5F4nWa1aIDseAKKGL0W3ygxeYKkrIJ0fggWa8MOon66BmG3xCqmX1m9YAOw==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.48.0.tgz", + "integrity": "sha512-IkKp8rnIyQLW6Jt+6jragCbUVYSayk55lapiprLjIVvt4NczLyO/nwX2GgefLQ5iaBdfS8UEAFgCs/pLO6Cl0w==", "cpu": [ "arm64" ], @@ -1265,9 +1276,9 @@ } }, "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.50.0.tgz", - "integrity": "sha512-WT4MOYG4mv9IXrH0m60vHsJh+rRMPSOKTQmwDpwmgQ+DuW/i5dU4pqc0HDO5uclO5vjz5IFX5z/taW86LSVe/g==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.48.0.tgz", + "integrity": "sha512-+aFuhsGIuvnoOjXyKVHMhPKJZR1kQkAl8QyrKoMlA7yJsSTC3N0Asl53La8TChSHhW8epToQ/Q0nvLmEmfNmLg==", "cpu": [ "x64" ], @@ -1282,9 +1293,9 @@ } }, "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.50.0.tgz", - "integrity": "sha512-gH0rycVXqV4juWkvLs2uPMtTyppDc7qEUVzXAxnQ7FpcSZNXqKowUgtjH8q67ngj416r8+4NnAlyR/D35zwwhQ==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.48.0.tgz", + "integrity": "sha512-fbqzQL8FjI9gGnktI7RIo0dksDziTAYBy7xlI7jU7eID5fxLF/25fS4Xj6GydD8Y5oWHL83U4NK160QaOAxtyg==", "cpu": [ "x64" ], @@ -1299,9 +1310,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.50.0.tgz", - "integrity": "sha512-wL/k+o0hiTeRvi/gPzeC1L/yTHTXIeHDKWU09s2zTBmv7ma59wTm+fADNSGYxhJQDxyavQbwTf1QpW3Zj924tQ==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.48.0.tgz", + "integrity": "sha512-hn4i0zhAyTiB3ZHjQfYUZkDvrbVkohw1S7pySWxWUoZ87HnkDoTFThj7QTxk40hNPOTUP0vHbPRNamFIv1HBJQ==", "cpu": [ "arm" ], @@ -1316,9 +1327,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.50.0.tgz", - "integrity": "sha512-Y59FKqoUM3Gf00E395b4ixfWyJGwO2GzaZawF5MZoVWcb3f6CkWUXyao0jyOvoIxDMzMybcVRuXyG7ih/Nxweg==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.48.0.tgz", + "integrity": "sha512-R4WBD9qF3QM9hqgdAa+fBGXmquTvDUujrPQ36t2Sjk8RPOSKGHDeN7l/khr10hqbQaOq9KCgPHG9ubNET/X/RQ==", "cpu": [ "arm" ], @@ -1333,9 +1344,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.50.0.tgz", - "integrity": "sha512-OvXbfTjMignXWyJXg/NOFsiy996vFe8wb9tkxJaUq8ylq0XrzJg3ttavC5Tcmm6F8/GUs2r3XFJWWu9q/27uYw==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.48.0.tgz", + "integrity": "sha512-5bVdwSwlm1M8wbYCorLOxWxUBw/8tBvHYyQNIfwWVPwOJaj5vg1APSGJQVpwJfV5VNE9PSrR91UKEpoNwHhqUA==", "cpu": [ "arm64" ], @@ -1350,9 +1361,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.50.0.tgz", - "integrity": "sha512-rqmvHZm7vMa3NLYa0khwkhReCmp9tqKnF23TFZ7S5cYJLvIE4b0k8famWE7kO897/DXznJe675n5SohFBggbxA==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.48.0.tgz", + "integrity": "sha512-vCS3Fk7gFslTqE1lUE2IlroyVV7u/9SmMA/uBqDoshuck2psGWcjW0ePyPZI3rM3+qtf2pDaMVIKMHozraifuw==", "cpu": [ "arm64" ], @@ -1367,9 +1378,9 @@ } }, "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.50.0.tgz", - "integrity": "sha512-49bAdYbMSde42tzPDtuHnBWzOgmoS0PT9THCjvMnDVYMQYiHzPc2Mv5rkpBHVQOXM+PHfafJlxgK0anXSWBVvw==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.48.0.tgz", + "integrity": "sha512-gKtfFfueUClXDumyoHUbymqRf7prHejOOyzJK0eIJn93GF9JBdFHdo60TM1ZBHxkEwZvjuOgHmKtneKbEOc/Eg==", "cpu": [ "ppc64" ], @@ -1384,9 +1395,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.50.0.tgz", - "integrity": "sha512-VFT25/6kckkIM62KeWB2bi+xCEmC/zC+DcMaIpEfaio8ulkGDLSiTz11TyK0eqgTl3x5OklYEGDWohvAgOr8Bw==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.48.0.tgz", + "integrity": "sha512-SYt0UhOvZD/UwZz9sXq6J2uAw8o24f5VZpLB2DH01f6MevshmlgakQlZe2lwek2sZJkd07eLu7mZa0g7yeiw7Q==", "cpu": [ "riscv64" ], @@ -1401,9 +1412,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.50.0.tgz", - "integrity": "sha512-BBJMuNy6jjkXjUUINF5UTQqb/nvjmtJad43Gp7bab0AAURAdthhJvduR7rHpWInpWYiaMzYsdrmURNcrmpxdZA==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.48.0.tgz", + "integrity": "sha512-JLbrwck2AopG4ud/XklZO5N+qxGC7cS7ROvXZVNfx0MCLDDL2kGOLvzuWORkVjnjAM0CMAfIMU2zNBtQbM+4dw==", "cpu": [ "riscv64" ], @@ -1418,9 +1429,9 @@ } }, "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.50.0.tgz", - "integrity": "sha512-Xd4y+yjAYHKmryXhyUUwbyRD01iKfcvI74iE01L6p4F8SwjhZQXDshK+T8PcrPZLiFqH263P5xqJk94amjkjzQ==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.48.0.tgz", + "integrity": "sha512-mdxt5L8OQLxkQH+JVpdC/lknZNe0lX4hlO3d8+xvw2wToo+iDrid9tiGOd5bmHfUVd5wVhrUry0qlu5vq66NkQ==", "cpu": [ "s390x" ], @@ -1435,9 +1446,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.50.0.tgz", - "integrity": "sha512-Qp96rYJru7l++7mk4R+eh8qq9GFfFAMdmoN6VGoRHI8AA1XMnUIzH4u+zOcKZZwY+irHdsaBldDearwB4nOH7A==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.48.0.tgz", + "integrity": "sha512-oEz1BQwMrV7OMEFx/3VPDU3n9TM0AnxpktDYXjEg5i6nTX87wo18wSfBvkl4tzAICdKtoAQAdBIl7Y7hsPlx5w==", "cpu": [ "x64" ], @@ -1452,9 +1463,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.50.0.tgz", - "integrity": "sha512-5XLGp+yd5w2Key5LMqJO+X3XVsJKgeeUKljy32+MBF/J/JZ5m8WHl6dI5eOQOr3ixopxPiXIyDAxn3slI3UXiQ==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.48.0.tgz", + "integrity": "sha512-g2SKTTurP5mWjd8Ecait0erYqmltL4IqW1EwttM25BxM6NiTt4ubobJYMR1uox1V2QgG4UfHH10CGRvWlUixjw==", "cpu": [ "x64" ], @@ -1469,9 +1480,9 @@ } }, "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.50.0.tgz", - "integrity": "sha512-QAxwzh7+GHugCD7WuERolVs8TKQwXNIAZXAHHTecbKVc9oWBkWzOiLauQuezXS57tVcof5zhi1IjZ8tOV0htTg==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.48.0.tgz", + "integrity": "sha512-CIg24VgheEpvolHL2gQuax5qcQ602bRMHrJ9g8XsQr3iVj9aSPgopigBKuMqrXsupwkrU+RQCn5cG8PgFntR6w==", "cpu": [ "arm64" ], @@ -1486,9 +1497,9 @@ } }, "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.50.0.tgz", - "integrity": "sha512-3nKN/kqClm9iCFWTwtJ9UpR5SGyExp5l3nw6uIiBt+3XitQtszin+vjHrL7JHfDksZ7Svigdaow2zqz/IKCfqw==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.48.0.tgz", + "integrity": "sha512-zeaWkcxcEULwkGF3I/HgEvcDPN8buYDrxibBUa/IFh5Vmwyge+KpLO+hEwSovW349H0O/C0Z2kaFmEzEDm00/Q==", "cpu": [ "arm64" ], @@ -1503,9 +1514,9 @@ } }, "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.50.0.tgz", - "integrity": "sha512-3r6XZ8+X6qlLbXaPW2NygfiAWSpKbkE36pAVzS83mY+cYY+pSMalJ+qnCgkr92tr+Iqv988XKQ1CpARTg9ITbQ==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.48.0.tgz", + "integrity": "sha512-yiEKnIAGvx5CyZQOlMaNlZkAbwT7/Quk0j3WLt+PR5hK+qYjPTRRJYDfD77wCBPLvEYAG41v4KG3iL0H+uxoxg==", "cpu": [ "ia32" ], @@ -1520,9 +1531,9 @@ } }, "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.50.0.tgz", - "integrity": "sha512-BSE8D8KsvquMG9vU+Qt4qGuoOcZ36rxU5S6ZkHNguj+MlWkXWCBETnno3yJ9CfWvfCrbmieaN9LK6hdcdHNZ/w==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.48.0.tgz", + "integrity": "sha512-GSD2+7t2UoVMV2NgxXypa4bKewflPMAjYnF0Xw9/ht82ZfafAHhb8STwrEd7wlH2PFogt5zw3WVCxYJaHUdbeQ==", "cpu": [ "x64" ], @@ -1537,9 +1548,9 @@ } }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.65.0.tgz", - "integrity": "sha512-jDVaGNURT5pEA9qcabh6WusIoBNybOMMDPCx+EFt+gxo6rVvoUf0+73Xy5x81+ZrxU+ewk5uRBYifjy5pgkcnA==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.63.0.tgz", + "integrity": "sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==", "cpu": [ "arm" ], @@ -1554,9 +1565,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.65.0.tgz", - "integrity": "sha512-v0z80IWNA7c9RhUydq9YprBxCVZrQ6Ixls2tdxUC1F/1FFqSfa7xTX+EJf0mj6+BKRg2zWXqWfcbJUnETlLlIw==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.63.0.tgz", + "integrity": "sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A==", "cpu": [ "arm64" ], @@ -1571,9 +1582,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.65.0.tgz", - "integrity": "sha512-pL/mG/5gMzBwp1gdc5+Cwi87F9j3XRnPxHGyVj5Zd+dCEV5YkKt0L70PB3EGmEEHxgn4H+jnMS3xLuXs6mZW/Q==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.63.0.tgz", + "integrity": "sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g==", "cpu": [ "arm64" ], @@ -1588,9 +1599,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.65.0.tgz", - "integrity": "sha512-jVTneaeuHtqTrKYnhrdH1buhnSorinvpy1sv43ayclfWx/e/DfdRWv+h1fopJcHQbYr5WMcZMmDvnfEBkPZ+1A==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.63.0.tgz", + "integrity": "sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw==", "cpu": [ "x64" ], @@ -1605,9 +1616,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.65.0.tgz", - "integrity": "sha512-8lJQ7B6RloYDUhwVdbSpwT2eKsCN5KP1Scn18ly1tytCuhXhbs0nkfKHT4jWWZBJqmynWuzd+78bF7wILrj6pw==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.63.0.tgz", + "integrity": "sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA==", "cpu": [ "x64" ], @@ -1622,9 +1633,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.65.0.tgz", - "integrity": "sha512-EgmZY+DeWhLLEnNl70/49j3ltA8I6X9kxMfexupWi2Vwfp6RonGsBaHtGoedLolaU37ne7eDUgoxa3CFB95GZA==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.63.0.tgz", + "integrity": "sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w==", "cpu": [ "arm" ], @@ -1639,9 +1650,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.65.0.tgz", - "integrity": "sha512-OJMWmAYRVBCPPxnYr3j5sXRwHPh1bAuMlTStGco1Z8q3HkvSH4h+A10E9MiRNYmLhUuli5a2P5wmfj8cagiF5Q==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.63.0.tgz", + "integrity": "sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg==", "cpu": [ "arm" ], @@ -1656,9 +1667,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.65.0.tgz", - "integrity": "sha512-D8uNi50LsYKgS0vGARZDRx05TBZeSxAVdLGddSEqQLSU7xsiqdImHPEw55xq8sKA5rCc/4au/5uS7FQALWdLCg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.63.0.tgz", + "integrity": "sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ==", "cpu": [ "arm64" ], @@ -1673,9 +1684,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.65.0.tgz", - "integrity": "sha512-IpbA8QGbwFehQhO+YaHwmoI81f93xvywpspf8HrdPCWOIeKwYfM1dhVhO4YKfZewTRRQEPY/JFjTOXTgkwhKrA==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.63.0.tgz", + "integrity": "sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==", "cpu": [ "arm64" ], @@ -1690,9 +1701,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.65.0.tgz", - "integrity": "sha512-ZSe8HgaZdgyHSv2+/pTG68z10+OarB18CkFKQOhRs3lmmP/p2vuigedK2e9d0ztoG2DU/duJzhxXBSjy/492HQ==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.63.0.tgz", + "integrity": "sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==", "cpu": [ "ppc64" ], @@ -1707,9 +1718,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.65.0.tgz", - "integrity": "sha512-DcTERf++v6HyPHukKAr0JFTRqB+YeDEvqzRgNDMaz7jITPf+tlJIwRxodlAqoXMYhNVEZhXdQM5RAAYH8/oPuw==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.63.0.tgz", + "integrity": "sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==", "cpu": [ "riscv64" ], @@ -1724,9 +1735,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.65.0.tgz", - "integrity": "sha512-xjhMwuFJwRh40NOBzol4gM5gqAa0xPCJU+GQLM6BydV8TbfkIA7JeyCFNhyfbE9Q/5EWcKYTx62R0cRcjP7DAA==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.63.0.tgz", + "integrity": "sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==", "cpu": [ "riscv64" ], @@ -1741,9 +1752,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.65.0.tgz", - "integrity": "sha512-lrWSXb8JzboPWYBG6Kunt/eemvjo2oCFXktShsm3yMToY7HjzKLjxh7CljSvGnnZH9oohNFHOKc9xYpGKCPm6w==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.63.0.tgz", + "integrity": "sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==", "cpu": [ "s390x" ], @@ -1758,9 +1769,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.65.0.tgz", - "integrity": "sha512-A7xfghw250m4a1sPV+q44Mow2G5bhiC9FBvhAuIhJS6QovWnqzuL5AFQPEuwOB+PM4DhABkqxVa3Iwe3Y/nFlQ==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.63.0.tgz", + "integrity": "sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==", "cpu": [ "x64" ], @@ -1775,9 +1786,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.65.0.tgz", - "integrity": "sha512-reqOun1+pWO3fW6cv7bsa8hHG0TN3t/82qPdaoJo90FwugXiMjKhZMChmH5Z01cFNRHmxN4+543Fy8478cM/iA==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.63.0.tgz", + "integrity": "sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==", "cpu": [ "x64" ], @@ -1792,9 +1803,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.65.0.tgz", - "integrity": "sha512-KQpqOb/juDBO0xyloDkVDhOVxDUgAfZ2OAAVq99TJScJDzT319xry1QzB9LQohV9QGnA7p6m/XATZkMXc84lwA==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.63.0.tgz", + "integrity": "sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==", "cpu": [ "arm64" ], @@ -1809,9 +1820,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.65.0.tgz", - "integrity": "sha512-xfqcOc3nJFeAd1kDY4T9d3XeJIhr00twaaW0kOAzGPyUHkruXtNJv6zz1Ra9fRtSek5VpW2Yoj5AcwPIlT0ZiQ==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.63.0.tgz", + "integrity": "sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg==", "cpu": [ "arm64" ], @@ -1826,9 +1837,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.65.0.tgz", - "integrity": "sha512-JV+pXm45p8sdgs3c7LOPAohW23optCNZETFOXUcjn6cS4PYZhEU/RI54Z5dHdMudab3nw7T48PZILthM+Q0COQ==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.63.0.tgz", + "integrity": "sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w==", "cpu": [ "ia32" ], @@ -1843,9 +1854,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.65.0.tgz", - "integrity": "sha512-D7L/oBbskLss21bYrRbFuIs81AiSQV+wRzwck54dOkHIlq2qu1xjLz8u6jCqGH8Fltk8bB5DLBpVhE7v/fA8XQ==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.63.0.tgz", + "integrity": "sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA==", "cpu": [ "x64" ], @@ -1926,6 +1937,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1942,6 +1954,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1958,6 +1971,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1974,6 +1988,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1990,6 +2005,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2006,6 +2022,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2022,6 +2039,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2038,6 +2056,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2054,6 +2073,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2070,6 +2090,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2086,6 +2107,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2102,6 +2124,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2118,6 +2141,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2136,6 +2160,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2152,6 +2177,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2932,6 +2958,7 @@ "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": { @@ -2970,13 +2997,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.8.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", - "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", - "devOptional": true, + "version": "25.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz", + "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", + "dev": true, "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~7.19.0" } }, "node_modules/@types/normalize-package-data": { @@ -2987,16 +3014,16 @@ "license": "MIT" }, "node_modules/@vitest/expect": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", - "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3005,13 +3032,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", - "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.6", + "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3032,9 +3059,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", - "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3045,13 +3072,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", - "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.6", + "@vitest/utils": "4.1.5", "pathe": "^2.0.3" }, "funding": { @@ -3059,14 +3086,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", - "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3075,9 +3102,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", - "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", "devOptional": true, "license": "MIT", "funding": { @@ -3085,13 +3112,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", - "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", + "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4545,6 +4572,7 @@ "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, @@ -5459,6 +5487,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5479,6 +5508,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5499,6 +5529,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5519,6 +5550,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5539,6 +5571,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5559,6 +5592,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5579,6 +5613,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5599,6 +5634,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5619,6 +5655,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5639,6 +5676,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5659,6 +5697,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8058,15 +8097,6 @@ "node": ">=18" } }, - "node_modules/oauth4webapi": { - "version": "3.8.6", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", - "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8129,9 +8159,9 @@ } }, "node_modules/oxfmt": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.50.0.tgz", - "integrity": "sha512-owwjTnhfM5aCOJhYeqDvk7iM504OeYFZpdRU7cxx7xtZMo4uVpjlryTUon+Cf76CugsvnqA32e6rC73pr1hXaw==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.48.0.tgz", + "integrity": "sha512-AVaLh+7XeGx+R1zfFV+f6VV61nT2MWVJXVUDhbTm5LBWGyNt64xAyh3NYYyjeY2WykNt9AvqSQLPHcbWquYF9g==", "dev": true, "license": "MIT", "dependencies": { @@ -8147,39 +8177,31 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.50.0", - "@oxfmt/binding-android-arm64": "0.50.0", - "@oxfmt/binding-darwin-arm64": "0.50.0", - "@oxfmt/binding-darwin-x64": "0.50.0", - "@oxfmt/binding-freebsd-x64": "0.50.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.50.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.50.0", - "@oxfmt/binding-linux-arm64-gnu": "0.50.0", - "@oxfmt/binding-linux-arm64-musl": "0.50.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.50.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.50.0", - "@oxfmt/binding-linux-riscv64-musl": "0.50.0", - "@oxfmt/binding-linux-s390x-gnu": "0.50.0", - "@oxfmt/binding-linux-x64-gnu": "0.50.0", - "@oxfmt/binding-linux-x64-musl": "0.50.0", - "@oxfmt/binding-openharmony-arm64": "0.50.0", - "@oxfmt/binding-win32-arm64-msvc": "0.50.0", - "@oxfmt/binding-win32-ia32-msvc": "0.50.0", - "@oxfmt/binding-win32-x64-msvc": "0.50.0" - }, - "peerDependencies": { - "svelte": "^5.0.0" - }, - "peerDependenciesMeta": { - "svelte": { - "optional": true - } + "@oxfmt/binding-android-arm-eabi": "0.48.0", + "@oxfmt/binding-android-arm64": "0.48.0", + "@oxfmt/binding-darwin-arm64": "0.48.0", + "@oxfmt/binding-darwin-x64": "0.48.0", + "@oxfmt/binding-freebsd-x64": "0.48.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.48.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.48.0", + "@oxfmt/binding-linux-arm64-gnu": "0.48.0", + "@oxfmt/binding-linux-arm64-musl": "0.48.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.48.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.48.0", + "@oxfmt/binding-linux-riscv64-musl": "0.48.0", + "@oxfmt/binding-linux-s390x-gnu": "0.48.0", + "@oxfmt/binding-linux-x64-gnu": "0.48.0", + "@oxfmt/binding-linux-x64-musl": "0.48.0", + "@oxfmt/binding-openharmony-arm64": "0.48.0", + "@oxfmt/binding-win32-arm64-msvc": "0.48.0", + "@oxfmt/binding-win32-ia32-msvc": "0.48.0", + "@oxfmt/binding-win32-x64-msvc": "0.48.0" } }, "node_modules/oxlint": { - "version": "1.65.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.65.0.tgz", - "integrity": "sha512-ChUuE3Q7XnAbscvT4XLMsH7HFJmLgLVv9lu+RRgFL5wSXnDqUOzTp5IS8qWDBGd/ZDSzQ2tbX8fjAmijlGLC7A==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.63.0.tgz", + "integrity": "sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg==", "dev": true, "license": "MIT", "bin": { @@ -8192,25 +8214,25 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.65.0", - "@oxlint/binding-android-arm64": "1.65.0", - "@oxlint/binding-darwin-arm64": "1.65.0", - "@oxlint/binding-darwin-x64": "1.65.0", - "@oxlint/binding-freebsd-x64": "1.65.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.65.0", - "@oxlint/binding-linux-arm-musleabihf": "1.65.0", - "@oxlint/binding-linux-arm64-gnu": "1.65.0", - "@oxlint/binding-linux-arm64-musl": "1.65.0", - "@oxlint/binding-linux-ppc64-gnu": "1.65.0", - "@oxlint/binding-linux-riscv64-gnu": "1.65.0", - "@oxlint/binding-linux-riscv64-musl": "1.65.0", - "@oxlint/binding-linux-s390x-gnu": "1.65.0", - "@oxlint/binding-linux-x64-gnu": "1.65.0", - "@oxlint/binding-linux-x64-musl": "1.65.0", - "@oxlint/binding-openharmony-arm64": "1.65.0", - "@oxlint/binding-win32-arm64-msvc": "1.65.0", - "@oxlint/binding-win32-ia32-msvc": "1.65.0", - "@oxlint/binding-win32-x64-msvc": "1.65.0" + "@oxlint/binding-android-arm-eabi": "1.63.0", + "@oxlint/binding-android-arm64": "1.63.0", + "@oxlint/binding-darwin-arm64": "1.63.0", + "@oxlint/binding-darwin-x64": "1.63.0", + "@oxlint/binding-freebsd-x64": "1.63.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.63.0", + "@oxlint/binding-linux-arm-musleabihf": "1.63.0", + "@oxlint/binding-linux-arm64-gnu": "1.63.0", + "@oxlint/binding-linux-arm64-musl": "1.63.0", + "@oxlint/binding-linux-ppc64-gnu": "1.63.0", + "@oxlint/binding-linux-riscv64-gnu": "1.63.0", + "@oxlint/binding-linux-riscv64-musl": "1.63.0", + "@oxlint/binding-linux-s390x-gnu": "1.63.0", + "@oxlint/binding-linux-x64-gnu": "1.63.0", + "@oxlint/binding-linux-x64-musl": "1.63.0", + "@oxlint/binding-openharmony-arm64": "1.63.0", + "@oxlint/binding-win32-arm64-msvc": "1.63.0", + "@oxlint/binding-win32-ia32-msvc": "1.63.0", + "@oxlint/binding-win32-x64-msvc": "1.63.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" @@ -10068,6 +10090,7 @@ "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 }, @@ -10146,10 +10169,10 @@ } }, "node_modules/undici-types": { - "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==", - "devOptional": true, + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { @@ -10345,19 +10368,19 @@ } }, "node_modules/vitest": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", - "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.6", - "@vitest/mocker": "4.1.6", - "@vitest/pretty-format": "4.1.6", - "@vitest/runner": "4.1.6", - "@vitest/snapshot": "4.1.6", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -10385,12 +10408,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.6", - "@vitest/browser-preview": "4.1.6", - "@vitest/browser-webdriverio": "4.1.6", - "@vitest/coverage-istanbul": "4.1.6", - "@vitest/coverage-v8": "4.1.6", - "@vitest/ui": "4.1.6", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -10655,21 +10678,6 @@ "node": ">=8" } }, - "node_modules/yocto-spinner": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.0.tgz", - "integrity": "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==", - "license": "MIT", - "dependencies": { - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": ">=18.19" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", diff --git a/skills/twist-cli/SKILL.md b/skills/twist-cli/SKILL.md index 2436d07..1ee9edb 100644 --- a/skills/twist-cli/SKILL.md +++ b/skills/twist-cli/SKILL.md @@ -217,14 +217,14 @@ tw channel threads --since 2026-01-01 # Filter by last-updated date (ISO) tw channel threads --limit 20 # Max threads per page (default: 50) tw channel threads --limit 20 --cursor # Paginate tw channel threads --json # { results, nextCursor } with isUnread + url -tw channel members # List members + groups whose membership ⊆ channel -tw channel members --json # JSON with id, name, members, groupsFullyInChannel -tw channel add # Add users and/or group: (group expansion is one-shot) -tw channel add alice group:Frontend # Mix of user refs and group: prefix -tw channel add id:123 --dry-run # Preview the diff -tw channel remove # Remove users and/or group: -tw channel sync # Replace membership; dry-run by default -tw channel sync group:Squad --apply # Mutate. Refuses to remove self unless --include-self +tw channel members # List members + groups whose membership ⊆ channel +tw channel members --json # JSON with id, name, members, groupsFullyInChannel +tw channel members add # Add users and/or group: (group expansion is one-shot) +tw channel members add alice group:Frontend # Mix of user refs and group: prefix +tw channel members add id:123 --dry-run # Preview the diff +tw channel members remove # Remove users and/or group: +tw channel members set # Replace membership; dry-run by default +tw channel members set group:Squad --apply # Mutate. Refuses to remove self unless --include-self tw groups # List workspace groups tw groups --search "frontend" # Filter groups by name (case-insensitive) tw groups --json # JSON output @@ -250,7 +250,7 @@ If a channel is not found in `tw channels`, widen with broader listings such as `tw channel threads` returns every thread in the channel; pagination filters (`--limit`, `--cursor`, `--since`, `--until`, `--unread`) are applied client-side after fetch. `--archive-filter` is applied server-side. Results are sorted newest-first by last activity. In `--json` / `--ndjson`, the response includes a `nextCursor` string (opaque) you can pass via `--cursor` to fetch the next page; NDJSON emits the cursor as a final `{ "_meta": true, "nextCursor": "..." }` line. -`tw channel add`, `remove`, and `sync` accept user refs (id:N, email, name) and `group:` mixed in any order. Groups are expanded to their current users at call time — the group is **not** persistently linked to the channel, so users added later to the group will not auto-join. `sync` is dry-run by default; pass `--apply` to mutate and `--include-self` to allow the diff to remove the acting user. `tw channel members` shows the user list plus a "groups fully in channel" hint — groups whose entire membership is currently present in the channel. +`tw channel members add`, `remove`, and `set` accept user refs (id:N, email, name) and `group:` mixed in any order. Groups are expanded to their current users at call time — the group is **not** persistently linked to the channel, so users added later to the group will not auto-join. `set` replaces the channel's membership with the resolved ref set; it is dry-run by default, pass `--apply` to mutate and `--include-self` to allow the diff to remove the acting user. `tw channel members` (with no subcommand) shows the user list plus a "groups fully in channel" hint — groups whose entire membership is currently present in the channel. ## Away Status diff --git a/src/commands/channel/add.test.ts b/src/commands/channel/add.test.ts index 46596c2..915109e 100644 --- a/src/commands/channel/add.test.ts +++ b/src/commands/channel/add.test.ts @@ -56,7 +56,7 @@ beforeEach(() => { refsMocks.resolveChannelRef.mockResolvedValue(sampleChannel) }) -describe('tw channel add', () => { +describe('tw channel members add', () => { it('adds users (no group expansion)', async () => { refsMocks.resolveChannelMemberRefs.mockResolvedValue({ userIds: [4, 5], @@ -69,6 +69,7 @@ describe('tw channel add', () => { 'node', 'tw', 'channel', + 'members', 'add', 'general', 'dave@d.com', @@ -94,6 +95,7 @@ describe('tw channel add', () => { 'node', 'tw', 'channel', + 'members', 'add', 'general', 'group:Backend', @@ -114,7 +116,16 @@ describe('tw channel add', () => { const program = createProgram() vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'channel', 'add', 'general', 'id:2', 'id:4']) + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'members', + 'add', + 'general', + 'id:2', + 'id:4', + ]) expect(apiMocks.addUsersToChannel).toHaveBeenCalledWith(500, [4]) }) @@ -127,7 +138,16 @@ describe('tw channel add', () => { const program = createProgram() vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'channel', 'add', 'general', 'id:1', 'id:2']) + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'members', + 'add', + 'general', + 'id:1', + 'id:2', + ]) expect(apiMocks.addUsersToChannel).not.toHaveBeenCalled() }) @@ -144,6 +164,7 @@ describe('tw channel add', () => { 'node', 'tw', 'channel', + 'members', 'add', 'general', 'id:4', @@ -167,6 +188,7 @@ describe('tw channel add', () => { 'node', 'tw', 'channel', + 'members', 'add', 'general', 'id:2', diff --git a/src/commands/channel/index.ts b/src/commands/channel/index.ts index 8dca717..03cfad5 100644 --- a/src/commands/channel/index.ts +++ b/src/commands/channel/index.ts @@ -4,7 +4,7 @@ import { addChannelMembers } from './add.js' import { listChannels } from './list.js' import { listChannelMembers } from './members.js' import { removeChannelMembers } from './remove.js' -import { syncChannelMembers } from './sync.js' +import { setChannelMembers } from './set.js' import { showChannelThreads } from './threads.js' export function registerChannelCommand(program: Command): void { @@ -91,9 +91,13 @@ Notes: ) .action(showChannelThreads) - channel - .command('members ') - .description('List channel members and groups whose members are all in the channel') + const members = channel + .command('members') + .description('Channel membership operations (list, add, remove, set)') + + members + .command('list ', { isDefault: true }) + .description("List a channel's members and groups fully present in the channel") .option('--json', 'Output as JSON') .option('--ndjson', 'Output as newline-delimited JSON') .option('--full', 'Include all fields in JSON output') @@ -103,6 +107,9 @@ Notes: Examples: tw channel members 12345 tw channel members "general" --json + tw channel members add 12345 alice group:Design + tw channel members remove 12345 alice + tw channel members set 12345 group:Squad --apply Notes: "Groups fully in channel" lists groups whose entire current membership is @@ -110,7 +117,7 @@ Notes: ) .action(listChannelMembers) - channel + members .command('add [refs...]') .description('Add users and/or groups to a channel') .option('--dry-run', 'Show what would change without changing') @@ -120,9 +127,9 @@ Notes: 'after', ` Examples: - tw channel add 12345 alice@doist.com bob@doist.com - tw channel add "general" group:Frontend - tw channel add 12345 alice group:Design id:789 --json + tw channel members add 12345 alice@doist.com bob@doist.com + tw channel members add "general" group:Frontend + tw channel members add 12345 alice group:Design id:789 --json Notes: Refs accept user identifiers (id:N, email, name) or "group:" to expand @@ -131,7 +138,7 @@ Notes: ) .action(addChannelMembers) - channel + members .command('remove [refs...]') .description('Remove users and/or groups from a channel') .option('--dry-run', 'Show what would change without changing') @@ -141,8 +148,8 @@ Notes: 'after', ` Examples: - tw channel remove 12345 alice@doist.com - tw channel remove "general" group:Frontend + tw channel members remove 12345 alice@doist.com + tw channel members remove "general" group:Frontend Notes: Refs accept user identifiers (id:N, email, name) or "group:" to expand @@ -150,11 +157,11 @@ Notes: ) .action(removeChannelMembers) - channel - .command('sync [refs...]') + members + .command('set [refs...]') .description('Replace channel membership with the resolved set of refs') .option('--apply', 'Actually mutate (otherwise dry-run)') - .option('--include-self', 'Allow sync to remove the acting user') + .option('--include-self', 'Allow set to remove the acting user') .option('--dry-run', 'Force dry-run (default behaviour)') .option('--json', 'Output result as JSON') .option('--full', 'Include the full updated channel in JSON output') @@ -162,9 +169,9 @@ Notes: 'after', ` Examples: - tw channel sync 12345 group:Frontend group:Design - tw channel sync "general" alice bob carol --apply - tw channel sync 12345 group:Squad --apply --include-self + tw channel members set 12345 group:Frontend group:Design + tw channel members set "general" alice bob carol --apply + tw channel members set 12345 group:Squad --apply --include-self Notes: Dry-run by default. Pass --apply to mutate. @@ -172,5 +179,5 @@ Notes: Group expansion is one-shot — users added later to a referenced group will not auto-join the channel.`, ) - .action(syncChannelMembers) + .action(setChannelMembers) } diff --git a/src/commands/channel/remove.test.ts b/src/commands/channel/remove.test.ts index 09824bf..2a011b2 100644 --- a/src/commands/channel/remove.test.ts +++ b/src/commands/channel/remove.test.ts @@ -56,7 +56,7 @@ beforeEach(() => { refsMocks.resolveChannelRef.mockResolvedValue(sampleChannel) }) -describe('tw channel remove', () => { +describe('tw channel members remove', () => { it('only removes users that are members', async () => { refsMocks.resolveChannelMemberRefs.mockResolvedValue({ userIds: [2, 3, 99], @@ -69,6 +69,7 @@ describe('tw channel remove', () => { 'node', 'tw', 'channel', + 'members', 'remove', 'general', 'id:2', @@ -87,7 +88,16 @@ describe('tw channel remove', () => { const program = createProgram() vi.spyOn(console, 'log').mockImplementation(() => {}) - await program.parseAsync(['node', 'tw', 'channel', 'remove', 'general', 'id:99', 'id:100']) + await program.parseAsync([ + 'node', + 'tw', + 'channel', + 'members', + 'remove', + 'general', + 'id:99', + 'id:100', + ]) expect(apiMocks.removeUsersFromChannel).not.toHaveBeenCalled() }) @@ -104,6 +114,7 @@ describe('tw channel remove', () => { 'node', 'tw', 'channel', + 'members', 'remove', 'general', 'id:2', @@ -127,6 +138,7 @@ describe('tw channel remove', () => { 'node', 'tw', 'channel', + 'members', 'remove', 'general', 'id:2', @@ -151,6 +163,7 @@ describe('tw channel remove', () => { 'node', 'tw', 'channel', + 'members', 'remove', 'general', 'id:2', diff --git a/src/commands/channel/sync.test.ts b/src/commands/channel/set.test.ts similarity index 95% rename from src/commands/channel/sync.test.ts rename to src/commands/channel/set.test.ts index 268976b..2483846 100644 --- a/src/commands/channel/sync.test.ts +++ b/src/commands/channel/set.test.ts @@ -52,7 +52,7 @@ beforeEach(() => { refsMocks.resolveChannelRef.mockResolvedValue(sampleChannel) }) -describe('tw channel sync', () => { +describe('tw channel members set', () => { it('dry-run by default — does not mutate', async () => { refsMocks.resolveChannelMemberRefs.mockResolvedValue({ userIds: [1, 4, 5], @@ -65,7 +65,8 @@ describe('tw channel sync', () => { 'node', 'tw', 'channel', - 'sync', + 'members', + 'set', 'general', 'id:1', 'id:4', @@ -91,7 +92,8 @@ describe('tw channel sync', () => { 'node', 'tw', 'channel', - 'sync', + 'members', + 'set', 'general', 'id:1', 'id:4', @@ -116,7 +118,8 @@ describe('tw channel sync', () => { 'node', 'tw', 'channel', - 'sync', + 'members', + 'set', 'general', 'id:2', 'id:3', @@ -138,7 +141,8 @@ describe('tw channel sync', () => { 'node', 'tw', 'channel', - 'sync', + 'members', + 'set', 'general', 'id:2', 'id:3', @@ -163,7 +167,8 @@ describe('tw channel sync', () => { 'node', 'tw', 'channel', - 'sync', + 'members', + 'set', 'general', 'id:1', 'group:Backend', diff --git a/src/commands/channel/sync.ts b/src/commands/channel/set.ts similarity index 86% rename from src/commands/channel/sync.ts rename to src/commands/channel/set.ts index d352369..344b5cd 100644 --- a/src/commands/channel/sync.ts +++ b/src/commands/channel/set.ts @@ -12,15 +12,15 @@ import { formatJson, pluralize, printDryRun } from '../../lib/output.js' import { resolveChannelMemberRefs, resolveChannelRef } from '../../lib/refs.js' import { channelUserIds, describeExpansion, logExpansion } from './membership-helpers.js' -export type SyncOptions = MutationOptions & { +export type SetOptions = MutationOptions & { apply?: boolean includeSelf?: boolean } -export async function syncChannelMembers( +export async function setChannelMembers( channelRef: string, refs: string[], - options: SyncOptions, + options: SetOptions, ): Promise { const workspaceId = await getCurrentWorkspaceId() const [channel, sessionUser, memberRefs] = await Promise.all([ @@ -40,7 +40,7 @@ export async function syncChannelMembers( if (wouldRemoveSelf && !options.includeSelf) { throw new CliError( 'INVALID_VALUE', - `Sync would remove you (id:${selfId}) from "${channel.name}".`, + `Set would remove you (id:${selfId}) from "${channel.name}".`, [ 'Pass --include-self to allow removing yourself, or include yourself in the ref list.', ], @@ -54,12 +54,12 @@ export async function syncChannelMembers( const isDryRun = options.dryRun || !options.apply if (isDryRun) { - printDryRun(`sync channel membership`, { + printDryRun(`set channel membership`, { Channel: `${channel.name} (id:${channel.id})`, 'Expanded from groups': describeExpansion(expandedFrom), 'To add': toAdd.length > 0 ? toAdd.join(', ') : '(none)', 'To remove': toRemove.length > 0 ? toRemove.join(', ') : '(none)', - Note: options.apply ? undefined : 'sync is dry-run by default; pass --apply to mutate.', + Note: options.apply ? undefined : 'set is dry-run by default; pass --apply to mutate.', }) return } @@ -91,7 +91,7 @@ export async function syncChannelMembers( logExpansion(expandedFrom) console.log( - `Synced "${channel.name}": +${toAdd.length} / -${toRemove.length} (now ${newMemberCount} ${pluralize(newMemberCount, 'member')}).`, + `Set "${channel.name}": +${toAdd.length} / -${toRemove.length} (now ${newMemberCount} ${pluralize(newMemberCount, 'member')}).`, ) if (toAdd.length > 0) console.log(` Added: ${toAdd.join(', ')}`) if (toRemove.length > 0) console.log(` Removed: ${toRemove.join(', ')}`) diff --git a/src/lib/refs.ts b/src/lib/refs.ts index 2ff24e5..c08e7a4 100644 --- a/src/lib/refs.ts +++ b/src/lib/refs.ts @@ -398,7 +398,7 @@ export async function resolveGroupRef(ref: string, workspaceId: number): Promise ]) } -export interface ChannelMemberRefs { +export type ChannelMemberRefs = { userIds: number[] expandedFrom: { groupId: number; groupName: string; userIds: number[] }[] } diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 7d27aaa..b7cfa36 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -221,14 +221,14 @@ tw channel threads --since 2026-01-01 # Filter by last-updated date (ISO) tw channel threads --limit 20 # Max threads per page (default: 50) tw channel threads --limit 20 --cursor # Paginate tw channel threads --json # { results, nextCursor } with isUnread + url -tw channel members # List members + groups whose membership ⊆ channel -tw channel members --json # JSON with id, name, members, groupsFullyInChannel -tw channel add # Add users and/or group: (group expansion is one-shot) -tw channel add alice group:Frontend # Mix of user refs and group: prefix -tw channel add id:123 --dry-run # Preview the diff -tw channel remove # Remove users and/or group: -tw channel sync # Replace membership; dry-run by default -tw channel sync group:Squad --apply # Mutate. Refuses to remove self unless --include-self +tw channel members # List members + groups whose membership ⊆ channel +tw channel members --json # JSON with id, name, members, groupsFullyInChannel +tw channel members add # Add users and/or group: (group expansion is one-shot) +tw channel members add alice group:Frontend # Mix of user refs and group: prefix +tw channel members add id:123 --dry-run # Preview the diff +tw channel members remove # Remove users and/or group: +tw channel members set # Replace membership; dry-run by default +tw channel members set group:Squad --apply # Mutate. Refuses to remove self unless --include-self tw groups # List workspace groups tw groups --search "frontend" # Filter groups by name (case-insensitive) tw groups --json # JSON output @@ -254,7 +254,7 @@ If a channel is not found in \`tw channels\`, widen with broader listings such a \`tw channel threads\` returns every thread in the channel; pagination filters (\`--limit\`, \`--cursor\`, \`--since\`, \`--until\`, \`--unread\`) are applied client-side after fetch. \`--archive-filter\` is applied server-side. Results are sorted newest-first by last activity. In \`--json\` / \`--ndjson\`, the response includes a \`nextCursor\` string (opaque) you can pass via \`--cursor\` to fetch the next page; NDJSON emits the cursor as a final \`{ "_meta": true, "nextCursor": "..." }\` line. -\`tw channel add\`, \`remove\`, and \`sync\` accept user refs (id:N, email, name) and \`group:\` mixed in any order. Groups are expanded to their current users at call time — the group is **not** persistently linked to the channel, so users added later to the group will not auto-join. \`sync\` is dry-run by default; pass \`--apply\` to mutate and \`--include-self\` to allow the diff to remove the acting user. \`tw channel members\` shows the user list plus a "groups fully in channel" hint — groups whose entire membership is currently present in the channel. +\`tw channel members add\`, \`remove\`, and \`set\` accept user refs (id:N, email, name) and \`group:\` mixed in any order. Groups are expanded to their current users at call time — the group is **not** persistently linked to the channel, so users added later to the group will not auto-join. \`set\` replaces the channel's membership with the resolved ref set; it is dry-run by default, pass \`--apply\` to mutate and \`--include-self\` to allow the diff to remove the acting user. \`tw channel members\` (with no subcommand) shows the user list plus a "groups fully in channel" hint — groups whose entire membership is currently present in the channel. ## Away Status From 2c54fd5071fac803a64908a3ad8d43a9fd3cd700 Mon Sep 17 00:00:00 2001 From: lmjabreu Date: Fri, 22 May 2026 20:05:16 +0100 Subject: [PATCH 9/9] fix(deps): restore package-lock.json to match origin/main The previous reset used my local `main` ref which was stale; the resulting lockfile was inconsistent with package.json's bumped `@doist/cli-core` and `@doist/twist-sdk` versions, so CI's `npm ci` rejected it across lint, test, and SKILL.md Sync jobs. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 503 ++++++++++++++++++++++++---------------------- 1 file changed, 264 insertions(+), 239 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5dd9172..99464a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,23 @@ { "name": "@doist/twist-cli", - "version": "2.36.3", + "version": "2.42.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@doist/twist-cli", - "version": "2.36.3", + "version": "2.42.0", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@doist/cli-core": "0.10.0", - "@doist/twist-sdk": "2.5.1", + "@doist/cli-core": "0.20.0", + "@doist/twist-sdk": "2.7.0", "@pnpm/tabtab": "0.5.4", "chalk": "5.6.2", "commander": "14.0.3", "marked": "18.0.3", "marked-terminal-renderer": "2.2.0", + "oauth4webapi": "3.8.6", "open": "11.0.0" }, "bin": { @@ -26,14 +27,14 @@ "@semantic-release/changelog": "6.0.3", "@semantic-release/exec": "7.1.0", "@semantic-release/git": "10.0.1", - "@types/node": "25.6.2", + "@types/node": "25.8.0", "conventional-changelog-conventionalcommits": "9.3.1", "lefthook": "2.1.6", - "oxfmt": "0.48.0", - "oxlint": "1.63.0", + "oxfmt": "0.50.0", + "oxlint": "1.65.0", "semantic-release": "25.0.3", "typescript": "6.0.3", - "vitest": "4.1.5" + "vitest": "4.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -137,21 +138,25 @@ } }, "node_modules/@doist/cli-core": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@doist/cli-core/-/cli-core-0.10.0.tgz", - "integrity": "sha512-ya/+G0fJ8s+KJxIKsftKUAFGSHKFPGSPfNYJY961xoIuz1F+fm575vesPzWxaCRbmD6Z3vaiXUAxxG7SyfmgLQ==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@doist/cli-core/-/cli-core-0.20.0.tgz", + "integrity": "sha512-18Mh68yoVND+kKKtFHggEqR+eLqA9gl5jSUOFE0uqjbHBzOAb6VBzwvYVocceZaVRxF3j/Pu+Rbkpwta5Lkvww==", "license": "MIT", "dependencies": { "chalk": "5.6.2", - "yocto-spinner": "1.1.0" + "yocto-spinner": "1.2.0" }, "engines": { "node": ">=20.18.1" }, + "optionalDependencies": { + "@napi-rs/keyring": "1.3.0" + }, "peerDependencies": { "commander": ">=14", "marked": ">=18", "marked-terminal-renderer": ">=2", + "oauth4webapi": ">=3", "open": ">=10", "vitest": ">=4.1" }, @@ -165,6 +170,9 @@ "marked-terminal-renderer": { "optional": true }, + "oauth4webapi": { + "optional": true + }, "open": { "optional": true }, @@ -173,25 +181,10 @@ } } }, - "node_modules/@doist/cli-core/node_modules/yocto-spinner": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.1.0.tgz", - "integrity": "sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==", - "license": "MIT", - "dependencies": { - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": ">=18.19" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@doist/twist-sdk": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@doist/twist-sdk/-/twist-sdk-2.5.1.tgz", - "integrity": "sha512-e1DDJAPdIcqhpeZz1MB797S7suZW8rUurKxcpb4/oIYVaFzSllZw8EoFtyOlz6KcxWgcrtNECzIOmCwXhMfWXg==", + "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==", "license": "MIT", "dependencies": { "camelcase": "8.0.0", @@ -1225,9 +1218,9 @@ } }, "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.48.0.tgz", - "integrity": "sha512-uwqk+/KhQvBIpULD8SMM/zAafMRC/+DV/xsEQjkkIsJ/kLmEI/2bxonVowcYTiXqqZ/a0FEW8DPkZY3VvwELDA==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.50.0.tgz", + "integrity": "sha512-ICXQVKrDvsWUtfx6EiVJxfWrajKTwTfRV8vz2XiMkxZeuCKJLgD4YAj6dE3BWvpqDlkVkie4VSTAtMUWO9LDXg==", "cpu": [ "arm" ], @@ -1242,9 +1235,9 @@ } }, "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.48.0.tgz", - "integrity": "sha512-VUCiKuXK5+McVssgHEJdrcGK7hRJzrRb36zm9/jwzMholyYt4BgXhw5Nm1V1DX6Ce717Zi/1jk432b/tgmQgtQ==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.50.0.tgz", + "integrity": "sha512-quwjLQFkuW6OwLHeDeIXsTzOmipQFQbqsYN9HLk2B5I01IlAQZHP1UiLIg0O7pP+dUgPD2AD7SCYA3gs6NH5/g==", "cpu": [ "arm64" ], @@ -1259,9 +1252,9 @@ } }, "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.48.0.tgz", - "integrity": "sha512-IkKp8rnIyQLW6Jt+6jragCbUVYSayk55lapiprLjIVvt4NczLyO/nwX2GgefLQ5iaBdfS8UEAFgCs/pLO6Cl0w==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.50.0.tgz", + "integrity": "sha512-ikU5umElcMi78/TNI334wtjr5WZ5F4nWa1aIDseAKKGL0W3ygxeYKkrIJ0fggWa8MOon66BmG3xCqmX1m9YAOw==", "cpu": [ "arm64" ], @@ -1276,9 +1269,9 @@ } }, "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.48.0.tgz", - "integrity": "sha512-+aFuhsGIuvnoOjXyKVHMhPKJZR1kQkAl8QyrKoMlA7yJsSTC3N0Asl53La8TChSHhW8epToQ/Q0nvLmEmfNmLg==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.50.0.tgz", + "integrity": "sha512-WT4MOYG4mv9IXrH0m60vHsJh+rRMPSOKTQmwDpwmgQ+DuW/i5dU4pqc0HDO5uclO5vjz5IFX5z/taW86LSVe/g==", "cpu": [ "x64" ], @@ -1293,9 +1286,9 @@ } }, "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.48.0.tgz", - "integrity": "sha512-fbqzQL8FjI9gGnktI7RIo0dksDziTAYBy7xlI7jU7eID5fxLF/25fS4Xj6GydD8Y5oWHL83U4NK160QaOAxtyg==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.50.0.tgz", + "integrity": "sha512-gH0rycVXqV4juWkvLs2uPMtTyppDc7qEUVzXAxnQ7FpcSZNXqKowUgtjH8q67ngj416r8+4NnAlyR/D35zwwhQ==", "cpu": [ "x64" ], @@ -1310,9 +1303,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.48.0.tgz", - "integrity": "sha512-hn4i0zhAyTiB3ZHjQfYUZkDvrbVkohw1S7pySWxWUoZ87HnkDoTFThj7QTxk40hNPOTUP0vHbPRNamFIv1HBJQ==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.50.0.tgz", + "integrity": "sha512-wL/k+o0hiTeRvi/gPzeC1L/yTHTXIeHDKWU09s2zTBmv7ma59wTm+fADNSGYxhJQDxyavQbwTf1QpW3Zj924tQ==", "cpu": [ "arm" ], @@ -1327,9 +1320,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.48.0.tgz", - "integrity": "sha512-R4WBD9qF3QM9hqgdAa+fBGXmquTvDUujrPQ36t2Sjk8RPOSKGHDeN7l/khr10hqbQaOq9KCgPHG9ubNET/X/RQ==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.50.0.tgz", + "integrity": "sha512-Y59FKqoUM3Gf00E395b4ixfWyJGwO2GzaZawF5MZoVWcb3f6CkWUXyao0jyOvoIxDMzMybcVRuXyG7ih/Nxweg==", "cpu": [ "arm" ], @@ -1344,9 +1337,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.48.0.tgz", - "integrity": "sha512-5bVdwSwlm1M8wbYCorLOxWxUBw/8tBvHYyQNIfwWVPwOJaj5vg1APSGJQVpwJfV5VNE9PSrR91UKEpoNwHhqUA==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.50.0.tgz", + "integrity": "sha512-OvXbfTjMignXWyJXg/NOFsiy996vFe8wb9tkxJaUq8ylq0XrzJg3ttavC5Tcmm6F8/GUs2r3XFJWWu9q/27uYw==", "cpu": [ "arm64" ], @@ -1361,9 +1354,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.48.0.tgz", - "integrity": "sha512-vCS3Fk7gFslTqE1lUE2IlroyVV7u/9SmMA/uBqDoshuck2psGWcjW0ePyPZI3rM3+qtf2pDaMVIKMHozraifuw==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.50.0.tgz", + "integrity": "sha512-rqmvHZm7vMa3NLYa0khwkhReCmp9tqKnF23TFZ7S5cYJLvIE4b0k8famWE7kO897/DXznJe675n5SohFBggbxA==", "cpu": [ "arm64" ], @@ -1378,9 +1371,9 @@ } }, "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.48.0.tgz", - "integrity": "sha512-gKtfFfueUClXDumyoHUbymqRf7prHejOOyzJK0eIJn93GF9JBdFHdo60TM1ZBHxkEwZvjuOgHmKtneKbEOc/Eg==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.50.0.tgz", + "integrity": "sha512-49bAdYbMSde42tzPDtuHnBWzOgmoS0PT9THCjvMnDVYMQYiHzPc2Mv5rkpBHVQOXM+PHfafJlxgK0anXSWBVvw==", "cpu": [ "ppc64" ], @@ -1395,9 +1388,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.48.0.tgz", - "integrity": "sha512-SYt0UhOvZD/UwZz9sXq6J2uAw8o24f5VZpLB2DH01f6MevshmlgakQlZe2lwek2sZJkd07eLu7mZa0g7yeiw7Q==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.50.0.tgz", + "integrity": "sha512-VFT25/6kckkIM62KeWB2bi+xCEmC/zC+DcMaIpEfaio8ulkGDLSiTz11TyK0eqgTl3x5OklYEGDWohvAgOr8Bw==", "cpu": [ "riscv64" ], @@ -1412,9 +1405,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.48.0.tgz", - "integrity": "sha512-JLbrwck2AopG4ud/XklZO5N+qxGC7cS7ROvXZVNfx0MCLDDL2kGOLvzuWORkVjnjAM0CMAfIMU2zNBtQbM+4dw==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.50.0.tgz", + "integrity": "sha512-BBJMuNy6jjkXjUUINF5UTQqb/nvjmtJad43Gp7bab0AAURAdthhJvduR7rHpWInpWYiaMzYsdrmURNcrmpxdZA==", "cpu": [ "riscv64" ], @@ -1429,9 +1422,9 @@ } }, "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.48.0.tgz", - "integrity": "sha512-mdxt5L8OQLxkQH+JVpdC/lknZNe0lX4hlO3d8+xvw2wToo+iDrid9tiGOd5bmHfUVd5wVhrUry0qlu5vq66NkQ==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.50.0.tgz", + "integrity": "sha512-Xd4y+yjAYHKmryXhyUUwbyRD01iKfcvI74iE01L6p4F8SwjhZQXDshK+T8PcrPZLiFqH263P5xqJk94amjkjzQ==", "cpu": [ "s390x" ], @@ -1446,9 +1439,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.48.0.tgz", - "integrity": "sha512-oEz1BQwMrV7OMEFx/3VPDU3n9TM0AnxpktDYXjEg5i6nTX87wo18wSfBvkl4tzAICdKtoAQAdBIl7Y7hsPlx5w==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.50.0.tgz", + "integrity": "sha512-Qp96rYJru7l++7mk4R+eh8qq9GFfFAMdmoN6VGoRHI8AA1XMnUIzH4u+zOcKZZwY+irHdsaBldDearwB4nOH7A==", "cpu": [ "x64" ], @@ -1463,9 +1456,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.48.0.tgz", - "integrity": "sha512-g2SKTTurP5mWjd8Ecait0erYqmltL4IqW1EwttM25BxM6NiTt4ubobJYMR1uox1V2QgG4UfHH10CGRvWlUixjw==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.50.0.tgz", + "integrity": "sha512-5XLGp+yd5w2Key5LMqJO+X3XVsJKgeeUKljy32+MBF/J/JZ5m8WHl6dI5eOQOr3ixopxPiXIyDAxn3slI3UXiQ==", "cpu": [ "x64" ], @@ -1480,9 +1473,9 @@ } }, "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.48.0.tgz", - "integrity": "sha512-CIg24VgheEpvolHL2gQuax5qcQ602bRMHrJ9g8XsQr3iVj9aSPgopigBKuMqrXsupwkrU+RQCn5cG8PgFntR6w==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.50.0.tgz", + "integrity": "sha512-QAxwzh7+GHugCD7WuERolVs8TKQwXNIAZXAHHTecbKVc9oWBkWzOiLauQuezXS57tVcof5zhi1IjZ8tOV0htTg==", "cpu": [ "arm64" ], @@ -1497,9 +1490,9 @@ } }, "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.48.0.tgz", - "integrity": "sha512-zeaWkcxcEULwkGF3I/HgEvcDPN8buYDrxibBUa/IFh5Vmwyge+KpLO+hEwSovW349H0O/C0Z2kaFmEzEDm00/Q==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.50.0.tgz", + "integrity": "sha512-3nKN/kqClm9iCFWTwtJ9UpR5SGyExp5l3nw6uIiBt+3XitQtszin+vjHrL7JHfDksZ7Svigdaow2zqz/IKCfqw==", "cpu": [ "arm64" ], @@ -1514,9 +1507,9 @@ } }, "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.48.0.tgz", - "integrity": "sha512-yiEKnIAGvx5CyZQOlMaNlZkAbwT7/Quk0j3WLt+PR5hK+qYjPTRRJYDfD77wCBPLvEYAG41v4KG3iL0H+uxoxg==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.50.0.tgz", + "integrity": "sha512-3r6XZ8+X6qlLbXaPW2NygfiAWSpKbkE36pAVzS83mY+cYY+pSMalJ+qnCgkr92tr+Iqv988XKQ1CpARTg9ITbQ==", "cpu": [ "ia32" ], @@ -1531,9 +1524,9 @@ } }, "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.48.0.tgz", - "integrity": "sha512-GSD2+7t2UoVMV2NgxXypa4bKewflPMAjYnF0Xw9/ht82ZfafAHhb8STwrEd7wlH2PFogt5zw3WVCxYJaHUdbeQ==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.50.0.tgz", + "integrity": "sha512-BSE8D8KsvquMG9vU+Qt4qGuoOcZ36rxU5S6ZkHNguj+MlWkXWCBETnno3yJ9CfWvfCrbmieaN9LK6hdcdHNZ/w==", "cpu": [ "x64" ], @@ -1548,9 +1541,9 @@ } }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.63.0.tgz", - "integrity": "sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.65.0.tgz", + "integrity": "sha512-jDVaGNURT5pEA9qcabh6WusIoBNybOMMDPCx+EFt+gxo6rVvoUf0+73Xy5x81+ZrxU+ewk5uRBYifjy5pgkcnA==", "cpu": [ "arm" ], @@ -1565,9 +1558,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.63.0.tgz", - "integrity": "sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.65.0.tgz", + "integrity": "sha512-v0z80IWNA7c9RhUydq9YprBxCVZrQ6Ixls2tdxUC1F/1FFqSfa7xTX+EJf0mj6+BKRg2zWXqWfcbJUnETlLlIw==", "cpu": [ "arm64" ], @@ -1582,9 +1575,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.63.0.tgz", - "integrity": "sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.65.0.tgz", + "integrity": "sha512-pL/mG/5gMzBwp1gdc5+Cwi87F9j3XRnPxHGyVj5Zd+dCEV5YkKt0L70PB3EGmEEHxgn4H+jnMS3xLuXs6mZW/Q==", "cpu": [ "arm64" ], @@ -1599,9 +1592,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.63.0.tgz", - "integrity": "sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.65.0.tgz", + "integrity": "sha512-jVTneaeuHtqTrKYnhrdH1buhnSorinvpy1sv43ayclfWx/e/DfdRWv+h1fopJcHQbYr5WMcZMmDvnfEBkPZ+1A==", "cpu": [ "x64" ], @@ -1616,9 +1609,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.63.0.tgz", - "integrity": "sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.65.0.tgz", + "integrity": "sha512-8lJQ7B6RloYDUhwVdbSpwT2eKsCN5KP1Scn18ly1tytCuhXhbs0nkfKHT4jWWZBJqmynWuzd+78bF7wILrj6pw==", "cpu": [ "x64" ], @@ -1633,9 +1626,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.63.0.tgz", - "integrity": "sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.65.0.tgz", + "integrity": "sha512-EgmZY+DeWhLLEnNl70/49j3ltA8I6X9kxMfexupWi2Vwfp6RonGsBaHtGoedLolaU37ne7eDUgoxa3CFB95GZA==", "cpu": [ "arm" ], @@ -1650,9 +1643,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.63.0.tgz", - "integrity": "sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.65.0.tgz", + "integrity": "sha512-OJMWmAYRVBCPPxnYr3j5sXRwHPh1bAuMlTStGco1Z8q3HkvSH4h+A10E9MiRNYmLhUuli5a2P5wmfj8cagiF5Q==", "cpu": [ "arm" ], @@ -1667,9 +1660,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.63.0.tgz", - "integrity": "sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.65.0.tgz", + "integrity": "sha512-D8uNi50LsYKgS0vGARZDRx05TBZeSxAVdLGddSEqQLSU7xsiqdImHPEw55xq8sKA5rCc/4au/5uS7FQALWdLCg==", "cpu": [ "arm64" ], @@ -1684,9 +1677,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.63.0.tgz", - "integrity": "sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.65.0.tgz", + "integrity": "sha512-IpbA8QGbwFehQhO+YaHwmoI81f93xvywpspf8HrdPCWOIeKwYfM1dhVhO4YKfZewTRRQEPY/JFjTOXTgkwhKrA==", "cpu": [ "arm64" ], @@ -1701,9 +1694,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.63.0.tgz", - "integrity": "sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.65.0.tgz", + "integrity": "sha512-ZSe8HgaZdgyHSv2+/pTG68z10+OarB18CkFKQOhRs3lmmP/p2vuigedK2e9d0ztoG2DU/duJzhxXBSjy/492HQ==", "cpu": [ "ppc64" ], @@ -1718,9 +1711,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.63.0.tgz", - "integrity": "sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.65.0.tgz", + "integrity": "sha512-DcTERf++v6HyPHukKAr0JFTRqB+YeDEvqzRgNDMaz7jITPf+tlJIwRxodlAqoXMYhNVEZhXdQM5RAAYH8/oPuw==", "cpu": [ "riscv64" ], @@ -1735,9 +1728,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.63.0.tgz", - "integrity": "sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.65.0.tgz", + "integrity": "sha512-xjhMwuFJwRh40NOBzol4gM5gqAa0xPCJU+GQLM6BydV8TbfkIA7JeyCFNhyfbE9Q/5EWcKYTx62R0cRcjP7DAA==", "cpu": [ "riscv64" ], @@ -1752,9 +1745,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.63.0.tgz", - "integrity": "sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.65.0.tgz", + "integrity": "sha512-lrWSXb8JzboPWYBG6Kunt/eemvjo2oCFXktShsm3yMToY7HjzKLjxh7CljSvGnnZH9oohNFHOKc9xYpGKCPm6w==", "cpu": [ "s390x" ], @@ -1769,9 +1762,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.63.0.tgz", - "integrity": "sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.65.0.tgz", + "integrity": "sha512-A7xfghw250m4a1sPV+q44Mow2G5bhiC9FBvhAuIhJS6QovWnqzuL5AFQPEuwOB+PM4DhABkqxVa3Iwe3Y/nFlQ==", "cpu": [ "x64" ], @@ -1786,9 +1779,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.63.0.tgz", - "integrity": "sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.65.0.tgz", + "integrity": "sha512-reqOun1+pWO3fW6cv7bsa8hHG0TN3t/82qPdaoJo90FwugXiMjKhZMChmH5Z01cFNRHmxN4+543Fy8478cM/iA==", "cpu": [ "x64" ], @@ -1803,9 +1796,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.63.0.tgz", - "integrity": "sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.65.0.tgz", + "integrity": "sha512-KQpqOb/juDBO0xyloDkVDhOVxDUgAfZ2OAAVq99TJScJDzT319xry1QzB9LQohV9QGnA7p6m/XATZkMXc84lwA==", "cpu": [ "arm64" ], @@ -1820,9 +1813,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.63.0.tgz", - "integrity": "sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.65.0.tgz", + "integrity": "sha512-xfqcOc3nJFeAd1kDY4T9d3XeJIhr00twaaW0kOAzGPyUHkruXtNJv6zz1Ra9fRtSek5VpW2Yoj5AcwPIlT0ZiQ==", "cpu": [ "arm64" ], @@ -1837,9 +1830,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.63.0.tgz", - "integrity": "sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.65.0.tgz", + "integrity": "sha512-JV+pXm45p8sdgs3c7LOPAohW23optCNZETFOXUcjn6cS4PYZhEU/RI54Z5dHdMudab3nw7T48PZILthM+Q0COQ==", "cpu": [ "ia32" ], @@ -1854,9 +1847,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.63.0.tgz", - "integrity": "sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.65.0.tgz", + "integrity": "sha512-D7L/oBbskLss21bYrRbFuIs81AiSQV+wRzwck54dOkHIlq2qu1xjLz8u6jCqGH8Fltk8bB5DLBpVhE7v/fA8XQ==", "cpu": [ "x64" ], @@ -2997,13 +2990,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz", - "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", + "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, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/normalize-package-data": { @@ -3014,16 +3007,16 @@ "license": "MIT" }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", + "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3032,13 +3025,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3059,9 +3052,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3072,13 +3065,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", + "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.6", "pathe": "^2.0.3" }, "funding": { @@ -3086,14 +3079,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", + "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3102,9 +3095,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", "devOptional": true, "license": "MIT", "funding": { @@ -3112,13 +3105,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -8097,6 +8090,15 @@ "node": ">=18" } }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8159,9 +8161,9 @@ } }, "node_modules/oxfmt": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.48.0.tgz", - "integrity": "sha512-AVaLh+7XeGx+R1zfFV+f6VV61nT2MWVJXVUDhbTm5LBWGyNt64xAyh3NYYyjeY2WykNt9AvqSQLPHcbWquYF9g==", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.50.0.tgz", + "integrity": "sha512-owwjTnhfM5aCOJhYeqDvk7iM504OeYFZpdRU7cxx7xtZMo4uVpjlryTUon+Cf76CugsvnqA32e6rC73pr1hXaw==", "dev": true, "license": "MIT", "dependencies": { @@ -8177,31 +8179,39 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.48.0", - "@oxfmt/binding-android-arm64": "0.48.0", - "@oxfmt/binding-darwin-arm64": "0.48.0", - "@oxfmt/binding-darwin-x64": "0.48.0", - "@oxfmt/binding-freebsd-x64": "0.48.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.48.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.48.0", - "@oxfmt/binding-linux-arm64-gnu": "0.48.0", - "@oxfmt/binding-linux-arm64-musl": "0.48.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.48.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.48.0", - "@oxfmt/binding-linux-riscv64-musl": "0.48.0", - "@oxfmt/binding-linux-s390x-gnu": "0.48.0", - "@oxfmt/binding-linux-x64-gnu": "0.48.0", - "@oxfmt/binding-linux-x64-musl": "0.48.0", - "@oxfmt/binding-openharmony-arm64": "0.48.0", - "@oxfmt/binding-win32-arm64-msvc": "0.48.0", - "@oxfmt/binding-win32-ia32-msvc": "0.48.0", - "@oxfmt/binding-win32-x64-msvc": "0.48.0" + "@oxfmt/binding-android-arm-eabi": "0.50.0", + "@oxfmt/binding-android-arm64": "0.50.0", + "@oxfmt/binding-darwin-arm64": "0.50.0", + "@oxfmt/binding-darwin-x64": "0.50.0", + "@oxfmt/binding-freebsd-x64": "0.50.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.50.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.50.0", + "@oxfmt/binding-linux-arm64-gnu": "0.50.0", + "@oxfmt/binding-linux-arm64-musl": "0.50.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.50.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.50.0", + "@oxfmt/binding-linux-riscv64-musl": "0.50.0", + "@oxfmt/binding-linux-s390x-gnu": "0.50.0", + "@oxfmt/binding-linux-x64-gnu": "0.50.0", + "@oxfmt/binding-linux-x64-musl": "0.50.0", + "@oxfmt/binding-openharmony-arm64": "0.50.0", + "@oxfmt/binding-win32-arm64-msvc": "0.50.0", + "@oxfmt/binding-win32-ia32-msvc": "0.50.0", + "@oxfmt/binding-win32-x64-msvc": "0.50.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } } }, "node_modules/oxlint": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.63.0.tgz", - "integrity": "sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.65.0.tgz", + "integrity": "sha512-ChUuE3Q7XnAbscvT4XLMsH7HFJmLgLVv9lu+RRgFL5wSXnDqUOzTp5IS8qWDBGd/ZDSzQ2tbX8fjAmijlGLC7A==", "dev": true, "license": "MIT", "bin": { @@ -8214,25 +8224,25 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.63.0", - "@oxlint/binding-android-arm64": "1.63.0", - "@oxlint/binding-darwin-arm64": "1.63.0", - "@oxlint/binding-darwin-x64": "1.63.0", - "@oxlint/binding-freebsd-x64": "1.63.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.63.0", - "@oxlint/binding-linux-arm-musleabihf": "1.63.0", - "@oxlint/binding-linux-arm64-gnu": "1.63.0", - "@oxlint/binding-linux-arm64-musl": "1.63.0", - "@oxlint/binding-linux-ppc64-gnu": "1.63.0", - "@oxlint/binding-linux-riscv64-gnu": "1.63.0", - "@oxlint/binding-linux-riscv64-musl": "1.63.0", - "@oxlint/binding-linux-s390x-gnu": "1.63.0", - "@oxlint/binding-linux-x64-gnu": "1.63.0", - "@oxlint/binding-linux-x64-musl": "1.63.0", - "@oxlint/binding-openharmony-arm64": "1.63.0", - "@oxlint/binding-win32-arm64-msvc": "1.63.0", - "@oxlint/binding-win32-ia32-msvc": "1.63.0", - "@oxlint/binding-win32-x64-msvc": "1.63.0" + "@oxlint/binding-android-arm-eabi": "1.65.0", + "@oxlint/binding-android-arm64": "1.65.0", + "@oxlint/binding-darwin-arm64": "1.65.0", + "@oxlint/binding-darwin-x64": "1.65.0", + "@oxlint/binding-freebsd-x64": "1.65.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.65.0", + "@oxlint/binding-linux-arm-musleabihf": "1.65.0", + "@oxlint/binding-linux-arm64-gnu": "1.65.0", + "@oxlint/binding-linux-arm64-musl": "1.65.0", + "@oxlint/binding-linux-ppc64-gnu": "1.65.0", + "@oxlint/binding-linux-riscv64-gnu": "1.65.0", + "@oxlint/binding-linux-riscv64-musl": "1.65.0", + "@oxlint/binding-linux-s390x-gnu": "1.65.0", + "@oxlint/binding-linux-x64-gnu": "1.65.0", + "@oxlint/binding-linux-x64-musl": "1.65.0", + "@oxlint/binding-openharmony-arm64": "1.65.0", + "@oxlint/binding-win32-arm64-msvc": "1.65.0", + "@oxlint/binding-win32-ia32-msvc": "1.65.0", + "@oxlint/binding-win32-x64-msvc": "1.65.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" @@ -10169,9 +10179,9 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "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, "license": "MIT" }, @@ -10368,19 +10378,19 @@ } }, "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", + "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -10408,12 +10418,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", + "@vitest/browser-playwright": "4.1.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -10678,6 +10688,21 @@ "node": ">=8" } }, + "node_modules/yocto-spinner": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.0.tgz", + "integrity": "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==", + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",