@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.
@@ -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))