@sqaoss/flowy 1.1.2 → 1.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/package.json +1 -1
- package/skills/using-flowy/SKILL.md +32 -1
- package/src/commands/billing.test.ts +97 -0
- package/src/commands/billing.ts +30 -0
- package/src/commands/key.test.ts +125 -0
- package/src/commands/key.ts +41 -0
- package/src/commands/setup.test.ts +96 -5
- package/src/commands/setup.ts +28 -6
- package/src/commands/whoami.test.ts +74 -0
- package/src/commands/whoami.ts +1 -1
- package/src/index.test.ts +60 -0
- package/src/index.ts +4 -0
- package/src/util/client.test.ts +170 -0
- package/src/util/client.ts +23 -2
package/package.json
CHANGED
|
@@ -20,9 +20,11 @@ flowy init # auto-detects the git repo, creates a project, maps this d
|
|
|
20
20
|
If Flowy isn't set up yet, the human needs to run:
|
|
21
21
|
```bash
|
|
22
22
|
npm i -g @sqaoss/flowy
|
|
23
|
-
flowy setup remote --email their@email.com
|
|
23
|
+
flowy setup remote --email their@email.com --tier explorer
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
After registration, complete payment at the checkout URL provided.
|
|
27
|
+
|
|
26
28
|
## Core Workflow
|
|
27
29
|
|
|
28
30
|
```bash
|
|
@@ -53,6 +55,18 @@ project -> feature -> task
|
|
|
53
55
|
|
|
54
56
|
Every task belongs to a feature. Every feature belongs to a project. No orphans. The project is set automatically by `flowy init`.
|
|
55
57
|
|
|
58
|
+
## Subscription Tiers
|
|
59
|
+
|
|
60
|
+
Flowy requires an active subscription for all data operations.
|
|
61
|
+
|
|
62
|
+
| Tier | Projects | Description |
|
|
63
|
+
|------|----------|-------------|
|
|
64
|
+
| `explorer` | Up to 10 | For individual developers |
|
|
65
|
+
| `pro` | Unlimited | For power users |
|
|
66
|
+
| `team` | Unlimited | For teams |
|
|
67
|
+
|
|
68
|
+
After registration, complete payment at the checkout URL. Existing users can get a new checkout URL with `flowy billing checkout --tier <tier>`.
|
|
69
|
+
|
|
56
70
|
## Status Flow
|
|
57
71
|
|
|
58
72
|
```
|
|
@@ -104,6 +118,23 @@ flowy search "query" --type task --status draft --limit 10
|
|
|
104
118
|
flowy tree <project-id> --depth 3 # Show full subtree
|
|
105
119
|
```
|
|
106
120
|
|
|
121
|
+
### Setup
|
|
122
|
+
```bash
|
|
123
|
+
flowy setup remote --email <email> --tier <tier> # Register (tier: explorer, pro, team)
|
|
124
|
+
flowy setup local # Self-hosted Docker server
|
|
125
|
+
flowy whoami # Show user info (includes grace period)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Billing
|
|
129
|
+
```bash
|
|
130
|
+
flowy billing checkout --tier <tier> # Get checkout URL for subscription
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### API Key Management
|
|
134
|
+
```bash
|
|
135
|
+
flowy key rotate # Revoke all keys and generate a new one
|
|
136
|
+
```
|
|
137
|
+
|
|
107
138
|
## Validation Rules
|
|
108
139
|
|
|
109
140
|
- **Title is required** and cannot be empty
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
let mockGraphql: ReturnType<typeof vi.fn>
|
|
4
|
+
let mockOutput: ReturnType<typeof vi.fn>
|
|
5
|
+
let mockOutputError: ReturnType<typeof vi.fn>
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
mockGraphql = vi.fn()
|
|
9
|
+
mockOutput = vi.fn()
|
|
10
|
+
mockOutputError = vi.fn()
|
|
11
|
+
|
|
12
|
+
vi.doMock('../util/client.ts', () => ({
|
|
13
|
+
graphql: mockGraphql,
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
vi.doMock('../util/format.ts', () => ({
|
|
17
|
+
output: mockOutput,
|
|
18
|
+
outputError: mockOutputError,
|
|
19
|
+
}))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.resetModules()
|
|
24
|
+
vi.restoreAllMocks()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('billing command', () => {
|
|
28
|
+
test('exports a command named "billing"', async () => {
|
|
29
|
+
const { billingCommand } = await import('./billing.ts')
|
|
30
|
+
expect(billingCommand.name()).toBe('billing')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('has checkout subcommand', async () => {
|
|
34
|
+
const { billingCommand } = await import('./billing.ts')
|
|
35
|
+
const subcommandNames = billingCommand.commands.map((c) => c.name())
|
|
36
|
+
expect(subcommandNames).toContain('checkout')
|
|
37
|
+
expect(billingCommand.commands).toHaveLength(1)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('checkout calls createCheckout mutation and outputs result', async () => {
|
|
41
|
+
const checkoutResult = { url: 'https://checkout.stripe.com/session_123' }
|
|
42
|
+
mockGraphql.mockResolvedValue({ createCheckout: checkoutResult })
|
|
43
|
+
|
|
44
|
+
const { billingCommand } = await import('./billing.ts')
|
|
45
|
+
await billingCommand.parseAsync(['checkout', '--tier', 'pro'], {
|
|
46
|
+
from: 'user',
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
expect(mockGraphql).toHaveBeenCalledWith(
|
|
50
|
+
expect.stringContaining('createCheckout'),
|
|
51
|
+
{ tier: 'pro' },
|
|
52
|
+
)
|
|
53
|
+
expect(mockOutput).toHaveBeenCalledWith(checkoutResult)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('checkout errors when --tier is not provided', async () => {
|
|
57
|
+
const { billingCommand } = await import('./billing.ts')
|
|
58
|
+
|
|
59
|
+
billingCommand.exitOverride()
|
|
60
|
+
billingCommand.configureOutput({ writeErr: () => {}, writeOut: () => {} })
|
|
61
|
+
for (const cmd of billingCommand.commands) {
|
|
62
|
+
cmd.exitOverride()
|
|
63
|
+
cmd.configureOutput({ writeErr: () => {}, writeOut: () => {} })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await expect(
|
|
67
|
+
billingCommand.parseAsync(['checkout'], { from: 'user' }),
|
|
68
|
+
).rejects.toThrow('--tier')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('checkout sends correct tier in mutation variables', async () => {
|
|
72
|
+
mockGraphql.mockResolvedValue({
|
|
73
|
+
createCheckout: { url: 'https://checkout.stripe.com/session_abc' },
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const { billingCommand } = await import('./billing.ts')
|
|
77
|
+
await billingCommand.parseAsync(['checkout', '--tier', 'team'], {
|
|
78
|
+
from: 'user',
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(mockGraphql).toHaveBeenCalledOnce()
|
|
82
|
+
const [, variables] = mockGraphql.mock.calls[0]!
|
|
83
|
+
expect(variables).toEqual({ tier: 'team' })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('checkout calls outputError on failure', async () => {
|
|
87
|
+
const error = new Error('Unauthorized')
|
|
88
|
+
mockGraphql.mockRejectedValue(error)
|
|
89
|
+
|
|
90
|
+
const { billingCommand } = await import('./billing.ts')
|
|
91
|
+
await billingCommand.parseAsync(['checkout', '--tier', 'explorer'], {
|
|
92
|
+
from: 'user',
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(mockOutputError).toHaveBeenCalledWith(error)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Command, Option } from 'commander'
|
|
2
|
+
import { graphql } from '../util/client.ts'
|
|
3
|
+
import { output, outputError } from '../util/format.ts'
|
|
4
|
+
|
|
5
|
+
const checkoutCommand = new Command('checkout')
|
|
6
|
+
.description('Create a checkout session for a subscription tier')
|
|
7
|
+
.addOption(
|
|
8
|
+
new Option('--tier <tier>', 'Subscription tier')
|
|
9
|
+
.choices(['explorer', 'pro', 'team'])
|
|
10
|
+
.makeOptionMandatory(),
|
|
11
|
+
)
|
|
12
|
+
.action(async (opts: { tier: string }) => {
|
|
13
|
+
try {
|
|
14
|
+
const data = await graphql<{ createCheckout: { url: string } }>(
|
|
15
|
+
`mutation CreateCheckout($tier: String!) {
|
|
16
|
+
createCheckout(tier: $tier) {
|
|
17
|
+
url
|
|
18
|
+
}
|
|
19
|
+
}`,
|
|
20
|
+
{ tier: opts.tier },
|
|
21
|
+
)
|
|
22
|
+
output(data.createCheckout)
|
|
23
|
+
} catch (error) {
|
|
24
|
+
outputError(error)
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
export const billingCommand = new Command('billing')
|
|
29
|
+
.description('Billing and subscription management')
|
|
30
|
+
.addCommand(checkoutCommand)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
let mockLoadConfig: ReturnType<typeof vi.fn>
|
|
4
|
+
let mockSaveConfig: ReturnType<typeof vi.fn>
|
|
5
|
+
let mockOutput: ReturnType<typeof vi.fn>
|
|
6
|
+
let mockOutputError: ReturnType<typeof vi.fn>
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mockLoadConfig = vi.fn(() => ({
|
|
10
|
+
mode: 'saas',
|
|
11
|
+
apiUrl: 'https://flowy-ai.fly.dev/graphql',
|
|
12
|
+
apiKey: 'old-key',
|
|
13
|
+
client: { name: '' },
|
|
14
|
+
projects: {},
|
|
15
|
+
}))
|
|
16
|
+
mockSaveConfig = vi.fn()
|
|
17
|
+
mockOutput = vi.fn()
|
|
18
|
+
mockOutputError = vi.fn()
|
|
19
|
+
|
|
20
|
+
vi.doMock('../util/config.ts', () => ({
|
|
21
|
+
loadConfig: mockLoadConfig,
|
|
22
|
+
saveConfig: mockSaveConfig,
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
vi.doMock('../util/format.ts', () => ({
|
|
26
|
+
output: mockOutput,
|
|
27
|
+
outputError: mockOutputError,
|
|
28
|
+
}))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.resetModules()
|
|
33
|
+
vi.restoreAllMocks()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('key command', () => {
|
|
37
|
+
test('exports a command named "key"', async () => {
|
|
38
|
+
const { keyCommand } = await import('./key.ts')
|
|
39
|
+
expect(keyCommand.name()).toBe('key')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('has rotate subcommand', async () => {
|
|
43
|
+
const { keyCommand } = await import('./key.ts')
|
|
44
|
+
const subcommandNames = keyCommand.commands.map((c) => c.name())
|
|
45
|
+
expect(subcommandNames).toContain('rotate')
|
|
46
|
+
expect(keyCommand.commands).toHaveLength(1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('rotate calls rotateApiKey mutation, saves new key to config, and outputs result', async () => {
|
|
50
|
+
const mockGraphql = vi.fn().mockResolvedValue({
|
|
51
|
+
rotateApiKey: {
|
|
52
|
+
user: {
|
|
53
|
+
id: 'user_1',
|
|
54
|
+
email: 'test@example.com',
|
|
55
|
+
tier: 'free',
|
|
56
|
+
createdAt: '2025-01-01T00:00:00Z',
|
|
57
|
+
graceEndsAt: null,
|
|
58
|
+
},
|
|
59
|
+
apiKey: 'flowy_new_key_456',
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
vi.doMock('../util/client.ts', () => ({
|
|
63
|
+
graphql: mockGraphql,
|
|
64
|
+
}))
|
|
65
|
+
|
|
66
|
+
const { keyCommand } = await import('./key.ts')
|
|
67
|
+
await keyCommand.parseAsync(['rotate'], { from: 'user' })
|
|
68
|
+
|
|
69
|
+
expect(mockGraphql).toHaveBeenCalledWith(
|
|
70
|
+
expect.stringContaining('rotateApiKey'),
|
|
71
|
+
)
|
|
72
|
+
expect(mockSaveConfig).toHaveBeenCalledWith(
|
|
73
|
+
expect.objectContaining({
|
|
74
|
+
apiKey: 'flowy_new_key_456',
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
expect(mockOutput).toHaveBeenCalledWith(
|
|
78
|
+
expect.objectContaining({
|
|
79
|
+
user: expect.objectContaining({ email: 'test@example.com' }),
|
|
80
|
+
apiKey: 'flowy_new_key_456',
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('rotate saves the exact new apiKey to config', async () => {
|
|
86
|
+
const newKey = 'flowy_rotated_key_789'
|
|
87
|
+
const mockGraphql = vi.fn().mockResolvedValue({
|
|
88
|
+
rotateApiKey: {
|
|
89
|
+
user: {
|
|
90
|
+
id: 'user_1',
|
|
91
|
+
email: 'dev@example.com',
|
|
92
|
+
tier: 'pro',
|
|
93
|
+
createdAt: '2025-06-01T00:00:00Z',
|
|
94
|
+
graceEndsAt: null,
|
|
95
|
+
},
|
|
96
|
+
apiKey: newKey,
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
vi.doMock('../util/client.ts', () => ({
|
|
100
|
+
graphql: mockGraphql,
|
|
101
|
+
}))
|
|
102
|
+
|
|
103
|
+
const { keyCommand } = await import('./key.ts')
|
|
104
|
+
await keyCommand.parseAsync(['rotate'], { from: 'user' })
|
|
105
|
+
|
|
106
|
+
expect(mockSaveConfig).toHaveBeenCalledOnce()
|
|
107
|
+
const savedConfig = mockSaveConfig.mock.calls[0]![0]
|
|
108
|
+
expect(savedConfig.apiKey).toBe(newKey)
|
|
109
|
+
expect(savedConfig.apiKey).not.toBe('old-key')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('rotate outputs error when mutation fails', async () => {
|
|
113
|
+
const mockGraphql = vi.fn().mockRejectedValue(new Error('Unauthorized'))
|
|
114
|
+
vi.doMock('../util/client.ts', () => ({
|
|
115
|
+
graphql: mockGraphql,
|
|
116
|
+
}))
|
|
117
|
+
|
|
118
|
+
const { keyCommand } = await import('./key.ts')
|
|
119
|
+
await keyCommand.parseAsync(['rotate'], { from: 'user' })
|
|
120
|
+
|
|
121
|
+
expect(mockOutputError).toHaveBeenCalledWith(expect.any(Error))
|
|
122
|
+
expect(mockSaveConfig).not.toHaveBeenCalled()
|
|
123
|
+
expect(mockOutput).not.toHaveBeenCalled()
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import { graphql } from '../util/client.ts'
|
|
3
|
+
import { loadConfig, saveConfig } from '../util/config.ts'
|
|
4
|
+
import { output, outputError } from '../util/format.ts'
|
|
5
|
+
|
|
6
|
+
export const keyCommand = new Command('key').description('API key management')
|
|
7
|
+
|
|
8
|
+
keyCommand
|
|
9
|
+
.command('rotate')
|
|
10
|
+
.description('Rotate API key')
|
|
11
|
+
.action(async () => {
|
|
12
|
+
try {
|
|
13
|
+
const data = await graphql<{
|
|
14
|
+
rotateApiKey: {
|
|
15
|
+
user: {
|
|
16
|
+
id: string
|
|
17
|
+
email: string
|
|
18
|
+
tier: string
|
|
19
|
+
createdAt: string
|
|
20
|
+
graceEndsAt: string | null
|
|
21
|
+
}
|
|
22
|
+
apiKey: string
|
|
23
|
+
}
|
|
24
|
+
}>(
|
|
25
|
+
`mutation RotateApiKey {
|
|
26
|
+
rotateApiKey {
|
|
27
|
+
user { id email tier createdAt graceEndsAt }
|
|
28
|
+
apiKey
|
|
29
|
+
}
|
|
30
|
+
}`,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const config = loadConfig()
|
|
34
|
+
config.apiKey = data.rotateApiKey.apiKey
|
|
35
|
+
saveConfig(config)
|
|
36
|
+
|
|
37
|
+
output(data.rotateApiKey)
|
|
38
|
+
} catch (error) {
|
|
39
|
+
outputError(error)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
@@ -99,11 +99,92 @@ describe('setup command', () => {
|
|
|
99
99
|
)
|
|
100
100
|
})
|
|
101
101
|
|
|
102
|
+
test('setup remote requires --tier', async () => {
|
|
103
|
+
const { setupCommand } = await import('./setup.ts')
|
|
104
|
+
await setupCommand.parseAsync(['remote', '--email', 'test@example.com'], {
|
|
105
|
+
from: 'user',
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
expect(mockOutputError).toHaveBeenCalledWith(
|
|
109
|
+
expect.objectContaining({
|
|
110
|
+
message: expect.stringContaining('--tier is required'),
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('setup remote outputs error when registration fails', async () => {
|
|
116
|
+
const mockGraphql = vi
|
|
117
|
+
.fn()
|
|
118
|
+
.mockRejectedValue(new Error('Registration is temporarily closed.'))
|
|
119
|
+
vi.doMock('../util/client.ts', () => ({
|
|
120
|
+
graphql: mockGraphql,
|
|
121
|
+
}))
|
|
122
|
+
|
|
123
|
+
const { setupCommand } = await import('./setup.ts')
|
|
124
|
+
await setupCommand.parseAsync(
|
|
125
|
+
['remote', '--email', 'test@example.com', '--tier', 'explorer'],
|
|
126
|
+
{ from: 'user' },
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
expect(mockOutputError).toHaveBeenCalledWith(
|
|
130
|
+
expect.objectContaining({
|
|
131
|
+
message: 'Registration is temporarily closed.',
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
expect(mockSaveConfig).toHaveBeenCalledTimes(1)
|
|
135
|
+
expect(mockSaveConfig).toHaveBeenCalledWith(
|
|
136
|
+
expect.objectContaining({
|
|
137
|
+
mode: 'remote',
|
|
138
|
+
apiUrl: 'https://flowy-ai.fly.dev/graphql',
|
|
139
|
+
}),
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('setup remote sends correct email and tier in mutation', async () => {
|
|
144
|
+
const mockGraphql = vi.fn().mockResolvedValue({
|
|
145
|
+
register: {
|
|
146
|
+
user: {
|
|
147
|
+
id: 'user_1',
|
|
148
|
+
email: 'a@b.com',
|
|
149
|
+
tier: 'pro',
|
|
150
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
151
|
+
graceEndsAt: null,
|
|
152
|
+
},
|
|
153
|
+
apiKey: 'flowy_key',
|
|
154
|
+
checkoutUrl: null,
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
vi.doMock('../util/client.ts', () => ({
|
|
158
|
+
graphql: mockGraphql,
|
|
159
|
+
}))
|
|
160
|
+
mockSpawnSync.mockReturnValue({
|
|
161
|
+
status: 0,
|
|
162
|
+
stdout: Buffer.from(''),
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const { setupCommand } = await import('./setup.ts')
|
|
166
|
+
await setupCommand.parseAsync(
|
|
167
|
+
['remote', '--email', 'a@b.com', '--tier', 'pro'],
|
|
168
|
+
{ from: 'user' },
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
expect(mockGraphql).toHaveBeenCalledOnce()
|
|
172
|
+
const [, variables] = mockGraphql.mock.calls[0]!
|
|
173
|
+
expect(variables).toEqual({ email: 'a@b.com', tier: 'pro' })
|
|
174
|
+
})
|
|
175
|
+
|
|
102
176
|
test('setup remote registers, saves API key, and outputs result', async () => {
|
|
103
177
|
const mockGraphql = vi.fn().mockResolvedValue({
|
|
104
178
|
register: {
|
|
105
|
-
user: {
|
|
179
|
+
user: {
|
|
180
|
+
id: 'user_1',
|
|
181
|
+
email: 'test@example.com',
|
|
182
|
+
tier: 'explorer',
|
|
183
|
+
createdAt: '2026-03-30T00:00:00Z',
|
|
184
|
+
graceEndsAt: '2026-04-13T00:00:00Z',
|
|
185
|
+
},
|
|
106
186
|
apiKey: 'flowy_test_key_123',
|
|
187
|
+
checkoutUrl: 'https://checkout.stripe.com/session_123',
|
|
107
188
|
},
|
|
108
189
|
})
|
|
109
190
|
vi.doMock('../util/client.ts', () => ({
|
|
@@ -115,10 +196,15 @@ describe('setup command', () => {
|
|
|
115
196
|
})
|
|
116
197
|
|
|
117
198
|
const { setupCommand } = await import('./setup.ts')
|
|
118
|
-
await setupCommand.parseAsync(
|
|
119
|
-
|
|
120
|
-
|
|
199
|
+
await setupCommand.parseAsync(
|
|
200
|
+
['remote', '--email', 'test@example.com', '--tier', 'explorer'],
|
|
201
|
+
{ from: 'user' },
|
|
202
|
+
)
|
|
121
203
|
|
|
204
|
+
expect(mockGraphql).toHaveBeenCalledWith(
|
|
205
|
+
expect.stringContaining('register(email: $email, tier: $tier)'),
|
|
206
|
+
{ email: 'test@example.com', tier: 'explorer' },
|
|
207
|
+
)
|
|
122
208
|
expect(mockSaveConfig).toHaveBeenCalledWith(
|
|
123
209
|
expect.objectContaining({
|
|
124
210
|
mode: 'remote',
|
|
@@ -127,8 +213,13 @@ describe('setup command', () => {
|
|
|
127
213
|
)
|
|
128
214
|
expect(mockOutput).toHaveBeenCalledWith(
|
|
129
215
|
expect.objectContaining({
|
|
130
|
-
user: expect.objectContaining({
|
|
216
|
+
user: expect.objectContaining({
|
|
217
|
+
email: 'test@example.com',
|
|
218
|
+
tier: 'explorer',
|
|
219
|
+
graceEndsAt: '2026-04-13T00:00:00Z',
|
|
220
|
+
}),
|
|
131
221
|
apiKey: 'flowy_test_key_123',
|
|
222
|
+
checkoutUrl: 'https://checkout.stripe.com/session_123',
|
|
132
223
|
}),
|
|
133
224
|
)
|
|
134
225
|
})
|
package/src/commands/setup.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process'
|
|
|
2
2
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { resolve } from 'node:path'
|
|
5
|
-
import { Command } from 'commander'
|
|
5
|
+
import { Command, Option } from 'commander'
|
|
6
6
|
import { loadConfig, saveConfig } from '../util/config.ts'
|
|
7
7
|
import { output, outputError } from '../util/format.ts'
|
|
8
8
|
|
|
@@ -90,6 +90,10 @@ setupCommand
|
|
|
90
90
|
config.mode = 'local'
|
|
91
91
|
config.apiUrl = apiUrl
|
|
92
92
|
saveConfig(config)
|
|
93
|
+
spawnSync('npx', ['skills', 'add', 'sqaoss/flowy', '--yes'], {
|
|
94
|
+
stdio: 'inherit',
|
|
95
|
+
})
|
|
96
|
+
|
|
93
97
|
output({ mode: 'local', apiUrl })
|
|
94
98
|
} catch (error) {
|
|
95
99
|
outputError(error)
|
|
@@ -100,11 +104,21 @@ setupCommand
|
|
|
100
104
|
.command('remote')
|
|
101
105
|
.description('Connect to the hosted Flowy service')
|
|
102
106
|
.option('--email <email>', 'Email address for registration')
|
|
107
|
+
.addOption(
|
|
108
|
+
new Option('--tier <tier>', 'Subscription tier').choices([
|
|
109
|
+
'explorer',
|
|
110
|
+
'pro',
|
|
111
|
+
'team',
|
|
112
|
+
]),
|
|
113
|
+
)
|
|
103
114
|
.action(async (opts) => {
|
|
104
115
|
try {
|
|
105
116
|
if (!opts.email) {
|
|
106
117
|
throw new Error('--email is required for registration')
|
|
107
118
|
}
|
|
119
|
+
if (!opts.tier) {
|
|
120
|
+
throw new Error('--tier is required for registration')
|
|
121
|
+
}
|
|
108
122
|
|
|
109
123
|
const { graphql } = await import('../util/client.ts')
|
|
110
124
|
|
|
@@ -115,17 +129,25 @@ setupCommand
|
|
|
115
129
|
|
|
116
130
|
const data = await graphql<{
|
|
117
131
|
register: {
|
|
118
|
-
user: {
|
|
132
|
+
user: {
|
|
133
|
+
id: string
|
|
134
|
+
email: string
|
|
135
|
+
tier: string
|
|
136
|
+
createdAt: string
|
|
137
|
+
graceEndsAt: string
|
|
138
|
+
}
|
|
119
139
|
apiKey: string
|
|
140
|
+
checkoutUrl: string
|
|
120
141
|
}
|
|
121
142
|
}>(
|
|
122
|
-
`mutation Register($email: String!) {
|
|
123
|
-
register(email: $email) {
|
|
124
|
-
user { id email tier }
|
|
143
|
+
`mutation Register($email: String!, $tier: String!) {
|
|
144
|
+
register(email: $email, tier: $tier) {
|
|
145
|
+
user { id email tier createdAt graceEndsAt }
|
|
125
146
|
apiKey
|
|
147
|
+
checkoutUrl
|
|
126
148
|
}
|
|
127
149
|
}`,
|
|
128
|
-
{ email: opts.email },
|
|
150
|
+
{ email: opts.email, tier: opts.tier },
|
|
129
151
|
)
|
|
130
152
|
|
|
131
153
|
config.apiKey = data.register.apiKey
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
let mockGraphql: ReturnType<typeof vi.fn>
|
|
4
|
+
let mockOutput: ReturnType<typeof vi.fn>
|
|
5
|
+
let mockOutputError: ReturnType<typeof vi.fn>
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
mockOutput = vi.fn()
|
|
9
|
+
mockOutputError = vi.fn()
|
|
10
|
+
mockGraphql = vi.fn().mockResolvedValue({
|
|
11
|
+
whoami: {
|
|
12
|
+
id: 'user_1',
|
|
13
|
+
email: 'test@example.com',
|
|
14
|
+
tier: 'free',
|
|
15
|
+
createdAt: '2026-01-01',
|
|
16
|
+
graceEndsAt: null,
|
|
17
|
+
},
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
vi.doMock('../util/format.ts', () => ({
|
|
21
|
+
output: mockOutput,
|
|
22
|
+
outputError: mockOutputError,
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
vi.doMock('../util/client.ts', () => ({
|
|
26
|
+
graphql: mockGraphql,
|
|
27
|
+
}))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.resetModules()
|
|
32
|
+
vi.restoreAllMocks()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('whoami command', () => {
|
|
36
|
+
test('whoami outputs user data from query', async () => {
|
|
37
|
+
const userData = {
|
|
38
|
+
id: '1',
|
|
39
|
+
email: 'a@b.com',
|
|
40
|
+
tier: 'explorer',
|
|
41
|
+
createdAt: '2026-01-01',
|
|
42
|
+
graceEndsAt: null,
|
|
43
|
+
}
|
|
44
|
+
mockGraphql.mockResolvedValue({ whoami: userData })
|
|
45
|
+
|
|
46
|
+
const { whoamiCommand } = await import('./whoami.ts')
|
|
47
|
+
await whoamiCommand.parseAsync([], { from: 'user' })
|
|
48
|
+
|
|
49
|
+
expect(mockOutput).toHaveBeenCalledWith(userData)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('whoami outputs error when query fails', async () => {
|
|
53
|
+
mockGraphql.mockRejectedValue(new Error('Auth required'))
|
|
54
|
+
|
|
55
|
+
const { whoamiCommand } = await import('./whoami.ts')
|
|
56
|
+
await whoamiCommand.parseAsync([], { from: 'user' })
|
|
57
|
+
|
|
58
|
+
expect(mockOutputError).toHaveBeenCalledWith(
|
|
59
|
+
expect.objectContaining({
|
|
60
|
+
message: 'Auth required',
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
63
|
+
expect(mockOutput).not.toHaveBeenCalled()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('whoami queries graceEndsAt field', async () => {
|
|
67
|
+
const { whoamiCommand } = await import('./whoami.ts')
|
|
68
|
+
await whoamiCommand.parseAsync([], { from: 'user' })
|
|
69
|
+
|
|
70
|
+
expect(mockGraphql).toHaveBeenCalledOnce()
|
|
71
|
+
const query = mockGraphql.mock.calls[0]?.[0] as string
|
|
72
|
+
expect(query).toContain('graceEndsAt')
|
|
73
|
+
})
|
|
74
|
+
})
|
package/src/commands/whoami.ts
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('./commands/approve.ts', () => ({
|
|
4
|
+
approveCommand: { name: () => 'approve' },
|
|
5
|
+
}))
|
|
6
|
+
vi.mock('./commands/client.ts', () => ({
|
|
7
|
+
clientCommand: { name: () => 'client' },
|
|
8
|
+
}))
|
|
9
|
+
vi.mock('./commands/feature.ts', () => ({
|
|
10
|
+
featureCommand: { name: () => 'feature' },
|
|
11
|
+
}))
|
|
12
|
+
vi.mock('./commands/init.ts', () => ({
|
|
13
|
+
initCommand: { name: () => 'init' },
|
|
14
|
+
}))
|
|
15
|
+
vi.mock('./commands/project.ts', () => ({
|
|
16
|
+
projectCommand: { name: () => 'project' },
|
|
17
|
+
}))
|
|
18
|
+
vi.mock('./commands/search.ts', () => ({
|
|
19
|
+
searchCommand: { name: () => 'search' },
|
|
20
|
+
}))
|
|
21
|
+
vi.mock('./commands/setup.ts', () => ({
|
|
22
|
+
setupCommand: { name: () => 'setup' },
|
|
23
|
+
}))
|
|
24
|
+
vi.mock('./commands/status.ts', () => ({
|
|
25
|
+
statusCommand: { name: () => 'status' },
|
|
26
|
+
}))
|
|
27
|
+
vi.mock('./commands/task.ts', () => ({
|
|
28
|
+
taskCommand: { name: () => 'task' },
|
|
29
|
+
}))
|
|
30
|
+
vi.mock('./commands/tree.ts', () => ({
|
|
31
|
+
treeCommand: { name: () => 'tree' },
|
|
32
|
+
}))
|
|
33
|
+
vi.mock('./commands/whoami.ts', () => ({
|
|
34
|
+
whoamiCommand: { name: () => 'whoami' },
|
|
35
|
+
}))
|
|
36
|
+
vi.mock('./commands/billing.ts', () => ({
|
|
37
|
+
billingCommand: { name: () => 'billing' },
|
|
38
|
+
}))
|
|
39
|
+
vi.mock('./commands/key.ts', () => ({
|
|
40
|
+
keyCommand: { name: () => 'key' },
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
describe('index.ts command registration', () => {
|
|
44
|
+
test('registers billing and key commands', async () => {
|
|
45
|
+
const { readFileSync } = await import('node:fs')
|
|
46
|
+
const indexSource = readFileSync(
|
|
47
|
+
new URL('./index.ts', import.meta.url).pathname,
|
|
48
|
+
'utf-8',
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
expect(indexSource).toContain(
|
|
52
|
+
"import { billingCommand } from './commands/billing.ts'",
|
|
53
|
+
)
|
|
54
|
+
expect(indexSource).toContain(
|
|
55
|
+
"import { keyCommand } from './commands/key.ts'",
|
|
56
|
+
)
|
|
57
|
+
expect(indexSource).toContain('program.addCommand(billingCommand)')
|
|
58
|
+
expect(indexSource).toContain('program.addCommand(keyCommand)')
|
|
59
|
+
})
|
|
60
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -12,9 +12,11 @@ const pkgPath = resolve(
|
|
|
12
12
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
13
13
|
|
|
14
14
|
import { approveCommand } from './commands/approve.ts'
|
|
15
|
+
import { billingCommand } from './commands/billing.ts'
|
|
15
16
|
import { clientCommand } from './commands/client.ts'
|
|
16
17
|
import { featureCommand } from './commands/feature.ts'
|
|
17
18
|
import { initCommand } from './commands/init.ts'
|
|
19
|
+
import { keyCommand } from './commands/key.ts'
|
|
18
20
|
import { projectCommand } from './commands/project.ts'
|
|
19
21
|
import { searchCommand } from './commands/search.ts'
|
|
20
22
|
import { setupCommand } from './commands/setup.ts'
|
|
@@ -36,6 +38,8 @@ program.addCommand(featureCommand)
|
|
|
36
38
|
program.addCommand(taskCommand)
|
|
37
39
|
program.addCommand(statusCommand)
|
|
38
40
|
program.addCommand(approveCommand)
|
|
41
|
+
program.addCommand(billingCommand)
|
|
42
|
+
program.addCommand(keyCommand)
|
|
39
43
|
program.addCommand(searchCommand)
|
|
40
44
|
program.addCommand(treeCommand)
|
|
41
45
|
program.addCommand(whoamiCommand)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
beforeEach(() => {
|
|
4
|
+
vi.doMock('./config.ts', () => ({
|
|
5
|
+
getConfig: () => ({
|
|
6
|
+
apiUrl: 'http://test/graphql',
|
|
7
|
+
apiKey: 'test-key',
|
|
8
|
+
}),
|
|
9
|
+
}))
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.resetModules()
|
|
14
|
+
vi.restoreAllMocks()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('graphql client', () => {
|
|
18
|
+
test('returns data on successful response', async () => {
|
|
19
|
+
vi.stubGlobal(
|
|
20
|
+
'fetch',
|
|
21
|
+
vi.fn().mockResolvedValue({
|
|
22
|
+
json: () =>
|
|
23
|
+
Promise.resolve({
|
|
24
|
+
data: { whoami: { id: '1' } },
|
|
25
|
+
}),
|
|
26
|
+
}),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const { graphql } = await import('./client.ts')
|
|
30
|
+
const result = await graphql('query { whoami { id } }')
|
|
31
|
+
expect(result).toEqual({ whoami: { id: '1' } })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('throws original server message for unknown error codes', async () => {
|
|
35
|
+
vi.stubGlobal(
|
|
36
|
+
'fetch',
|
|
37
|
+
vi.fn().mockResolvedValue({
|
|
38
|
+
json: () =>
|
|
39
|
+
Promise.resolve({
|
|
40
|
+
errors: [
|
|
41
|
+
{
|
|
42
|
+
message: 'Something broke',
|
|
43
|
+
extensions: { code: 'UNKNOWN_CODE' },
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
}),
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const { graphql } = await import('./client.ts')
|
|
51
|
+
await expect(graphql('query { whoami { id } }')).rejects.toThrow(
|
|
52
|
+
'Something broke',
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('throws error message when extensions are absent', async () => {
|
|
57
|
+
vi.stubGlobal(
|
|
58
|
+
'fetch',
|
|
59
|
+
vi.fn().mockResolvedValue({
|
|
60
|
+
json: () =>
|
|
61
|
+
Promise.resolve({
|
|
62
|
+
errors: [{ message: 'Auth required' }],
|
|
63
|
+
}),
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const { graphql } = await import('./client.ts')
|
|
68
|
+
await expect(graphql('query { whoami { id } }')).rejects.toThrow(
|
|
69
|
+
'Auth required',
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('omits Authorization header when no apiKey configured', async () => {
|
|
74
|
+
vi.doMock('./config.ts', () => ({
|
|
75
|
+
getConfig: () => ({
|
|
76
|
+
apiUrl: 'http://test/graphql',
|
|
77
|
+
apiKey: '',
|
|
78
|
+
}),
|
|
79
|
+
}))
|
|
80
|
+
|
|
81
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
82
|
+
json: () =>
|
|
83
|
+
Promise.resolve({
|
|
84
|
+
data: { whoami: { id: '1' } },
|
|
85
|
+
}),
|
|
86
|
+
})
|
|
87
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
88
|
+
|
|
89
|
+
const { graphql } = await import('./client.ts')
|
|
90
|
+
await graphql('query { whoami { id } }')
|
|
91
|
+
|
|
92
|
+
const callHeaders = mockFetch.mock.calls[0]![1].headers
|
|
93
|
+
expect(callHeaders).not.toHaveProperty('Authorization')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('throws on network error', async () => {
|
|
97
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('fetch failed')))
|
|
98
|
+
|
|
99
|
+
const { graphql } = await import('./client.ts')
|
|
100
|
+
await expect(graphql('query { whoami { id } }')).rejects.toThrow(
|
|
101
|
+
'fetch failed',
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('throws friendly message for SUBSCRIPTION_REQUIRED error code', async () => {
|
|
106
|
+
vi.stubGlobal(
|
|
107
|
+
'fetch',
|
|
108
|
+
vi.fn().mockResolvedValue({
|
|
109
|
+
json: () =>
|
|
110
|
+
Promise.resolve({
|
|
111
|
+
errors: [
|
|
112
|
+
{
|
|
113
|
+
message: 'Active subscription required.',
|
|
114
|
+
extensions: { code: 'SUBSCRIPTION_REQUIRED' },
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
}),
|
|
118
|
+
}),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const { graphql } = await import('./client.ts')
|
|
122
|
+
await expect(graphql('query { whoami { id } }')).rejects.toThrow(
|
|
123
|
+
/flowy billing checkout/,
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('throws friendly message for SUBSCRIPTION_EXPIRED error code', async () => {
|
|
128
|
+
vi.stubGlobal(
|
|
129
|
+
'fetch',
|
|
130
|
+
vi.fn().mockResolvedValue({
|
|
131
|
+
json: () =>
|
|
132
|
+
Promise.resolve({
|
|
133
|
+
errors: [
|
|
134
|
+
{
|
|
135
|
+
message: 'Subscription has expired.',
|
|
136
|
+
extensions: { code: 'SUBSCRIPTION_EXPIRED' },
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
}),
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const { graphql } = await import('./client.ts')
|
|
144
|
+
await expect(graphql('query { whoami { id } }')).rejects.toThrow(
|
|
145
|
+
/flowy billing checkout/,
|
|
146
|
+
)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('throws friendly message for SUBSCRIPTION_SUSPENDED error code', async () => {
|
|
150
|
+
vi.stubGlobal(
|
|
151
|
+
'fetch',
|
|
152
|
+
vi.fn().mockResolvedValue({
|
|
153
|
+
json: () =>
|
|
154
|
+
Promise.resolve({
|
|
155
|
+
errors: [
|
|
156
|
+
{
|
|
157
|
+
message: 'Subscription is suspended.',
|
|
158
|
+
extensions: { code: 'SUBSCRIPTION_SUSPENDED' },
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
}),
|
|
162
|
+
}),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
const { graphql } = await import('./client.ts')
|
|
166
|
+
await expect(graphql('query { whoami { id } }')).rejects.toThrow(
|
|
167
|
+
/suspended.*contact support/,
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
})
|
package/src/util/client.ts
CHANGED
|
@@ -22,11 +22,32 @@ export async function graphql<T = unknown>(
|
|
|
22
22
|
|
|
23
23
|
const json = (await res.json()) as {
|
|
24
24
|
data?: T
|
|
25
|
-
errors?: Array<{ message: string }>
|
|
25
|
+
errors?: Array<{ message: string; extensions?: { code?: string } }>
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
if (json.errors?.length) {
|
|
29
|
-
|
|
29
|
+
const error = json.errors[0]
|
|
30
|
+
const code = error?.extensions?.code
|
|
31
|
+
|
|
32
|
+
if (code === 'SUBSCRIPTION_REQUIRED') {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'An active subscription is required. Run `flowy billing checkout` to subscribe.',
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (code === 'SUBSCRIPTION_EXPIRED') {
|
|
39
|
+
throw new Error(
|
|
40
|
+
'Your subscription has expired. Run `flowy billing checkout` to renew.',
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (code === 'SUBSCRIPTION_SUSPENDED') {
|
|
45
|
+
throw new Error(
|
|
46
|
+
'Your subscription is suspended. Please contact support to resolve this.',
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
throw new Error(error?.message)
|
|
30
51
|
}
|
|
31
52
|
|
|
32
53
|
return json.data as T
|