@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqaoss/flowy",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Agentic persistent planning",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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: { id: 'user_1', email: 'test@example.com', tier: 'free' },
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(['remote', '--email', 'test@example.com'], {
119
- from: 'user',
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({ email: 'test@example.com' }),
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
  })
@@ -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: { id: string; email: string; tier: string }
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
+ })
@@ -9,7 +9,7 @@ export const whoamiCommand = new Command('whoami')
9
9
  const data = await graphql<{ whoami: unknown }>(
10
10
  `query Whoami {
11
11
  whoami {
12
- id email tier createdAt
12
+ id email tier createdAt graceEndsAt
13
13
  }
14
14
  }`,
15
15
  )
@@ -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
+ })
@@ -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
- throw new Error(json.errors[0]?.message)
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