@sqaoss/flowy 1.1.3 → 1.2.1
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/project.ts +1 -1
- package/src/commands/setup.test.ts +96 -5
- package/src/commands/setup.ts +24 -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
|
+
})
|
package/src/commands/project.ts
CHANGED
|
@@ -53,7 +53,7 @@ projectCommand
|
|
|
53
53
|
)
|
|
54
54
|
const project = data.nodes.find((n) => n.title === name)
|
|
55
55
|
if (!project) {
|
|
56
|
-
throw new Error(`Project "${name}" not found
|
|
56
|
+
throw new Error(`Project "${name}" not found.`)
|
|
57
57
|
}
|
|
58
58
|
await setProject(project.id, project.title)
|
|
59
59
|
} catch (error) {
|
|
@@ -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
|
|
|
@@ -104,11 +104,21 @@ setupCommand
|
|
|
104
104
|
.command('remote')
|
|
105
105
|
.description('Connect to the hosted Flowy service')
|
|
106
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
|
+
)
|
|
107
114
|
.action(async (opts) => {
|
|
108
115
|
try {
|
|
109
116
|
if (!opts.email) {
|
|
110
117
|
throw new Error('--email is required for registration')
|
|
111
118
|
}
|
|
119
|
+
if (!opts.tier) {
|
|
120
|
+
throw new Error('--tier is required for registration')
|
|
121
|
+
}
|
|
112
122
|
|
|
113
123
|
const { graphql } = await import('../util/client.ts')
|
|
114
124
|
|
|
@@ -119,17 +129,25 @@ setupCommand
|
|
|
119
129
|
|
|
120
130
|
const data = await graphql<{
|
|
121
131
|
register: {
|
|
122
|
-
user: {
|
|
132
|
+
user: {
|
|
133
|
+
id: string
|
|
134
|
+
email: string
|
|
135
|
+
tier: string
|
|
136
|
+
createdAt: string
|
|
137
|
+
graceEndsAt: string
|
|
138
|
+
}
|
|
123
139
|
apiKey: string
|
|
140
|
+
checkoutUrl: string
|
|
124
141
|
}
|
|
125
142
|
}>(
|
|
126
|
-
`mutation Register($email: String!) {
|
|
127
|
-
register(email: $email) {
|
|
128
|
-
user { id email tier }
|
|
143
|
+
`mutation Register($email: String!, $tier: String!) {
|
|
144
|
+
register(email: $email, tier: $tier) {
|
|
145
|
+
user { id email tier createdAt graceEndsAt }
|
|
129
146
|
apiKey
|
|
147
|
+
checkoutUrl
|
|
130
148
|
}
|
|
131
149
|
}`,
|
|
132
|
-
{ email: opts.email },
|
|
150
|
+
{ email: opts.email, tier: opts.tier },
|
|
133
151
|
)
|
|
134
152
|
|
|
135
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
|