@sqaoss/flowy 1.6.1 → 1.8.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/README.md +14 -0
- package/package.json +2 -1
- package/server/src/contract.test.ts +407 -0
- package/src/commands/approve.ts +2 -6
- package/src/commands/billing.test.ts +32 -0
- package/src/commands/billing.ts +4 -5
- package/src/commands/export.ts +8 -19
- package/src/commands/feature.ts +28 -48
- package/src/commands/import.ts +14 -24
- package/src/commands/init.ts +2 -5
- package/src/commands/key.test.ts +81 -8
- package/src/commands/key.ts +22 -12
- package/src/commands/project.ts +23 -43
- package/src/commands/search.ts +7 -13
- package/src/commands/setup.test.ts +95 -7
- package/src/commands/setup.ts +14 -12
- package/src/commands/status.ts +5 -8
- package/src/commands/task.ts +48 -87
- package/src/commands/tree.ts +5 -8
- package/src/commands/whoami.test.ts +64 -2
- package/src/commands/whoami.ts +14 -8
- package/src/util/config.test.ts +218 -3
- package/src/util/config.ts +224 -21
- package/src/util/operations.test.ts +114 -0
- package/src/util/operations.ts +331 -0
|
@@ -8,7 +8,7 @@ let mockSpawnSync: ReturnType<typeof vi.fn>
|
|
|
8
8
|
|
|
9
9
|
beforeEach(() => {
|
|
10
10
|
mockLoadConfig = vi.fn(() => ({
|
|
11
|
-
mode: '
|
|
11
|
+
mode: 'remote',
|
|
12
12
|
apiUrl: 'https://flowy-ai.fly.dev/graphql',
|
|
13
13
|
apiKey: '',
|
|
14
14
|
client: { name: '' },
|
|
@@ -19,10 +19,17 @@ beforeEach(() => {
|
|
|
19
19
|
mockOutputError = vi.fn()
|
|
20
20
|
mockSpawnSync = vi.fn()
|
|
21
21
|
|
|
22
|
-
vi.doMock('../util/config.ts', () =>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
vi.doMock('../util/config.ts', async () => {
|
|
23
|
+
const actual =
|
|
24
|
+
await vi.importActual<typeof import('../util/config.ts')>(
|
|
25
|
+
'../util/config.ts',
|
|
26
|
+
)
|
|
27
|
+
return {
|
|
28
|
+
loadConfig: mockLoadConfig,
|
|
29
|
+
saveConfig: mockSaveConfig,
|
|
30
|
+
fingerprintKey: actual.fingerprintKey,
|
|
31
|
+
}
|
|
32
|
+
})
|
|
26
33
|
|
|
27
34
|
vi.doMock('../util/format.ts', () => ({
|
|
28
35
|
output: mockOutput,
|
|
@@ -230,17 +237,51 @@ describe('setup command', () => {
|
|
|
230
237
|
apiKey: 'flowy_test_key_123',
|
|
231
238
|
}),
|
|
232
239
|
)
|
|
233
|
-
|
|
240
|
+
// Default output surfaces a fingerprint, never the raw secret (F35).
|
|
241
|
+
const outputArg = mockOutput.mock.calls[0]![0]
|
|
242
|
+
expect(JSON.stringify(outputArg)).not.toContain('flowy_test_key_123')
|
|
243
|
+
expect(outputArg).toEqual(
|
|
234
244
|
expect.objectContaining({
|
|
235
245
|
user: expect.objectContaining({
|
|
236
246
|
email: 'test@example.com',
|
|
237
247
|
tier: 'explorer',
|
|
238
248
|
graceEndsAt: '2026-04-13T00:00:00Z',
|
|
239
249
|
}),
|
|
240
|
-
|
|
250
|
+
keyFingerprint: expect.stringMatching(/sha256:[0-9a-f]{12}/),
|
|
241
251
|
checkoutUrl: 'https://checkout.stripe.com/session_123',
|
|
242
252
|
}),
|
|
243
253
|
)
|
|
254
|
+
expect(outputArg).not.toHaveProperty('apiKey')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('setup remote --show-key reveals the full API key', async () => {
|
|
258
|
+
const mockGraphql = vi.fn().mockResolvedValue({
|
|
259
|
+
register: {
|
|
260
|
+
user: {
|
|
261
|
+
id: 'user_1',
|
|
262
|
+
email: 'test@example.com',
|
|
263
|
+
tier: 'explorer',
|
|
264
|
+
createdAt: '2026-03-30T00:00:00Z',
|
|
265
|
+
graceEndsAt: null,
|
|
266
|
+
},
|
|
267
|
+
apiKey: 'flowy_test_key_123',
|
|
268
|
+
checkoutUrl: null,
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
vi.doMock('../util/client.ts', () => ({
|
|
272
|
+
graphql: mockGraphql,
|
|
273
|
+
}))
|
|
274
|
+
mockSpawnSync.mockReturnValue({ status: 0, stdout: Buffer.from('') })
|
|
275
|
+
|
|
276
|
+
const { setupCommand } = await import('./setup.ts')
|
|
277
|
+
await setupCommand.parseAsync(
|
|
278
|
+
['remote', '--email', 'test@example.com', '--show-key'],
|
|
279
|
+
{ from: 'user' },
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
expect(mockOutput).toHaveBeenCalledWith(
|
|
283
|
+
expect.objectContaining({ apiKey: 'flowy_test_key_123' }),
|
|
284
|
+
)
|
|
244
285
|
})
|
|
245
286
|
|
|
246
287
|
test('setup local warns when the skill install fails', async () => {
|
|
@@ -274,6 +315,53 @@ describe('setup command', () => {
|
|
|
274
315
|
errSpy.mockRestore()
|
|
275
316
|
})
|
|
276
317
|
|
|
318
|
+
test('setup remote persists the API key before any later step (save-after-register)', async () => {
|
|
319
|
+
const mockGraphql = vi.fn().mockResolvedValue({
|
|
320
|
+
register: {
|
|
321
|
+
user: {
|
|
322
|
+
id: 'user_1',
|
|
323
|
+
email: 'a@b.com',
|
|
324
|
+
tier: null,
|
|
325
|
+
createdAt: '2026-06-13T00:00:00Z',
|
|
326
|
+
graceEndsAt: null,
|
|
327
|
+
},
|
|
328
|
+
apiKey: 'flowy_key_persisted',
|
|
329
|
+
checkoutUrl: null,
|
|
330
|
+
},
|
|
331
|
+
})
|
|
332
|
+
vi.doMock('../util/client.ts', () => ({ graphql: mockGraphql }))
|
|
333
|
+
|
|
334
|
+
// The skill install (a step AFTER the key is obtained) blows up hard.
|
|
335
|
+
// The key must already be on disk by then so the user is never stranded.
|
|
336
|
+
let keySavedBeforeSkillInstall = false
|
|
337
|
+
mockSaveConfig.mockImplementation((cfg: { apiKey?: string }) => {
|
|
338
|
+
if (cfg.apiKey === 'flowy_key_persisted')
|
|
339
|
+
keySavedBeforeSkillInstall = true
|
|
340
|
+
})
|
|
341
|
+
mockSpawnSync.mockImplementation((cmd: string) => {
|
|
342
|
+
if (cmd === 'npx') {
|
|
343
|
+
// By the time the (post-register) skill install runs, the key is saved.
|
|
344
|
+
expect(keySavedBeforeSkillInstall).toBe(true)
|
|
345
|
+
throw new Error('npx exploded')
|
|
346
|
+
}
|
|
347
|
+
return { status: 0, stdout: Buffer.from('') }
|
|
348
|
+
})
|
|
349
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
350
|
+
|
|
351
|
+
const { setupCommand } = await import('./setup.ts')
|
|
352
|
+
await setupCommand.parseAsync(['remote', '--email', 'a@b.com'], {
|
|
353
|
+
from: 'user',
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
// The key was saved at least once with the real value before the failure.
|
|
357
|
+
expect(
|
|
358
|
+
mockSaveConfig.mock.calls.some(
|
|
359
|
+
(c) => (c[0] as { apiKey?: string }).apiKey === 'flowy_key_persisted',
|
|
360
|
+
),
|
|
361
|
+
).toBe(true)
|
|
362
|
+
errSpy.mockRestore()
|
|
363
|
+
})
|
|
364
|
+
|
|
277
365
|
test('setup remote warns when the skill install fails', async () => {
|
|
278
366
|
const mockGraphql = vi.fn().mockResolvedValue({
|
|
279
367
|
register: {
|
package/src/commands/setup.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
2
|
import { Command, Option } from 'commander'
|
|
3
|
-
import { loadConfig, saveConfig } from '../util/config.ts'
|
|
3
|
+
import { fingerprintKey, loadConfig, saveConfig } from '../util/config.ts'
|
|
4
4
|
import { output, outputError } from '../util/format.ts'
|
|
5
|
+
import { REGISTER } from '../util/operations.ts'
|
|
5
6
|
import { pinnedInstallSpec } from './serve.ts'
|
|
6
7
|
|
|
7
8
|
export const setupCommand = new Command('setup').description(
|
|
@@ -69,6 +70,10 @@ setupCommand
|
|
|
69
70
|
.command('remote')
|
|
70
71
|
.description('Connect to the hosted Flowy service')
|
|
71
72
|
.option('--email <email>', 'Email address for registration')
|
|
73
|
+
.option(
|
|
74
|
+
'--show-key',
|
|
75
|
+
'Print the full API key instead of a non-reversible fingerprint',
|
|
76
|
+
)
|
|
72
77
|
.addOption(
|
|
73
78
|
new Option(
|
|
74
79
|
'--tier <tier>',
|
|
@@ -100,23 +105,20 @@ setupCommand
|
|
|
100
105
|
apiKey: string
|
|
101
106
|
checkoutUrl: string
|
|
102
107
|
}
|
|
103
|
-
}>(
|
|
104
|
-
`mutation Register($email: String!, $tier: String) {
|
|
105
|
-
register(email: $email, tier: $tier) {
|
|
106
|
-
user { id email tier createdAt graceEndsAt }
|
|
107
|
-
apiKey
|
|
108
|
-
checkoutUrl
|
|
109
|
-
}
|
|
110
|
-
}`,
|
|
111
|
-
{ email: opts.email, tier: opts.tier },
|
|
112
|
-
)
|
|
108
|
+
}>(REGISTER, { email: opts.email, tier: opts.tier })
|
|
113
109
|
|
|
114
110
|
config.apiKey = data.register.apiKey
|
|
115
111
|
saveConfig(config)
|
|
116
112
|
|
|
117
113
|
installSkill()
|
|
118
114
|
|
|
119
|
-
|
|
115
|
+
const { user, apiKey, checkoutUrl } = data.register
|
|
116
|
+
// Default output never leaks the secret; --show-key opts in (F35).
|
|
117
|
+
output(
|
|
118
|
+
opts.showKey
|
|
119
|
+
? { user, apiKey, checkoutUrl }
|
|
120
|
+
: { user, keyFingerprint: fingerprintKey(apiKey), checkoutUrl },
|
|
121
|
+
)
|
|
120
122
|
} catch (error) {
|
|
121
123
|
outputError(error)
|
|
122
124
|
}
|
package/src/commands/status.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
3
|
import { output, outputError } from '../util/format.ts'
|
|
4
|
+
import { UPDATE_STATUS } from '../util/operations.ts'
|
|
4
5
|
|
|
5
6
|
export const statusCommand = new Command('status')
|
|
6
7
|
.description('Update a node status (shorthand)')
|
|
@@ -11,14 +12,10 @@ export const statusCommand = new Command('status')
|
|
|
11
12
|
)
|
|
12
13
|
.action(async (id: string, status: string) => {
|
|
13
14
|
try {
|
|
14
|
-
const data = await graphql<{ updateNode: unknown }>(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
}`,
|
|
20
|
-
{ id, status },
|
|
21
|
-
)
|
|
15
|
+
const data = await graphql<{ updateNode: unknown }>(UPDATE_STATUS, {
|
|
16
|
+
id,
|
|
17
|
+
status,
|
|
18
|
+
})
|
|
22
19
|
output(data.updateNode)
|
|
23
20
|
} catch (error) {
|
|
24
21
|
outputError(error)
|
package/src/commands/task.ts
CHANGED
|
@@ -3,6 +3,19 @@ import { graphql } from '../util/client.ts'
|
|
|
3
3
|
import { requireFeature, resolveProject } from '../util/config.ts'
|
|
4
4
|
import { resolveDescription } from '../util/description.ts'
|
|
5
5
|
import { output, outputError } from '../util/format.ts'
|
|
6
|
+
import {
|
|
7
|
+
ALL_TASKS,
|
|
8
|
+
BLOCK_TASK,
|
|
9
|
+
CREATE_TASK,
|
|
10
|
+
DELETE_NODE,
|
|
11
|
+
LINK_TASK,
|
|
12
|
+
LIST_TASKS,
|
|
13
|
+
READY_TASKS,
|
|
14
|
+
SHOW_TASK,
|
|
15
|
+
TASK_DEPS,
|
|
16
|
+
UNBLOCK_TASK,
|
|
17
|
+
UPDATE_NODE,
|
|
18
|
+
} from '../util/operations.ts'
|
|
6
19
|
|
|
7
20
|
export const taskCommand = new Command('task').description(
|
|
8
21
|
'Manage tasks in the active feature',
|
|
@@ -27,23 +40,17 @@ taskCommand
|
|
|
27
40
|
description: opts.description,
|
|
28
41
|
descriptionFile: opts.descriptionFile,
|
|
29
42
|
})
|
|
30
|
-
const data = await graphql<{ createNode: { id: string } }>(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}`,
|
|
36
|
-
{ type: 'task', title: opts.title, description },
|
|
37
|
-
)
|
|
43
|
+
const data = await graphql<{ createNode: { id: string } }>(CREATE_TASK, {
|
|
44
|
+
type: 'task',
|
|
45
|
+
title: opts.title,
|
|
46
|
+
description,
|
|
47
|
+
})
|
|
38
48
|
const taskId = data.createNode.id
|
|
39
|
-
await graphql(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}`,
|
|
45
|
-
{ sourceId: taskId, targetId: featureId, relation: 'part_of' },
|
|
46
|
-
)
|
|
49
|
+
await graphql(LINK_TASK, {
|
|
50
|
+
sourceId: taskId,
|
|
51
|
+
targetId: featureId,
|
|
52
|
+
relation: 'part_of',
|
|
53
|
+
})
|
|
47
54
|
output(data.createNode)
|
|
48
55
|
} catch (error) {
|
|
49
56
|
outputError(error)
|
|
@@ -67,40 +74,27 @@ taskCommand
|
|
|
67
74
|
if (opts.ready) {
|
|
68
75
|
const projectId =
|
|
69
76
|
opts.project ?? (opts.all ? undefined : resolveProject()?.id)
|
|
70
|
-
const data = await graphql<{ readyTasks: unknown[] }>(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
id type title status createdAt
|
|
74
|
-
}
|
|
75
|
-
}`,
|
|
76
|
-
{ projectId: projectId ?? null },
|
|
77
|
-
)
|
|
77
|
+
const data = await graphql<{ readyTasks: unknown[] }>(READY_TASKS, {
|
|
78
|
+
projectId: projectId ?? null,
|
|
79
|
+
})
|
|
78
80
|
output(data.readyTasks)
|
|
79
81
|
return
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
if (opts.all) {
|
|
83
|
-
const data = await graphql<{ nodes: unknown[] }>(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
id type title status createdAt
|
|
87
|
-
}
|
|
88
|
-
}`,
|
|
89
|
-
{ type: 'task' },
|
|
90
|
-
)
|
|
85
|
+
const data = await graphql<{ nodes: unknown[] }>(ALL_TASKS, {
|
|
86
|
+
type: 'task',
|
|
87
|
+
})
|
|
91
88
|
output(data.nodes)
|
|
92
89
|
return
|
|
93
90
|
}
|
|
94
91
|
|
|
95
92
|
const featureId = requireFeature()
|
|
96
|
-
const data = await graphql<{ descendants: unknown[] }>(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}`,
|
|
102
|
-
{ nodeId: featureId, relation: 'part_of', maxDepth: 1 },
|
|
103
|
-
)
|
|
93
|
+
const data = await graphql<{ descendants: unknown[] }>(LIST_TASKS, {
|
|
94
|
+
nodeId: featureId,
|
|
95
|
+
relation: 'part_of',
|
|
96
|
+
maxDepth: 1,
|
|
97
|
+
})
|
|
104
98
|
const tasks = data.descendants.filter(
|
|
105
99
|
(n: unknown) => (n as { type: string }).type === 'task',
|
|
106
100
|
)
|
|
@@ -120,20 +114,7 @@ taskCommand
|
|
|
120
114
|
node: Record<string, unknown>
|
|
121
115
|
blockedBy: unknown[]
|
|
122
116
|
blocks: unknown[]
|
|
123
|
-
}>(
|
|
124
|
-
`query ShowTask($id: String!) {
|
|
125
|
-
node(id: $id) {
|
|
126
|
-
id type title description status metadata createdAt updatedAt
|
|
127
|
-
}
|
|
128
|
-
blockedBy: edges(nodeId: $id, relation: "blocks", direction: "incoming") {
|
|
129
|
-
id type title status
|
|
130
|
-
}
|
|
131
|
-
blocks: edges(nodeId: $id, relation: "blocks", direction: "outgoing") {
|
|
132
|
-
id type title status
|
|
133
|
-
}
|
|
134
|
-
}`,
|
|
135
|
-
{ id },
|
|
136
|
-
)
|
|
117
|
+
}>(SHOW_TASK, { id })
|
|
137
118
|
output({
|
|
138
119
|
...data.node,
|
|
139
120
|
blockedBy: data.blockedBy,
|
|
@@ -170,11 +151,7 @@ taskCommand
|
|
|
170
151
|
}
|
|
171
152
|
if (opts.metadata != null) variables.metadata = opts.metadata
|
|
172
153
|
const data = await graphql<{ updateNode: unknown }>(
|
|
173
|
-
|
|
174
|
-
updateNode(id: $id, title: $title, description: $description, metadata: $metadata) {
|
|
175
|
-
id type title description status metadata createdAt updatedAt
|
|
176
|
-
}
|
|
177
|
-
}`,
|
|
154
|
+
UPDATE_NODE,
|
|
178
155
|
variables,
|
|
179
156
|
)
|
|
180
157
|
output(data.updateNode)
|
|
@@ -189,12 +166,7 @@ taskCommand
|
|
|
189
166
|
.argument('<id>', 'Task ID')
|
|
190
167
|
.action(async (id: string) => {
|
|
191
168
|
try {
|
|
192
|
-
const data = await graphql<{ deleteNode: boolean }>(
|
|
193
|
-
`mutation DeleteNode($id: String!) {
|
|
194
|
-
deleteNode(id: $id)
|
|
195
|
-
}`,
|
|
196
|
-
{ id },
|
|
197
|
-
)
|
|
169
|
+
const data = await graphql<{ deleteNode: boolean }>(DELETE_NODE, { id })
|
|
198
170
|
output({ deleted: data.deleteNode })
|
|
199
171
|
} catch (error) {
|
|
200
172
|
outputError(error)
|
|
@@ -208,14 +180,11 @@ taskCommand
|
|
|
208
180
|
.argument('<id2>', 'Blocked task ID')
|
|
209
181
|
.action(async (id1: string, id2: string) => {
|
|
210
182
|
try {
|
|
211
|
-
const data = await graphql<{ createEdge: unknown }>(
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}`,
|
|
217
|
-
{ sourceId: id1, targetId: id2, relation: 'blocks' },
|
|
218
|
-
)
|
|
183
|
+
const data = await graphql<{ createEdge: unknown }>(BLOCK_TASK, {
|
|
184
|
+
sourceId: id1,
|
|
185
|
+
targetId: id2,
|
|
186
|
+
relation: 'blocks',
|
|
187
|
+
})
|
|
219
188
|
output(data.createEdge)
|
|
220
189
|
} catch (error) {
|
|
221
190
|
outputError(error)
|
|
@@ -229,12 +198,11 @@ taskCommand
|
|
|
229
198
|
.argument('<id2>', 'Blocked task ID')
|
|
230
199
|
.action(async (id1: string, id2: string) => {
|
|
231
200
|
try {
|
|
232
|
-
const data = await graphql<{ removeEdge: boolean }>(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
)
|
|
201
|
+
const data = await graphql<{ removeEdge: boolean }>(UNBLOCK_TASK, {
|
|
202
|
+
sourceId: id1,
|
|
203
|
+
targetId: id2,
|
|
204
|
+
relation: 'blocks',
|
|
205
|
+
})
|
|
238
206
|
output({ removed: data.removeEdge })
|
|
239
207
|
} catch (error) {
|
|
240
208
|
outputError(error)
|
|
@@ -248,14 +216,7 @@ taskCommand
|
|
|
248
216
|
.action(async (id: string) => {
|
|
249
217
|
try {
|
|
250
218
|
const data = await graphql<{ blockedBy: unknown[]; blocks: unknown[] }>(
|
|
251
|
-
|
|
252
|
-
blockedBy: edges(nodeId: $id, relation: "blocks", direction: "incoming") {
|
|
253
|
-
id type title status
|
|
254
|
-
}
|
|
255
|
-
blocks: edges(nodeId: $id, relation: "blocks", direction: "outgoing") {
|
|
256
|
-
id type title status
|
|
257
|
-
}
|
|
258
|
-
}`,
|
|
219
|
+
TASK_DEPS,
|
|
259
220
|
{ id },
|
|
260
221
|
)
|
|
261
222
|
output({ id, blockedBy: data.blockedBy, blocks: data.blocks })
|
package/src/commands/tree.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
3
|
import { output, outputError } from '../util/format.ts'
|
|
4
|
+
import { SUBTREE } from '../util/operations.ts'
|
|
4
5
|
|
|
5
6
|
export const treeCommand = new Command('tree')
|
|
6
7
|
.description('Show subtree from any entity')
|
|
@@ -8,14 +9,10 @@ export const treeCommand = new Command('tree')
|
|
|
8
9
|
.option('--depth <n>', 'Max depth', '10')
|
|
9
10
|
.action(async (id: string, opts) => {
|
|
10
11
|
try {
|
|
11
|
-
const data = await graphql<{ subtree: unknown[] }>(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
}`,
|
|
17
|
-
{ nodeId: id, maxDepth: Number.parseInt(opts.depth, 10) },
|
|
18
|
-
)
|
|
12
|
+
const data = await graphql<{ subtree: unknown[] }>(SUBTREE, {
|
|
13
|
+
nodeId: id,
|
|
14
|
+
maxDepth: Number.parseInt(opts.depth, 10),
|
|
15
|
+
})
|
|
19
16
|
output(data.subtree)
|
|
20
17
|
} catch (error) {
|
|
21
18
|
outputError(error)
|
|
@@ -3,10 +3,18 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
|
3
3
|
let mockGraphql: ReturnType<typeof vi.fn>
|
|
4
4
|
let mockOutput: ReturnType<typeof vi.fn>
|
|
5
5
|
let mockOutputError: ReturnType<typeof vi.fn>
|
|
6
|
+
let mockLoadConfig: ReturnType<typeof vi.fn>
|
|
6
7
|
|
|
7
8
|
beforeEach(() => {
|
|
8
9
|
mockOutput = vi.fn()
|
|
9
10
|
mockOutputError = vi.fn()
|
|
11
|
+
mockLoadConfig = vi.fn(() => ({
|
|
12
|
+
mode: 'remote',
|
|
13
|
+
apiUrl: 'https://flowy-ai.fly.dev/graphql',
|
|
14
|
+
apiKey: 'flowy_secret_abcdef0123456789',
|
|
15
|
+
client: { name: '' },
|
|
16
|
+
projects: {},
|
|
17
|
+
}))
|
|
10
18
|
mockGraphql = vi.fn().mockResolvedValue({
|
|
11
19
|
whoami: {
|
|
12
20
|
id: 'user_1',
|
|
@@ -25,6 +33,27 @@ beforeEach(() => {
|
|
|
25
33
|
vi.doMock('../util/client.ts', () => ({
|
|
26
34
|
graphql: mockGraphql,
|
|
27
35
|
}))
|
|
36
|
+
|
|
37
|
+
vi.doMock('../util/config.ts', async () => {
|
|
38
|
+
const actual =
|
|
39
|
+
await vi.importActual<typeof import('../util/config.ts')>(
|
|
40
|
+
'../util/config.ts',
|
|
41
|
+
)
|
|
42
|
+
return {
|
|
43
|
+
loadConfig: mockLoadConfig,
|
|
44
|
+
fingerprintKey: actual.fingerprintKey,
|
|
45
|
+
requireRemoteMode: (commandName: string) => {
|
|
46
|
+
const cfg = (mockLoadConfig as unknown as () => { mode: string })()
|
|
47
|
+
if (cfg.mode === 'local') {
|
|
48
|
+
const err = new Error(
|
|
49
|
+
`"flowy ${commandName}" is only available in remote mode. The active mode is local mode.`,
|
|
50
|
+
) as Error & { code?: string }
|
|
51
|
+
err.code = 'LOCAL_MODE'
|
|
52
|
+
throw err
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
})
|
|
28
57
|
})
|
|
29
58
|
|
|
30
59
|
afterEach(() => {
|
|
@@ -33,7 +62,7 @@ afterEach(() => {
|
|
|
33
62
|
})
|
|
34
63
|
|
|
35
64
|
describe('whoami command', () => {
|
|
36
|
-
test('whoami outputs user data
|
|
65
|
+
test('whoami outputs user data plus a non-reversible key fingerprint', async () => {
|
|
37
66
|
const userData = {
|
|
38
67
|
id: '1',
|
|
39
68
|
email: 'a@b.com',
|
|
@@ -46,7 +75,17 @@ describe('whoami command', () => {
|
|
|
46
75
|
const { whoamiCommand } = await import('./whoami.ts')
|
|
47
76
|
await whoamiCommand.parseAsync([], { from: 'user' })
|
|
48
77
|
|
|
49
|
-
|
|
78
|
+
const outputArg = mockOutput.mock.calls[0]![0]
|
|
79
|
+
expect(outputArg).toEqual(
|
|
80
|
+
expect.objectContaining({
|
|
81
|
+
...userData,
|
|
82
|
+
keyFingerprint: expect.stringMatching(/sha256:[0-9a-f]{12}/),
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
85
|
+
// Fingerprint must not leak the configured secret.
|
|
86
|
+
expect(JSON.stringify(outputArg)).not.toContain(
|
|
87
|
+
'flowy_secret_abcdef0123456789',
|
|
88
|
+
)
|
|
50
89
|
})
|
|
51
90
|
|
|
52
91
|
test('whoami outputs error when query fails', async () => {
|
|
@@ -71,4 +110,27 @@ describe('whoami command', () => {
|
|
|
71
110
|
const query = mockGraphql.mock.calls[0]?.[0] as string
|
|
72
111
|
expect(query).toContain('graceEndsAt')
|
|
73
112
|
})
|
|
113
|
+
|
|
114
|
+
test('whoami errors cleanly in local mode without hitting the server', async () => {
|
|
115
|
+
mockLoadConfig.mockReturnValue({
|
|
116
|
+
mode: 'local',
|
|
117
|
+
apiUrl: 'http://localhost:4000/graphql',
|
|
118
|
+
apiKey: '',
|
|
119
|
+
client: { name: '' },
|
|
120
|
+
projects: {},
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const { whoamiCommand } = await import('./whoami.ts')
|
|
124
|
+
await whoamiCommand.parseAsync([], { from: 'user' })
|
|
125
|
+
|
|
126
|
+
// No GraphQL call against the local server.
|
|
127
|
+
expect(mockGraphql).not.toHaveBeenCalled()
|
|
128
|
+
expect(mockOutput).not.toHaveBeenCalled()
|
|
129
|
+
expect(mockOutputError).toHaveBeenCalledWith(
|
|
130
|
+
expect.objectContaining({
|
|
131
|
+
message: expect.stringMatching(/local mode/i),
|
|
132
|
+
code: 'LOCAL_MODE',
|
|
133
|
+
}),
|
|
134
|
+
)
|
|
135
|
+
})
|
|
74
136
|
})
|
package/src/commands/whoami.ts
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
|
+
import {
|
|
4
|
+
fingerprintKey,
|
|
5
|
+
loadConfig,
|
|
6
|
+
requireRemoteMode,
|
|
7
|
+
} from '../util/config.ts'
|
|
3
8
|
import { output, outputError } from '../util/format.ts'
|
|
9
|
+
import { WHOAMI } from '../util/operations.ts'
|
|
4
10
|
|
|
5
11
|
export const whoamiCommand = new Command('whoami')
|
|
6
12
|
.description('Show current user info')
|
|
7
13
|
.action(async () => {
|
|
8
14
|
try {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
requireRemoteMode('whoami')
|
|
16
|
+
const data = await graphql<{ whoami: Record<string, unknown> }>(WHOAMI)
|
|
17
|
+
// Surface a non-reversible fingerprint of the configured key so a human
|
|
18
|
+
// can confirm *which* credential is active without exposing it (F35).
|
|
19
|
+
output({
|
|
20
|
+
...data.whoami,
|
|
21
|
+
keyFingerprint: fingerprintKey(loadConfig().apiKey),
|
|
22
|
+
})
|
|
17
23
|
} catch (error) {
|
|
18
24
|
outputError(error)
|
|
19
25
|
}
|