@sqaoss/flowy 1.7.0 → 1.9.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 +440 -0
- package/server/src/resolvers.test.ts +121 -1
- package/server/src/resolvers.ts +43 -8
- package/server/src/schema.ts +19 -1
- 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 +36 -1
- package/src/commands/key.ts +9 -9
- package/src/commands/project.ts +23 -43
- package/src/commands/search.ts +7 -13
- package/src/commands/setup.test.ts +48 -1
- package/src/commands/setup.ts +2 -10
- package/src/commands/status.ts +5 -8
- package/src/commands/task.ts +48 -87
- package/src/commands/tree.test.ts +89 -1
- package/src/commands/tree.ts +11 -8
- package/src/commands/whoami.test.ts +34 -1
- package/src/commands/whoami.ts +8 -8
- package/src/util/config.test.ts +168 -1
- package/src/util/config.ts +188 -18
- package/src/util/operations.test.ts +114 -0
- package/src/util/operations.ts +332 -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: '' },
|
|
@@ -315,6 +315,53 @@ describe('setup command', () => {
|
|
|
315
315
|
errSpy.mockRestore()
|
|
316
316
|
})
|
|
317
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
|
+
|
|
318
365
|
test('setup remote warns when the skill install fails', async () => {
|
|
319
366
|
const mockGraphql = vi.fn().mockResolvedValue({
|
|
320
367
|
register: {
|
package/src/commands/setup.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'
|
|
|
2
2
|
import { Command, Option } from 'commander'
|
|
3
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(
|
|
@@ -104,16 +105,7 @@ setupCommand
|
|
|
104
105
|
apiKey: string
|
|
105
106
|
checkoutUrl: string
|
|
106
107
|
}
|
|
107
|
-
}>(
|
|
108
|
-
`mutation Register($email: String!, $tier: String) {
|
|
109
|
-
register(email: $email, tier: $tier) {
|
|
110
|
-
user { id email tier createdAt graceEndsAt }
|
|
111
|
-
apiKey
|
|
112
|
-
checkoutUrl
|
|
113
|
-
}
|
|
114
|
-
}`,
|
|
115
|
-
{ email: opts.email, tier: opts.tier },
|
|
116
|
-
)
|
|
108
|
+
}>(REGISTER, { email: opts.email, tier: opts.tier })
|
|
117
109
|
|
|
118
110
|
config.apiKey = data.register.apiKey
|
|
119
111
|
saveConfig(config)
|
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 })
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from 'vitest'
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
2
|
|
|
3
3
|
describe('tree command', () => {
|
|
4
4
|
test('exports a flat command with id argument and depth option', async () => {
|
|
@@ -6,4 +6,92 @@ describe('tree command', () => {
|
|
|
6
6
|
expect(treeCommand.name()).toBe('tree')
|
|
7
7
|
expect(treeCommand.commands).toHaveLength(0)
|
|
8
8
|
})
|
|
9
|
+
|
|
10
|
+
test('exposes a --relation option defaulting to part_of', async () => {
|
|
11
|
+
const { treeCommand } = await import('./tree.ts')
|
|
12
|
+
const relationOpt = treeCommand.options.find((o) => o.long === '--relation')
|
|
13
|
+
expect(relationOpt).toBeDefined()
|
|
14
|
+
expect(relationOpt?.defaultValue).toBe('part_of')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('action', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.resetModules()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.restoreAllMocks()
|
|
24
|
+
vi.unstubAllEnvs()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('sends the SUBTREE op with the relation filter and renders depth/relation', async () => {
|
|
28
|
+
const calls: Array<{ query: string; variables: unknown }> = []
|
|
29
|
+
vi.doMock('../util/client.ts', () => ({
|
|
30
|
+
graphql: vi.fn(async (query: string, variables: unknown) => {
|
|
31
|
+
calls.push({ query, variables })
|
|
32
|
+
return {
|
|
33
|
+
subtree: [
|
|
34
|
+
{
|
|
35
|
+
id: 'feat_1',
|
|
36
|
+
type: 'feature',
|
|
37
|
+
title: 'F1',
|
|
38
|
+
status: 'draft',
|
|
39
|
+
parentId: 'proj_1',
|
|
40
|
+
depth: 1,
|
|
41
|
+
relation: 'part_of',
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
}),
|
|
46
|
+
}))
|
|
47
|
+
const outputs: unknown[] = []
|
|
48
|
+
vi.doMock('../util/format.ts', () => ({
|
|
49
|
+
output: vi.fn((data: unknown) => outputs.push(data)),
|
|
50
|
+
outputError: vi.fn(),
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
const { SUBTREE } = await import('../util/operations.ts')
|
|
54
|
+
const { treeCommand } = await import('./tree.ts')
|
|
55
|
+
await treeCommand.parseAsync(['proj_1'], { from: 'user' })
|
|
56
|
+
|
|
57
|
+
expect(calls).toHaveLength(1)
|
|
58
|
+
expect(calls[0]!.query).toBe(SUBTREE)
|
|
59
|
+
expect(calls[0]!.variables).toMatchObject({
|
|
60
|
+
nodeId: 'proj_1',
|
|
61
|
+
relation: 'part_of',
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const rendered = outputs[0] as Array<Record<string, unknown>>
|
|
65
|
+
expect(rendered[0]).toMatchObject({
|
|
66
|
+
id: 'feat_1',
|
|
67
|
+
parentId: 'proj_1',
|
|
68
|
+
depth: 1,
|
|
69
|
+
relation: 'part_of',
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('passes an overridden relation through to the op', async () => {
|
|
74
|
+
const calls: Array<{ variables: unknown }> = []
|
|
75
|
+
vi.doMock('../util/client.ts', () => ({
|
|
76
|
+
graphql: vi.fn(async (_query: string, variables: unknown) => {
|
|
77
|
+
calls.push({ variables })
|
|
78
|
+
return { subtree: [] }
|
|
79
|
+
}),
|
|
80
|
+
}))
|
|
81
|
+
vi.doMock('../util/format.ts', () => ({
|
|
82
|
+
output: vi.fn(),
|
|
83
|
+
outputError: vi.fn(),
|
|
84
|
+
}))
|
|
85
|
+
|
|
86
|
+
const { treeCommand } = await import('./tree.ts')
|
|
87
|
+
await treeCommand.parseAsync(['task_1', '--relation', 'blocks'], {
|
|
88
|
+
from: 'user',
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(calls[0]!.variables).toMatchObject({
|
|
92
|
+
nodeId: 'task_1',
|
|
93
|
+
relation: 'blocks',
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
})
|
|
9
97
|
})
|
package/src/commands/tree.ts
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
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')
|
|
7
8
|
.argument('<id>', 'Root node ID')
|
|
8
9
|
.option('--depth <n>', 'Max depth', '10')
|
|
10
|
+
.option(
|
|
11
|
+
'--relation <relation>',
|
|
12
|
+
'Edge relation to follow (e.g. part_of, blocks)',
|
|
13
|
+
'part_of',
|
|
14
|
+
)
|
|
9
15
|
.action(async (id: string, opts) => {
|
|
10
16
|
try {
|
|
11
|
-
const data = await graphql<{ subtree: unknown[] }>(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}`,
|
|
17
|
-
{ nodeId: id, maxDepth: Number.parseInt(opts.depth, 10) },
|
|
18
|
-
)
|
|
17
|
+
const data = await graphql<{ subtree: unknown[] }>(SUBTREE, {
|
|
18
|
+
nodeId: id,
|
|
19
|
+
relation: opts.relation,
|
|
20
|
+
maxDepth: Number.parseInt(opts.depth, 10),
|
|
21
|
+
})
|
|
19
22
|
output(data.subtree)
|
|
20
23
|
} catch (error) {
|
|
21
24
|
outputError(error)
|
|
@@ -9,7 +9,7 @@ beforeEach(() => {
|
|
|
9
9
|
mockOutput = vi.fn()
|
|
10
10
|
mockOutputError = vi.fn()
|
|
11
11
|
mockLoadConfig = vi.fn(() => ({
|
|
12
|
-
mode: '
|
|
12
|
+
mode: 'remote',
|
|
13
13
|
apiUrl: 'https://flowy-ai.fly.dev/graphql',
|
|
14
14
|
apiKey: 'flowy_secret_abcdef0123456789',
|
|
15
15
|
client: { name: '' },
|
|
@@ -42,6 +42,16 @@ beforeEach(() => {
|
|
|
42
42
|
return {
|
|
43
43
|
loadConfig: mockLoadConfig,
|
|
44
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
|
+
},
|
|
45
55
|
}
|
|
46
56
|
})
|
|
47
57
|
})
|
|
@@ -100,4 +110,27 @@ describe('whoami command', () => {
|
|
|
100
110
|
const query = mockGraphql.mock.calls[0]?.[0] as string
|
|
101
111
|
expect(query).toContain('graceEndsAt')
|
|
102
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
|
+
})
|
|
103
136
|
})
|
package/src/commands/whoami.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
fingerprintKey,
|
|
5
|
+
loadConfig,
|
|
6
|
+
requireRemoteMode,
|
|
7
|
+
} from '../util/config.ts'
|
|
4
8
|
import { output, outputError } from '../util/format.ts'
|
|
9
|
+
import { WHOAMI } from '../util/operations.ts'
|
|
5
10
|
|
|
6
11
|
export const whoamiCommand = new Command('whoami')
|
|
7
12
|
.description('Show current user info')
|
|
8
13
|
.action(async () => {
|
|
9
14
|
try {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
whoami {
|
|
13
|
-
id email tier createdAt graceEndsAt
|
|
14
|
-
}
|
|
15
|
-
}`,
|
|
16
|
-
)
|
|
15
|
+
requireRemoteMode('whoami')
|
|
16
|
+
const data = await graphql<{ whoami: Record<string, unknown> }>(WHOAMI)
|
|
17
17
|
// Surface a non-reversible fingerprint of the configured key so a human
|
|
18
18
|
// can confirm *which* credential is active without exposing it (F35).
|
|
19
19
|
output({
|