@sqaoss/flowy 0.1.1 → 1.0.2
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/LICENSE +201 -661
- package/README.md +89 -46
- package/docker-compose.yml +14 -0
- package/package.json +24 -7
- package/server/Dockerfile +14 -0
- package/server/package.json +25 -0
- package/server/src/db.test.ts +93 -0
- package/server/src/db.ts +47 -0
- package/server/src/index.test.ts +25 -0
- package/server/src/index.ts +45 -0
- package/server/src/resolvers.test.ts +855 -0
- package/server/src/resolvers.ts +308 -0
- package/server/src/schema.test.ts +93 -0
- package/server/src/schema.ts +45 -0
- package/skills/using-flowy/SKILL.md +153 -0
- package/src/commands/client.test.ts +40 -0
- package/src/commands/client.ts +34 -0
- package/src/commands/feature.test.ts +71 -0
- package/src/commands/feature.ts +143 -0
- package/src/commands/project.test.ts +83 -0
- package/src/commands/project.ts +104 -0
- package/src/commands/setup.test.ts +101 -0
- package/src/commands/setup.ts +83 -0
- package/src/commands/task.test.ts +83 -0
- package/src/commands/task.ts +127 -0
- package/src/commands/tree.test.ts +9 -0
- package/src/commands/tree.ts +2 -59
- package/src/index.ts +12 -8
- package/src/util/config.test.ts +151 -0
- package/src/util/config.ts +107 -2
- package/src/util/description.test.ts +29 -0
- package/src/util/description.ts +8 -0
- package/src/commands/edge.ts +0 -84
- package/src/commands/node.ts +0 -134
- package/src/commands/register.ts +0 -25
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid'
|
|
2
|
+
import type { FlowyDb } from './db.ts'
|
|
3
|
+
|
|
4
|
+
type Db = FlowyDb
|
|
5
|
+
|
|
6
|
+
interface NodeRow {
|
|
7
|
+
id: string
|
|
8
|
+
type: string
|
|
9
|
+
title: string
|
|
10
|
+
description: string | null
|
|
11
|
+
status: string
|
|
12
|
+
metadata: string | null
|
|
13
|
+
created_at: string
|
|
14
|
+
updated_at: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface NodeGql {
|
|
18
|
+
id: string
|
|
19
|
+
type: string
|
|
20
|
+
title: string
|
|
21
|
+
description: string | null
|
|
22
|
+
status: string
|
|
23
|
+
metadata: string | null
|
|
24
|
+
createdAt: string
|
|
25
|
+
updatedAt: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const NODE_COLS =
|
|
29
|
+
'id, type, title, description, status, metadata, created_at, updated_at'
|
|
30
|
+
|
|
31
|
+
const PREFIX_MAP: Record<string, string> = {
|
|
32
|
+
project: 'proj',
|
|
33
|
+
feature: 'feat',
|
|
34
|
+
task: 'task',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const VALID_STATUSES = new Set([
|
|
38
|
+
'draft',
|
|
39
|
+
'pending_review',
|
|
40
|
+
'approved',
|
|
41
|
+
'in_progress',
|
|
42
|
+
'done',
|
|
43
|
+
'blocked',
|
|
44
|
+
'cancelled',
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
function generateId(type: string): string {
|
|
48
|
+
const prefix = PREFIX_MAP[type] ?? type
|
|
49
|
+
return `${prefix}_${nanoid(12)}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function rowToNode(row: NodeRow): NodeGql {
|
|
53
|
+
return {
|
|
54
|
+
id: row.id,
|
|
55
|
+
type: row.type,
|
|
56
|
+
title: row.title,
|
|
57
|
+
description: row.description,
|
|
58
|
+
status: row.status,
|
|
59
|
+
metadata: row.metadata,
|
|
60
|
+
createdAt: row.created_at,
|
|
61
|
+
updatedAt: row.updated_at,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function selectNode(db: Db, id: string): NodeGql | null {
|
|
66
|
+
const row = db.raw
|
|
67
|
+
.query(`SELECT ${NODE_COLS} FROM nodes WHERE id = ?`)
|
|
68
|
+
.get(id) as NodeRow | null
|
|
69
|
+
return row ? rowToNode(row) : null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function selectNodes(rows: NodeRow[]): NodeGql[] {
|
|
73
|
+
return rows.map(rowToNode)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function prefixedCols() {
|
|
77
|
+
return NODE_COLS.split(', ')
|
|
78
|
+
.map((c) => `n.${c}`)
|
|
79
|
+
.join(', ')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createResolvers(db: Db) {
|
|
83
|
+
return {
|
|
84
|
+
Query: {
|
|
85
|
+
node: (_: unknown, args: { id: string }) => {
|
|
86
|
+
return selectNode(db, args.id)
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
nodes: (_: unknown, args: { type?: string }) => {
|
|
90
|
+
const conditions: string[] = []
|
|
91
|
+
const params: string[] = []
|
|
92
|
+
if (args.type) {
|
|
93
|
+
conditions.push('type = ?')
|
|
94
|
+
params.push(args.type)
|
|
95
|
+
}
|
|
96
|
+
const where =
|
|
97
|
+
conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
|
98
|
+
const rows = db.raw
|
|
99
|
+
.query(`SELECT ${NODE_COLS} FROM nodes ${where}`)
|
|
100
|
+
.all(...params) as NodeRow[]
|
|
101
|
+
return selectNodes(rows)
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
descendants: (
|
|
105
|
+
_: unknown,
|
|
106
|
+
args: { nodeId: string; relation?: string; maxDepth?: number },
|
|
107
|
+
) => {
|
|
108
|
+
const maxDepth = args.maxDepth ?? 100
|
|
109
|
+
if (maxDepth === 0) return []
|
|
110
|
+
let rows: NodeRow[]
|
|
111
|
+
if (args.relation) {
|
|
112
|
+
rows = db.raw
|
|
113
|
+
.query(
|
|
114
|
+
`WITH RECURSIVE tree(id, depth) AS (
|
|
115
|
+
SELECT source_id, 1 FROM edges WHERE target_id = ?1 AND relation = ?3
|
|
116
|
+
UNION ALL
|
|
117
|
+
SELECT e.source_id, t.depth + 1 FROM edges e
|
|
118
|
+
JOIN tree t ON e.target_id = t.id WHERE t.depth < ?2 AND e.relation = ?3
|
|
119
|
+
)
|
|
120
|
+
SELECT DISTINCT ${prefixedCols()} FROM nodes n JOIN tree t ON n.id = t.id`,
|
|
121
|
+
)
|
|
122
|
+
.all(args.nodeId, maxDepth, args.relation) as NodeRow[]
|
|
123
|
+
} else {
|
|
124
|
+
rows = db.raw
|
|
125
|
+
.query(
|
|
126
|
+
`WITH RECURSIVE tree(id, depth) AS (
|
|
127
|
+
SELECT source_id, 1 FROM edges WHERE target_id = ?1
|
|
128
|
+
UNION ALL
|
|
129
|
+
SELECT e.source_id, t.depth + 1 FROM edges e
|
|
130
|
+
JOIN tree t ON e.target_id = t.id WHERE t.depth < ?2
|
|
131
|
+
)
|
|
132
|
+
SELECT DISTINCT ${prefixedCols()} FROM nodes n JOIN tree t ON n.id = t.id`,
|
|
133
|
+
)
|
|
134
|
+
.all(args.nodeId, maxDepth) as NodeRow[]
|
|
135
|
+
}
|
|
136
|
+
return selectNodes(rows)
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
subtree: (_: unknown, args: { nodeId: string; maxDepth?: number }) => {
|
|
140
|
+
const maxDepth = args.maxDepth ?? 100
|
|
141
|
+
if (maxDepth === 0) return []
|
|
142
|
+
const rows = db.raw
|
|
143
|
+
.query(
|
|
144
|
+
`WITH RECURSIVE tree(id, depth) AS (
|
|
145
|
+
SELECT source_id, 1 FROM edges WHERE target_id = ?1
|
|
146
|
+
UNION ALL
|
|
147
|
+
SELECT e.source_id, t.depth + 1 FROM edges e
|
|
148
|
+
JOIN tree t ON e.target_id = t.id WHERE t.depth < ?2
|
|
149
|
+
)
|
|
150
|
+
SELECT DISTINCT ${prefixedCols()} FROM nodes n JOIN tree t ON n.id = t.id`,
|
|
151
|
+
)
|
|
152
|
+
.all(args.nodeId, maxDepth) as NodeRow[]
|
|
153
|
+
return selectNodes(rows)
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
search: (
|
|
157
|
+
_: unknown,
|
|
158
|
+
args: {
|
|
159
|
+
query: string
|
|
160
|
+
type?: string
|
|
161
|
+
status?: string
|
|
162
|
+
limit?: number
|
|
163
|
+
},
|
|
164
|
+
) => {
|
|
165
|
+
if (args.query.trim().length < 3) {
|
|
166
|
+
throw new Error('Search query must be at least 3 characters')
|
|
167
|
+
}
|
|
168
|
+
const escaped = args.query.replace(/[%_\\]/g, '\\$&')
|
|
169
|
+
const conditions = [
|
|
170
|
+
"(title LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\')",
|
|
171
|
+
]
|
|
172
|
+
const params: (string | number)[] = [`%${escaped}%`, `%${escaped}%`]
|
|
173
|
+
if (args.type) {
|
|
174
|
+
conditions.push('type = ?')
|
|
175
|
+
params.push(args.type)
|
|
176
|
+
}
|
|
177
|
+
if (args.status) {
|
|
178
|
+
conditions.push('status = ?')
|
|
179
|
+
params.push(args.status)
|
|
180
|
+
}
|
|
181
|
+
const limit = args.limit ?? 50
|
|
182
|
+
const rows = db.raw
|
|
183
|
+
.query(
|
|
184
|
+
`SELECT ${NODE_COLS} FROM nodes WHERE ${conditions.join(' AND ')} LIMIT ?`,
|
|
185
|
+
)
|
|
186
|
+
.all(...params, limit) as NodeRow[]
|
|
187
|
+
return selectNodes(rows)
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
Mutation: {
|
|
192
|
+
createNode: (
|
|
193
|
+
_: unknown,
|
|
194
|
+
args: { type: string; title: string; description?: string },
|
|
195
|
+
): NodeGql => {
|
|
196
|
+
if (!args.title.trim()) throw new Error('Title is required')
|
|
197
|
+
if (args.description != null && !args.description.trim()) {
|
|
198
|
+
throw new Error('Description cannot be empty')
|
|
199
|
+
}
|
|
200
|
+
const id = generateId(args.type)
|
|
201
|
+
const now = new Date().toISOString()
|
|
202
|
+
const description = args.description ?? null
|
|
203
|
+
db.raw.run(
|
|
204
|
+
'INSERT INTO nodes (id, type, title, description, status, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
205
|
+
[id, args.type, args.title, description, 'draft', null, now, now],
|
|
206
|
+
)
|
|
207
|
+
return {
|
|
208
|
+
id,
|
|
209
|
+
type: args.type,
|
|
210
|
+
title: args.title,
|
|
211
|
+
description,
|
|
212
|
+
status: 'draft',
|
|
213
|
+
metadata: null,
|
|
214
|
+
createdAt: now,
|
|
215
|
+
updatedAt: now,
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
updateNode: (_: unknown, args: { id: string; status?: string }) => {
|
|
220
|
+
const existing = db.raw
|
|
221
|
+
.query('SELECT * FROM nodes WHERE id = ?')
|
|
222
|
+
.get(args.id) as NodeRow | null
|
|
223
|
+
if (!existing) throw new Error(`Node ${args.id} not found`)
|
|
224
|
+
const newStatus = args.status ?? existing.status
|
|
225
|
+
if (args.status && !VALID_STATUSES.has(args.status)) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`Invalid status: ${args.status}. Must be one of: ${[...VALID_STATUSES].join(', ')}`,
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
const now = new Date().toISOString()
|
|
231
|
+
db.raw.run('UPDATE nodes SET status = ?, updated_at = ? WHERE id = ?', [
|
|
232
|
+
newStatus,
|
|
233
|
+
now,
|
|
234
|
+
args.id,
|
|
235
|
+
])
|
|
236
|
+
return rowToNode({ ...existing, status: newStatus, updated_at: now })
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
approveNode: (_: unknown, args: { id: string }) => {
|
|
240
|
+
const existing = db.raw
|
|
241
|
+
.query('SELECT * FROM nodes WHERE id = ?')
|
|
242
|
+
.get(args.id) as NodeRow | null
|
|
243
|
+
if (!existing) throw new Error(`Node ${args.id} not found`)
|
|
244
|
+
if (existing.status !== 'pending_review') {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`Cannot approve node with status "${existing.status}", must be "pending_review"`,
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
const now = new Date().toISOString()
|
|
250
|
+
db.raw.run('UPDATE nodes SET status = ?, updated_at = ? WHERE id = ?', [
|
|
251
|
+
'approved',
|
|
252
|
+
now,
|
|
253
|
+
args.id,
|
|
254
|
+
])
|
|
255
|
+
return rowToNode({ ...existing, status: 'approved', updated_at: now })
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
createEdge: (
|
|
259
|
+
_: unknown,
|
|
260
|
+
args: { sourceId: string; targetId: string; relation: string },
|
|
261
|
+
) => {
|
|
262
|
+
const validRelations = new Set(['part_of', 'blocks'])
|
|
263
|
+
if (!validRelations.has(args.relation)) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Invalid relation: ${args.relation}. Must be 'part_of' or 'blocks'`,
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
const sourceExists = db.raw
|
|
269
|
+
.query('SELECT id FROM nodes WHERE id = ?')
|
|
270
|
+
.get(args.sourceId)
|
|
271
|
+
if (!sourceExists) {
|
|
272
|
+
throw new Error(`Source node ${args.sourceId} not found`)
|
|
273
|
+
}
|
|
274
|
+
const targetExists = db.raw
|
|
275
|
+
.query('SELECT id FROM nodes WHERE id = ?')
|
|
276
|
+
.get(args.targetId)
|
|
277
|
+
if (!targetExists) {
|
|
278
|
+
throw new Error(`Target node ${args.targetId} not found`)
|
|
279
|
+
}
|
|
280
|
+
if (args.relation === 'blocks' && args.sourceId === args.targetId) {
|
|
281
|
+
throw new Error('A node cannot block itself')
|
|
282
|
+
}
|
|
283
|
+
const now = new Date().toISOString()
|
|
284
|
+
db.raw.run(
|
|
285
|
+
'INSERT INTO edges (source_id, target_id, relation, created_at) VALUES (?, ?, ?, ?)',
|
|
286
|
+
[args.sourceId, args.targetId, args.relation, now],
|
|
287
|
+
)
|
|
288
|
+
return {
|
|
289
|
+
sourceId: args.sourceId,
|
|
290
|
+
targetId: args.targetId,
|
|
291
|
+
relation: args.relation,
|
|
292
|
+
createdAt: now,
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
removeEdge: (
|
|
297
|
+
_: unknown,
|
|
298
|
+
args: { sourceId: string; targetId: string; relation: string },
|
|
299
|
+
) => {
|
|
300
|
+
const result = db.raw.run(
|
|
301
|
+
'DELETE FROM edges WHERE source_id = ? AND target_id = ? AND relation = ?',
|
|
302
|
+
[args.sourceId, args.targetId, args.relation],
|
|
303
|
+
)
|
|
304
|
+
return result.changes > 0
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { schema } from './schema.ts'
|
|
3
|
+
|
|
4
|
+
describe('schema', () => {
|
|
5
|
+
it('exports a valid GraphQL schema object', () => {
|
|
6
|
+
expect(schema).toBeDefined()
|
|
7
|
+
expect(typeof schema).toBe('object')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('defines Node type with required fields', () => {
|
|
11
|
+
const nodeType = schema.getType(
|
|
12
|
+
'Node',
|
|
13
|
+
) as import('graphql').GraphQLObjectType
|
|
14
|
+
expect(nodeType).toBeDefined()
|
|
15
|
+
const fields = nodeType.getFields()
|
|
16
|
+
expect(fields.id).toBeDefined()
|
|
17
|
+
expect(fields.type).toBeDefined()
|
|
18
|
+
expect(fields.title).toBeDefined()
|
|
19
|
+
expect(fields.description).toBeDefined()
|
|
20
|
+
expect(fields.status).toBeDefined()
|
|
21
|
+
expect(fields.metadata).toBeDefined()
|
|
22
|
+
expect(fields.createdAt).toBeDefined()
|
|
23
|
+
expect(fields.updatedAt).toBeDefined()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('uses String types instead of enums (no NodeType, Status, Relation enums)', () => {
|
|
27
|
+
expect(schema.getType('NodeType')).toBeUndefined()
|
|
28
|
+
expect(schema.getType('Status')).toBeUndefined()
|
|
29
|
+
expect(schema.getType('Relation')).toBeUndefined()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('Node type does not have children, blockedBy, or blocking fields', () => {
|
|
33
|
+
const nodeType = schema.getType(
|
|
34
|
+
'Node',
|
|
35
|
+
) as import('graphql').GraphQLObjectType
|
|
36
|
+
const fields = nodeType.getFields()
|
|
37
|
+
expect(fields.children).toBeUndefined()
|
|
38
|
+
expect(fields.blockedBy).toBeUndefined()
|
|
39
|
+
expect(fields.blocking).toBeUndefined()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('Edge type has sourceId, targetId, relation, createdAt as String fields', () => {
|
|
43
|
+
const edgeType = schema.getType(
|
|
44
|
+
'Edge',
|
|
45
|
+
) as import('graphql').GraphQLObjectType
|
|
46
|
+
const fields = edgeType.getFields()
|
|
47
|
+
expect(fields.sourceId).toBeDefined()
|
|
48
|
+
expect(fields.targetId).toBeDefined()
|
|
49
|
+
expect(fields.relation).toBeDefined()
|
|
50
|
+
expect(fields.createdAt).toBeDefined()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('Query type has node, nodes, descendants, subtree, search', () => {
|
|
54
|
+
const queryType = schema.getType(
|
|
55
|
+
'Query',
|
|
56
|
+
) as import('graphql').GraphQLObjectType
|
|
57
|
+
const fields = queryType.getFields()
|
|
58
|
+
expect(fields.node).toBeDefined()
|
|
59
|
+
expect(fields.nodes).toBeDefined()
|
|
60
|
+
expect(fields.descendants).toBeDefined()
|
|
61
|
+
expect(fields.subtree).toBeDefined()
|
|
62
|
+
expect(fields.search).toBeDefined()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('Query type does not have tree', () => {
|
|
66
|
+
const queryType = schema.getType(
|
|
67
|
+
'Query',
|
|
68
|
+
) as import('graphql').GraphQLObjectType
|
|
69
|
+
const fields = queryType.getFields()
|
|
70
|
+
expect(fields.tree).toBeUndefined()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('Mutation type has createNode, updateNode, approveNode, createEdge, removeEdge', () => {
|
|
74
|
+
const mutationType = schema.getType(
|
|
75
|
+
'Mutation',
|
|
76
|
+
) as import('graphql').GraphQLObjectType
|
|
77
|
+
const fields = mutationType.getFields()
|
|
78
|
+
expect(fields.createNode).toBeDefined()
|
|
79
|
+
expect(fields.updateNode).toBeDefined()
|
|
80
|
+
expect(fields.approveNode).toBeDefined()
|
|
81
|
+
expect(fields.createEdge).toBeDefined()
|
|
82
|
+
expect(fields.removeEdge).toBeDefined()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('Mutation type does not have deleteNode or deleteEdge', () => {
|
|
86
|
+
const mutationType = schema.getType(
|
|
87
|
+
'Mutation',
|
|
88
|
+
) as import('graphql').GraphQLObjectType
|
|
89
|
+
const fields = mutationType.getFields()
|
|
90
|
+
expect(fields.deleteNode).toBeUndefined()
|
|
91
|
+
expect(fields.deleteEdge).toBeUndefined()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createSchema } from 'graphql-yoga'
|
|
2
|
+
|
|
3
|
+
export const typeDefs = /* GraphQL */ `
|
|
4
|
+
type Node {
|
|
5
|
+
id: String!
|
|
6
|
+
type: String!
|
|
7
|
+
title: String!
|
|
8
|
+
description: String
|
|
9
|
+
status: String!
|
|
10
|
+
metadata: String
|
|
11
|
+
createdAt: String!
|
|
12
|
+
updatedAt: String!
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Edge {
|
|
16
|
+
sourceId: String!
|
|
17
|
+
targetId: String!
|
|
18
|
+
relation: String!
|
|
19
|
+
createdAt: String!
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Query {
|
|
23
|
+
node(id: String!): Node
|
|
24
|
+
nodes(type: String): [Node!]!
|
|
25
|
+
descendants(nodeId: String!, relation: String, maxDepth: Int): [Node!]!
|
|
26
|
+
subtree(nodeId: String!, maxDepth: Int): [Node!]!
|
|
27
|
+
search(query: String!, type: String, status: String, limit: Int): [Node!]!
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Mutation {
|
|
31
|
+
createNode(type: String!, title: String!, description: String): Node!
|
|
32
|
+
updateNode(id: String!, status: String): Node!
|
|
33
|
+
approveNode(id: String!): Node!
|
|
34
|
+
createEdge(sourceId: String!, targetId: String!, relation: String!): Edge!
|
|
35
|
+
removeEdge(sourceId: String!, targetId: String!, relation: String!): Boolean!
|
|
36
|
+
}
|
|
37
|
+
`
|
|
38
|
+
|
|
39
|
+
export const schema = createSchema({
|
|
40
|
+
typeDefs,
|
|
41
|
+
resolvers: {
|
|
42
|
+
Query: {},
|
|
43
|
+
Mutation: {},
|
|
44
|
+
},
|
|
45
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: using-flowy
|
|
3
|
+
description: Project management CLI for AI coding agents — create projects, features, tasks, track status and approvals
|
|
4
|
+
metadata:
|
|
5
|
+
sources:
|
|
6
|
+
- src/commands/project.ts
|
|
7
|
+
- src/commands/feature.ts
|
|
8
|
+
- src/commands/task.ts
|
|
9
|
+
- src/commands/status.ts
|
|
10
|
+
- src/commands/approve.ts
|
|
11
|
+
- src/commands/search.ts
|
|
12
|
+
- src/commands/tree.ts
|
|
13
|
+
- src/commands/setup.ts
|
|
14
|
+
- src/util/config.ts
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Flowy — Project Management for AI Agents
|
|
18
|
+
|
|
19
|
+
Flowy is a hosted project management backend. You interact with it via the `flowy` CLI.
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Install globally
|
|
25
|
+
bun add -g @sqaoss/flowy # or: npm i -g @sqaoss/flowy
|
|
26
|
+
|
|
27
|
+
# SaaS mode (hosted)
|
|
28
|
+
flowy setup --mode saas --email you@example.com
|
|
29
|
+
export FLOWY_API_KEY=flowy_xxx_yyy
|
|
30
|
+
|
|
31
|
+
# Self-hosted mode
|
|
32
|
+
flowy setup --mode local --api-url http://localhost:4000/graphql
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Hierarchy
|
|
36
|
+
|
|
37
|
+
Strict top-down: **client → project → feature → task**. No orphans at any level.
|
|
38
|
+
|
|
39
|
+
- A client has many projects
|
|
40
|
+
- A project has many features
|
|
41
|
+
- A feature has many tasks
|
|
42
|
+
|
|
43
|
+
## Context
|
|
44
|
+
|
|
45
|
+
Flowy uses context to know which project and feature you're working in:
|
|
46
|
+
|
|
47
|
+
- **Project**: mapped to a directory via `flowy project set <name>`, or `FLOWY_PROJECT` env var
|
|
48
|
+
- **Feature**: set via `flowy feature set <name-or-id>`, or `FLOWY_FEATURE` env var
|
|
49
|
+
|
|
50
|
+
Features and tasks cannot be created without active project/feature context.
|
|
51
|
+
|
|
52
|
+
## Commands
|
|
53
|
+
|
|
54
|
+
### Identity
|
|
55
|
+
```bash
|
|
56
|
+
flowy whoami # Show current user
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Client
|
|
60
|
+
```bash
|
|
61
|
+
flowy client set name "Acme Corp" # Set client display name
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Projects
|
|
65
|
+
```bash
|
|
66
|
+
flowy project create "Auth System" # Create a project
|
|
67
|
+
flowy project set "Auth System" # Map current dir to project
|
|
68
|
+
flowy project list # List all projects
|
|
69
|
+
flowy project show # Show active project details
|
|
70
|
+
flowy project show <id> # Show specific project
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Features (requires active project)
|
|
74
|
+
```bash
|
|
75
|
+
flowy feature create --title "SSO Support" --description sso-spec.md
|
|
76
|
+
flowy feature create --title "Stability" --description "Improve error handling"
|
|
77
|
+
flowy feature set "SSO Support" # Set active feature
|
|
78
|
+
flowy feature unset # Clear active feature
|
|
79
|
+
flowy feature list # List features in project
|
|
80
|
+
flowy feature show # Show active feature
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Tasks (requires active feature)
|
|
84
|
+
```bash
|
|
85
|
+
flowy task create --title "Implement OAuth" --description oauth.md
|
|
86
|
+
flowy task create --title "Write tests" --description "Unit tests for auth"
|
|
87
|
+
flowy task list # List tasks in feature
|
|
88
|
+
flowy task show <id> # Show task details
|
|
89
|
+
flowy task block <id1> <id2> # Mark id1 blocks id2
|
|
90
|
+
flowy task unblock <id1> <id2> # Remove block
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Status & Approval
|
|
94
|
+
```bash
|
|
95
|
+
flowy status <id> in_progress
|
|
96
|
+
flowy status <id> done
|
|
97
|
+
flowy status <id> pending_review
|
|
98
|
+
flowy approve <id> # Must be pending_review
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Search & Explore
|
|
102
|
+
```bash
|
|
103
|
+
flowy search "OAuth" --type task
|
|
104
|
+
flowy search "auth" --status draft --limit 5
|
|
105
|
+
flowy tree <id> --depth 3 # Show subtree
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Entity Types
|
|
109
|
+
- `client` — a client or company
|
|
110
|
+
- `project` — a codebase or product
|
|
111
|
+
- `feature` — a unit of work (replaces epic)
|
|
112
|
+
- `task` — an individual work item
|
|
113
|
+
|
|
114
|
+
## Status Flow
|
|
115
|
+
`draft` → `pending_review` → `approved` → `in_progress` → `done`
|
|
116
|
+
|
|
117
|
+
Also: `blocked`, `cancelled`
|
|
118
|
+
|
|
119
|
+
## Description Field
|
|
120
|
+
`--description` accepts a file path or an inline string:
|
|
121
|
+
- `--description spec.md` — reads file, sends raw content
|
|
122
|
+
- `--description "Do the thing"` — sends string as-is
|
|
123
|
+
|
|
124
|
+
## Workflow Example
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# One-time setup
|
|
128
|
+
flowy setup --mode saas --email you@example.com
|
|
129
|
+
flowy client set name "Acme Corp"
|
|
130
|
+
|
|
131
|
+
# Create and activate project
|
|
132
|
+
flowy project create "Auth System"
|
|
133
|
+
flowy project set "Auth System"
|
|
134
|
+
|
|
135
|
+
# Plan a feature
|
|
136
|
+
flowy feature create --title "SSO Support" --description sso-spec.md
|
|
137
|
+
flowy feature set "SSO Support"
|
|
138
|
+
|
|
139
|
+
# Break into tasks
|
|
140
|
+
flowy task create --title "Implement OAuth" --description oauth.md
|
|
141
|
+
flowy task create --title "Write auth tests" --description tests.md
|
|
142
|
+
|
|
143
|
+
# Track progress
|
|
144
|
+
flowy status <task-id> in_progress
|
|
145
|
+
flowy status <task-id> done
|
|
146
|
+
|
|
147
|
+
# Move to next feature
|
|
148
|
+
flowy feature create --title "API Rate Limiting" --description rate-limit.md
|
|
149
|
+
flowy feature set "API Rate Limiting"
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Output Format
|
|
153
|
+
All commands output JSON. Parse with `jq` or directly in your agent code.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const CONFIG_PATH = resolve(homedir(), '.config', 'flowy', 'config.json')
|
|
7
|
+
|
|
8
|
+
describe('client command', () => {
|
|
9
|
+
let originalConfig: string | null = null
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
originalConfig = existsSync(CONFIG_PATH)
|
|
13
|
+
? readFileSync(CONFIG_PATH, 'utf-8')
|
|
14
|
+
: null
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (originalConfig !== null) {
|
|
19
|
+
writeFileSync(CONFIG_PATH, originalConfig)
|
|
20
|
+
} else if (existsSync(CONFIG_PATH)) {
|
|
21
|
+
rmSync(CONFIG_PATH)
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('exports a command group named client', async () => {
|
|
26
|
+
const { clientCommand } = await import('./client.ts')
|
|
27
|
+
expect(clientCommand.name()).toBe('client')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('set name updates config', async () => {
|
|
31
|
+
const { clientCommand } = await import('./client.ts')
|
|
32
|
+
await clientCommand.parseAsync(['set', 'name', 'Acme Corp'], {
|
|
33
|
+
from: 'user',
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const { loadConfig } = await import('../util/config.ts')
|
|
37
|
+
const config = loadConfig()
|
|
38
|
+
expect(config.client.name).toBe('Acme Corp')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import { loadConfig, saveConfig } from '../util/config.ts'
|
|
3
|
+
import { output, outputError } from '../util/format.ts'
|
|
4
|
+
|
|
5
|
+
const SUPPORTED_PROPERTIES = ['name'] as const
|
|
6
|
+
|
|
7
|
+
export const clientCommand = new Command('client').description(
|
|
8
|
+
'Manage client settings',
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
clientCommand
|
|
12
|
+
.command('set')
|
|
13
|
+
.description('Set a client property')
|
|
14
|
+
.argument('<property>', 'Property to set')
|
|
15
|
+
.argument('<value>', 'Value to set')
|
|
16
|
+
.action((property: string, value: string) => {
|
|
17
|
+
try {
|
|
18
|
+
if (
|
|
19
|
+
!SUPPORTED_PROPERTIES.includes(
|
|
20
|
+
property as (typeof SUPPORTED_PROPERTIES)[number],
|
|
21
|
+
)
|
|
22
|
+
) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`Unknown property "${property}". Supported: ${SUPPORTED_PROPERTIES.join(', ')}`,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
const config = loadConfig()
|
|
28
|
+
config.client.name = value
|
|
29
|
+
saveConfig(config)
|
|
30
|
+
output({ property, value })
|
|
31
|
+
} catch (error) {
|
|
32
|
+
outputError(error)
|
|
33
|
+
}
|
|
34
|
+
})
|