@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,71 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const mockUpdateProjectConfig = vi.fn()
|
|
4
|
+
|
|
5
|
+
vi.mock('../util/config.ts', () => ({
|
|
6
|
+
requireProject: vi.fn(() => {
|
|
7
|
+
throw new Error(
|
|
8
|
+
'No active project. Run "flowy project set <name>" or set FLOWY_PROJECT.',
|
|
9
|
+
)
|
|
10
|
+
}),
|
|
11
|
+
resolveFeature: vi.fn(() => null),
|
|
12
|
+
updateProjectConfig: (...args: unknown[]) => mockUpdateProjectConfig(...args),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
vi.mock('../util/client.ts', () => ({
|
|
16
|
+
graphql: vi.fn(),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
vi.mock('../util/format.ts', () => ({
|
|
20
|
+
output: vi.fn(),
|
|
21
|
+
outputError: vi.fn(),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
describe('feature command', () => {
|
|
25
|
+
test('exports a command group named "feature" with subcommands', async () => {
|
|
26
|
+
const { featureCommand } = await import('./feature.ts')
|
|
27
|
+
expect(featureCommand.name()).toBe('feature')
|
|
28
|
+
const subcommandNames = featureCommand.commands.map((c) => c.name())
|
|
29
|
+
expect(subcommandNames).toContain('create')
|
|
30
|
+
expect(subcommandNames).toContain('set')
|
|
31
|
+
expect(subcommandNames).toContain('unset')
|
|
32
|
+
expect(subcommandNames).toContain('list')
|
|
33
|
+
expect(subcommandNames).toContain('show')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('create calls outputError when no active project', async () => {
|
|
37
|
+
const { featureCommand } = await import('./feature.ts')
|
|
38
|
+
const { outputError } = await import('../util/format.ts')
|
|
39
|
+
|
|
40
|
+
const createCmd = featureCommand.commands.find((c) => c.name() === 'create')
|
|
41
|
+
expect(createCmd).toBeDefined()
|
|
42
|
+
await createCmd?.parseAsync(['--title', 'Test', '--description', 'Desc'], {
|
|
43
|
+
from: 'user',
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
47
|
+
expect.objectContaining({
|
|
48
|
+
message: expect.stringContaining('No active project'),
|
|
49
|
+
}),
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('unset calls updateProjectConfig to delete activeFeature', async () => {
|
|
54
|
+
const { featureCommand } = await import('./feature.ts')
|
|
55
|
+
const { output } = await import('../util/format.ts')
|
|
56
|
+
|
|
57
|
+
const unsetCmd = featureCommand.commands.find((c) => c.name() === 'unset')
|
|
58
|
+
expect(unsetCmd).toBeDefined()
|
|
59
|
+
await unsetCmd?.parseAsync([], { from: 'user' })
|
|
60
|
+
|
|
61
|
+
expect(mockUpdateProjectConfig).toHaveBeenCalledWith(expect.any(Function))
|
|
62
|
+
|
|
63
|
+
// Verify the updater function deletes activeFeature
|
|
64
|
+
const updater = mockUpdateProjectConfig.mock.calls[0]?.[0]
|
|
65
|
+
const fakeProject = { id: 'p1', name: 'Test', activeFeature: 'feat_abc' }
|
|
66
|
+
updater(fakeProject)
|
|
67
|
+
expect(fakeProject.activeFeature).toBeUndefined()
|
|
68
|
+
|
|
69
|
+
expect(output).toHaveBeenCalledWith({ activeFeature: null })
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import { graphql } from '../util/client.ts'
|
|
3
|
+
import {
|
|
4
|
+
requireProject,
|
|
5
|
+
resolveFeature,
|
|
6
|
+
updateProjectConfig,
|
|
7
|
+
} from '../util/config.ts'
|
|
8
|
+
import { resolveDescription } from '../util/description.ts'
|
|
9
|
+
import { output, outputError } from '../util/format.ts'
|
|
10
|
+
|
|
11
|
+
export const featureCommand = new Command('feature').description(
|
|
12
|
+
'Manage features in the active project',
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
featureCommand
|
|
16
|
+
.command('create')
|
|
17
|
+
.description('Create a feature in the active project')
|
|
18
|
+
.requiredOption('--title <title>', 'Feature title')
|
|
19
|
+
.requiredOption('--description <description>', 'Feature description')
|
|
20
|
+
.action(async (opts) => {
|
|
21
|
+
try {
|
|
22
|
+
const project = requireProject()
|
|
23
|
+
const description = await resolveDescription(opts.description)
|
|
24
|
+
const nodeData = await graphql<{ createNode: { id: string } }>(
|
|
25
|
+
`mutation CreateNode($type: String!, $title: String!, $description: String) {
|
|
26
|
+
createNode(type: $type, title: $title, description: $description) {
|
|
27
|
+
id type title description status createdAt updatedAt
|
|
28
|
+
}
|
|
29
|
+
}`,
|
|
30
|
+
{ type: 'feature', title: opts.title, description },
|
|
31
|
+
)
|
|
32
|
+
const featureId = nodeData.createNode.id
|
|
33
|
+
await graphql(
|
|
34
|
+
`mutation CreateEdge($sourceId: String!, $targetId: String!, $relation: String!) {
|
|
35
|
+
createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) {
|
|
36
|
+
sourceId targetId relation createdAt
|
|
37
|
+
}
|
|
38
|
+
}`,
|
|
39
|
+
{ sourceId: featureId, targetId: project.id, relation: 'part_of' },
|
|
40
|
+
)
|
|
41
|
+
output(nodeData.createNode)
|
|
42
|
+
} catch (error) {
|
|
43
|
+
outputError(error)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
featureCommand
|
|
48
|
+
.command('set')
|
|
49
|
+
.description('Set the active feature')
|
|
50
|
+
.argument('<name-or-id>', 'Feature name or ID')
|
|
51
|
+
.action(async (nameOrId: string) => {
|
|
52
|
+
try {
|
|
53
|
+
const project = requireProject()
|
|
54
|
+
const data = await graphql<{
|
|
55
|
+
descendants: Array<{ id: string; type: string; title: string }>
|
|
56
|
+
}>(
|
|
57
|
+
`query Descendants($nodeId: String!, $relation: String, $maxDepth: Int) {
|
|
58
|
+
descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
|
|
59
|
+
id type title status
|
|
60
|
+
}
|
|
61
|
+
}`,
|
|
62
|
+
{ nodeId: project.id, relation: 'part_of', maxDepth: 1 },
|
|
63
|
+
)
|
|
64
|
+
const features = data.descendants.filter((n) => n.type === 'feature')
|
|
65
|
+
const match = features.find(
|
|
66
|
+
(f) => f.id === nameOrId || f.title === nameOrId,
|
|
67
|
+
)
|
|
68
|
+
if (!match) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Feature "${nameOrId}" not found in project "${project.name}".`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
updateProjectConfig((p) => {
|
|
74
|
+
p.activeFeature = match.id
|
|
75
|
+
})
|
|
76
|
+
output(match)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
outputError(error)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
featureCommand
|
|
83
|
+
.command('unset')
|
|
84
|
+
.description('Clear the active feature')
|
|
85
|
+
.action(async () => {
|
|
86
|
+
try {
|
|
87
|
+
updateProjectConfig((p) => {
|
|
88
|
+
delete p.activeFeature
|
|
89
|
+
})
|
|
90
|
+
output({ activeFeature: null })
|
|
91
|
+
} catch (error) {
|
|
92
|
+
outputError(error)
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
featureCommand
|
|
97
|
+
.command('list')
|
|
98
|
+
.description('List features in the active project')
|
|
99
|
+
.action(async () => {
|
|
100
|
+
try {
|
|
101
|
+
const project = requireProject()
|
|
102
|
+
const data = await graphql<{
|
|
103
|
+
descendants: Array<{ id: string; type: string }>
|
|
104
|
+
}>(
|
|
105
|
+
`query Descendants($nodeId: String!, $relation: String, $maxDepth: Int) {
|
|
106
|
+
descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
|
|
107
|
+
id type title description status createdAt updatedAt
|
|
108
|
+
}
|
|
109
|
+
}`,
|
|
110
|
+
{ nodeId: project.id, relation: 'part_of', maxDepth: 1 },
|
|
111
|
+
)
|
|
112
|
+
const features = data.descendants.filter((n) => n.type === 'feature')
|
|
113
|
+
output(features)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
outputError(error)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
featureCommand
|
|
120
|
+
.command('show')
|
|
121
|
+
.description('Show feature details')
|
|
122
|
+
.argument('[id]', 'Feature ID (defaults to active feature)')
|
|
123
|
+
.action(async (id?: string) => {
|
|
124
|
+
try {
|
|
125
|
+
const featureId = id ?? resolveFeature()
|
|
126
|
+
if (!featureId) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
'No feature specified. Pass an ID or set an active feature.',
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
const data = await graphql<{ node: unknown }>(
|
|
132
|
+
`query GetNode($id: String!) {
|
|
133
|
+
node(id: $id) {
|
|
134
|
+
id type title description status metadata createdAt updatedAt
|
|
135
|
+
}
|
|
136
|
+
}`,
|
|
137
|
+
{ id: featureId },
|
|
138
|
+
)
|
|
139
|
+
output(data.node)
|
|
140
|
+
} catch (error) {
|
|
141
|
+
outputError(error)
|
|
142
|
+
}
|
|
143
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
let mockGraphql: ReturnType<typeof vi.fn>
|
|
4
|
+
let mockLoadConfig: ReturnType<typeof vi.fn>
|
|
5
|
+
let mockSaveConfig: ReturnType<typeof vi.fn>
|
|
6
|
+
let mockOutput: ReturnType<typeof vi.fn>
|
|
7
|
+
let mockOutputError: ReturnType<typeof vi.fn>
|
|
8
|
+
let mockSpawnSync: ReturnType<typeof vi.fn>
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
mockGraphql = vi.fn()
|
|
12
|
+
mockLoadConfig = vi.fn(() => ({
|
|
13
|
+
mode: 'saas',
|
|
14
|
+
apiUrl: 'https://flowy-ai.fly.dev/graphql',
|
|
15
|
+
apiKey: 'test-key',
|
|
16
|
+
client: { name: '' },
|
|
17
|
+
projects: {},
|
|
18
|
+
}))
|
|
19
|
+
mockSaveConfig = vi.fn()
|
|
20
|
+
mockOutput = vi.fn()
|
|
21
|
+
mockOutputError = vi.fn()
|
|
22
|
+
mockSpawnSync = vi.fn()
|
|
23
|
+
|
|
24
|
+
vi.doMock('../util/client.ts', () => ({
|
|
25
|
+
graphql: mockGraphql,
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
vi.doMock('../util/config.ts', () => ({
|
|
29
|
+
loadConfig: mockLoadConfig,
|
|
30
|
+
saveConfig: mockSaveConfig,
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
vi.doMock('../util/format.ts', () => ({
|
|
34
|
+
output: mockOutput,
|
|
35
|
+
outputError: mockOutputError,
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
vi.doMock('node:child_process', () => ({
|
|
39
|
+
spawnSync: mockSpawnSync,
|
|
40
|
+
}))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
vi.resetModules()
|
|
45
|
+
vi.restoreAllMocks()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('init command', () => {
|
|
49
|
+
test('exports a command named "init" with no subcommands', async () => {
|
|
50
|
+
const { initCommand } = await import('./init.ts')
|
|
51
|
+
expect(initCommand.name()).toBe('init')
|
|
52
|
+
expect(initCommand.commands).toHaveLength(0)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('detects repo name from SSH git remote URL', async () => {
|
|
56
|
+
mockSpawnSync
|
|
57
|
+
.mockReturnValueOnce({
|
|
58
|
+
status: 0,
|
|
59
|
+
stdout: '/home/user/my-repo\n',
|
|
60
|
+
})
|
|
61
|
+
.mockReturnValueOnce({
|
|
62
|
+
status: 0,
|
|
63
|
+
stdout: 'git@github.com:sqaoss/flowy.git\n',
|
|
64
|
+
})
|
|
65
|
+
mockGraphql.mockResolvedValue({
|
|
66
|
+
createNode: { id: 'proj_123', title: 'flowy' },
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const { initCommand } = await import('./init.ts')
|
|
70
|
+
await initCommand.parseAsync([], { from: 'user' })
|
|
71
|
+
|
|
72
|
+
expect(mockGraphql).toHaveBeenCalledWith(
|
|
73
|
+
expect.stringContaining('createNode'),
|
|
74
|
+
expect.objectContaining({ type: 'project', title: 'flowy' }),
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('detects repo name from HTTPS git remote URL', async () => {
|
|
79
|
+
mockSpawnSync
|
|
80
|
+
.mockReturnValueOnce({
|
|
81
|
+
status: 0,
|
|
82
|
+
stdout: '/home/user/my-repo\n',
|
|
83
|
+
})
|
|
84
|
+
.mockReturnValueOnce({
|
|
85
|
+
status: 0,
|
|
86
|
+
stdout: 'https://github.com/sqaoss/flowy.git\n',
|
|
87
|
+
})
|
|
88
|
+
mockGraphql.mockResolvedValue({
|
|
89
|
+
createNode: { id: 'proj_123', title: 'flowy' },
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const { initCommand } = await import('./init.ts')
|
|
93
|
+
await initCommand.parseAsync([], { from: 'user' })
|
|
94
|
+
|
|
95
|
+
expect(mockGraphql).toHaveBeenCalledWith(
|
|
96
|
+
expect.stringContaining('createNode'),
|
|
97
|
+
expect.objectContaining({ type: 'project', title: 'flowy' }),
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('falls back to directory name when no remote', async () => {
|
|
102
|
+
mockSpawnSync
|
|
103
|
+
.mockReturnValueOnce({
|
|
104
|
+
status: 0,
|
|
105
|
+
stdout: '/home/user/my-cool-project\n',
|
|
106
|
+
})
|
|
107
|
+
.mockReturnValueOnce({
|
|
108
|
+
status: 1,
|
|
109
|
+
stdout: '',
|
|
110
|
+
})
|
|
111
|
+
mockGraphql.mockResolvedValue({
|
|
112
|
+
createNode: { id: 'proj_456', title: 'my-cool-project' },
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const { initCommand } = await import('./init.ts')
|
|
116
|
+
await initCommand.parseAsync([], { from: 'user' })
|
|
117
|
+
|
|
118
|
+
expect(mockGraphql).toHaveBeenCalledWith(
|
|
119
|
+
expect.stringContaining('createNode'),
|
|
120
|
+
expect.objectContaining({
|
|
121
|
+
type: 'project',
|
|
122
|
+
title: 'my-cool-project',
|
|
123
|
+
}),
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('calls graphql to create project and maps directory via config', async () => {
|
|
128
|
+
mockSpawnSync
|
|
129
|
+
.mockReturnValueOnce({
|
|
130
|
+
status: 0,
|
|
131
|
+
stdout: '/home/user/flowy\n',
|
|
132
|
+
})
|
|
133
|
+
.mockReturnValueOnce({
|
|
134
|
+
status: 0,
|
|
135
|
+
stdout: 'git@github.com:sqaoss/flowy.git\n',
|
|
136
|
+
})
|
|
137
|
+
mockGraphql.mockResolvedValue({
|
|
138
|
+
createNode: { id: 'proj_789', title: 'flowy' },
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const { initCommand } = await import('./init.ts')
|
|
142
|
+
await initCommand.parseAsync([], { from: 'user' })
|
|
143
|
+
|
|
144
|
+
expect(mockGraphql).toHaveBeenCalledOnce()
|
|
145
|
+
expect(mockSaveConfig).toHaveBeenCalledWith(
|
|
146
|
+
expect.objectContaining({
|
|
147
|
+
projects: expect.objectContaining({
|
|
148
|
+
[process.cwd()]: { id: 'proj_789', name: 'flowy' },
|
|
149
|
+
}),
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
expect(mockOutput).toHaveBeenCalledWith({
|
|
153
|
+
id: 'proj_789',
|
|
154
|
+
name: 'flowy',
|
|
155
|
+
directory: process.cwd(),
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('throws when not in a git repo', async () => {
|
|
160
|
+
mockSpawnSync.mockReturnValueOnce({
|
|
161
|
+
status: 128,
|
|
162
|
+
stdout: '',
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const { initCommand } = await import('./init.ts')
|
|
166
|
+
await initCommand.parseAsync([], { from: 'user' })
|
|
167
|
+
|
|
168
|
+
expect(mockOutputError).toHaveBeenCalledWith(
|
|
169
|
+
expect.objectContaining({
|
|
170
|
+
message: expect.stringContaining('Not a git repository'),
|
|
171
|
+
}),
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { basename } from 'node:path'
|
|
3
|
+
import { Command } from 'commander'
|
|
4
|
+
import { graphql } from '../util/client.ts'
|
|
5
|
+
import { loadConfig, saveConfig } from '../util/config.ts'
|
|
6
|
+
import { output, outputError } from '../util/format.ts'
|
|
7
|
+
|
|
8
|
+
export const initCommand = new Command('init')
|
|
9
|
+
.description('Initialize Flowy for the current git repository')
|
|
10
|
+
.action(async () => {
|
|
11
|
+
try {
|
|
12
|
+
const toplevel = spawnSync('git', ['rev-parse', '--show-toplevel'])
|
|
13
|
+
if (toplevel.status !== 0) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
'Not a git repository. Run flowy init from inside a git project.',
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let repoName: string
|
|
20
|
+
const remote = spawnSync('git', ['remote', 'get-url', 'origin'])
|
|
21
|
+
if (remote.status === 0) {
|
|
22
|
+
const url = String(remote.stdout).trim()
|
|
23
|
+
repoName =
|
|
24
|
+
url
|
|
25
|
+
.split('/')
|
|
26
|
+
.pop()
|
|
27
|
+
?.replace(/\.git$/, '') ?? ''
|
|
28
|
+
} else {
|
|
29
|
+
repoName = basename(String(toplevel.stdout).trim())
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const data = await graphql<{ createNode: { id: string; title: string } }>(
|
|
33
|
+
`mutation CreateProject($type: String!, $title: String!) {
|
|
34
|
+
createNode(type: $type, title: $title) {
|
|
35
|
+
id type title description status metadata createdAt updatedAt
|
|
36
|
+
}
|
|
37
|
+
}`,
|
|
38
|
+
{ type: 'project', title: repoName },
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const { id, title } = data.createNode
|
|
42
|
+
const config = loadConfig()
|
|
43
|
+
const cwd = process.cwd()
|
|
44
|
+
config.projects[cwd] = { id, name: title }
|
|
45
|
+
saveConfig(config)
|
|
46
|
+
output({ id, name: title, directory: cwd })
|
|
47
|
+
} catch (error) {
|
|
48
|
+
outputError(error)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const CONFIG_PATH = resolve(homedir(), '.config', 'flowy', 'config.json')
|
|
7
|
+
|
|
8
|
+
describe('project 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(async () => {
|
|
18
|
+
if (originalConfig !== null) {
|
|
19
|
+
const { writeFileSync } = await import('node:fs')
|
|
20
|
+
writeFileSync(CONFIG_PATH, originalConfig)
|
|
21
|
+
} else if (existsSync(CONFIG_PATH)) {
|
|
22
|
+
rmSync(CONFIG_PATH)
|
|
23
|
+
}
|
|
24
|
+
vi.restoreAllMocks()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('exports a command group named "project" with create, set, list, show subcommands', async () => {
|
|
28
|
+
const { projectCommand } = await import('./project.ts')
|
|
29
|
+
expect(projectCommand.name()).toBe('project')
|
|
30
|
+
const subcommandNames = projectCommand.commands.map((c) => c.name())
|
|
31
|
+
expect(subcommandNames).toContain('create')
|
|
32
|
+
expect(subcommandNames).toContain('set')
|
|
33
|
+
expect(subcommandNames).toContain('list')
|
|
34
|
+
expect(subcommandNames).toContain('show')
|
|
35
|
+
expect(projectCommand.commands).toHaveLength(4)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('show without id calls requireProject which throws when no project configured', async () => {
|
|
39
|
+
const mockExit = vi
|
|
40
|
+
.spyOn(process, 'exit')
|
|
41
|
+
.mockImplementation(() => undefined as never)
|
|
42
|
+
const mockStderr = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
43
|
+
|
|
44
|
+
const { showProject } = await import('./project.ts')
|
|
45
|
+
await showProject(undefined)
|
|
46
|
+
|
|
47
|
+
expect(mockStderr).toHaveBeenCalledWith(
|
|
48
|
+
expect.stringContaining('No active project'),
|
|
49
|
+
)
|
|
50
|
+
expect(mockExit).toHaveBeenCalledWith(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('setProject saves cwd-to-project mapping in config', async () => {
|
|
54
|
+
const { setProject } = await import('./project.ts')
|
|
55
|
+
const { loadConfig } = await import('../util/config.ts')
|
|
56
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
57
|
+
|
|
58
|
+
await setProject('proj_42', 'My Project')
|
|
59
|
+
|
|
60
|
+
const config = loadConfig()
|
|
61
|
+
const cwd = process.cwd()
|
|
62
|
+
expect(config.projects[cwd]).toEqual({
|
|
63
|
+
id: 'proj_42',
|
|
64
|
+
name: 'My Project',
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('setProject overwrites existing mapping for same directory', async () => {
|
|
69
|
+
const { setProject } = await import('./project.ts')
|
|
70
|
+
const { loadConfig } = await import('../util/config.ts')
|
|
71
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
72
|
+
|
|
73
|
+
await setProject('proj_1', 'First')
|
|
74
|
+
await setProject('proj_2', 'Second')
|
|
75
|
+
|
|
76
|
+
const config = loadConfig()
|
|
77
|
+
const cwd = process.cwd()
|
|
78
|
+
expect(config.projects[cwd]).toEqual({
|
|
79
|
+
id: 'proj_2',
|
|
80
|
+
name: 'Second',
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import { graphql } from '../util/client.ts'
|
|
3
|
+
import { loadConfig, requireProject, saveConfig } from '../util/config.ts'
|
|
4
|
+
import { output, outputError } from '../util/format.ts'
|
|
5
|
+
|
|
6
|
+
export const projectCommand = new Command('project').description(
|
|
7
|
+
'Manage projects',
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
projectCommand
|
|
11
|
+
.command('create')
|
|
12
|
+
.description('Create a new project')
|
|
13
|
+
.argument('<name>', 'Project name')
|
|
14
|
+
.action(async (name: string) => {
|
|
15
|
+
try {
|
|
16
|
+
const data = await graphql<{ createNode: unknown }>(
|
|
17
|
+
`mutation CreateProject($type: String!, $title: String!) {
|
|
18
|
+
createNode(type: $type, title: $title) {
|
|
19
|
+
id type title description status metadata createdAt updatedAt
|
|
20
|
+
}
|
|
21
|
+
}`,
|
|
22
|
+
{ type: 'project', title: name },
|
|
23
|
+
)
|
|
24
|
+
output(data.createNode)
|
|
25
|
+
} catch (error) {
|
|
26
|
+
outputError(error)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export async function setProject(id: string, name: string): Promise<void> {
|
|
31
|
+
const config = loadConfig()
|
|
32
|
+
const cwd = process.cwd()
|
|
33
|
+
config.projects[cwd] = { id, name }
|
|
34
|
+
saveConfig(config)
|
|
35
|
+
output({ id, name, directory: cwd })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
projectCommand
|
|
39
|
+
.command('set')
|
|
40
|
+
.description('Map current directory to a project')
|
|
41
|
+
.argument('<name>', 'Project name')
|
|
42
|
+
.action(async (name: string) => {
|
|
43
|
+
try {
|
|
44
|
+
const data = await graphql<{
|
|
45
|
+
nodes: Array<{ id: string; title: string }>
|
|
46
|
+
}>(
|
|
47
|
+
`query ListProjects($type: String) {
|
|
48
|
+
nodes(type: $type) {
|
|
49
|
+
id title
|
|
50
|
+
}
|
|
51
|
+
}`,
|
|
52
|
+
{ type: 'project' },
|
|
53
|
+
)
|
|
54
|
+
const project = data.nodes.find((n) => n.title === name)
|
|
55
|
+
if (!project) {
|
|
56
|
+
throw new Error(`Project "${name}" not found`)
|
|
57
|
+
}
|
|
58
|
+
await setProject(project.id, project.title)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
outputError(error)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
projectCommand
|
|
65
|
+
.command('list')
|
|
66
|
+
.description('List all projects')
|
|
67
|
+
.action(async () => {
|
|
68
|
+
try {
|
|
69
|
+
const data = await graphql<{ nodes: unknown[] }>(
|
|
70
|
+
`query ListProjects($type: String) {
|
|
71
|
+
nodes(type: $type) {
|
|
72
|
+
id type title description status createdAt updatedAt
|
|
73
|
+
}
|
|
74
|
+
}`,
|
|
75
|
+
{ type: 'project' },
|
|
76
|
+
)
|
|
77
|
+
output(data.nodes)
|
|
78
|
+
} catch (error) {
|
|
79
|
+
outputError(error)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
export async function showProject(id?: string): Promise<void> {
|
|
84
|
+
try {
|
|
85
|
+
const projectId = id ?? requireProject().id
|
|
86
|
+
const data = await graphql<{ node: unknown }>(
|
|
87
|
+
`query GetProject($id: String!) {
|
|
88
|
+
node(id: $id) {
|
|
89
|
+
id type title description status metadata createdAt updatedAt
|
|
90
|
+
}
|
|
91
|
+
}`,
|
|
92
|
+
{ id: projectId },
|
|
93
|
+
)
|
|
94
|
+
output(data.node)
|
|
95
|
+
} catch (error) {
|
|
96
|
+
outputError(error)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
projectCommand
|
|
101
|
+
.command('show')
|
|
102
|
+
.description('Show project details')
|
|
103
|
+
.argument('[id]', 'Project ID (defaults to active project)')
|
|
104
|
+
.action(async (id?: string) => showProject(id))
|