@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.
@@ -7,6 +7,15 @@ import {
7
7
  } from '../util/config.ts'
8
8
  import { resolveDescription } from '../util/description.ts'
9
9
  import { output, outputError } from '../util/format.ts'
10
+ import {
11
+ CREATE_EDGE,
12
+ CREATE_NODE,
13
+ DELETE_NODE,
14
+ DESCENDANTS,
15
+ DESCENDANTS_BRIEF,
16
+ GET_NODE,
17
+ UPDATE_NODE,
18
+ } from '../util/operations.ts'
10
19
 
11
20
  export const featureCommand = new Command('feature').description(
12
21
  'Manage features in the active project',
@@ -32,22 +41,15 @@ featureCommand
32
41
  descriptionFile: opts.descriptionFile,
33
42
  })
34
43
  const nodeData = await graphql<{ createNode: { id: string } }>(
35
- `mutation CreateNode($type: String!, $title: String!, $description: String) {
36
- createNode(type: $type, title: $title, description: $description) {
37
- id type title description status createdAt updatedAt
38
- }
39
- }`,
44
+ CREATE_NODE,
40
45
  { type: 'feature', title: opts.title, description },
41
46
  )
42
47
  const featureId = nodeData.createNode.id
43
- await graphql(
44
- `mutation CreateEdge($sourceId: String!, $targetId: String!, $relation: String!) {
45
- createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
46
- sourceId targetId relation createdAt
47
- }
48
- }`,
49
- { sourceId: featureId, targetId: project.id, relation: 'part_of' },
50
- )
48
+ await graphql(CREATE_EDGE, {
49
+ sourceId: featureId,
50
+ targetId: project.id,
51
+ relation: 'part_of',
52
+ })
51
53
  output(nodeData.createNode)
52
54
  } catch (error) {
53
55
  outputError(error)
@@ -63,14 +65,11 @@ featureCommand
63
65
  const project = requireProject()
64
66
  const data = await graphql<{
65
67
  descendants: Array<{ id: string; type: string; title: string }>
66
- }>(
67
- `query Descendants($nodeId: String!, $relation: String, $maxDepth: Int) {
68
- descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
69
- id type title status
70
- }
71
- }`,
72
- { nodeId: project.id, relation: 'part_of', maxDepth: 1 },
73
- )
68
+ }>(DESCENDANTS_BRIEF, {
69
+ nodeId: project.id,
70
+ relation: 'part_of',
71
+ maxDepth: 1,
72
+ })
74
73
  const features = data.descendants.filter((n) => n.type === 'feature')
75
74
  const match = features.find(
76
75
  (f) => f.id === nameOrId || f.title === nameOrId,
@@ -111,14 +110,7 @@ featureCommand
111
110
  const project = requireProject()
112
111
  const data = await graphql<{
113
112
  descendants: Array<{ id: string; type: string }>
114
- }>(
115
- `query Descendants($nodeId: String!, $relation: String, $maxDepth: Int) {
116
- descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
117
- id type title description status createdAt updatedAt
118
- }
119
- }`,
120
- { nodeId: project.id, relation: 'part_of', maxDepth: 1 },
121
- )
113
+ }>(DESCENDANTS, { nodeId: project.id, relation: 'part_of', maxDepth: 1 })
122
114
  const features = data.descendants.filter((n) => n.type === 'feature')
123
115
  output(features)
124
116
  } catch (error) {
@@ -158,11 +150,7 @@ featureCommand
158
150
  }
159
151
  if (opts.metadata != null) variables.metadata = opts.metadata
160
152
  const data = await graphql<{ updateNode: unknown }>(
161
- `mutation UpdateNode($id: String!, $title: String, $description: String, $metadata: String) {
162
- updateNode(id: $id, title: $title, description: $description, metadata: $metadata) {
163
- id type title description status metadata createdAt updatedAt
164
- }
165
- }`,
153
+ UPDATE_NODE,
166
154
  variables,
167
155
  )
168
156
  output(data.updateNode)
@@ -183,12 +171,9 @@ featureCommand
183
171
  'No feature specified. Pass an ID or set an active feature.',
184
172
  )
185
173
  }
186
- const data = await graphql<{ deleteNode: boolean }>(
187
- `mutation DeleteNode($id: String!) {
188
- deleteNode(id: $id)
189
- }`,
190
- { id: featureId },
191
- )
174
+ const data = await graphql<{ deleteNode: boolean }>(DELETE_NODE, {
175
+ id: featureId,
176
+ })
192
177
  output({ deleted: data.deleteNode })
193
178
  } catch (error) {
194
179
  outputError(error)
@@ -207,14 +192,9 @@ featureCommand
207
192
  'No feature specified. Pass an ID or set an active feature.',
208
193
  )
209
194
  }
210
- const data = await graphql<{ node: unknown }>(
211
- `query GetNode($id: String!) {
212
- node(id: $id) {
213
- id type title description status metadata createdAt updatedAt
214
- }
215
- }`,
216
- { id: featureId },
217
- )
195
+ const data = await graphql<{ node: unknown }>(GET_NODE, {
196
+ id: featureId,
197
+ })
218
198
  output(data.node)
219
199
  } catch (error) {
220
200
  outputError(error)
@@ -9,6 +9,13 @@ import {
9
9
  parseManifest,
10
10
  readClientKey,
11
11
  } from '../util/manifest.ts'
12
+ import {
13
+ IMPORT_CREATE,
14
+ IMPORT_EDGE,
15
+ IMPORT_EDGES,
16
+ IMPORT_EXISTING,
17
+ IMPORT_UPDATE,
18
+ } from '../util/operations.ts'
12
19
 
13
20
  const NODE_TYPES = ['project', 'feature', 'task'] as const
14
21
 
@@ -53,12 +60,9 @@ function desiredEdges(manifest: Manifest): DesiredEdge[] {
53
60
  async function loadExisting(): Promise<Map<string, string>> {
54
61
  const idByKey = new Map<string, string>()
55
62
  for (const type of NODE_TYPES) {
56
- const data = await graphql<{ nodes: ServerNode[] }>(
57
- `query ImportExisting($type: String) {
58
- nodes(type: $type) { id type title metadata }
59
- }`,
60
- { type },
61
- )
63
+ const data = await graphql<{ nodes: ServerNode[] }>(IMPORT_EXISTING, {
64
+ type,
65
+ })
62
66
  for (const node of data.nodes) {
63
67
  const key = readClientKey(node.metadata)
64
68
  if (key) idByKey.set(key, node.id)
@@ -78,9 +82,7 @@ async function loadExistingEdges(nodeIds: string[]): Promise<Set<string>> {
78
82
  for (const nodeId of nodeIds) {
79
83
  for (const relation of RELATIONS) {
80
84
  const data = await graphql<{ edges: Array<{ id: string }> }>(
81
- `query ImportEdges($nodeId: String!, $relation: String!) {
82
- edges(nodeId: $nodeId, relation: $relation, direction: "outgoing") { id }
83
- }`,
85
+ IMPORT_EDGES,
84
86
  { nodeId, relation },
85
87
  )
86
88
  for (const target of data.edges) {
@@ -91,25 +93,13 @@ async function loadExistingEdges(nodeIds: string[]): Promise<Set<string>> {
91
93
  return existing
92
94
  }
93
95
 
94
- const CREATE_NODE = `mutation ImportCreate($type: String!, $title: String!, $description: String, $status: String, $metadata: String) {
95
- createNode(type: $type, title: $title, description: $description, status: $status, metadata: $metadata) { id }
96
- }`
97
-
98
- const UPDATE_NODE = `mutation ImportUpdate($id: String!, $title: String, $description: String, $status: String, $metadata: String) {
99
- updateNode(id: $id, title: $title, description: $description, status: $status, metadata: $metadata) { id }
100
- }`
101
-
102
- const CREATE_EDGE = `mutation ImportEdge($sourceId: String!, $targetId: String!, $relation: String!) {
103
- createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) { sourceId targetId relation }
104
- }`
105
-
106
96
  async function upsertNode(
107
97
  node: ManifestNode,
108
98
  existingId: string | undefined,
109
99
  ): Promise<string> {
110
100
  const metadata = buildNodeMetadata(node.key, node.metadata)
111
101
  if (existingId) {
112
- await graphql<{ updateNode: { id: string } }>(UPDATE_NODE, {
102
+ await graphql<{ updateNode: { id: string } }>(IMPORT_UPDATE, {
113
103
  id: existingId,
114
104
  title: node.title,
115
105
  description: node.description ?? null,
@@ -118,7 +108,7 @@ async function upsertNode(
118
108
  })
119
109
  return existingId
120
110
  }
121
- const data = await graphql<{ createNode: { id: string } }>(CREATE_NODE, {
111
+ const data = await graphql<{ createNode: { id: string } }>(IMPORT_CREATE, {
122
112
  type: node.type,
123
113
  title: node.title,
124
114
  description: node.description ?? null,
@@ -162,7 +152,7 @@ export const importCommand = new Command('import')
162
152
  const targetId = idByKey.get(edge.targetKey)
163
153
  if (!sourceId || !targetId) continue
164
154
  if (present.has(edgeKey(sourceId, targetId, edge.relation))) continue
165
- await graphql(CREATE_EDGE, {
155
+ await graphql(IMPORT_EDGE, {
166
156
  sourceId,
167
157
  targetId,
168
158
  relation: edge.relation,
@@ -4,6 +4,7 @@ import { Command } from 'commander'
4
4
  import { graphql } from '../util/client.ts'
5
5
  import { loadConfig, saveConfig } from '../util/config.ts'
6
6
  import { output, outputError } from '../util/format.ts'
7
+ import { CREATE_PROJECT } from '../util/operations.ts'
7
8
 
8
9
  export const initCommand = new Command('init')
9
10
  .description('Initialize Flowy for the current git repository')
@@ -30,11 +31,7 @@ export const initCommand = new Command('init')
30
31
  }
31
32
 
32
33
  const data = await graphql<{ createNode: { id: string; title: string } }>(
33
- `mutation CreateProject($type: String!, $title: String!) {
34
- createNode(type: $type, title: $title) {
35
- id type title description status metadata createdAt updatedAt
36
- }
37
- }`,
34
+ CREATE_PROJECT,
38
35
  { type: 'project', title: repoName },
39
36
  )
40
37
 
@@ -7,7 +7,7 @@ let mockOutputError: ReturnType<typeof vi.fn>
7
7
 
8
8
  beforeEach(() => {
9
9
  mockLoadConfig = vi.fn(() => ({
10
- mode: 'saas',
10
+ mode: 'remote',
11
11
  apiUrl: 'https://flowy-ai.fly.dev/graphql',
12
12
  apiKey: 'old-key',
13
13
  client: { name: '' },
@@ -17,10 +17,27 @@ beforeEach(() => {
17
17
  mockOutput = vi.fn()
18
18
  mockOutputError = vi.fn()
19
19
 
20
- vi.doMock('../util/config.ts', () => ({
21
- loadConfig: mockLoadConfig,
22
- saveConfig: mockSaveConfig,
23
- }))
20
+ vi.doMock('../util/config.ts', async () => {
21
+ const actual =
22
+ await vi.importActual<typeof import('../util/config.ts')>(
23
+ '../util/config.ts',
24
+ )
25
+ return {
26
+ loadConfig: mockLoadConfig,
27
+ saveConfig: mockSaveConfig,
28
+ fingerprintKey: actual.fingerprintKey,
29
+ requireRemoteMode: (commandName: string) => {
30
+ const cfg = (mockLoadConfig as unknown as () => { mode: string })()
31
+ if (cfg.mode === 'local') {
32
+ const err = new Error(
33
+ `"flowy ${commandName}" is only available in remote mode. The active mode is local mode.`,
34
+ ) as Error & { code?: string }
35
+ err.code = 'LOCAL_MODE'
36
+ throw err
37
+ }
38
+ },
39
+ }
40
+ })
24
41
 
25
42
  vi.doMock('../util/format.ts', () => ({
26
43
  output: mockOutput,
@@ -46,7 +63,7 @@ describe('key command', () => {
46
63
  expect(keyCommand.commands).toHaveLength(1)
47
64
  })
48
65
 
49
- test('rotate calls rotateApiKey mutation, saves new key to config, and outputs result', async () => {
66
+ test('rotate calls rotateApiKey mutation, saves new key to config, and outputs a fingerprint (not the secret)', async () => {
50
67
  const mockGraphql = vi.fn().mockResolvedValue({
51
68
  rotateApiKey: {
52
69
  user: {
@@ -74,12 +91,43 @@ describe('key command', () => {
74
91
  apiKey: 'flowy_new_key_456',
75
92
  }),
76
93
  )
77
- expect(mockOutput).toHaveBeenCalledWith(
94
+
95
+ // Default output must NOT leak the full secret.
96
+ const outputArg = mockOutput.mock.calls[0]![0]
97
+ expect(JSON.stringify(outputArg)).not.toContain('flowy_new_key_456')
98
+ expect(outputArg).toEqual(
78
99
  expect.objectContaining({
79
100
  user: expect.objectContaining({ email: 'test@example.com' }),
80
- apiKey: 'flowy_new_key_456',
101
+ keyFingerprint: expect.stringMatching(/sha256:[0-9a-f]{12}/),
81
102
  }),
82
103
  )
104
+ expect(outputArg).not.toHaveProperty('apiKey')
105
+ })
106
+
107
+ test('rotate --show-key reveals the full secret', async () => {
108
+ const mockGraphql = vi.fn().mockResolvedValue({
109
+ rotateApiKey: {
110
+ user: {
111
+ id: 'user_1',
112
+ email: 'test@example.com',
113
+ tier: 'free',
114
+ createdAt: '2025-01-01T00:00:00Z',
115
+ graceEndsAt: null,
116
+ },
117
+ apiKey: 'flowy_new_key_456',
118
+ },
119
+ })
120
+ vi.doMock('../util/client.ts', () => ({
121
+ graphql: mockGraphql,
122
+ }))
123
+
124
+ const { keyCommand } = await import('./key.ts')
125
+ await keyCommand.parseAsync(['rotate', '--show-key'], { from: 'user' })
126
+
127
+ const outputArg = mockOutput.mock.calls[0]![0]
128
+ expect(outputArg).toEqual(
129
+ expect.objectContaining({ apiKey: 'flowy_new_key_456' }),
130
+ )
83
131
  })
84
132
 
85
133
  test('rotate saves the exact new apiKey to config', async () => {
@@ -122,4 +170,29 @@ describe('key command', () => {
122
170
  expect(mockSaveConfig).not.toHaveBeenCalled()
123
171
  expect(mockOutput).not.toHaveBeenCalled()
124
172
  })
173
+
174
+ test('rotate errors cleanly in local mode without hitting the server', async () => {
175
+ mockLoadConfig.mockReturnValue({
176
+ mode: 'local',
177
+ apiUrl: 'http://localhost:4000/graphql',
178
+ apiKey: 'old-key',
179
+ client: { name: '' },
180
+ projects: {},
181
+ })
182
+ const mockGraphql = vi.fn()
183
+ vi.doMock('../util/client.ts', () => ({ graphql: mockGraphql }))
184
+
185
+ const { keyCommand } = await import('./key.ts')
186
+ await keyCommand.parseAsync(['rotate'], { from: 'user' })
187
+
188
+ expect(mockGraphql).not.toHaveBeenCalled()
189
+ expect(mockSaveConfig).not.toHaveBeenCalled()
190
+ expect(mockOutput).not.toHaveBeenCalled()
191
+ expect(mockOutputError).toHaveBeenCalledWith(
192
+ expect.objectContaining({
193
+ message: expect.stringMatching(/local mode/i),
194
+ code: 'LOCAL_MODE',
195
+ }),
196
+ )
197
+ })
125
198
  })
@@ -1,15 +1,26 @@
1
1
  import { Command } from 'commander'
2
2
  import { graphql } from '../util/client.ts'
3
- import { loadConfig, saveConfig } from '../util/config.ts'
3
+ import {
4
+ fingerprintKey,
5
+ loadConfig,
6
+ requireRemoteMode,
7
+ saveConfig,
8
+ } from '../util/config.ts'
4
9
  import { output, outputError } from '../util/format.ts'
10
+ import { ROTATE_API_KEY } from '../util/operations.ts'
5
11
 
6
12
  export const keyCommand = new Command('key').description('API key management')
7
13
 
8
14
  keyCommand
9
15
  .command('rotate')
10
16
  .description('Rotate API key')
11
- .action(async () => {
17
+ .option(
18
+ '--show-key',
19
+ 'Print the full API key instead of a non-reversible fingerprint',
20
+ )
21
+ .action(async (opts) => {
12
22
  try {
23
+ requireRemoteMode('key rotate')
13
24
  const data = await graphql<{
14
25
  rotateApiKey: {
15
26
  user: {
@@ -21,20 +32,19 @@ keyCommand
21
32
  }
22
33
  apiKey: string
23
34
  }
24
- }>(
25
- `mutation RotateApiKey {
26
- rotateApiKey {
27
- user { id email tier createdAt graceEndsAt }
28
- apiKey
29
- }
30
- }`,
31
- )
35
+ }>(ROTATE_API_KEY)
32
36
 
37
+ const { user, apiKey } = data.rotateApiKey
33
38
  const config = loadConfig()
34
- config.apiKey = data.rotateApiKey.apiKey
39
+ config.apiKey = apiKey
35
40
  saveConfig(config)
36
41
 
37
- output(data.rotateApiKey)
42
+ // Default output never leaks the secret; --show-key opts in (F35).
43
+ output(
44
+ opts.showKey
45
+ ? { user, apiKey }
46
+ : { user, keyFingerprint: fingerprintKey(apiKey) },
47
+ )
38
48
  } catch (error) {
39
49
  outputError(error)
40
50
  }
@@ -3,6 +3,14 @@ import { graphql } from '../util/client.ts'
3
3
  import { loadConfig, requireProject, saveConfig } from '../util/config.ts'
4
4
  import { resolveDescription } from '../util/description.ts'
5
5
  import { output, outputError } from '../util/format.ts'
6
+ import {
7
+ CREATE_PROJECT,
8
+ DELETE_NODE,
9
+ GET_PROJECT,
10
+ LIST_PROJECTS,
11
+ LIST_PROJECTS_FOR_SET,
12
+ UPDATE_NODE,
13
+ } from '../util/operations.ts'
6
14
 
7
15
  export const projectCommand = new Command('project').description(
8
16
  'Manage projects',
@@ -14,14 +22,10 @@ projectCommand
14
22
  .argument('<name>', 'Project name')
15
23
  .action(async (name: string) => {
16
24
  try {
17
- const data = await graphql<{ createNode: unknown }>(
18
- `mutation CreateProject($type: String!, $title: String!) {
19
- createNode(type: $type, title: $title) {
20
- id type title description status metadata createdAt updatedAt
21
- }
22
- }`,
23
- { type: 'project', title: name },
24
- )
25
+ const data = await graphql<{ createNode: unknown }>(CREATE_PROJECT, {
26
+ type: 'project',
27
+ title: name,
28
+ })
25
29
  output(data.createNode)
26
30
  } catch (error) {
27
31
  outputError(error)
@@ -44,14 +48,7 @@ projectCommand
44
48
  try {
45
49
  const data = await graphql<{
46
50
  nodes: Array<{ id: string; title: string }>
47
- }>(
48
- `query ListProjects($type: String) {
49
- nodes(type: $type) {
50
- id title
51
- }
52
- }`,
53
- { type: 'project' },
54
- )
51
+ }>(LIST_PROJECTS_FOR_SET, { type: 'project' })
55
52
  const project = data.nodes.find((n) => n.title === name)
56
53
  if (!project) {
57
54
  throw new Error(`Project "${name}" not found.`)
@@ -67,14 +64,9 @@ projectCommand
67
64
  .description('List all projects')
68
65
  .action(async () => {
69
66
  try {
70
- const data = await graphql<{ nodes: unknown[] }>(
71
- `query ListProjects($type: String) {
72
- nodes(type: $type) {
73
- id type title description status createdAt updatedAt
74
- }
75
- }`,
76
- { type: 'project' },
77
- )
67
+ const data = await graphql<{ nodes: unknown[] }>(LIST_PROJECTS, {
68
+ type: 'project',
69
+ })
78
70
  output(data.nodes)
79
71
  } catch (error) {
80
72
  outputError(error)
@@ -84,14 +76,9 @@ projectCommand
84
76
  export async function showProject(id?: string): Promise<void> {
85
77
  try {
86
78
  const projectId = id ?? requireProject().id
87
- const data = await graphql<{ node: unknown }>(
88
- `query GetProject($id: String!) {
89
- node(id: $id) {
90
- id type title description status metadata createdAt updatedAt
91
- }
92
- }`,
93
- { id: projectId },
94
- )
79
+ const data = await graphql<{ node: unknown }>(GET_PROJECT, {
80
+ id: projectId,
81
+ })
95
82
  output(data.node)
96
83
  } catch (error) {
97
84
  outputError(error)
@@ -131,11 +118,7 @@ projectCommand
131
118
  }
132
119
  if (opts.metadata != null) variables.metadata = opts.metadata
133
120
  const data = await graphql<{ updateNode: unknown }>(
134
- `mutation UpdateNode($id: String!, $title: String, $description: String, $metadata: String) {
135
- updateNode(id: $id, title: $title, description: $description, metadata: $metadata) {
136
- id type title description status metadata createdAt updatedAt
137
- }
138
- }`,
121
+ UPDATE_NODE,
139
122
  variables,
140
123
  )
141
124
  output(data.updateNode)
@@ -151,12 +134,9 @@ projectCommand
151
134
  .action(async (id?: string) => {
152
135
  try {
153
136
  const projectId = id ?? requireProject().id
154
- const data = await graphql<{ deleteNode: boolean }>(
155
- `mutation DeleteNode($id: String!) {
156
- deleteNode(id: $id)
157
- }`,
158
- { id: projectId },
159
- )
137
+ const data = await graphql<{ deleteNode: boolean }>(DELETE_NODE, {
138
+ id: projectId,
139
+ })
160
140
  output({ deleted: data.deleteNode })
161
141
  } catch (error) {
162
142
  outputError(error)
@@ -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 { SEARCH } from '../util/operations.ts'
4
5
 
5
6
  export const searchCommand = new Command('search')
6
7
  .description('Search nodes by text')
@@ -10,19 +11,12 @@ export const searchCommand = new Command('search')
10
11
  .option('--limit <n>', 'Limit results', '50')
11
12
  .action(async (query: string, opts) => {
12
13
  try {
13
- const data = await graphql<{ search: unknown[] }>(
14
- `query Search($query: String!, $type: String, $status: String, $limit: Int) {
15
- search(query: $query, type: $type, status: $status, limit: $limit) {
16
- id type title description status
17
- }
18
- }`,
19
- {
20
- query,
21
- type: opts.type,
22
- status: opts.status,
23
- limit: opts.limit ? Number.parseInt(opts.limit, 10) : undefined,
24
- },
25
- )
14
+ const data = await graphql<{ search: unknown[] }>(SEARCH, {
15
+ query,
16
+ type: opts.type,
17
+ status: opts.status,
18
+ limit: opts.limit ? Number.parseInt(opts.limit, 10) : undefined,
19
+ })
26
20
  output(data.search)
27
21
  } catch (error) {
28
22
  outputError(error)