@baseworks/account 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,94 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { beforeAll, afterEach, afterAll, describe, it, expect } from 'vitest'
3
+ import { run, mocks, server } from './helpers.js'
4
+
5
+ beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
6
+ afterEach(() => server.resetHandlers())
7
+ afterAll(() => server.close())
8
+
9
+ const MEMBER1 = {
10
+ userId: '019f10aa-1111-7000-0001-aaaaaaaaaaaa',
11
+ email: 'ali@dotlabs.io',
12
+ name: 'Ali',
13
+ role: 'admin',
14
+ joinedAt: 1782507760630,
15
+ }
16
+
17
+ const MEMBER2 = {
18
+ userId: '019f10ab-2222-7000-0002-bbbbbbbbbbbb',
19
+ email: 'zeynep@dotlabs.io',
20
+ name: 'Zeynep',
21
+ role: 'member',
22
+ joinedAt: 1782507771000,
23
+ }
24
+
25
+ // ── list ─────────────────────────────────────────────────────────────────────
26
+
27
+ describe('flect members (list)', () => {
28
+ it('default columns: EMAIL → NAME → ROLE', async () => {
29
+ mocks.mockGet('/v1/account/members', { members: [MEMBER1, MEMBER2] })
30
+ const r = await run(['members'])
31
+ expect(r.exitCode).toBe(0)
32
+ expect(r.stdout).toContain('ali@dotlabs.io')
33
+ expect(r.stdout).toContain('admin')
34
+ expect(r.stdout.indexOf('EMAIL')).toBeLessThan(r.stdout.indexOf('NAME'))
35
+ expect(r.stdout.indexOf('NAME')).toBeLessThan(r.stdout.indexOf('ROLE'))
36
+ })
37
+
38
+ it('does not show full UUID in default view', async () => {
39
+ mocks.mockGet('/v1/account/members', { members: [MEMBER1] })
40
+ const r = await run(['members'])
41
+ expect(r.stdout).not.toContain(MEMBER1.userId)
42
+ })
43
+
44
+ it('-o wide adds JOINED and USER ID columns', async () => {
45
+ mocks.mockGet('/v1/account/members', { members: [MEMBER1] })
46
+ const r = await run(['members', '-o', 'wide'])
47
+ expect(r.exitCode).toBe(0)
48
+ expect(r.stdout).toContain('JOINED')
49
+ expect(r.stdout).toContain('USER ID')
50
+ expect(r.stdout).toContain(MEMBER1.userId)
51
+ })
52
+
53
+ it('-o json returns raw array', async () => {
54
+ mocks.mockGet('/v1/account/members', { members: [MEMBER1] })
55
+ const r = await run(['members', '-o', 'json'])
56
+ const parsed = JSON.parse(r.stdout)
57
+ expect(Array.isArray(parsed)).toBe(true)
58
+ expect(parsed[0].email).toBe('ali@dotlabs.io')
59
+ })
60
+ })
61
+
62
+ // ── role ─────────────────────────────────────────────────────────────────────
63
+
64
+ describe('flect members role', () => {
65
+ it('works with email', async () => {
66
+ const captured = mocks.mockPatchCapture('/v1/account/members/ali@dotlabs.io/role', { ok: true })
67
+ const r = await run(['members', 'role', 'ali@dotlabs.io', 'member'])
68
+ expect(r.exitCode).toBe(0)
69
+ expect((captured.body as Record<string, unknown>).role).toBe('member')
70
+ })
71
+
72
+ it('works with userId', async () => {
73
+ const captured = mocks.mockPatchCapture(`/v1/account/members/${MEMBER1.userId}/role`, { ok: true })
74
+ const r = await run(['members', 'role', MEMBER1.userId, 'viewer'])
75
+ expect(r.exitCode).toBe(0)
76
+ expect((captured.body as Record<string, unknown>).role).toBe('viewer')
77
+ })
78
+ })
79
+
80
+ // ── remove ────────────────────────────────────────────────────────────────────
81
+
82
+ describe('flect members remove', () => {
83
+ it('works with email', async () => {
84
+ mocks.mockDelete('/v1/account/members/ali@dotlabs.io')
85
+ const r = await run(['members', 'remove', 'ali@dotlabs.io'])
86
+ expect(r.exitCode).toBe(0)
87
+ })
88
+
89
+ it('works with userId', async () => {
90
+ mocks.mockDelete(`/v1/account/members/${MEMBER1.userId}`)
91
+ const r = await run(['members', 'remove', MEMBER1.userId])
92
+ expect(r.exitCode).toBe(0)
93
+ })
94
+ })
@@ -0,0 +1,96 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { beforeAll, afterEach, afterAll, describe, it, expect } from 'vitest'
3
+ import { run, mocks, server } from './helpers.js'
4
+
5
+ beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
6
+ afterEach(() => server.resetHandlers())
7
+ afterAll(() => server.close())
8
+
9
+ const TOKEN = {
10
+ id: '019f20cc-3333-7000-aaaa-ffffffffffff',
11
+ keyPrefix: 'flect_a1b2c3',
12
+ name: 'CI deploy',
13
+ lastUsedAt: null,
14
+ createdAt: 1782507760630,
15
+ }
16
+
17
+ const TOKEN2 = {
18
+ id: '019f20cd-4444-7000-bbbb-eeeeeeeeeeee',
19
+ keyPrefix: 'flect_x9y8z7',
20
+ name: 'Local dev',
21
+ lastUsedAt: 1782507990000,
22
+ createdAt: 1782507760000,
23
+ }
24
+
25
+ // ── list ─────────────────────────────────────────────────────────────────────
26
+
27
+ describe('flect tokens (list)', () => {
28
+ it('default columns: PREFIX → NAME → LAST USED', async () => {
29
+ mocks.mockGet('/v1/account/me/tokens', { tokens: [TOKEN, TOKEN2] })
30
+ const r = await run(['tokens'])
31
+ expect(r.exitCode).toBe(0)
32
+ expect(r.stdout).toContain('flect_a1b2c3')
33
+ expect(r.stdout).toContain('CI deploy')
34
+ expect(r.stdout.indexOf('PREFIX')).toBeLessThan(r.stdout.indexOf('NAME'))
35
+ })
36
+
37
+ it('does not show full UUID in default view', async () => {
38
+ mocks.mockGet('/v1/account/me/tokens', { tokens: [TOKEN] })
39
+ const r = await run(['tokens'])
40
+ expect(r.stdout).not.toContain(TOKEN.id)
41
+ })
42
+
43
+ it('-o wide adds CREATED and FULL ID columns', async () => {
44
+ mocks.mockGet('/v1/account/me/tokens', { tokens: [TOKEN] })
45
+ const r = await run(['tokens', '-o', 'wide'])
46
+ expect(r.exitCode).toBe(0)
47
+ expect(r.stdout).toContain('FULL ID')
48
+ expect(r.stdout).toContain(TOKEN.id)
49
+ })
50
+
51
+ it('-o json returns raw array', async () => {
52
+ mocks.mockGet('/v1/account/me/tokens', { tokens: [TOKEN] })
53
+ const r = await run(['tokens', '-o', 'json'])
54
+ const parsed = JSON.parse(r.stdout)
55
+ expect(Array.isArray(parsed)).toBe(true)
56
+ expect(parsed[0].keyPrefix).toBe('flect_a1b2c3')
57
+ })
58
+ })
59
+
60
+ // ── create ────────────────────────────────────────────────────────────────────
61
+
62
+ describe('flect tokens create', () => {
63
+ it('sends name, shows prefix in output', async () => {
64
+ const captured = mocks.mockPostCapture('/v1/account/me/tokens', {
65
+ token: { ...TOKEN, raw: 'flect_a1b2c3_secretpart' },
66
+ })
67
+ const r = await run(['tokens', 'create', '--name', 'CI deploy'])
68
+ expect(r.exitCode).toBe(0)
69
+ expect((captured.body as Record<string, unknown>).name).toBe('CI deploy')
70
+ expect(r.stdout).toContain('flect_a1b2c3')
71
+ })
72
+
73
+ it('shows raw token in output once', async () => {
74
+ mocks.mockPost('/v1/account/me/tokens', {
75
+ token: { ...TOKEN, raw: 'flect_a1b2c3_secretpart' },
76
+ })
77
+ const r = await run(['tokens', 'create', '--name', 'CI deploy'])
78
+ expect(r.stdout).toContain('flect_a1b2c3_secretpart')
79
+ })
80
+ })
81
+
82
+ // ── revoke ────────────────────────────────────────────────────────────────────
83
+
84
+ describe('flect tokens revoke', () => {
85
+ it('works with prefix', async () => {
86
+ mocks.mockDelete('/v1/account/me/tokens/flect_a1b2c3')
87
+ const r = await run(['tokens', 'revoke', 'flect_a1b2c3'])
88
+ expect(r.exitCode).toBe(0)
89
+ })
90
+
91
+ it('works with full id', async () => {
92
+ mocks.mockDelete(`/v1/account/me/tokens/${TOKEN.id}`)
93
+ const r = await run(['tokens', 'revoke', TOKEN.id])
94
+ expect(r.exitCode).toBe(0)
95
+ })
96
+ })
@@ -0,0 +1,42 @@
1
+ import { Command } from 'commander'
2
+ import {
3
+ buildLoginCommand, buildLogoutCommand, buildWhoamiCommand,
4
+ buildMeCommand, buildMembersCommand, buildTokensCommand,
5
+ type AccountCliDeps,
6
+ } from '../cli.js'
7
+ import { createApiClient } from '@baseworks/cli/client'
8
+ import { createContextManager } from '@baseworks/cli/context'
9
+ import { runCommand, createMocks, server } from '@baseworks/cli/testing'
10
+
11
+ export { runCommand, server }
12
+
13
+ export const TEST_BASE = 'http://flect.test'
14
+ export const mocks = createMocks(TEST_BASE)
15
+
16
+ export let lastLoginResult: unknown = null
17
+
18
+ export function makeProgram(token = 'test-token') {
19
+ const ctx = createContextManager('account-test')
20
+ const http = createApiClient(() => token, () => TEST_BASE)
21
+ const deps: AccountCliDeps = {
22
+ http,
23
+ ctx,
24
+ appBase: TEST_BASE,
25
+ apiBase: TEST_BASE,
26
+ cliName: 'flect',
27
+ onLoginSuccess: (r) => { lastLoginResult = r },
28
+ }
29
+
30
+ const program = new Command('flect').exitOverride().enablePositionalOptions()
31
+ program.addCommand(buildLoginCommand(deps))
32
+ program.addCommand(buildLogoutCommand(deps))
33
+ program.addCommand(buildWhoamiCommand(deps))
34
+ program.addCommand(buildMeCommand(deps))
35
+ program.addCommand(buildMembersCommand(deps))
36
+ program.addCommand(buildTokensCommand(deps))
37
+ return program
38
+ }
39
+
40
+ export function run(args: string[]) {
41
+ return runCommand(makeProgram(), args)
42
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,356 @@
1
+ import { Command } from 'commander'
2
+ import { clr, kv, table, success, warn, fatal, printOutput, outputOption } from '@baseworks/cli/display'
3
+ import { fmtDate } from '@baseworks/cli/fmt'
4
+ import type { ColDef } from '@baseworks/cli/display'
5
+ import type { ApiClient } from '@baseworks/cli/client'
6
+ import type { ContextManager } from '@baseworks/cli/context'
7
+
8
+ // ─── Types ────────────────────────────────────────────────────────────────────
9
+
10
+ export interface LoginResult {
11
+ token: string
12
+ user: { id: string; email: string; name: string | null } | null
13
+ role: string | null
14
+ org: { slug: string; id: string }
15
+ workspace: { slug: string; id: string }
16
+ project: { slug: string; id: string }
17
+ env: { slug: string; id: string }
18
+ }
19
+
20
+ export interface AccountCliDeps {
21
+ http: ApiClient
22
+ ctx: ContextManager<Record<string, string | undefined>>
23
+ appBase: string
24
+ apiBase: string
25
+ cliName?: string
26
+ onLoginSuccess: (result: LoginResult) => void
27
+ }
28
+
29
+ type MemberRow = {
30
+ userId: string
31
+ email: string | null
32
+ name: string | null
33
+ role: string
34
+ joinedAt: number
35
+ }
36
+
37
+ type TokenRow = {
38
+ id: string
39
+ keyPrefix: string
40
+ name: string
41
+ lastUsedAt: number | null
42
+ createdAt: number
43
+ }
44
+
45
+ // ─── buildLoginCommand ────────────────────────────────────────────────────────
46
+
47
+ export function buildLoginCommand(deps: AccountCliDeps): Command {
48
+ const base = deps.apiBase.replace(/\/+$/, '')
49
+
50
+ const cmd = new Command('login').description('Log in via browser (OIDC)')
51
+
52
+ cmd.addCommand(
53
+ new Command('browser')
54
+ .description('Open browser for OIDC login (default)')
55
+ .action(async () => {
56
+ const { exec } = await import('child_process')
57
+
58
+ const startRes = await fetch(`${base}/v1/auth/start`)
59
+ if (!startRes.ok) fatal(`Auth start failed (${startRes.status})`)
60
+ const { state, url: authUrl } = await startRes.json() as { state: string; url: string }
61
+
62
+ warn(`Login URL: ${clr.cyan}${authUrl}${clr.reset}`)
63
+ warn('Opening browser…')
64
+ const opener = process.platform === 'win32' ? 'start'
65
+ : process.platform === 'darwin' ? 'open' : 'xdg-open'
66
+ exec(`${opener} "${authUrl}"`)
67
+ warn('Waiting for browser login… (Ctrl+C to cancel)')
68
+
69
+ let token: string | undefined
70
+ const deadline = Date.now() + 300_000
71
+ const startedAt = Date.now()
72
+ let attempt = 0
73
+
74
+ while (Date.now() < deadline) {
75
+ await new Promise(r => setTimeout(r, attempt < 10 ? 1_000 : 2_000))
76
+ attempt++
77
+
78
+ try {
79
+ const pollRes = await fetch(`${base}/v1/auth/poll/${state}`)
80
+ if (!pollRes.ok) continue
81
+ const body = await pollRes.json() as { status: string; token?: string }
82
+ if (body.status === 'done' && body.token) { token = body.token; break }
83
+ if (body.status === 'expired') fatal('Auth session expired — run login again.')
84
+ } catch { /* keep polling */ }
85
+
86
+ if (attempt % 5 === 0) {
87
+ const elapsed = Math.round((Date.now() - startedAt) / 1000)
88
+ process.stderr.write(`\r Waiting for browser… ${elapsed}s`)
89
+ }
90
+ }
91
+
92
+ if (!token) { process.stderr.write('\n'); fatal('Login timed out.') }
93
+ process.stderr.write('\n')
94
+
95
+ const meRes = await fetch(`${base}/v1/account/me`, { headers: { Authorization: `Bearer ${token}` } })
96
+ if (!meRes.ok) fatal(`Failed to fetch user info (${meRes.status})`)
97
+ const me = await meRes.json() as {
98
+ user: { id: string; email: string; name: string | null } | null
99
+ org: { id: string; slug: string; name: string }
100
+ role: string | null
101
+ }
102
+
103
+ const orgSlug = me.org.slug
104
+ let ws = { id: '', slug: 'default' }
105
+ let prj = { id: '', slug: 'default' }
106
+ let env = { id: '', slug: 'production' }
107
+
108
+ try {
109
+ const headers = { Authorization: `Bearer ${token}` }
110
+ const wsRes = await fetch(`${base}/v1/orgs/${orgSlug}/workspaces`, { headers })
111
+ if (wsRes.ok) {
112
+ const b = await wsRes.json() as { workspaces: { id: string; slug: string }[] }
113
+ if (b.workspaces[0]) ws = b.workspaces[0]!
114
+ }
115
+ if (ws.id) {
116
+ const prjRes = await fetch(`${base}/v1/orgs/${orgSlug}/workspaces/${ws.slug}/projects`, { headers })
117
+ if (prjRes.ok) {
118
+ const b = await prjRes.json() as { projects: { id: string; slug: string }[] }
119
+ if (b.projects[0]) prj = b.projects[0]!
120
+ }
121
+ }
122
+ if (prj.id) {
123
+ const envRes = await fetch(`${base}/v1/orgs/${orgSlug}/workspaces/${ws.slug}/projects/${prj.slug}/envs`, { headers })
124
+ if (envRes.ok) {
125
+ const b = await envRes.json() as { envs: { id: string; slug: string }[] }
126
+ if (b.envs[0]) env = b.envs[0]!
127
+ }
128
+ }
129
+ } catch { /* defaults */ }
130
+
131
+ const result: LoginResult = {
132
+ token: token!,
133
+ user: me.user,
134
+ role: me.role,
135
+ org: { id: me.org.id, slug: orgSlug },
136
+ workspace: ws,
137
+ project: prj,
138
+ env,
139
+ }
140
+
141
+ deps.onLoginSuccess(result)
142
+ success('Logged in.')
143
+ const rows: [string, string][] = []
144
+ if (result.user) { rows.push(['user', result.user.email], ['role', result.role ?? '—']) }
145
+ rows.push(
146
+ ['org', result.org.slug],
147
+ ['workspace', result.workspace.slug],
148
+ ['project', result.project.slug],
149
+ ['env', result.env.slug],
150
+ ['token', token!.slice(0, 14) + '…'],
151
+ )
152
+ kv(rows)
153
+ }),
154
+ )
155
+
156
+ // flect login → default to browser subcommand
157
+ cmd.action(async () => {
158
+ const sub = cmd.commands.find(c => c.name() === 'browser')!
159
+ await sub.parseAsync([], { from: 'user' })
160
+ })
161
+
162
+ return cmd
163
+ }
164
+
165
+ // ─── buildLogoutCommand ───────────────────────────────────────────────────────
166
+
167
+ export function buildLogoutCommand(deps: AccountCliDeps): Command {
168
+ const { ctx } = deps
169
+ return new Command('logout')
170
+ .description('Remove saved token and clear active context')
171
+ .option('--all', 'Remove all stored org tokens')
172
+ .action((opts: { all?: boolean }) => {
173
+ const cfg = ctx.loadConfig() as Record<string, unknown>
174
+ if (opts.all) {
175
+ delete cfg['orgs']
176
+ delete cfg['activeOrg']
177
+ } else {
178
+ const activeOrg = cfg['activeOrg'] as string | undefined
179
+ const orgs = cfg['orgs'] as Record<string, unknown> | undefined
180
+ if (activeOrg && orgs) {
181
+ delete orgs[activeOrg]
182
+ if (Object.keys(orgs).length === 0) delete cfg['orgs']
183
+ }
184
+ delete cfg['activeOrg']
185
+ }
186
+ ctx.clearContext()
187
+ ctx.saveConfig(cfg as never)
188
+ success(opts.all ? 'All tokens removed.' : 'Logged out.')
189
+ })
190
+ }
191
+
192
+ // ─── buildWhoamiCommand ───────────────────────────────────────────────────────
193
+
194
+ export function buildWhoamiCommand(deps: AccountCliDeps): Command {
195
+ const { ctx } = deps
196
+ const cli = deps.cliName ?? 'cli'
197
+ return new Command('whoami')
198
+ .alias('wh')
199
+ .description('Show active token and context')
200
+ .action(() => {
201
+ const cfg = ctx.loadConfig() as Record<string, unknown>
202
+ const activeOrg = cfg['activeOrg'] as string | undefined
203
+ const orgs = cfg['orgs'] as Record<string, { token?: string }> | undefined
204
+ const token = (activeOrg && orgs?.[activeOrg]?.token) ?? (cfg['token'] as string | undefined)
205
+
206
+ if (!token) { warn(`No token set. Run: ${cli} login`); return }
207
+
208
+ const rows: [string, string][] = [['token', clr.cyan + token.slice(0, 14) + '…' + clr.reset]]
209
+ if (activeOrg) rows.push(['org', clr.bold + activeOrg + clr.reset])
210
+ if (orgs && Object.keys(orgs).length > 1) {
211
+ rows.push(['other orgs', clr.dim + Object.keys(orgs).filter(s => s !== activeOrg).join(', ') + clr.reset])
212
+ }
213
+ kv(rows)
214
+ })
215
+ }
216
+
217
+ // ─── buildMeCommand ───────────────────────────────────────────────────────────
218
+
219
+ export function buildMeCommand(deps: AccountCliDeps): Command {
220
+ const { http } = deps
221
+ const cli = deps.cliName ?? 'cli'
222
+ return new Command('me')
223
+ .description('Current user and org membership')
224
+ .option(...outputOption())
225
+ .action(async (opts: { output: string }) => {
226
+ type MeRes = {
227
+ user: { id: string; email: string; name: string | null; picture: string | null } | null
228
+ role: string | null
229
+ org: { id: string; slug?: string; name?: string }
230
+ }
231
+ const res = await http.get<MeRes>('/v1/account/me').catch(e => { console.error(e.message); process.exit(1) })
232
+ if (!res.user) {
233
+ warn(`Org-level token (no user identity). Run: ${cli} login`)
234
+ kv([['org_id', res.org.id]])
235
+ return
236
+ }
237
+ printOutput(res as unknown as Record<string, unknown>, opts.output, () => kv([
238
+ ['email', res.user!.email],
239
+ ['name', res.user!.name ?? '—'],
240
+ ['role', clr.bold + (res.role ?? '—') + clr.reset],
241
+ ['org', res.org.slug ?? res.org.id],
242
+ ['user_id', res.user!.id],
243
+ ]))
244
+ })
245
+ }
246
+
247
+ // ─── buildMembersCommand ──────────────────────────────────────────────────────
248
+
249
+ export function buildMembersCommand(deps: AccountCliDeps): Command {
250
+ const { http } = deps
251
+
252
+ const cmd = new Command('members')
253
+ .alias('member')
254
+ .description('Org member management')
255
+ .enablePositionalOptions()
256
+ .option(...outputOption())
257
+ .action(async (opts: { output: string }) => {
258
+ const res = await http.get<{ members: MemberRow[] }>('/v1/account/members').catch(e => { console.error(e.message); process.exit(1) })
259
+ printOutput(res.members, opts.output, (wide) => {
260
+ table(res.members, [
261
+ { key: 'email', label: 'EMAIL' },
262
+ { key: 'name', label: 'NAME', fmt: v => (v as string | null) ?? '—' },
263
+ { key: 'role', label: 'ROLE' },
264
+ { key: 'joinedAt', label: 'JOINED', fmt: v => fmtDate(v as number), wide: true },
265
+ { key: 'userId', label: 'USER ID', wide: true },
266
+ ] as ColDef<MemberRow>[], { wide, emptyHint: `No members yet.` } as { wide: boolean; emptyHint: string })
267
+ }, 'email')
268
+ })
269
+
270
+ cmd.addCommand(
271
+ new Command('role').argument('<ref>').argument('<role>')
272
+ .description('Change member role (admin+). ref = email or userId')
273
+ .action(async (ref: string, role: string) => {
274
+ await http.patch(`/v1/account/members/${ref}/role`, { role }).catch(e => { console.error(e.message); process.exit(1) })
275
+ success(`Role updated → ${role}`)
276
+ }),
277
+ )
278
+
279
+ cmd.addCommand(
280
+ new Command('remove').argument('<ref>')
281
+ .description('Remove member from org (admin+). ref = email or userId')
282
+ .action(async (ref: string) => {
283
+ await http.del(`/v1/account/members/${ref}`).catch(e => { console.error(e.message); process.exit(1) })
284
+ success('Member removed.')
285
+ }),
286
+ )
287
+
288
+ return cmd
289
+ }
290
+
291
+ // ─── buildTokensCommand ───────────────────────────────────────────────────────
292
+
293
+ export function buildTokensCommand(deps: AccountCliDeps): Command {
294
+ const { http } = deps
295
+
296
+ const cmd = new Command('tokens')
297
+ .alias('token')
298
+ .description('API token management')
299
+ .enablePositionalOptions()
300
+ .option(...outputOption())
301
+ .action(async (opts: { output: string }) => {
302
+ const res = await http.get<{ tokens: TokenRow[] }>('/v1/account/me/tokens').catch(e => { console.error(e.message); process.exit(1) })
303
+ printOutput(res.tokens, opts.output, (wide) => {
304
+ table(res.tokens, [
305
+ { key: 'keyPrefix', label: 'PREFIX' },
306
+ { key: 'name', label: 'NAME' },
307
+ { key: 'lastUsedAt', label: 'LAST USED', fmt: v => fmtDate(v as number | null) },
308
+ { key: 'createdAt', label: 'CREATED', fmt: v => fmtDate(v as number), wide: true },
309
+ { key: 'id', label: 'FULL ID', wide: true },
310
+ ] as ColDef<TokenRow>[], { wide, emptyHint: 'No tokens.' } as { wide: boolean; emptyHint: string })
311
+ }, 'keyPrefix')
312
+ })
313
+
314
+ cmd.addCommand(
315
+ new Command('create')
316
+ .description('Create a new API token')
317
+ .requiredOption('--name <name>', 'Token name')
318
+ .action(async (opts: { name: string }) => {
319
+ const res = await http.post<{ token: TokenRow & { raw: string } }>('/v1/account/me/tokens', { name: opts.name }).catch(e => { console.error(e.message); process.exit(1) })
320
+ success('Token created — save it now, it will not be shown again:')
321
+ console.log(`\n ${clr.cyan}${clr.bold}${res.token.raw}${clr.reset}\n`)
322
+ kv([['prefix', res.token.keyPrefix], ['name', res.token.name]])
323
+ }),
324
+ )
325
+
326
+ cmd.addCommand(
327
+ new Command('revoke').argument('<ref>')
328
+ .description('Revoke a token. ref = prefix or full id')
329
+ .action(async (ref: string) => {
330
+ await http.del(`/v1/account/me/tokens/${ref}`).catch(e => { console.error(e.message); process.exit(1) })
331
+ success(`Token ${ref} revoked.`)
332
+ }),
333
+ )
334
+
335
+ return cmd
336
+ }
337
+
338
+ // ─── buildAccountCommand ──────────────────────────────────────────────────────
339
+
340
+ export function buildAccountCommand(deps: AccountCliDeps): Command {
341
+ const cmd = new Command('account')
342
+ .alias('acc')
343
+ .description('Identity, members, and token management')
344
+
345
+ cmd.addCommand(buildLoginCommand(deps))
346
+ cmd.addCommand(buildLogoutCommand(deps))
347
+ cmd.addCommand(buildWhoamiCommand(deps))
348
+ cmd.addCommand(buildMeCommand(deps))
349
+ cmd.addCommand(buildMembersCommand(deps))
350
+ cmd.addCommand(buildTokensCommand(deps))
351
+
352
+ return cmd
353
+ }
354
+
355
+ // ─── backward compat ─────────────────────────────────────────────────────────
356
+ export { buildAccountCommand as accountCommand }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './types.js'
2
+ export * from './repo/index.js'
@@ -0,0 +1,91 @@
1
+ import { and, eq, isNull } from 'drizzle-orm'
2
+ import { generateId } from '@baseworks/core'
3
+ import type { ApiToken } from '../types.js'
4
+
5
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
+ type AnyDB = any
7
+
8
+ export function createApiTokenRepo(db: AnyDB, schema: { apiTokens: any }) {
9
+ const { apiTokens } = schema
10
+
11
+ return {
12
+ async create(data: {
13
+ organizationId: string
14
+ name: string
15
+ tokenHash: string
16
+ keyPrefix: string
17
+ userId?: string
18
+ scopes?: string[]
19
+ expiresAt?: number
20
+ }): Promise<ApiToken> {
21
+ const now = Date.now()
22
+ const row: ApiToken = {
23
+ id: generateId(),
24
+ organizationId: data.organizationId,
25
+ userId: data.userId ?? null,
26
+ name: data.name,
27
+ tokenHash: data.tokenHash,
28
+ keyPrefix: data.keyPrefix,
29
+ scopes: JSON.stringify(data.scopes ?? []),
30
+ lastUsedAt: null,
31
+ expiresAt: data.expiresAt ?? null,
32
+ revokedAt: null,
33
+ createdAt: now,
34
+ updatedAt: now,
35
+ }
36
+ await db.insert(apiTokens).values(row)
37
+ return row
38
+ },
39
+
40
+ async findByHash(tokenHash: string): Promise<ApiToken | undefined> {
41
+ const rows: ApiToken[] = await db
42
+ .select()
43
+ .from(apiTokens)
44
+ .where(and(eq(apiTokens.tokenHash, tokenHash), isNull(apiTokens.revokedAt)))
45
+ .limit(1)
46
+ return rows[0]
47
+ },
48
+
49
+ async touch(id: string): Promise<void> {
50
+ await db
51
+ .update(apiTokens)
52
+ .set({ lastUsedAt: Date.now(), updatedAt: Date.now() })
53
+ .where(eq(apiTokens.id, id))
54
+ },
55
+
56
+ async revoke(id: string): Promise<void> {
57
+ const now = Date.now()
58
+ await db.update(apiTokens).set({ revokedAt: now, updatedAt: now }).where(eq(apiTokens.id, id))
59
+ },
60
+
61
+ async listByOrg(organizationId: string): Promise<ApiToken[]> {
62
+ return db
63
+ .select()
64
+ .from(apiTokens)
65
+ .where(and(eq(apiTokens.organizationId, organizationId), isNull(apiTokens.revokedAt)))
66
+ },
67
+
68
+ async listByUser(userId: string): Promise<ApiToken[]> {
69
+ return db
70
+ .select()
71
+ .from(apiTokens)
72
+ .where(and(eq(apiTokens.userId, userId), isNull(apiTokens.revokedAt)))
73
+ },
74
+
75
+ async countActive(organizationId: string, userId?: string): Promise<number> {
76
+ const rows: unknown[] = await db
77
+ .select({ id: apiTokens.id })
78
+ .from(apiTokens)
79
+ .where(
80
+ and(
81
+ eq(apiTokens.organizationId, organizationId),
82
+ isNull(apiTokens.revokedAt),
83
+ userId ? eq(apiTokens.userId, userId) : isNull(apiTokens.userId),
84
+ ),
85
+ )
86
+ return rows.length
87
+ },
88
+ }
89
+ }
90
+
91
+ export type ApiTokenRepo = ReturnType<typeof createApiTokenRepo>