@sqaoss/flowy 0.1.1 → 1.1.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/LICENSE +201 -661
- package/README.md +90 -56
- 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 +128 -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/init.test.ts +174 -0
- package/src/commands/init.ts +50 -0
- package/src/commands/project.test.ts +83 -0
- package/src/commands/project.ts +104 -0
- package/src/commands/setup.test.ts +135 -0
- package/src/commands/setup.ts +109 -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 +14 -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,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: flowy
|
|
3
|
+
description: Store plans and track execution with Flowy CLI. Use when you need to create features, break work into tasks, track progress, or manage project structure. Trigger on any planning, task tracking, or work organization request.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Flowy — Agentic Persistent Planning
|
|
7
|
+
|
|
8
|
+
Flowy gives you a persistent store for plans and execution tracking. Features are your master plans. Tasks are your execution steps. Everything persists across sessions, no files in git, no context lost.
|
|
9
|
+
|
|
10
|
+
## Why Use Flowy
|
|
11
|
+
|
|
12
|
+
Without Flowy, your plans live in markdown files that clutter git history, get deleted when done, and leave no record of what you accomplished. With Flowy, plans persist in a database. You flow through work without friction. Your human gets full observability.
|
|
13
|
+
|
|
14
|
+
## First Time in a Project
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
flowy init # auto-detects the git repo, creates a project, maps this directory
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
If Flowy isn't set up yet, the human needs to run:
|
|
21
|
+
```bash
|
|
22
|
+
npm i -g @sqaoss/flowy
|
|
23
|
+
flowy setup remote --email their@email.com
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Core Workflow
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# 1. Plan a feature (master plan)
|
|
30
|
+
flowy feature create --title "User Auth" --description auth-spec.md
|
|
31
|
+
flowy feature set "User Auth"
|
|
32
|
+
|
|
33
|
+
# 2. Break into tasks (execution steps)
|
|
34
|
+
flowy task create --title "Implement OAuth" --description oauth.md
|
|
35
|
+
flowy task create --title "Write tests" --description "Unit + integration tests"
|
|
36
|
+
|
|
37
|
+
# 3. Execute and track
|
|
38
|
+
flowy status <task-id> in_progress
|
|
39
|
+
# ... do the work ...
|
|
40
|
+
flowy status <task-id> done
|
|
41
|
+
|
|
42
|
+
# 4. Move to next task or feature
|
|
43
|
+
flowy feature create --title "API Rate Limiting" --description rate-limit.md
|
|
44
|
+
flowy feature set "API Rate Limiting"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Entity Hierarchy
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
project -> feature -> task
|
|
51
|
+
1:many 1:many
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Every task belongs to a feature. Every feature belongs to a project. No orphans. The project is set automatically by `flowy init`.
|
|
55
|
+
|
|
56
|
+
## Status Flow
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
draft -> pending_review -> approved -> in_progress -> done
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Also: `blocked`, `cancelled`
|
|
63
|
+
|
|
64
|
+
Only `pending_review` entities can be approved via `flowy approve <id>`.
|
|
65
|
+
|
|
66
|
+
## Commands
|
|
67
|
+
|
|
68
|
+
### Project Context
|
|
69
|
+
```bash
|
|
70
|
+
flowy init # Auto-detect repo, create + set project
|
|
71
|
+
flowy project list # List all projects
|
|
72
|
+
flowy project show [<id>] # Show project details
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Features (requires active project)
|
|
76
|
+
```bash
|
|
77
|
+
flowy feature create --title "Title" --description "description or file.md"
|
|
78
|
+
flowy feature set "Title or ID" # Set active feature
|
|
79
|
+
flowy feature unset # Clear active feature
|
|
80
|
+
flowy feature list # List features in project
|
|
81
|
+
flowy feature show [<id>] # Show feature details
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Tasks (requires active feature)
|
|
85
|
+
```bash
|
|
86
|
+
flowy task create --title "Title" --description "description or file.md"
|
|
87
|
+
flowy task list # List tasks in feature
|
|
88
|
+
flowy task show <id> # Show task details
|
|
89
|
+
flowy task block <blocker> <blocked> # Mark dependency
|
|
90
|
+
flowy task unblock <blocker> <blocked>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Status and Approval
|
|
94
|
+
```bash
|
|
95
|
+
flowy status <id> in_progress
|
|
96
|
+
flowy status <id> pending_review
|
|
97
|
+
flowy approve <id> # Only works on pending_review
|
|
98
|
+
flowy status <id> done
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Search and Explore
|
|
102
|
+
```bash
|
|
103
|
+
flowy search "query" --type task --status draft --limit 10
|
|
104
|
+
flowy tree <project-id> --depth 3 # Show full subtree
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Validation Rules
|
|
108
|
+
|
|
109
|
+
- **Title is required** and cannot be empty
|
|
110
|
+
- **Description** is optional, but if provided cannot be empty
|
|
111
|
+
- **--description** accepts a file path (reads content) or an inline string
|
|
112
|
+
- **Search** requires at least 3 characters
|
|
113
|
+
- **Status** must be one of: draft, pending_review, approved, in_progress, done, blocked, cancelled
|
|
114
|
+
- **Blocking**: a task cannot block itself
|
|
115
|
+
- **Edges**: both source and target nodes must exist
|
|
116
|
+
|
|
117
|
+
## Output Format
|
|
118
|
+
|
|
119
|
+
All commands output JSON to stdout. Errors go to stderr as `{ "error": "message" }`.
|
|
120
|
+
|
|
121
|
+
## Environment Variables
|
|
122
|
+
|
|
123
|
+
| Variable | Description |
|
|
124
|
+
|----------|-------------|
|
|
125
|
+
| `FLOWY_PROJECT` | Override active project by name |
|
|
126
|
+
| `FLOWY_FEATURE` | Override active feature by ID |
|
|
127
|
+
| `FLOWY_API_URL` | GraphQL endpoint |
|
|
128
|
+
| `FLOWY_API_KEY` | API key (from setup) |
|
|
@@ -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
|
+
})
|