@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.
- package/dist/cli.d.ts +46 -0
- package/dist/cli.js +268 -0
- package/dist/index.d.ts +91 -0
- package/dist/index.js +135 -0
- package/dist/memberships-Re0HbIz4.d.ts +117 -0
- package/dist/schema/pg/index.d.ts +360 -0
- package/dist/schema/pg/index.js +47 -0
- package/dist/schema/sqlite/index.d.ts +506 -0
- package/dist/schema/sqlite/index.js +60 -0
- package/package.json +35 -0
- package/src/__tests__/cli-me.test.ts +32 -0
- package/src/__tests__/cli-members.test.ts +94 -0
- package/src/__tests__/cli-tokens.test.ts +96 -0
- package/src/__tests__/helpers.ts +42 -0
- package/src/cli.ts +356 -0
- package/src/index.ts +2 -0
- package/src/repo/api-tokens.ts +91 -0
- package/src/repo/index.ts +6 -0
- package/src/repo/memberships.ts +60 -0
- package/src/repo/users.ts +64 -0
- package/src/schema/pg/api-tokens.ts +17 -0
- package/src/schema/pg/index.ts +4 -0
- package/src/schema/pg/memberships.ts +14 -0
- package/src/schema/pg/users.ts +12 -0
- package/src/schema/sqlite/api-tokens.ts +18 -0
- package/src/schema/sqlite/index.ts +4 -0
- package/src/schema/sqlite/memberships.ts +15 -0
- package/src/schema/sqlite/users.ts +16 -0
- package/src/types.ts +45 -0
|
@@ -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,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>
|