@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,135 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
let mockLoadConfig: ReturnType<typeof vi.fn>
|
|
4
|
+
let mockSaveConfig: ReturnType<typeof vi.fn>
|
|
5
|
+
let mockOutput: ReturnType<typeof vi.fn>
|
|
6
|
+
let mockOutputError: ReturnType<typeof vi.fn>
|
|
7
|
+
let mockSpawnSync: ReturnType<typeof vi.fn>
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
mockLoadConfig = vi.fn(() => ({
|
|
11
|
+
mode: 'saas',
|
|
12
|
+
apiUrl: 'https://flowy-ai.fly.dev/graphql',
|
|
13
|
+
apiKey: '',
|
|
14
|
+
client: { name: '' },
|
|
15
|
+
projects: {},
|
|
16
|
+
}))
|
|
17
|
+
mockSaveConfig = vi.fn()
|
|
18
|
+
mockOutput = vi.fn()
|
|
19
|
+
mockOutputError = vi.fn()
|
|
20
|
+
mockSpawnSync = vi.fn()
|
|
21
|
+
|
|
22
|
+
vi.doMock('../util/config.ts', () => ({
|
|
23
|
+
loadConfig: mockLoadConfig,
|
|
24
|
+
saveConfig: mockSaveConfig,
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
vi.doMock('../util/format.ts', () => ({
|
|
28
|
+
output: mockOutput,
|
|
29
|
+
outputError: mockOutputError,
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
vi.doMock('node:child_process', () => ({
|
|
33
|
+
spawnSync: mockSpawnSync,
|
|
34
|
+
}))
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
vi.resetModules()
|
|
39
|
+
vi.restoreAllMocks()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('setup command', () => {
|
|
43
|
+
test('exports a command named "setup"', async () => {
|
|
44
|
+
const { setupCommand } = await import('./setup.ts')
|
|
45
|
+
expect(setupCommand.name()).toBe('setup')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('has local and remote subcommands', async () => {
|
|
49
|
+
const { setupCommand } = await import('./setup.ts')
|
|
50
|
+
const subcommandNames = setupCommand.commands.map((c) => c.name())
|
|
51
|
+
expect(subcommandNames).toContain('local')
|
|
52
|
+
expect(subcommandNames).toContain('remote')
|
|
53
|
+
expect(setupCommand.commands).toHaveLength(2)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('setup local checks for docker and errors if not found', async () => {
|
|
57
|
+
mockSpawnSync.mockReturnValue({ status: 1 })
|
|
58
|
+
|
|
59
|
+
const { setupCommand } = await import('./setup.ts')
|
|
60
|
+
await setupCommand.parseAsync(['local'], { from: 'user' })
|
|
61
|
+
|
|
62
|
+
expect(mockSpawnSync).toHaveBeenCalledWith('docker', ['--version'], {
|
|
63
|
+
stdio: 'ignore',
|
|
64
|
+
})
|
|
65
|
+
expect(mockOutputError).toHaveBeenCalledWith(
|
|
66
|
+
expect.objectContaining({
|
|
67
|
+
message: expect.stringContaining('Docker is required'),
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('setup local saves config with mode "local" and apiUrl on success', async () => {
|
|
73
|
+
mockSpawnSync.mockReturnValue({ status: 0 })
|
|
74
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
|
|
75
|
+
vi.doMock('node:fs', async () => {
|
|
76
|
+
const actual = await vi.importActual<typeof import('node:fs')>('node:fs')
|
|
77
|
+
return { ...actual, existsSync: () => true }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const { setupCommand } = await import('./setup.ts')
|
|
81
|
+
await setupCommand.parseAsync(['local'], { from: 'user' })
|
|
82
|
+
|
|
83
|
+
expect(mockSaveConfig).toHaveBeenCalledWith(
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
mode: 'local',
|
|
86
|
+
apiUrl: 'http://localhost:4000/graphql',
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('setup remote requires --email', async () => {
|
|
92
|
+
const { setupCommand } = await import('./setup.ts')
|
|
93
|
+
await setupCommand.parseAsync(['remote'], { from: 'user' })
|
|
94
|
+
|
|
95
|
+
expect(mockOutputError).toHaveBeenCalledWith(
|
|
96
|
+
expect.objectContaining({
|
|
97
|
+
message: expect.stringContaining('--email is required'),
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('setup remote registers, saves API key, and outputs result', async () => {
|
|
103
|
+
const mockGraphql = vi.fn().mockResolvedValue({
|
|
104
|
+
register: {
|
|
105
|
+
user: { id: 'user_1', email: 'test@example.com', tier: 'free' },
|
|
106
|
+
apiKey: 'flowy_test_key_123',
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
vi.doMock('../util/client.ts', () => ({
|
|
110
|
+
graphql: mockGraphql,
|
|
111
|
+
}))
|
|
112
|
+
mockSpawnSync.mockReturnValue({
|
|
113
|
+
status: 0,
|
|
114
|
+
stdout: Buffer.from(''),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const { setupCommand } = await import('./setup.ts')
|
|
118
|
+
await setupCommand.parseAsync(['remote', '--email', 'test@example.com'], {
|
|
119
|
+
from: 'user',
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
expect(mockSaveConfig).toHaveBeenCalledWith(
|
|
123
|
+
expect.objectContaining({
|
|
124
|
+
mode: 'remote',
|
|
125
|
+
apiKey: 'flowy_test_key_123',
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
expect(mockOutput).toHaveBeenCalledWith(
|
|
129
|
+
expect.objectContaining({
|
|
130
|
+
user: expect.objectContaining({ email: 'test@example.com' }),
|
|
131
|
+
apiKey: 'flowy_test_key_123',
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { dirname, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { Command } from 'commander'
|
|
6
|
+
import { loadConfig, saveConfig } from '../util/config.ts'
|
|
7
|
+
import { output, outputError } from '../util/format.ts'
|
|
8
|
+
|
|
9
|
+
function findComposeFile(): string {
|
|
10
|
+
let dir = dirname(fileURLToPath(import.meta.url))
|
|
11
|
+
while (dir !== dirname(dir)) {
|
|
12
|
+
const candidate = resolve(dir, 'docker-compose.yml')
|
|
13
|
+
if (existsSync(candidate)) return candidate
|
|
14
|
+
dir = dirname(dir)
|
|
15
|
+
}
|
|
16
|
+
throw new Error('docker-compose.yml not found in any parent directory.')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function pollHealth(url: string, timeoutMs = 30_000): Promise<void> {
|
|
20
|
+
const start = Date.now()
|
|
21
|
+
while (Date.now() - start < timeoutMs) {
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(url)
|
|
24
|
+
if (res.ok) return
|
|
25
|
+
} catch {}
|
|
26
|
+
await new Promise((r) => setTimeout(r, 1_000))
|
|
27
|
+
}
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Health check at ${url} did not respond within ${timeoutMs / 1_000}s.`,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const setupCommand = new Command('setup').description(
|
|
34
|
+
'Configure the Flowy CLI — use "flowy setup local" or "flowy setup remote"',
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
setupCommand
|
|
38
|
+
.command('local')
|
|
39
|
+
.description('Set up Flowy with a local Docker server')
|
|
40
|
+
.action(async () => {
|
|
41
|
+
try {
|
|
42
|
+
const dockerCheck = spawnSync('docker', ['--version'], {
|
|
43
|
+
stdio: 'ignore',
|
|
44
|
+
})
|
|
45
|
+
if (dockerCheck.status !== 0) {
|
|
46
|
+
throw new Error('Docker is required but was not found.')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const composePath = findComposeFile()
|
|
50
|
+
spawnSync(
|
|
51
|
+
'docker',
|
|
52
|
+
['compose', '-f', composePath, 'up', '-d', '--build'],
|
|
53
|
+
{
|
|
54
|
+
stdio: 'inherit',
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const apiUrl = 'http://localhost:4000/graphql'
|
|
59
|
+
await pollHealth('http://localhost:4000/health')
|
|
60
|
+
|
|
61
|
+
const config = loadConfig()
|
|
62
|
+
config.mode = 'local'
|
|
63
|
+
config.apiUrl = apiUrl
|
|
64
|
+
saveConfig(config)
|
|
65
|
+
output({ mode: 'local', apiUrl })
|
|
66
|
+
} catch (error) {
|
|
67
|
+
outputError(error)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
setupCommand
|
|
72
|
+
.command('remote')
|
|
73
|
+
.description('Connect to the hosted Flowy service')
|
|
74
|
+
.option('--email <email>', 'Email address for registration')
|
|
75
|
+
.action(async (opts) => {
|
|
76
|
+
try {
|
|
77
|
+
if (!opts.email) {
|
|
78
|
+
throw new Error('--email is required for registration')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { graphql } = await import('../util/client.ts')
|
|
82
|
+
|
|
83
|
+
const config = loadConfig()
|
|
84
|
+
config.mode = 'remote'
|
|
85
|
+
config.apiUrl = 'https://flowy-ai.fly.dev/graphql'
|
|
86
|
+
saveConfig(config)
|
|
87
|
+
|
|
88
|
+
const data = await graphql(
|
|
89
|
+
`mutation Register($email: String!) {
|
|
90
|
+
register(email: $email) {
|
|
91
|
+
user { id email tier }
|
|
92
|
+
apiKey
|
|
93
|
+
}
|
|
94
|
+
}`,
|
|
95
|
+
{ email: opts.email },
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
config.apiKey = data.register.apiKey
|
|
99
|
+
saveConfig(config)
|
|
100
|
+
|
|
101
|
+
spawnSync('npx', ['skills', 'add', 'sqaoss/flowy', '--yes'], {
|
|
102
|
+
stdio: 'inherit',
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
output(data.register)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
outputError(error)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('../util/config.ts', () => ({
|
|
4
|
+
requireFeature: vi.fn(() => {
|
|
5
|
+
throw new Error(
|
|
6
|
+
'No active feature. Run "flowy feature set <name-or-id>" or set FLOWY_FEATURE.',
|
|
7
|
+
)
|
|
8
|
+
}),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
vi.mock('../util/client.ts', () => ({
|
|
12
|
+
graphql: vi.fn(),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
vi.mock('../util/format.ts', () => ({
|
|
16
|
+
output: vi.fn(),
|
|
17
|
+
outputError: vi.fn(),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
describe('task command', () => {
|
|
21
|
+
test('exports a command group with 5 subcommands', async () => {
|
|
22
|
+
const { taskCommand } = await import('./task.ts')
|
|
23
|
+
expect(taskCommand.name()).toBe('task')
|
|
24
|
+
expect(taskCommand.commands).toHaveLength(5)
|
|
25
|
+
|
|
26
|
+
const names = taskCommand.commands.map((c) => c.name())
|
|
27
|
+
expect(names).toContain('create')
|
|
28
|
+
expect(names).toContain('list')
|
|
29
|
+
expect(names).toContain('show')
|
|
30
|
+
expect(names).toContain('block')
|
|
31
|
+
expect(names).toContain('unblock')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('create calls outputError when no active feature', async () => {
|
|
35
|
+
const { taskCommand } = await import('./task.ts')
|
|
36
|
+
const { outputError } = await import('../util/format.ts')
|
|
37
|
+
|
|
38
|
+
const createCmd = taskCommand.commands.find((c) => c.name() === 'create')!
|
|
39
|
+
await createCmd.parseAsync(['--title', 'Test', '--description', 'desc'], {
|
|
40
|
+
from: 'user',
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
44
|
+
expect.objectContaining({
|
|
45
|
+
message: expect.stringContaining('No active feature'),
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('show calls outputError when graphql throws network error', async () => {
|
|
51
|
+
const { graphql } = await import('../util/client.ts')
|
|
52
|
+
const { outputError } = await import('../util/format.ts')
|
|
53
|
+
const { taskCommand } = await import('./task.ts')
|
|
54
|
+
|
|
55
|
+
vi.mocked(graphql).mockRejectedValueOnce(new TypeError('fetch failed'))
|
|
56
|
+
|
|
57
|
+
const showCmd = taskCommand.commands.find((c) => c.name() === 'show')!
|
|
58
|
+
await showCmd.parseAsync(['task_abc123'], { from: 'user' })
|
|
59
|
+
|
|
60
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
61
|
+
expect.objectContaining({
|
|
62
|
+
message: 'fetch failed',
|
|
63
|
+
}),
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('show calls outputError when graphql returns error response', async () => {
|
|
68
|
+
const { graphql } = await import('../util/client.ts')
|
|
69
|
+
const { outputError } = await import('../util/format.ts')
|
|
70
|
+
const { taskCommand } = await import('./task.ts')
|
|
71
|
+
|
|
72
|
+
vi.mocked(graphql).mockRejectedValueOnce(new Error('Node not found'))
|
|
73
|
+
|
|
74
|
+
const showCmd = taskCommand.commands.find((c) => c.name() === 'show')!
|
|
75
|
+
await showCmd.parseAsync(['task_nonexistent'], { from: 'user' })
|
|
76
|
+
|
|
77
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
78
|
+
expect.objectContaining({
|
|
79
|
+
message: 'Node not found',
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -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,31 @@
|
|
|
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 { initCommand } from './commands/init.ts'
|
|
7
|
+
import { projectCommand } from './commands/project.ts'
|
|
7
8
|
import { searchCommand } from './commands/search.ts'
|
|
9
|
+
import { setupCommand } from './commands/setup.ts'
|
|
8
10
|
import { statusCommand } from './commands/status.ts'
|
|
11
|
+
import { taskCommand } from './commands/task.ts'
|
|
9
12
|
import { treeCommand } from './commands/tree.ts'
|
|
10
13
|
import { whoamiCommand } from './commands/whoami.ts'
|
|
11
14
|
|
|
12
15
|
const program = new Command()
|
|
13
16
|
.name('flowy')
|
|
14
17
|
.description('Project management for AI coding agents')
|
|
15
|
-
.version('0.
|
|
18
|
+
.version('0.2.0')
|
|
16
19
|
|
|
20
|
+
program.addCommand(initCommand)
|
|
21
|
+
program.addCommand(setupCommand)
|
|
22
|
+
program.addCommand(clientCommand)
|
|
23
|
+
program.addCommand(projectCommand)
|
|
24
|
+
program.addCommand(featureCommand)
|
|
25
|
+
program.addCommand(taskCommand)
|
|
26
|
+
program.addCommand(statusCommand)
|
|
17
27
|
program.addCommand(approveCommand)
|
|
18
|
-
program.addCommand(registerCommand)
|
|
19
|
-
program.addCommand(nodeCommand)
|
|
20
|
-
program.addCommand(edgeCommand)
|
|
21
28
|
program.addCommand(searchCommand)
|
|
22
|
-
program.addCommand(statusCommand)
|
|
23
29
|
program.addCommand(treeCommand)
|
|
24
30
|
program.addCommand(whoamiCommand)
|
|
25
31
|
|