@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.
@@ -8,7 +8,7 @@ let mockSpawnSync: ReturnType<typeof vi.fn>
8
8
 
9
9
  beforeEach(() => {
10
10
  mockLoadConfig = vi.fn(() => ({
11
- mode: 'saas',
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
- loadConfig: mockLoadConfig,
24
- saveConfig: mockSaveConfig,
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
- expect(mockOutput).toHaveBeenCalledWith(
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
- apiKey: 'flowy_test_key_123',
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: {
@@ -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
- output(data.register)
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
  }
@@ -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
- `mutation UpdateStatus($id: String!, $status: String) {
16
- updateNode(id: $id, status: $status) {
17
- id type title status updatedAt
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)
@@ -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
- `mutation CreateTask($type: String!, $title: String!, $description: String) {
32
- createNode(type: $type, title: $title, description: $description) {
33
- id type title description status createdAt
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
- `mutation LinkTask($sourceId: String!, $targetId: String!, $relation: String!) {
41
- createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
42
- sourceId targetId relation
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
- `query ReadyTasks($projectId: String) {
72
- readyTasks(projectId: $projectId) {
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
- `query AllTasks($type: String!) {
85
- nodes(type: $type) {
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
- `query ListTasks($nodeId: String!, $relation: String!, $maxDepth: Int) {
98
- descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
99
- id type title status createdAt
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
- `mutation UpdateNode($id: String!, $title: String, $description: String, $metadata: String) {
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
- `mutation BlockTask($sourceId: String!, $targetId: String!, $relation: String!) {
213
- createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
214
- sourceId targetId relation createdAt
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
- `mutation UnblockTask($sourceId: String!, $targetId: String!, $relation: String!) {
234
- removeEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation)
235
- }`,
236
- { sourceId: id1, targetId: id2, relation: 'blocks' },
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
- `query TaskDeps($id: String!) {
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,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
- `query Subtree($nodeId: String!, $maxDepth: Int) {
13
- subtree(nodeId: $nodeId, maxDepth: $maxDepth) {
14
- id type title status
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 from query', async () => {
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
- expect(mockOutput).toHaveBeenCalledWith(userData)
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
  })
@@ -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
- const data = await graphql<{ whoami: unknown }>(
10
- `query Whoami {
11
- whoami {
12
- id email tier createdAt graceEndsAt
13
- }
14
- }`,
15
- )
16
- output(data.whoami)
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
  }