@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,127 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import { graphql } from '../util/client.ts'
|
|
3
|
+
import { requireFeature } from '../util/config.ts'
|
|
4
|
+
import { resolveDescription } from '../util/description.ts'
|
|
5
|
+
import { output, outputError } from '../util/format.ts'
|
|
6
|
+
|
|
7
|
+
export const taskCommand = new Command('task').description(
|
|
8
|
+
'Manage tasks in the active feature',
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
taskCommand
|
|
12
|
+
.command('create')
|
|
13
|
+
.description('Create a task in the active feature')
|
|
14
|
+
.requiredOption('--title <title>', 'Task title')
|
|
15
|
+
.requiredOption(
|
|
16
|
+
'--description <desc>',
|
|
17
|
+
'Task description (text or file path)',
|
|
18
|
+
)
|
|
19
|
+
.action(async (opts) => {
|
|
20
|
+
try {
|
|
21
|
+
const featureId = requireFeature()
|
|
22
|
+
const description = await resolveDescription(opts.description)
|
|
23
|
+
const data = await graphql<{ createNode: { id: string } }>(
|
|
24
|
+
`mutation CreateTask($type: String!, $title: String!, $description: String) {
|
|
25
|
+
createNode(type: $type, title: $title, description: $description) {
|
|
26
|
+
id type title description status createdAt
|
|
27
|
+
}
|
|
28
|
+
}`,
|
|
29
|
+
{ type: 'task', title: opts.title, description },
|
|
30
|
+
)
|
|
31
|
+
const taskId = data.createNode.id
|
|
32
|
+
await graphql(
|
|
33
|
+
`mutation LinkTask($sourceId: String!, $targetId: String!, $relation: String!) {
|
|
34
|
+
createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
|
|
35
|
+
sourceId targetId relation
|
|
36
|
+
}
|
|
37
|
+
}`,
|
|
38
|
+
{ sourceId: taskId, targetId: featureId, relation: 'part_of' },
|
|
39
|
+
)
|
|
40
|
+
output(data.createNode)
|
|
41
|
+
} catch (error) {
|
|
42
|
+
outputError(error)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
taskCommand
|
|
47
|
+
.command('list')
|
|
48
|
+
.description('List tasks in the active feature')
|
|
49
|
+
.action(async () => {
|
|
50
|
+
try {
|
|
51
|
+
const featureId = requireFeature()
|
|
52
|
+
const data = await graphql<{ descendants: unknown[] }>(
|
|
53
|
+
`query ListTasks($nodeId: String!, $relation: String!, $maxDepth: Int) {
|
|
54
|
+
descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
|
|
55
|
+
id type title status createdAt
|
|
56
|
+
}
|
|
57
|
+
}`,
|
|
58
|
+
{ nodeId: featureId, relation: 'part_of', maxDepth: 1 },
|
|
59
|
+
)
|
|
60
|
+
const tasks = data.descendants.filter(
|
|
61
|
+
(n: unknown) => (n as { type: string }).type === 'task',
|
|
62
|
+
)
|
|
63
|
+
output(tasks)
|
|
64
|
+
} catch (error) {
|
|
65
|
+
outputError(error)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
taskCommand
|
|
70
|
+
.command('show')
|
|
71
|
+
.description('Show task details')
|
|
72
|
+
.argument('<id>', 'Task ID')
|
|
73
|
+
.action(async (id: string) => {
|
|
74
|
+
try {
|
|
75
|
+
const data = await graphql<{ node: unknown }>(
|
|
76
|
+
`query ShowTask($id: String!) {
|
|
77
|
+
node(id: $id) {
|
|
78
|
+
id type title description status metadata createdAt updatedAt
|
|
79
|
+
}
|
|
80
|
+
}`,
|
|
81
|
+
{ id },
|
|
82
|
+
)
|
|
83
|
+
output(data.node)
|
|
84
|
+
} catch (error) {
|
|
85
|
+
outputError(error)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
taskCommand
|
|
90
|
+
.command('block')
|
|
91
|
+
.description('Mark a task as blocking another')
|
|
92
|
+
.argument('<id1>', 'Blocking task ID')
|
|
93
|
+
.argument('<id2>', 'Blocked task ID')
|
|
94
|
+
.action(async (id1: string, id2: string) => {
|
|
95
|
+
try {
|
|
96
|
+
const data = await graphql<{ createEdge: unknown }>(
|
|
97
|
+
`mutation BlockTask($sourceId: String!, $targetId: String!, $relation: String!) {
|
|
98
|
+
createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
|
|
99
|
+
sourceId targetId relation createdAt
|
|
100
|
+
}
|
|
101
|
+
}`,
|
|
102
|
+
{ sourceId: id1, targetId: id2, relation: 'blocks' },
|
|
103
|
+
)
|
|
104
|
+
output(data.createEdge)
|
|
105
|
+
} catch (error) {
|
|
106
|
+
outputError(error)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
taskCommand
|
|
111
|
+
.command('unblock')
|
|
112
|
+
.description('Remove a blocking relationship')
|
|
113
|
+
.argument('<id1>', 'Blocking task ID')
|
|
114
|
+
.argument('<id2>', 'Blocked task ID')
|
|
115
|
+
.action(async (id1: string, id2: string) => {
|
|
116
|
+
try {
|
|
117
|
+
const data = await graphql<{ removeEdge: boolean }>(
|
|
118
|
+
`mutation UnblockTask($sourceId: String!, $targetId: String!, $relation: String!) {
|
|
119
|
+
removeEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation)
|
|
120
|
+
}`,
|
|
121
|
+
{ sourceId: id1, targetId: id2, relation: 'blocks' },
|
|
122
|
+
)
|
|
123
|
+
output({ removed: data.removeEdge })
|
|
124
|
+
} catch (error) {
|
|
125
|
+
outputError(error)
|
|
126
|
+
}
|
|
127
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
describe('tree command', () => {
|
|
4
|
+
test('exports a flat command with id argument and depth option', async () => {
|
|
5
|
+
const { treeCommand } = await import('./tree.ts')
|
|
6
|
+
expect(treeCommand.name()).toBe('tree')
|
|
7
|
+
expect(treeCommand.commands).toHaveLength(0)
|
|
8
|
+
})
|
|
9
|
+
})
|
package/src/commands/tree.ts
CHANGED
|
@@ -2,13 +2,8 @@ import { Command } from 'commander'
|
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
3
|
import { output, outputError } from '../util/format.ts'
|
|
4
4
|
|
|
5
|
-
export const treeCommand = new Command('tree')
|
|
6
|
-
'
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
treeCommand
|
|
10
|
-
.command('subtree')
|
|
11
|
-
.description('Show node subtree (node + all descendants)')
|
|
5
|
+
export const treeCommand = new Command('tree')
|
|
6
|
+
.description('Show subtree from any entity')
|
|
12
7
|
.argument('<id>', 'Root node ID')
|
|
13
8
|
.option('--depth <n>', 'Max depth', '10')
|
|
14
9
|
.action(async (id: string, opts) => {
|
|
@@ -26,55 +21,3 @@ treeCommand
|
|
|
26
21
|
outputError(error)
|
|
27
22
|
}
|
|
28
23
|
})
|
|
29
|
-
|
|
30
|
-
treeCommand
|
|
31
|
-
.command('ancestors')
|
|
32
|
-
.description('Show ancestors of a node')
|
|
33
|
-
.argument('<id>', 'Node ID')
|
|
34
|
-
.option('--depth <n>', 'Max depth', '10')
|
|
35
|
-
.option('--relation <type>', 'Edge relation filter', 'part_of')
|
|
36
|
-
.action(async (id: string, opts) => {
|
|
37
|
-
try {
|
|
38
|
-
const data = await graphql<{ ancestors: unknown[] }>(
|
|
39
|
-
`query Ancestors($nodeId: String!, $relation: String, $maxDepth: Int) {
|
|
40
|
-
ancestors(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
|
|
41
|
-
id type title status
|
|
42
|
-
}
|
|
43
|
-
}`,
|
|
44
|
-
{
|
|
45
|
-
nodeId: id,
|
|
46
|
-
relation: opts.relation,
|
|
47
|
-
maxDepth: Number.parseInt(opts.depth, 10),
|
|
48
|
-
},
|
|
49
|
-
)
|
|
50
|
-
output(data.ancestors)
|
|
51
|
-
} catch (error) {
|
|
52
|
-
outputError(error)
|
|
53
|
-
}
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
treeCommand
|
|
57
|
-
.command('descendants')
|
|
58
|
-
.description('Show descendants of a node')
|
|
59
|
-
.argument('<id>', 'Node ID')
|
|
60
|
-
.option('--depth <n>', 'Max depth', '10')
|
|
61
|
-
.option('--relation <type>', 'Edge relation filter', 'part_of')
|
|
62
|
-
.action(async (id: string, opts) => {
|
|
63
|
-
try {
|
|
64
|
-
const data = await graphql<{ descendants: unknown[] }>(
|
|
65
|
-
`query Descendants($nodeId: String!, $relation: String, $maxDepth: Int) {
|
|
66
|
-
descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
|
|
67
|
-
id type title status
|
|
68
|
-
}
|
|
69
|
-
}`,
|
|
70
|
-
{
|
|
71
|
-
nodeId: id,
|
|
72
|
-
relation: opts.relation,
|
|
73
|
-
maxDepth: Number.parseInt(opts.depth, 10),
|
|
74
|
-
},
|
|
75
|
-
)
|
|
76
|
-
output(data.descendants)
|
|
77
|
-
} catch (error) {
|
|
78
|
-
outputError(error)
|
|
79
|
-
}
|
|
80
|
-
})
|
package/src/index.ts
CHANGED
|
@@ -1,25 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Command } from 'commander'
|
|
3
3
|
import { approveCommand } from './commands/approve.ts'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { clientCommand } from './commands/client.ts'
|
|
5
|
+
import { featureCommand } from './commands/feature.ts'
|
|
6
|
+
import { projectCommand } from './commands/project.ts'
|
|
7
7
|
import { searchCommand } from './commands/search.ts'
|
|
8
|
+
import { setupCommand } from './commands/setup.ts'
|
|
8
9
|
import { statusCommand } from './commands/status.ts'
|
|
10
|
+
import { taskCommand } from './commands/task.ts'
|
|
9
11
|
import { treeCommand } from './commands/tree.ts'
|
|
10
12
|
import { whoamiCommand } from './commands/whoami.ts'
|
|
11
13
|
|
|
12
14
|
const program = new Command()
|
|
13
15
|
.name('flowy')
|
|
14
16
|
.description('Project management for AI coding agents')
|
|
15
|
-
.version('0.
|
|
17
|
+
.version('0.2.0')
|
|
16
18
|
|
|
19
|
+
program.addCommand(setupCommand)
|
|
20
|
+
program.addCommand(clientCommand)
|
|
21
|
+
program.addCommand(projectCommand)
|
|
22
|
+
program.addCommand(featureCommand)
|
|
23
|
+
program.addCommand(taskCommand)
|
|
24
|
+
program.addCommand(statusCommand)
|
|
17
25
|
program.addCommand(approveCommand)
|
|
18
|
-
program.addCommand(registerCommand)
|
|
19
|
-
program.addCommand(nodeCommand)
|
|
20
|
-
program.addCommand(edgeCommand)
|
|
21
26
|
program.addCommand(searchCommand)
|
|
22
|
-
program.addCommand(statusCommand)
|
|
23
27
|
program.addCommand(treeCommand)
|
|
24
28
|
program.addCommand(whoamiCommand)
|
|
25
29
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
afterAll,
|
|
6
|
+
afterEach,
|
|
7
|
+
beforeAll,
|
|
8
|
+
beforeEach,
|
|
9
|
+
describe,
|
|
10
|
+
expect,
|
|
11
|
+
test,
|
|
12
|
+
vi,
|
|
13
|
+
} from 'vitest'
|
|
14
|
+
|
|
15
|
+
const CONFIG_PATH = resolve(homedir(), '.config', 'flowy', 'config.json')
|
|
16
|
+
|
|
17
|
+
describe('config', () => {
|
|
18
|
+
let originalConfig: string | null = null
|
|
19
|
+
|
|
20
|
+
beforeAll(() => {
|
|
21
|
+
originalConfig = existsSync(CONFIG_PATH)
|
|
22
|
+
? readFileSync(CONFIG_PATH, 'utf-8')
|
|
23
|
+
: null
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterAll(() => {
|
|
27
|
+
if (originalConfig !== null) {
|
|
28
|
+
writeFileSync(CONFIG_PATH, originalConfig)
|
|
29
|
+
} else if (existsSync(CONFIG_PATH)) {
|
|
30
|
+
rmSync(CONFIG_PATH)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
if (existsSync(CONFIG_PATH)) rmSync(CONFIG_PATH)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
delete process.env.FLOWY_PROJECT
|
|
40
|
+
delete process.env.FLOWY_FEATURE
|
|
41
|
+
vi.resetModules()
|
|
42
|
+
})
|
|
43
|
+
test('loadConfig returns defaults when no config file exists', async () => {
|
|
44
|
+
const { loadConfig } = await import('./config.ts')
|
|
45
|
+
const config = loadConfig()
|
|
46
|
+
expect(config.mode).toBe('saas')
|
|
47
|
+
expect(config.apiUrl).toBe('https://flowy-ai.fly.dev/graphql')
|
|
48
|
+
expect(config.apiKey).toBe('')
|
|
49
|
+
expect(config.client.name).toBe('')
|
|
50
|
+
expect(config.projects).toEqual({})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('getConfig uses env var overrides', async () => {
|
|
54
|
+
process.env.FLOWY_API_URL = 'http://localhost:4000/graphql'
|
|
55
|
+
process.env.FLOWY_API_KEY = 'test-key'
|
|
56
|
+
const { getConfig } = await import('./config.ts')
|
|
57
|
+
const { apiUrl, apiKey } = getConfig()
|
|
58
|
+
expect(apiUrl).toBe('http://localhost:4000/graphql')
|
|
59
|
+
expect(apiKey).toBe('test-key')
|
|
60
|
+
delete process.env.FLOWY_API_URL
|
|
61
|
+
delete process.env.FLOWY_API_KEY
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('saveConfig writes and loadConfig reads from disk', async () => {
|
|
65
|
+
const { saveConfig, loadConfig } = await import('./config.ts')
|
|
66
|
+
const config = loadConfig()
|
|
67
|
+
config.client.name = 'Test Client'
|
|
68
|
+
saveConfig(config)
|
|
69
|
+
const reloaded = loadConfig()
|
|
70
|
+
expect(reloaded.client.name).toBe('Test Client')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('resolveProject returns null when no project configured', async () => {
|
|
74
|
+
const { resolveProject } = await import('./config.ts')
|
|
75
|
+
expect(resolveProject()).toBeNull()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('resolveProject matches cwd against project paths', async () => {
|
|
79
|
+
const { saveConfig, loadConfig, resolveProject } = await import(
|
|
80
|
+
'./config.ts'
|
|
81
|
+
)
|
|
82
|
+
const config = loadConfig()
|
|
83
|
+
const cwd = process.cwd()
|
|
84
|
+
config.projects[cwd] = { id: 'proj_1', name: 'Test Project' }
|
|
85
|
+
saveConfig(config)
|
|
86
|
+
const project = resolveProject()
|
|
87
|
+
expect(project).not.toBeNull()
|
|
88
|
+
expect(project?.id).toBe('proj_1')
|
|
89
|
+
expect(project?.name).toBe('Test Project')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('resolveProject uses FLOWY_PROJECT env var', async () => {
|
|
93
|
+
const { saveConfig, loadConfig, resolveProject } = await import(
|
|
94
|
+
'./config.ts'
|
|
95
|
+
)
|
|
96
|
+
const config = loadConfig()
|
|
97
|
+
config.projects['/other/path'] = { id: 'proj_2', name: 'Env Project' }
|
|
98
|
+
saveConfig(config)
|
|
99
|
+
process.env.FLOWY_PROJECT = 'Env Project'
|
|
100
|
+
const project = resolveProject()
|
|
101
|
+
expect(project?.name).toBe('Env Project')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('requireProject throws when no project configured', async () => {
|
|
105
|
+
const { requireProject } = await import('./config.ts')
|
|
106
|
+
expect(() => requireProject()).toThrow('No active project')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('resolveFeature returns FLOWY_FEATURE env var', async () => {
|
|
110
|
+
process.env.FLOWY_FEATURE = 'feat_123'
|
|
111
|
+
const { resolveFeature } = await import('./config.ts')
|
|
112
|
+
expect(resolveFeature()).toBe('feat_123')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('resolveFeature falls back to activeFeature from project config', async () => {
|
|
116
|
+
const { saveConfig, loadConfig, resolveFeature } = await import(
|
|
117
|
+
'./config.ts'
|
|
118
|
+
)
|
|
119
|
+
const config = loadConfig()
|
|
120
|
+
const cwd = process.cwd()
|
|
121
|
+
config.projects[cwd] = {
|
|
122
|
+
id: 'proj_1',
|
|
123
|
+
name: 'Test',
|
|
124
|
+
activeFeature: 'feat_abc',
|
|
125
|
+
}
|
|
126
|
+
saveConfig(config)
|
|
127
|
+
expect(resolveFeature()).toBe('feat_abc')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('requireFeature throws when no feature set', async () => {
|
|
131
|
+
const { requireFeature } = await import('./config.ts')
|
|
132
|
+
expect(() => requireFeature()).toThrow('No active feature')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('updateProjectConfig modifies project entry for cwd', async () => {
|
|
136
|
+
const { saveConfig, loadConfig, updateProjectConfig } = await import(
|
|
137
|
+
'./config.ts'
|
|
138
|
+
)
|
|
139
|
+
const config = loadConfig()
|
|
140
|
+
const cwd = process.cwd()
|
|
141
|
+
config.projects[cwd] = { id: 'proj_1', name: 'Test' }
|
|
142
|
+
saveConfig(config)
|
|
143
|
+
updateProjectConfig((p) => {
|
|
144
|
+
p.activeFeature = 'feat_999'
|
|
145
|
+
})
|
|
146
|
+
const updated = loadConfig()
|
|
147
|
+
expect(
|
|
148
|
+
(updated.projects[cwd] as { activeFeature?: string }).activeFeature,
|
|
149
|
+
).toBe('feat_999')
|
|
150
|
+
})
|
|
151
|
+
})
|
package/src/util/config.ts
CHANGED
|
@@ -1,5 +1,110 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = resolve(homedir(), '.config', 'flowy')
|
|
6
|
+
const CONFIG_PATH = resolve(CONFIG_DIR, 'config.json')
|
|
7
|
+
|
|
8
|
+
export function loadConfig() {
|
|
9
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
10
|
+
return {
|
|
11
|
+
mode: 'saas' as const,
|
|
12
|
+
apiUrl: 'https://flowy-ai.fly.dev/graphql',
|
|
13
|
+
apiKey: '',
|
|
14
|
+
client: { name: '' },
|
|
15
|
+
projects: {} as Record<string, unknown>,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function saveConfig(config: ReturnType<typeof loadConfig>): void {
|
|
22
|
+
mkdirSync(CONFIG_DIR, { recursive: true })
|
|
23
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ProjectConfig {
|
|
27
|
+
id: string
|
|
28
|
+
name: string
|
|
29
|
+
activeFeature?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveProject(): ProjectConfig | null {
|
|
33
|
+
const envProject = process.env.FLOWY_PROJECT
|
|
34
|
+
const config = loadConfig()
|
|
35
|
+
|
|
36
|
+
if (envProject) {
|
|
37
|
+
return (
|
|
38
|
+
(Object.values(config.projects).find(
|
|
39
|
+
(p) => (p as ProjectConfig).name === envProject,
|
|
40
|
+
) as ProjectConfig) ?? null
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cwd = process.cwd()
|
|
45
|
+
let bestMatch: ProjectConfig | null = null
|
|
46
|
+
let bestLength = 0
|
|
47
|
+
|
|
48
|
+
for (const [path, project] of Object.entries(config.projects)) {
|
|
49
|
+
if (
|
|
50
|
+
(cwd === path || cwd.startsWith(`${path}/`)) &&
|
|
51
|
+
path.length > bestLength
|
|
52
|
+
) {
|
|
53
|
+
bestMatch = project as ProjectConfig
|
|
54
|
+
bestLength = path.length
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return bestMatch
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveFeature(): string | null {
|
|
62
|
+
const envFeature = process.env.FLOWY_FEATURE
|
|
63
|
+
if (envFeature) return envFeature
|
|
64
|
+
const project = resolveProject()
|
|
65
|
+
return project?.activeFeature ?? null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function requireFeature(): string {
|
|
69
|
+
const feature = resolveFeature()
|
|
70
|
+
if (!feature) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
'No active feature. Run "flowy feature set <name-or-id>" or set FLOWY_FEATURE.',
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
return feature
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function requireProject(): ProjectConfig {
|
|
79
|
+
const project = resolveProject()
|
|
80
|
+
if (!project) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
'No active project. Run "flowy project set <name>" or set FLOWY_PROJECT.',
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
return project
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function updateProjectConfig(
|
|
89
|
+
updater: (project: ProjectConfig) => void,
|
|
90
|
+
): void {
|
|
91
|
+
const config = loadConfig()
|
|
92
|
+
const cwd = process.cwd()
|
|
93
|
+
|
|
94
|
+
for (const [path, project] of Object.entries(config.projects)) {
|
|
95
|
+
if (cwd === path || cwd.startsWith(`${path}/`)) {
|
|
96
|
+
updater(project as ProjectConfig)
|
|
97
|
+
saveConfig(config)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
throw new Error('No directory mapping. Run "flowy project set <name>" first.')
|
|
103
|
+
}
|
|
104
|
+
|
|
1
105
|
export function getConfig() {
|
|
2
|
-
const
|
|
3
|
-
const
|
|
106
|
+
const config = loadConfig()
|
|
107
|
+
const apiUrl = process.env.FLOWY_API_URL ?? config.apiUrl
|
|
108
|
+
const apiKey = process.env.FLOWY_API_KEY ?? config.apiKey
|
|
4
109
|
return { apiUrl, apiKey }
|
|
5
110
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { afterEach, describe, expect, test } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
|
|
8
|
+
describe('resolveDescription', () => {
|
|
9
|
+
const TEST_FILE = resolve(__dirname, '../../.test-desc.md')
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
try {
|
|
13
|
+
rmSync(TEST_FILE)
|
|
14
|
+
} catch {}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('returns file content when path exists', async () => {
|
|
18
|
+
writeFileSync(TEST_FILE, '# Test\nSome content')
|
|
19
|
+
const { resolveDescription } = await import('./description.ts')
|
|
20
|
+
const result = await resolveDescription(TEST_FILE)
|
|
21
|
+
expect(result).toBe('# Test\nSome content')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('returns value as-is when path does not exist', async () => {
|
|
25
|
+
const { resolveDescription } = await import('./description.ts')
|
|
26
|
+
const result = await resolveDescription('Just a plain description')
|
|
27
|
+
expect(result).toBe('Just a plain description')
|
|
28
|
+
})
|
|
29
|
+
})
|
package/src/commands/edge.ts
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { Command } from 'commander'
|
|
2
|
-
import { graphql } from '../util/client.ts'
|
|
3
|
-
import { output, outputError } from '../util/format.ts'
|
|
4
|
-
|
|
5
|
-
export const edgeCommand = new Command('edge').description(
|
|
6
|
-
'Manage edges between nodes',
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
edgeCommand
|
|
10
|
-
.command('create')
|
|
11
|
-
.description('Create an edge between two nodes')
|
|
12
|
-
.requiredOption('--source <id>', 'Source node ID')
|
|
13
|
-
.requiredOption('--target <id>', 'Target node ID')
|
|
14
|
-
.requiredOption(
|
|
15
|
-
'--relation <rel>',
|
|
16
|
-
'Relation type (part_of, depends_on, blocks, informs)',
|
|
17
|
-
)
|
|
18
|
-
.action(async (opts) => {
|
|
19
|
-
try {
|
|
20
|
-
const data = await graphql<{ createEdge: unknown }>(
|
|
21
|
-
`mutation CreateEdge($sourceId: String!, $targetId: String!, $relation: String!) {
|
|
22
|
-
createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
|
|
23
|
-
sourceId targetId relation createdAt
|
|
24
|
-
}
|
|
25
|
-
}`,
|
|
26
|
-
{
|
|
27
|
-
sourceId: opts.source,
|
|
28
|
-
targetId: opts.target,
|
|
29
|
-
relation: opts.relation,
|
|
30
|
-
},
|
|
31
|
-
)
|
|
32
|
-
output(data.createEdge)
|
|
33
|
-
} catch (error) {
|
|
34
|
-
outputError(error)
|
|
35
|
-
}
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
edgeCommand
|
|
39
|
-
.command('remove')
|
|
40
|
-
.description('Remove an edge')
|
|
41
|
-
.requiredOption('--source <id>', 'Source node ID')
|
|
42
|
-
.requiredOption('--target <id>', 'Target node ID')
|
|
43
|
-
.requiredOption('--relation <rel>', 'Relation type')
|
|
44
|
-
.action(async (opts) => {
|
|
45
|
-
try {
|
|
46
|
-
const data = await graphql<{ removeEdge: boolean }>(
|
|
47
|
-
`mutation RemoveEdge($sourceId: String!, $targetId: String!, $relation: String!) {
|
|
48
|
-
removeEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation)
|
|
49
|
-
}`,
|
|
50
|
-
{
|
|
51
|
-
sourceId: opts.source,
|
|
52
|
-
targetId: opts.target,
|
|
53
|
-
relation: opts.relation,
|
|
54
|
-
},
|
|
55
|
-
)
|
|
56
|
-
output({ removed: data.removeEdge })
|
|
57
|
-
} catch (error) {
|
|
58
|
-
outputError(error)
|
|
59
|
-
}
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
edgeCommand
|
|
63
|
-
.command('list')
|
|
64
|
-
.description('List edges')
|
|
65
|
-
.option('--node <id>', 'Filter by node ID')
|
|
66
|
-
.option('--relation <rel>', 'Filter by relation type')
|
|
67
|
-
.action(async (opts) => {
|
|
68
|
-
try {
|
|
69
|
-
const data = await graphql<{ edges: unknown[] }>(
|
|
70
|
-
`query ListEdges($nodeId: String, $relation: String) {
|
|
71
|
-
edges(nodeId: $nodeId, relation: $relation) {
|
|
72
|
-
sourceId targetId relation createdAt
|
|
73
|
-
}
|
|
74
|
-
}`,
|
|
75
|
-
{
|
|
76
|
-
nodeId: opts.node,
|
|
77
|
-
relation: opts.relation,
|
|
78
|
-
},
|
|
79
|
-
)
|
|
80
|
-
output(data.edges)
|
|
81
|
-
} catch (error) {
|
|
82
|
-
outputError(error)
|
|
83
|
-
}
|
|
84
|
-
})
|